feat: enhance federation key rotation and server discovery functionality
- Added new environment variables for MinIO configuration in .env.local.example. - Updated package.json and bun.lock to include new dependencies for key management and encryption. - Refactored server and route handling to support Ed25519 and X25519 key pairs for improved security during key rotation. - Implemented validation for public keys and enhanced error handling in the discovery routes. - Introduced new challenges for key rotation, ensuring secure communication between federations. - Updated README with additional instructions for the new key rotation process.
This commit is contained in:
parent
75f3a0ed04
commit
c587737f38
35 changed files with 2480 additions and 1759 deletions
|
|
@ -8,3 +8,12 @@ EMAIL_PORT=
|
|||
EMAIL_SECURE=
|
||||
EMAIL_USER=
|
||||
EMAIL_PASSWORD=
|
||||
|
||||
DEBUG=
|
||||
|
||||
MINIO_BUCKET=
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_PORT=
|
||||
MINIO_USE_SSL=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
49
README.md
49
README.md
|
|
@ -18,11 +18,60 @@ Your identity is `you@yourserver.com`. Your server, your data, your rules.
|
|||
- - [ ] — One server can create posts, have users following each other and dms (unencrypted for now) works.
|
||||
- - [ ] — Two servers can fetch posts, follows and other data from their users, including DMs.
|
||||
- **Phase 2** — Server trust scoring and a public vouch ledger.
|
||||
- - [ ] — Add a "nuke" endpoint where if a federation loses their old keys and cannot rotate them, it'll nuke everything and make the other federations reset that federation score.
|
||||
- **Phase 3** — Opt-in relay network for censorship resistance.
|
||||
- **Phase 4** — End-to-end encryption via TBD.
|
||||
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
<details>
|
||||
<summary><strong>Rotating Federation Keys</strong></summary>
|
||||
|
||||
Federation identity is tied to two keypairs (Ed25519 for signing, X25519 for encryption). The `rotateKeys.ts` script walks through every known federation, proves ownership of both the old and new keys via a challenge-response protocol, and updates `.env.local` when all federations confirm.
|
||||
|
||||
You **need** the old keys in order to run this script, if you lost them, you'll have to use the nuke endpoint. (Yet to be made)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A running database with the server registry populated (at least one peer federation).
|
||||
- `.env.local` with valid `FEDERATION_*` keys and `BETTER_AUTH_URL`.
|
||||
|
||||
### Basic rotation
|
||||
|
||||
```sh
|
||||
bun run rotateKeys.ts
|
||||
```
|
||||
|
||||
The script will:
|
||||
|
||||
1. List all federations in the registry.
|
||||
2. Ask for confirmation before proceeding.
|
||||
3. For each federation: request a challenge, solve it, and confirm.
|
||||
4. On full success: back up `.env.local` and write the new keys.
|
||||
5. On any failure: print a retry command and exit without writing keys.
|
||||
|
||||
### Retrying after partial failure
|
||||
|
||||
If some federations failed while others succeeded, the script prints a ready-to-copy command targeting only the failures:
|
||||
|
||||
```sh
|
||||
bun run rotateKeys.ts --resume '<keys-json>' --only '<failed-urls>'
|
||||
```
|
||||
|
||||
- `--resume <json>` — Reuse the new keys from the previous run instead of generating fresh ones (required because successful federations already registered them).
|
||||
- `--only <urls>` — Comma-separated list of federation URLs to retry. Federations not in this list are skipped.
|
||||
|
||||
You can also retry all federations with just `--resume`:
|
||||
|
||||
```sh
|
||||
bun run rotateKeys.ts --resume '<keys-json>'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Author
|
||||
|
||||
**Marcello Brito** (Tocka) — [tockanest.com](https://tockanest.com)
|
||||
|
|
|
|||
51
bun.lock
51
bun.lock
|
|
@ -20,14 +20,12 @@
|
|||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^12.35.2",
|
||||
"http-signature": "^1.4.0",
|
||||
"ioredis": "^5.10.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"minio": "^8.0.7",
|
||||
"nanostores": "^1.1.1",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-forge": "^1.3.3",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
|
@ -38,6 +36,8 @@
|
|||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -45,8 +45,7 @@
|
|||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/node": "^25.4.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^19.2.14",
|
||||
|
|
@ -56,7 +55,7 @@
|
|||
"cross-env": "^10.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"react-email": "5.2.9",
|
||||
"shadcn": "^4.0.2",
|
||||
"shadcn": "^4.0.5",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
|
@ -630,8 +629,6 @@
|
|||
|
||||
"@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="],
|
||||
|
||||
"@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="],
|
||||
|
||||
"@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="],
|
||||
|
||||
"@types/pg": ["@types/pg@8.18.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q=="],
|
||||
|
|
@ -666,10 +663,6 @@
|
|||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||
|
||||
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
|
||||
|
||||
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
|
@ -688,8 +681,6 @@
|
|||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||
|
||||
"better-auth": ["better-auth@1.5.4", "", { "dependencies": { "@better-auth/core": "1.5.4", "@better-auth/drizzle-adapter": "1.5.4", "@better-auth/kysely-adapter": "1.5.4", "@better-auth/memory-adapter": "1.5.4", "@better-auth/mongo-adapter": "1.5.4", "@better-auth/prisma-adapter": "1.5.4", "@better-auth/telemetry": "1.5.4", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-ReykcEKx6Kp9560jG1wtlDBnftA7L7xb3ZZdDWm5yGXKKe2pUf+oBjH0fqekrkRII0m4XBVQbQ0mOrFv+3FdYg=="],
|
||||
|
||||
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
|
||||
|
|
@ -778,8 +769,6 @@
|
|||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
|
||||
|
|
@ -794,8 +783,6 @@
|
|||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debounce": ["debounce@2.2.0", "", {}, "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw=="],
|
||||
|
|
@ -854,8 +841,6 @@
|
|||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="],
|
||||
|
||||
"eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
|
@ -916,8 +901,6 @@
|
|||
|
||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
|
||||
"extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="],
|
||||
|
||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
|
@ -986,8 +969,6 @@
|
|||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="],
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
|
@ -1018,8 +999,6 @@
|
|||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"http-signature": ["http-signature@1.4.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^2.0.2", "sshpk": "^1.18.0" } }, "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg=="],
|
||||
|
||||
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
|
@ -1088,14 +1067,10 @@
|
|||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
|
@ -1104,8 +1079,6 @@
|
|||
|
||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||
|
||||
"jsprim": ["jsprim@2.0.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="],
|
||||
|
|
@ -1232,8 +1205,6 @@
|
|||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
|
@ -1446,7 +1417,7 @@
|
|||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shadcn": ["shadcn@4.0.3", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-dWN9aw+fszDEMPdRqAVmv5xgrsO60r5XDinHvrCjyfSW/FOiOrJJpYHGDI67+ItIwhTGHK1cXCM5YPAWVOsUtw=="],
|
||||
"shadcn": ["shadcn@4.0.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
|
|
@ -1490,8 +1461,6 @@
|
|||
|
||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
||||
|
||||
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
|
@ -1564,7 +1533,9 @@
|
|||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
"tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="],
|
||||
|
||||
"tweetnacl-util": ["tweetnacl-util@0.15.1", "", {}, "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw=="],
|
||||
|
||||
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
|
||||
|
||||
|
|
@ -1602,8 +1573,6 @@
|
|||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
|
@ -1692,8 +1661,6 @@
|
|||
|
||||
"@types/cors/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
|
||||
|
||||
"@types/node-forge/@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="],
|
||||
|
||||
"@types/nodemailer/@types/node": ["@types/node@20.19.36", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-+3TQ+XhRjbmeKGHMhmUZfKlkF2/mAc+PpO2B90PBI7hRpkgPCSo5PaJ8tfWBJ4LMIuqrnKLD5TveeGMy+curtg=="],
|
||||
|
||||
"@types/pg/@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
|
||||
|
|
@ -1842,8 +1809,6 @@
|
|||
|
||||
"@types/cors/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@types/node-forge/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@types/nodemailer/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
allowedDevOrigins: ["172.21.157.201", "172.21.144.1"]
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -43,14 +43,12 @@
|
|||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^12.35.2",
|
||||
"http-signature": "^1.4.0",
|
||||
"ioredis": "^5.10.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"minio": "^8.0.7",
|
||||
"nanostores": "^1.1.1",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-forge": "^1.3.3",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
|
@ -61,6 +59,8 @@
|
|||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -69,7 +69,6 @@
|
|||
"@types/bun": "^1.3.10",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^25.4.0",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^19.2.14",
|
||||
|
|
@ -79,7 +78,7 @@
|
|||
"cross-env": "^10.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"react-email": "5.2.9",
|
||||
"shadcn": "^4.0.3",
|
||||
"shadcn": "^4.0.5",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
|
|
|||
379
rotateKeys.ts
Normal file
379
rotateKeys.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* This script is used to rotate the keys of the federation.
|
||||
* It will go through all known federations and request the key rotation challenge one by one.
|
||||
* It will then solve the challenges and send the proofs to the federation that we are who we say we are.
|
||||
*
|
||||
* This script is meant to be run manually and should not under any circumstances be run automatically under an endpoint.
|
||||
*
|
||||
* Usage:
|
||||
* bun run rotateKeys.ts — generate fresh keys and rotate all federations
|
||||
* bun run rotateKeys.ts --resume <json> — retry all federations with previously generated keys
|
||||
* bun run rotateKeys.ts --resume <json> --only <urls> — retry only specific federations (comma-separated URLs)
|
||||
*/
|
||||
|
||||
import db from "@/lib/db";
|
||||
import { serverRegistry } from "@/lib/db/schema";
|
||||
import { decryptPayload, EncryptedEnvelope, encryptPayload, signMessage } from "@/lib/federation/keytools";
|
||||
import { config } from "dotenv";
|
||||
import nacl from "tweetnacl";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FedKeys {
|
||||
signingPublicKey: string;
|
||||
signingSecretKey: string;
|
||||
encryptionPublicKey: string;
|
||||
encryptionSecretKey: string;
|
||||
}
|
||||
|
||||
function generateKeypair(): FedKeys {
|
||||
const signing = nacl.sign.keyPair();
|
||||
const encryption = nacl.box.keyPair();
|
||||
return {
|
||||
signingPublicKey: Buffer.from(signing.publicKey).toString("base64"),
|
||||
signingSecretKey: Buffer.from(signing.secretKey).toString("base64"),
|
||||
encryptionPublicKey: Buffer.from(encryption.publicKey).toString("base64"),
|
||||
encryptionSecretKey: Buffer.from(encryption.secretKey).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function printKeys(label: string, keys: FedKeys) {
|
||||
console.log(label);
|
||||
console.log(` FEDERATION_PUBLIC_KEY=${keys.signingPublicKey}`);
|
||||
console.log(` FEDERATION_PRIVATE_KEY=${keys.signingSecretKey}`);
|
||||
console.log(` FEDERATION_ENCRYPTION_PUBLIC_KEY=${keys.encryptionPublicKey}`);
|
||||
console.log(` FEDERATION_ENCRYPTION_PRIVATE_KEY=${keys.encryptionSecretKey}`);
|
||||
}
|
||||
|
||||
async function readErrorBody(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = await response.json();
|
||||
return body?.error ?? body?.message ?? JSON.stringify(body);
|
||||
} catch {
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm(prompt: string): Promise<boolean> {
|
||||
process.stdout.write(`${prompt} [y/N] `);
|
||||
for await (const line of console) {
|
||||
return line.trim().toLowerCase() === "y";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validate environment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REQUIRED_ENV = [
|
||||
"FEDERATION_PUBLIC_KEY",
|
||||
"FEDERATION_PRIVATE_KEY",
|
||||
"FEDERATION_ENCRYPTION_PUBLIC_KEY",
|
||||
"FEDERATION_ENCRYPTION_PRIVATE_KEY",
|
||||
"BETTER_AUTH_URL",
|
||||
] as const;
|
||||
|
||||
const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
|
||||
if (missing.length > 0) {
|
||||
console.error("Missing required environment variables:");
|
||||
missing.forEach((k) => console.error(` - ${k}`));
|
||||
console.error("Ensure .env.local is present and populated.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const oldFedKeys: FedKeys = {
|
||||
signingPublicKey: process.env.FEDERATION_PUBLIC_KEY!,
|
||||
signingSecretKey: process.env.FEDERATION_PRIVATE_KEY!,
|
||||
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!,
|
||||
encryptionSecretKey: process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!,
|
||||
};
|
||||
|
||||
const ORIGIN = process.env.BETTER_AUTH_URL!;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse --resume flag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let newFedKeys: FedKeys;
|
||||
|
||||
const resumeIdx = process.argv.indexOf("--resume");
|
||||
if (resumeIdx !== -1) {
|
||||
const raw = process.argv[resumeIdx + 1];
|
||||
if (!raw) {
|
||||
console.error("--resume requires a JSON string argument containing the new keys.");
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
!parsed.signingPublicKey ||
|
||||
!parsed.signingSecretKey ||
|
||||
!parsed.encryptionPublicKey ||
|
||||
!parsed.encryptionSecretKey
|
||||
) {
|
||||
throw new Error("Missing key fields");
|
||||
}
|
||||
newFedKeys = parsed as FedKeys;
|
||||
console.log("Resuming rotation with previously generated keys.");
|
||||
} catch (err) {
|
||||
console.error("Failed to parse --resume keys:", (err as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
newFedKeys = generateKeypair();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse --only filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const onlyIdx = process.argv.indexOf("--only");
|
||||
let onlyUrls: Set<string> | null = null;
|
||||
if (onlyIdx !== -1) {
|
||||
const raw = process.argv[onlyIdx + 1];
|
||||
if (!raw) {
|
||||
console.error("--only requires a comma-separated list of federation URLs.");
|
||||
process.exit(1);
|
||||
}
|
||||
onlyUrls = new Set(raw.split(",").map((u) => u.trim()).filter(Boolean));
|
||||
if (onlyUrls.size === 0) {
|
||||
console.error("--only list is empty.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch federations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const allFederations = await db.select().from(serverRegistry);
|
||||
|
||||
if (allFederations.length === 0) {
|
||||
console.log("No federations found in the registry. Nothing to rotate.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const federations = onlyUrls
|
||||
? allFederations.filter((f) => onlyUrls!.has(f.url))
|
||||
: allFederations;
|
||||
|
||||
if (federations.length === 0) {
|
||||
console.error("None of the --only URLs matched federations in the registry:");
|
||||
onlyUrls!.forEach((u) => console.error(` - ${u}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (onlyUrls) {
|
||||
const unmatched = [...onlyUrls].filter((u) => !federations.some((f) => f.url === u));
|
||||
if (unmatched.length > 0) {
|
||||
console.warn("Warning: these --only URLs were not found in the registry and will be skipped:");
|
||||
unmatched.forEach((u) => console.warn(` - ${u}`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Targeting ${federations.length} federation(s) for key rotation:`);
|
||||
federations.forEach((f) => console.log(` - ${f.url}`));
|
||||
|
||||
if (!await confirm("\nProceed with key rotation?")) {
|
||||
console.log("Aborted.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Solve init challenges
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InitChallenges {
|
||||
signingOldChallenge: string;
|
||||
signingNewChallenge: string;
|
||||
encryptionOldChallenge: EncryptedEnvelope;
|
||||
encryptionNewChallenge: EncryptedEnvelope;
|
||||
}
|
||||
|
||||
function solveInitChallenges(challenges: InitChallenges, oldKeys: FedKeys, newKeys: FedKeys) {
|
||||
const oldSigningSecret = new Uint8Array(Buffer.from(oldKeys.signingSecretKey, "base64"));
|
||||
const newSigningSecret = new Uint8Array(Buffer.from(newKeys.signingSecretKey, "base64"));
|
||||
const oldEncSecret = new Uint8Array(Buffer.from(oldKeys.encryptionSecretKey, "base64"));
|
||||
const newEncSecret = new Uint8Array(Buffer.from(newKeys.encryptionSecretKey, "base64"));
|
||||
|
||||
return {
|
||||
signingOldSignature: signMessage(challenges.signingOldChallenge, oldSigningSecret),
|
||||
signingNewSignature: signMessage(challenges.signingNewChallenge, newSigningSecret),
|
||||
encryptionOldPlaintext: decryptPayload(challenges.encryptionOldChallenge, oldEncSecret),
|
||||
encryptionNewPlaintext: decryptPayload(challenges.encryptionNewChallenge, newEncSecret),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rotate each federation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const transactions: Array<{
|
||||
url: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> = [];
|
||||
|
||||
for (const federation of federations) {
|
||||
const tag = federation.url;
|
||||
console.log(`\n[${tag}] Requesting rotation challenge...`);
|
||||
|
||||
try {
|
||||
// Step 1 — Init challenge
|
||||
const initResponse = await fetch(`${federation.url}/discover/rotate/init`, {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Origin": ORIGIN,
|
||||
"x-federation-origin": ORIGIN,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: ORIGIN,
|
||||
newSigningPublicKey: newFedKeys.signingPublicKey,
|
||||
newEncryptionPublicKey: newFedKeys.encryptionPublicKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
const detail = await readErrorBody(initResponse);
|
||||
console.error(`[${tag}] Init failed (${initResponse.status}): ${detail}`);
|
||||
transactions.push({ url: tag, success: false, message: detail });
|
||||
continue;
|
||||
}
|
||||
|
||||
const challenges: InitChallenges = await initResponse.json();
|
||||
|
||||
// Step 2 — Fetch the federation's public encryption key
|
||||
const discoverResponse = await fetch(`${federation.url}/discover`, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Origin": ORIGIN,
|
||||
"x-federation-origin": ORIGIN,
|
||||
},
|
||||
});
|
||||
|
||||
if (!discoverResponse.ok) {
|
||||
const detail = await readErrorBody(discoverResponse);
|
||||
console.error(`[${tag}] Discover failed (${discoverResponse.status}): ${detail}`);
|
||||
transactions.push({ url: tag, success: false, message: detail });
|
||||
continue;
|
||||
}
|
||||
|
||||
const discoverData: {
|
||||
url: string;
|
||||
publicKey: string;
|
||||
encryptionPublicKey: string;
|
||||
} = await discoverResponse.json();
|
||||
|
||||
// Step 3 — Solve challenges
|
||||
const proofs = solveInitChallenges(challenges, oldFedKeys, newFedKeys);
|
||||
|
||||
// Step 4 — Encrypt proofs with the federation's encryption public key
|
||||
const encPubKey = new Uint8Array(Buffer.from(discoverData.encryptionPublicKey, "base64"));
|
||||
const encryptedProofs = encryptPayload(JSON.stringify(proofs), encPubKey);
|
||||
|
||||
// Step 5 — Confirm
|
||||
const confirmResponse = await fetch(`${federation.url}/discover/rotate/confirm`, {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Origin": ORIGIN,
|
||||
"x-federation-origin": ORIGIN,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
serverUrl: ORIGIN,
|
||||
envelope: encryptedProofs,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmResponse.ok) {
|
||||
const detail = await readErrorBody(confirmResponse);
|
||||
console.error(`[${tag}] Confirm failed (${confirmResponse.status}): ${detail}`);
|
||||
transactions.push({ url: tag, success: false, message: detail });
|
||||
continue;
|
||||
}
|
||||
|
||||
const confirmData = await confirmResponse.json();
|
||||
console.log(`[${tag}] ${confirmData.message}`);
|
||||
transactions.push({ url: tag, success: true, message: confirmData.message });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[${tag}] Unexpected error: ${message}`);
|
||||
transactions.push({ url: tag, success: false, message });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const successes = transactions.filter((t) => t.success);
|
||||
const failures = transactions.filter((t) => !t.success);
|
||||
|
||||
console.log("\n================================");
|
||||
console.log(`Results: ${successes.length} succeeded, ${failures.length} failed out of ${transactions.length}`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("\nFailed federations:");
|
||||
failures.forEach((f) => console.error(` - ${f.url}: ${f.message}`));
|
||||
|
||||
const resumePayload = JSON.stringify(newFedKeys);
|
||||
const failedUrls = failures.map((f) => f.url).join(",");
|
||||
|
||||
if (successes.length > 0) {
|
||||
console.error("\nKeys NOT written to .env.local (some federations succeeded, some failed).");
|
||||
console.error("Retry ONLY the failed federations with:\n");
|
||||
console.log(` bun run rotateKeys.ts --resume '${resumePayload}' --only '${failedUrls}'\n`);
|
||||
} else {
|
||||
console.error("\nKeys NOT written to .env.local. Retry with:\n");
|
||||
console.log(` bun run rotateKeys.ts --resume '${resumePayload}'\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write new keys to .env.local (with backup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const envPath = ".env.local";
|
||||
const envContent = await Bun.file(envPath).text();
|
||||
|
||||
const backupPath = `.env.local.bak.${Date.now()}`;
|
||||
await Bun.write(backupPath, envContent);
|
||||
console.log(`\nBacked up .env.local → ${backupPath}`);
|
||||
|
||||
const envReplacements: [RegExp, string][] = [
|
||||
[/FEDERATION_PUBLIC_KEY=.*/, `FEDERATION_PUBLIC_KEY="${newFedKeys.signingPublicKey}"`],
|
||||
[/FEDERATION_PRIVATE_KEY=.*/, `FEDERATION_PRIVATE_KEY="${newFedKeys.signingSecretKey}"`],
|
||||
[/FEDERATION_ENCRYPTION_PUBLIC_KEY=.*/, `FEDERATION_ENCRYPTION_PUBLIC_KEY="${newFedKeys.encryptionPublicKey}"`],
|
||||
[/FEDERATION_ENCRYPTION_PRIVATE_KEY=.*/, `FEDERATION_ENCRYPTION_PRIVATE_KEY="${newFedKeys.encryptionSecretKey}"`],
|
||||
];
|
||||
|
||||
let updatedEnv = envContent;
|
||||
for (const [pattern, replacement] of envReplacements) {
|
||||
if (!pattern.test(updatedEnv)) {
|
||||
console.error(`Warning: ${pattern.source.split("=")[0]} not found in .env.local — appending.`);
|
||||
updatedEnv += `\n${replacement}`;
|
||||
} else {
|
||||
updatedEnv = updatedEnv.replace(pattern, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
await Bun.write(envPath, updatedEnv);
|
||||
|
||||
console.log("New keys written to .env.local successfully.");
|
||||
printKeys("\nOld keys (displayed once, not stored anywhere):", oldFedKeys);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import db from "@/lib/db";
|
||||
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
|
||||
import { decryptPayload } from "@/lib/federation/keytools";
|
||||
import { decryptPayload, verifySignature } from "@/lib/federation/keytools";
|
||||
import createDebug from "debug";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
|
@ -14,29 +14,27 @@ const debug = createDebug("app:discover:rotate:confirm");
|
|||
* Terminology: SA = this server (Server A), SB = the server rotating its keys (Server B).
|
||||
*
|
||||
* Full rotation flow:
|
||||
* 1. SB generates a new keypair. It keeps the old private key accessible until rotation is complete.
|
||||
* 2. SB sends { url, newPublicKey } to SA's /discover/rotate/init.
|
||||
* 3. SA issues two independent challenges and returns them to SB:
|
||||
* - oldKeyChallenge: a random token encrypted with SB's CURRENT (old) public key.
|
||||
* - newKeyChallenge: a random token encrypted with SB's NEW public key.
|
||||
* 4. SB decrypts both challenges using the respective private keys:
|
||||
* - oldKeyChallenge → decrypted with old private key → oldPlaintext
|
||||
* - newKeyChallenge → decrypted with new private key → newPlaintext
|
||||
* 5. SB fetches SA's public key from /discover, then re-encrypts both plaintexts with it:
|
||||
* - signedOldChallenge = encrypt(oldPlaintext, SA.publicKey)
|
||||
* - signedNewChallenge = encrypt(newPlaintext, SA.publicKey)
|
||||
* 6. SB sends { serverUrl, signedOldChallenge, signedNewChallenge } to this route.
|
||||
* 7. SA decrypts both with its own private key and compares to the stored tokens.
|
||||
* - If either mismatches: decrement attemptsLeft; blacklist server at 0 attempts.
|
||||
* - If both match: update serverRegistry with newPublicKey and delete the challenge.
|
||||
* 1. SB generates new Ed25519 + X25519 keypairs.
|
||||
* 2. SB sends { url, newSigningPublicKey, newEncryptionPublicKey } to SA's /discover/rotate/init.
|
||||
* 3. SA issues 4 challenges:
|
||||
* - signingOldChallenge: plaintext nonce (SB signs with old Ed25519 key)
|
||||
* - signingNewChallenge: plaintext nonce (SB signs with new Ed25519 key)
|
||||
* - encryptionOldChallenge: nonce encrypted with SB's current X25519 key
|
||||
* - encryptionNewChallenge: nonce encrypted with SB's new X25519 key
|
||||
* 4. SB solves all 4 challenges:
|
||||
* - Signs the signing challenges with respective Ed25519 keys
|
||||
* - Decrypts the encryption challenges with respective X25519 keys
|
||||
* 5. SB fetches SA's /discover to get SA's X25519 public key, then encrypts
|
||||
* all 4 proof values into a single EncryptedEnvelope using SA's X25519 key.
|
||||
* 6. SA decrypts the envelope and verifies all 4 proofs.
|
||||
*
|
||||
* What each check proves:
|
||||
* - signedOldChallenge match → SB holds the old private key (identity proof: "they are who they say they are")
|
||||
* - signedNewChallenge match → SB holds the new private key (ownership proof: "they own the key they want to rotate to")
|
||||
* - re-encryption with SA's public key → SB fetched SA's identity from /discover
|
||||
*
|
||||
* TODO: on success, announce the completed rotation to other known federation peers
|
||||
* so they can treat SA as a trusted proxy for confirming SB's new key. (Other federation servers could ignore this information and force the challenge to be completed for themselves.)
|
||||
* - signingOldSignature: SB holds the old Ed25519 private key (identity proof)
|
||||
* - signingNewSignature: SB holds the new Ed25519 private key (ownership proof)
|
||||
* - encryptionOldPlaintext: SB holds the old X25519 private key (encryption identity proof)
|
||||
* - encryptionNewPlaintext: SB holds the new X25519 private key (encryption ownership proof)
|
||||
* - Envelope encrypted with SA's X25519 key: SB fetched SA's /discover (identity binding)
|
||||
* - Discover being fetched: SB fetched SA's /discover endpoint (liveliness proof) <- Not accounted for but it is a proof that the other federation is alive and responsive.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
|
@ -44,12 +42,12 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const validated = z.object({
|
||||
serverUrl: z.url(),
|
||||
// SA decrypted oldKeyChallenge with their OLD private key,
|
||||
// then re-encrypted the plaintext with OUR public key.
|
||||
signedOldChallenge: z.string(),
|
||||
// SA decrypted newKeyChallenge with their NEW private key,
|
||||
// then re-encrypted the plaintext with OUR public key.
|
||||
signedNewChallenge: z.string(),
|
||||
envelope: z.object({
|
||||
ephemeralPublicKey: z.string(),
|
||||
iv: z.string(),
|
||||
ciphertext: z.string(),
|
||||
authTag: z.string(),
|
||||
}),
|
||||
}).safeParse(body);
|
||||
|
||||
if (!validated.success) {
|
||||
|
|
@ -57,9 +55,15 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({ error: validated.error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const [blacklisted] = await db.select().from(blacklistedServers)
|
||||
.where(eq(blacklistedServers.serverUrl, validated.data.serverUrl));
|
||||
if (blacklisted) {
|
||||
debug("POST /discover/rotate/confirm – server %s is blacklisted", validated.data.serverUrl);
|
||||
return NextResponse.json({ error: "Your server has been blacklisted. Please contact support to unblacklist your server." }, { status: 403 });
|
||||
}
|
||||
|
||||
debug("POST /discover/rotate/confirm – fetching pending challenge for %s", validated.data.serverUrl);
|
||||
|
||||
// transaction to ensure that the challenge is deleted and the server registry is updated atomically and that there's no race condition.
|
||||
return await db.transaction(async (tx) => {
|
||||
const [challenge] = await tx.select().from(rotateChallengeTokens)
|
||||
.where(eq(rotateChallengeTokens.serverUrl, validated.data.serverUrl))
|
||||
|
|
@ -88,38 +92,75 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({ error: "Your server has been blacklisted. Please contact support to unblacklist your server." }, { status: 403 });
|
||||
}
|
||||
|
||||
debug("POST /discover/rotate/confirm – %d attempt(s) left, decrypting challenges", challenge.attemptsLeft);
|
||||
let decryptedOld: string;
|
||||
let decryptedNew: string;
|
||||
debug("POST /discover/rotate/confirm – %d attempt(s) left, decrypting envelope", challenge.attemptsLeft);
|
||||
|
||||
const ownEncryptionSecretKey = new Uint8Array(
|
||||
Buffer.from(process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!, "base64"),
|
||||
);
|
||||
|
||||
let proofs: {
|
||||
signingOldSignature: string;
|
||||
signingNewSignature: string;
|
||||
encryptionOldPlaintext: string;
|
||||
encryptionNewPlaintext: string;
|
||||
};
|
||||
try {
|
||||
decryptedOld = decryptPayload(validated.data.signedOldChallenge, process.env.FEDERATION_PRIVATE_KEY!);
|
||||
decryptedNew = decryptPayload(validated.data.signedNewChallenge, process.env.FEDERATION_PRIVATE_KEY!);
|
||||
const decrypted = decryptPayload(validated.data.envelope, ownEncryptionSecretKey);
|
||||
proofs = JSON.parse(decrypted);
|
||||
} catch {
|
||||
debug("POST /discover/rotate/confirm – decryption failed, decrementing attempts");
|
||||
debug("POST /discover/rotate/confirm – envelope decryption failed, decrementing attempts");
|
||||
await tx.update(rotateChallengeTokens).set({
|
||||
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
|
||||
}).where(eq(rotateChallengeTokens.id, challenge.id))
|
||||
}).where(eq(rotateChallengeTokens.id, challenge.id));
|
||||
return NextResponse.json({
|
||||
error: `Failed to decrypt one or both challenges. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
|
||||
error: `Failed to decrypt envelope. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
if (decryptedOld !== challenge.oldKeyToken || decryptedNew !== challenge.newKeyToken) {
|
||||
debug("POST /discover/rotate/confirm – token mismatch (old=%s, new=%s), decrementing attempts",
|
||||
decryptedOld === challenge.oldKeyToken ? "ok" : "MISMATCH",
|
||||
decryptedNew === challenge.newKeyToken ? "ok" : "MISMATCH",
|
||||
const [server] = await tx.select().from(serverRegistry)
|
||||
.where(eq(serverRegistry.url, challenge.serverUrl));
|
||||
|
||||
if (!server) {
|
||||
debug("POST /discover/rotate/confirm – server not found in registry");
|
||||
return NextResponse.json({ error: "Server not found in registry." }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentSigningPub = new Uint8Array(Buffer.from(server.publicKey, "base64"));
|
||||
const newSigningPub = new Uint8Array(Buffer.from(challenge.newSigningPublicKey, "base64"));
|
||||
|
||||
const signingOldValid = verifySignature(
|
||||
challenge.signingOldToken,
|
||||
proofs.signingOldSignature,
|
||||
currentSigningPub,
|
||||
);
|
||||
const signingNewValid = verifySignature(
|
||||
challenge.signingNewToken,
|
||||
proofs.signingNewSignature,
|
||||
newSigningPub,
|
||||
);
|
||||
const encOldValid = proofs.encryptionOldPlaintext === challenge.encryptionOldToken;
|
||||
const encNewValid = proofs.encryptionNewPlaintext === challenge.encryptionNewToken;
|
||||
|
||||
if (!signingOldValid || !signingNewValid || !encOldValid || !encNewValid) {
|
||||
debug(
|
||||
"POST /discover/rotate/confirm – proof mismatch (sigOld=%s, sigNew=%s, encOld=%s, encNew=%s), decrementing",
|
||||
signingOldValid ? "ok" : "FAIL",
|
||||
signingNewValid ? "ok" : "FAIL",
|
||||
encOldValid ? "ok" : "FAIL",
|
||||
encNewValid ? "ok" : "FAIL",
|
||||
);
|
||||
await tx.update(rotateChallengeTokens).set({
|
||||
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
|
||||
}).where(eq(rotateChallengeTokens.id, challenge.id));
|
||||
return NextResponse.json({
|
||||
error: `Challenge mismatch. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
|
||||
error: `Challenge verification failed. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
debug("POST /discover/rotate/confirm – both challenges passed, rotating key for %s", challenge.serverUrl);
|
||||
debug("POST /discover/rotate/confirm – all 4 proofs passed, rotating keys for %s", challenge.serverUrl);
|
||||
await tx.update(serverRegistry).set({
|
||||
publicKey: challenge.newPublicKey,
|
||||
publicKey: challenge.newSigningPublicKey,
|
||||
encryptionPublicKey: challenge.newEncryptionPublicKey,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(serverRegistry.url, challenge.serverUrl));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +1,47 @@
|
|||
import db from "@/lib/db";
|
||||
import { rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
|
||||
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
|
||||
import { encryptPayload } from "@/lib/federation/keytools";
|
||||
import createDebug from "debug";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import forge from "node-forge";
|
||||
import { z } from "zod";
|
||||
|
||||
const debug = createDebug("app:discover:rotate:init");
|
||||
|
||||
const publicKeySchema = z.string().refine((key) => {
|
||||
const ED25519_PUBLIC_KEY_BYTES = 32;
|
||||
const X25519_PUBLIC_KEY_BYTES = 32;
|
||||
|
||||
function isValidBase64Key(key: string, expectedBytes: number): boolean {
|
||||
try {
|
||||
const pub = forge.pki.publicKeyFromPem(key);
|
||||
return pub.n.bitLength() >= 4096;
|
||||
const decoded = Buffer.from(key, "base64");
|
||||
return decoded.length === expectedBytes;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { message: "Invalid public key" });
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
url: z.url(),
|
||||
newPublicKey: publicKeySchema,
|
||||
newSigningPublicKey: z.string().refine(
|
||||
(key) => isValidBase64Key(key, ED25519_PUBLIC_KEY_BYTES),
|
||||
{ message: "Invalid Ed25519 signing public key" },
|
||||
),
|
||||
newEncryptionPublicKey: z.string().refine(
|
||||
(key) => isValidBase64Key(key, X25519_PUBLIC_KEY_BYTES),
|
||||
{ message: "Invalid X25519 encryption public key" },
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes a key rotation challenge for a server.
|
||||
*
|
||||
* This route is used to initiate the key rotation process. It will issue two independent challenges:
|
||||
* - oldKeyChallenge: a random token encrypted with the server's current public key.
|
||||
* - newKeyChallenge: a random token encrypted with the server's new public key.
|
||||
* Issues 4 independent challenges:
|
||||
* - signingOldChallenge: plaintext nonce (SB signs with old Ed25519 key)
|
||||
* - signingNewChallenge: plaintext nonce (SB signs with new Ed25519 key)
|
||||
* - encryptionOldChallenge: nonce encrypted with SB's current X25519 key (SB decrypts)
|
||||
* - encryptionNewChallenge: nonce encrypted with SB's new X25519 key (SB decrypts)
|
||||
*
|
||||
* The challenges are stored in the database and will expire in 5 minutes.
|
||||
*
|
||||
* The challenges are returned to the client and must be decrypted using the respective private keys.
|
||||
*
|
||||
* The client must then send the challenges to the server's /discover/rotate/confirm route to confirm the key rotation.
|
||||
*
|
||||
* The server will not send his current public key to the client, the client must fetch it from the server's /discover route as a part of the challenge validation.
|
||||
* Challenges expire in 5 minutes. SB confirms via /discover/rotate/confirm.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
|
@ -48,6 +53,13 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({ error: validated.error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const [blacklisted] = await db.select().from(blacklistedServers)
|
||||
.where(eq(blacklistedServers.serverUrl, validated.data.url.toString()));
|
||||
if (blacklisted) {
|
||||
debug("POST /discover/rotate/init – server %s is blacklisted", validated.data.url);
|
||||
return NextResponse.json({ error: "Your server has been blacklisted." }, { status: 403 });
|
||||
}
|
||||
|
||||
debug("POST /discover/rotate/init – looking up server %s", validated.data.url);
|
||||
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.url, validated.data.url.toString()));
|
||||
if (server.length === 0) {
|
||||
|
|
@ -55,13 +67,14 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({ error: "Server not found, please register your server first." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (server[0].publicKey === validated.data.newPublicKey) {
|
||||
debug("POST /discover/rotate/init – new key is identical to current key, rejecting");
|
||||
return NextResponse.json({ error: "Your server is already registered with this public key." }, { status: 400 });
|
||||
if (
|
||||
server[0].publicKey === validated.data.newSigningPublicKey &&
|
||||
server[0].encryptionPublicKey === validated.data.newEncryptionPublicKey
|
||||
) {
|
||||
debug("POST /discover/rotate/init – keys are identical to current keys, rejecting");
|
||||
return NextResponse.json({ error: "Your server is already registered with these keys." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check for existing pending challenges, only one active challenge per server is allowed.
|
||||
// This got removed by accident on a previous commit.
|
||||
const [existing] = await db.select().from(rotateChallengeTokens)
|
||||
.where(eq(rotateChallengeTokens.serverUrl, validated.data.url.toString()));
|
||||
|
||||
|
|
@ -77,37 +90,39 @@ export async function POST(request: NextRequest) {
|
|||
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, existing.id));
|
||||
}
|
||||
|
||||
// Issue two independent challenges:
|
||||
//
|
||||
// oldKeyChallenge — encrypted with the SA's CURRENT registered public key.
|
||||
// Only the holder of the current private key can decrypt this.
|
||||
// This is the identity proof: it shows the requester really is the
|
||||
// registered server and not someone who merely knows its URL.
|
||||
//
|
||||
// newKeyChallenge — encrypted with the submitted new public key.
|
||||
// Only the holder of the new private key can decrypt this.
|
||||
// This proves the SA actually owns the key they want to rotate to.
|
||||
//
|
||||
// Both plaintexts are stored. On confirm the SA must re-encrypt both
|
||||
// with OUR public key so we can decrypt and compare — proving they
|
||||
// fetched our identity as well.
|
||||
const oldKeyPlaintext = crypto.randomUUID();
|
||||
const newKeyPlaintext = crypto.randomUUID();
|
||||
const signingOldPlaintext = crypto.randomUUID();
|
||||
const signingNewPlaintext = crypto.randomUUID();
|
||||
const encryptionOldPlaintext = crypto.randomUUID();
|
||||
const encryptionNewPlaintext = crypto.randomUUID();
|
||||
|
||||
debug("POST /discover/rotate/init – issuing challenges for server %s", validated.data.url);
|
||||
const oldKeyChallenge = encryptPayload(oldKeyPlaintext, server[0].publicKey);
|
||||
const newKeyChallenge = encryptPayload(newKeyPlaintext, validated.data.newPublicKey);
|
||||
debug("POST /discover/rotate/init – issuing 4 challenges for server %s", validated.data.url);
|
||||
|
||||
const currentEncPubKey = new Uint8Array(Buffer.from(server[0].encryptionPublicKey, "base64"));
|
||||
const newEncPubKey = new Uint8Array(Buffer.from(validated.data.newEncryptionPublicKey, "base64"));
|
||||
|
||||
const encryptionOldChallenge = encryptPayload(encryptionOldPlaintext, currentEncPubKey);
|
||||
const encryptionNewChallenge = encryptPayload(encryptionNewPlaintext, newEncPubKey);
|
||||
|
||||
await db.insert(rotateChallengeTokens).values({
|
||||
id: crypto.randomUUID(),
|
||||
oldKeyToken: oldKeyPlaintext,
|
||||
newKeyToken: newKeyPlaintext,
|
||||
newPublicKey: validated.data.newPublicKey,
|
||||
signingOldToken: signingOldPlaintext,
|
||||
signingNewToken: signingNewPlaintext,
|
||||
encryptionOldToken: encryptionOldPlaintext,
|
||||
encryptionNewToken: encryptionNewPlaintext,
|
||||
newSigningPublicKey: validated.data.newSigningPublicKey,
|
||||
newEncryptionPublicKey: validated.data.newEncryptionPublicKey,
|
||||
serverUrl: validated.data.url.toString(),
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 5),
|
||||
});
|
||||
|
||||
debug("POST /discover/rotate/init – challenges issued, expires in 5 minutes");
|
||||
return NextResponse.json({ oldKeyChallenge, newKeyChallenge });
|
||||
const response = {
|
||||
signingOldChallenge: signingOldPlaintext,
|
||||
signingNewChallenge: signingNewPlaintext,
|
||||
encryptionOldChallenge,
|
||||
encryptionNewChallenge,
|
||||
}
|
||||
debug("POST /discover/rotate/init – response: %o", response);
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,75 @@
|
|||
import db from "@/lib/db";
|
||||
import { serverRegistry } from "@/lib/db/schema";
|
||||
import { decryptPayload } from "@/lib/federation/keytools";
|
||||
import { decryptPayload, fingerprintKey } from "@/lib/federation/keytools";
|
||||
import { assertSafeUrl, UrlGuardError } from "@/lib/federation/url-guard";
|
||||
import createDebug from "debug";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import forge from "node-forge";
|
||||
import { z } from "zod";
|
||||
|
||||
const debug = createDebug("app:discover");
|
||||
|
||||
const ED25519_PUBLIC_KEY_BYTES = 32;
|
||||
const X25519_PUBLIC_KEY_BYTES = 32;
|
||||
|
||||
function isValidBase64Key(key: string, expectedBytes: number): boolean {
|
||||
try {
|
||||
const decoded = Buffer.from(key, "base64");
|
||||
return decoded.length === expectedBytes;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const signingKeySchema = z.string().refine(
|
||||
(key) => isValidBase64Key(key, ED25519_PUBLIC_KEY_BYTES),
|
||||
{ message: `Signing public key must be a base64-encoded Ed25519 key (${ED25519_PUBLIC_KEY_BYTES} bytes)` },
|
||||
);
|
||||
|
||||
const encryptionKeySchema = z.string().refine(
|
||||
(key) => isValidBase64Key(key, X25519_PUBLIC_KEY_BYTES),
|
||||
{ message: `Encryption public key must be a base64-encoded X25519 key (${X25519_PUBLIC_KEY_BYTES} bytes)` },
|
||||
);
|
||||
|
||||
function getOwnEncryptionSecretKey(): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!, "base64"));
|
||||
}
|
||||
|
||||
const discoverSchema = z.object({
|
||||
method: z.literal("DISCOVER"),
|
||||
publicKey: signingKeySchema,
|
||||
encryptionPublicKey: encryptionKeySchema,
|
||||
envelope: z.object({
|
||||
ephemeralPublicKey: z.string(),
|
||||
iv: z.string(),
|
||||
ciphertext: z.string(),
|
||||
authTag: z.string(),
|
||||
}),
|
||||
}).superRefine((data, ctx) => {
|
||||
try {
|
||||
const decrypted = decryptPayload(data.envelope, getOwnEncryptionSecretKey());
|
||||
const parsed = JSON.parse(decrypted);
|
||||
if (parsed.publicKeyFingerprint !== fingerprintKey(data.publicKey)) {
|
||||
ctx.addIssue({ code: "custom", message: "Envelope does not match the provided signing public key" });
|
||||
}
|
||||
if (parsed.encryptionPublicKeyFingerprint !== fingerprintKey(data.encryptionPublicKey)) {
|
||||
ctx.addIssue({ code: "custom", message: "Envelope does not match the provided encryption public key" });
|
||||
}
|
||||
if (!parsed.url) {
|
||||
ctx.addIssue({ code: "custom", message: "Envelope is missing the url field" });
|
||||
}
|
||||
} catch {
|
||||
ctx.addIssue({ code: "custom", message: "Invalid envelope" });
|
||||
}
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
method: z.literal("REGISTER"),
|
||||
url: z.url(),
|
||||
publicKey: signingKeySchema,
|
||||
encryptionPublicKey: encryptionKeySchema,
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
debug("GET /discover – fetching healthy peers");
|
||||
const peers = await db.select({
|
||||
|
|
@ -19,17 +79,19 @@ export async function GET() {
|
|||
debug("GET /discover – found %d peer(s)", peers.length);
|
||||
|
||||
return NextResponse.json({
|
||||
url: process.env.BETTER_AUTH_URL,
|
||||
url: process.env.BETTER_AUTH_URL!,
|
||||
publicKey: process.env.FEDERATION_PUBLIC_KEY,
|
||||
peers
|
||||
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY,
|
||||
peers,
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertServer(url: string, publicKey: string) {
|
||||
async function upsertServer(url: string, publicKey: string, encryptionPublicKey: string) {
|
||||
return await db.insert(serverRegistry).values({
|
||||
id: crypto.randomUUID(),
|
||||
url: url,
|
||||
publicKey: publicKey,
|
||||
url,
|
||||
publicKey,
|
||||
encryptionPublicKey,
|
||||
lastSeen: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
|
@ -37,56 +99,6 @@ async function upsertServer(url: string, publicKey: string) {
|
|||
}).onConflictDoNothing();
|
||||
}
|
||||
|
||||
const publicKeySchema = z.string().superRefine((key, ctx) => {
|
||||
let pub: forge.pki.rsa.PublicKey;
|
||||
try {
|
||||
pub = forge.pki.publicKeyFromPem(key) as forge.pki.rsa.PublicKey;
|
||||
} catch {
|
||||
ctx.addIssue({ code: "custom", message: "Public key is not a valid PEM-encoded RSA key", input: key });
|
||||
return;
|
||||
}
|
||||
if (!pub.n) {
|
||||
ctx.addIssue({ code: "custom", message: "Public key is not an RSA key", input: key });
|
||||
return;
|
||||
}
|
||||
if (pub.n.bitLength() < 2048) {
|
||||
ctx.addIssue({ code: "custom", message: `RSA key must be at least 2048 bits (got ${pub.n.bitLength()})`, input: key });
|
||||
}
|
||||
});
|
||||
|
||||
function fingerprintKey(pem: string): string {
|
||||
const md = forge.md.sha256.create();
|
||||
md.update(pem, "utf8");
|
||||
return md.digest().toHex();
|
||||
}
|
||||
|
||||
const discoverSchema = z.object({
|
||||
method: z.literal("DISCOVER"),
|
||||
publicKey: publicKeySchema,
|
||||
signature: z.string(),
|
||||
}).superRefine((data, ctx) => {
|
||||
try {
|
||||
const decrypted = decryptPayload(data.signature, process.env.FEDERATION_PRIVATE_KEY!);
|
||||
const parsed = JSON.parse(decrypted);
|
||||
// The signature contains a SHA-256 fingerprint of the public key
|
||||
// (since the full PEM exceeds RSA-OAEP's size limit) plus a url.
|
||||
if (parsed.publicKeyFingerprint !== fingerprintKey(data.publicKey)) {
|
||||
ctx.addIssue({ code: "custom", message: "Signature does not match the provided public key" });
|
||||
}
|
||||
if (!parsed.url) {
|
||||
ctx.addIssue({ code: "custom", message: "Signature is missing the url field" });
|
||||
}
|
||||
} catch {
|
||||
ctx.addIssue({ code: "custom", message: "Invalid signature" });
|
||||
}
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
method: z.literal("REGISTER"),
|
||||
url: z.url(),
|
||||
publicKey: publicKeySchema,
|
||||
});
|
||||
|
||||
async function discoverServer(validated: z.infer<typeof discoverSchema>) {
|
||||
debug("DISCOVER – looking up server by public key");
|
||||
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.publicKey, validated.publicKey));
|
||||
|
|
@ -96,7 +108,9 @@ async function discoverServer(validated: z.infer<typeof discoverSchema>) {
|
|||
}
|
||||
|
||||
try {
|
||||
assertSafeUrl(server[0].url);
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
assertSafeUrl(server[0].url);
|
||||
}
|
||||
} catch (err) {
|
||||
debug("DISCOVER – stored URL failed SSRF check: %s", server[0].url);
|
||||
if (err instanceof UrlGuardError) {
|
||||
|
|
@ -126,7 +140,9 @@ async function discoverServer(validated: z.infer<typeof discoverSchema>) {
|
|||
|
||||
async function registerServer(validated: z.infer<typeof registerSchema>) {
|
||||
try {
|
||||
assertSafeUrl(validated.url);
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
assertSafeUrl(validated.url);
|
||||
}
|
||||
} catch (err) {
|
||||
debug("REGISTER – URL failed SSRF check: %s", validated.url);
|
||||
if (err instanceof UrlGuardError) {
|
||||
|
|
@ -136,7 +152,7 @@ async function registerServer(validated: z.infer<typeof registerSchema>) {
|
|||
}
|
||||
|
||||
debug("REGISTER – fetching /discover from %s to validate server", validated.url);
|
||||
let response: { publicKey?: string };
|
||||
let response: { publicKey?: string; encryptionPublicKey?: string };
|
||||
try {
|
||||
response = await (await fetch(validated.url + "/discover")).json();
|
||||
} catch (err) {
|
||||
|
|
@ -144,31 +160,30 @@ async function registerServer(validated: z.infer<typeof registerSchema>) {
|
|||
return NextResponse.json({ error: "Failed to reach the server" }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!response.publicKey) {
|
||||
debug("REGISTER – remote server returned no public key");
|
||||
if (!response.publicKey || !response.encryptionPublicKey) {
|
||||
debug("REGISTER – remote server returned incomplete keys");
|
||||
return NextResponse.json({ error: "Invalid server" }, { status: 400 });
|
||||
} else if (response.publicKey !== validated.publicKey) {
|
||||
debug("REGISTER – public key mismatch: provided vs fetched");
|
||||
debug("REGISTER – provided public key: %s", validated.publicKey);
|
||||
debug("REGISTER – fetched public key: %s", response.publicKey);
|
||||
return NextResponse.json({ error: "Invalid public key" }, { status: 400 });
|
||||
} else if (response.publicKey !== validated.publicKey || response.encryptionPublicKey !== validated.encryptionPublicKey) {
|
||||
debug("REGISTER – key mismatch: provided vs fetched");
|
||||
return NextResponse.json({ error: "Public keys do not match the ones reported by the server" }, { status: 400 });
|
||||
}
|
||||
|
||||
debug("REGISTER – checking for existing registration at %s", validated.url);
|
||||
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.url, validated.url.toString()));
|
||||
if (server.length > 0 && server[0].publicKey !== validated.publicKey) {
|
||||
debug("REGISTER – key mismatch against existing registration");
|
||||
return NextResponse.json({ error: "Your public key does not match the one registered on the server, to update your public key, please send a PATCH request instead." }, { status: 400 });
|
||||
return NextResponse.json({ error: "Your public key does not match the one registered on the server, to update your public key, please use the key rotation flow instead." }, { status: 400 });
|
||||
}
|
||||
|
||||
debug("REGISTER – upserting server %s", validated.url);
|
||||
await upsertServer(validated.url.toString(), validated.publicKey);
|
||||
await upsertServer(validated.url.toString(), validated.publicKey, validated.encryptionPublicKey);
|
||||
|
||||
debug("REGISTER – server registered successfully");
|
||||
return NextResponse.json({
|
||||
message: "Server registered successfully", echo: {
|
||||
url: process.env.NEXT_PUBLIC_APP_URL,
|
||||
url: process.env.BETTER_AUTH_URL!,
|
||||
publicKey: process.env.FEDERATION_PUBLIC_KEY,
|
||||
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,206 +6,216 @@
|
|||
|
||||
/* SiPher brand palette — Silent Whisper */
|
||||
:root {
|
||||
/* Brand tokens */
|
||||
--black: #080808;
|
||||
--surface: #0f0f0f;
|
||||
--card: #141414;
|
||||
--border: #1f1f1f;
|
||||
--muted: #2a2a2a;
|
||||
--dim: #555;
|
||||
--text: #e8e8e8;
|
||||
--subtle: #888;
|
||||
--acid: #c8f000;
|
||||
--static: #ff3c3c;
|
||||
--ghost: #9b9b9b;
|
||||
--void: #080808;
|
||||
--signal: #00e5ff;
|
||||
/* Brand tokens */
|
||||
--black: #080808;
|
||||
--surface: #0f0f0f;
|
||||
--card: #141414;
|
||||
--border: #1f1f1f;
|
||||
--muted: #2a2a2a;
|
||||
--dim: #555;
|
||||
--text: #e8e8e8;
|
||||
--subtle: #888;
|
||||
--acid: #c8f000;
|
||||
--static: #ff3c3c;
|
||||
--ghost: #9b9b9b;
|
||||
--void: #080808;
|
||||
--signal: #00e5ff;
|
||||
|
||||
/* Light mode — minimal variant for system preference */
|
||||
--background: #f5f5f5;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0a0a0a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0a0a0a;
|
||||
--primary: #8a9a00;
|
||||
--primary-foreground: #080808;
|
||||
--secondary: #e8e8e8;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
--muted: #e5e5e5;
|
||||
--muted-foreground: #555;
|
||||
--accent: #c8f000;
|
||||
--accent-foreground: #080808;
|
||||
--destructive: #ff3c3c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e5e5e5;
|
||||
--input: #e5e5e5;
|
||||
--ring: #8a9a00;
|
||||
--chart-1: #8a9a00;
|
||||
--chart-2: #c8f000;
|
||||
--chart-3: #00e5ff;
|
||||
--chart-4: #9b9b9b;
|
||||
--chart-5: #555;
|
||||
--sidebar: #fafafa;
|
||||
--sidebar-foreground: #0a0a0a;
|
||||
--sidebar-primary: #8a9a00;
|
||||
--sidebar-primary-foreground: #080808;
|
||||
--sidebar-accent: #e5e5e5;
|
||||
--sidebar-accent-foreground: #0a0a0a;
|
||||
--sidebar-border: #e5e5e5;
|
||||
--sidebar-ring: #8a9a00;
|
||||
/* Light mode — minimal variant for system preference */
|
||||
--background: #f5f5f5;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0a0a0a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0a0a0a;
|
||||
--primary: #8a9a00;
|
||||
--primary-foreground: #080808;
|
||||
--secondary: #e8e8e8;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
--muted: #e5e5e5;
|
||||
--muted-foreground: #555;
|
||||
--accent: #c8f000;
|
||||
--accent-foreground: #080808;
|
||||
--destructive: #ff3c3c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e5e5e5;
|
||||
--input: #e5e5e5;
|
||||
--ring: #8a9a00;
|
||||
--chart-1: #8a9a00;
|
||||
--chart-2: #c8f000;
|
||||
--chart-3: #00e5ff;
|
||||
--chart-4: #9b9b9b;
|
||||
--chart-5: #555;
|
||||
--sidebar: #fafafa;
|
||||
--sidebar-foreground: #0a0a0a;
|
||||
--sidebar-primary: #8a9a00;
|
||||
--sidebar-primary-foreground: #080808;
|
||||
--sidebar-accent: #e5e5e5;
|
||||
--sidebar-accent-foreground: #0a0a0a;
|
||||
--sidebar-border: #e5e5e5;
|
||||
--sidebar-ring: #8a9a00;
|
||||
|
||||
--font-sans: "DM Sans", sans-serif;
|
||||
--font-mono: "Space Mono", monospace;
|
||||
--font-display: "Bebas Neue", sans-serif;
|
||||
--font-sans: "DM Sans", sans-serif;
|
||||
--font-mono: "Space Mono", monospace;
|
||||
--font-display: "Bebas Neue", sans-serif;
|
||||
|
||||
--radius: 0.2rem;
|
||||
--tracking-normal: -0.01em;
|
||||
--spacing: 0.25rem;
|
||||
--radius: 0.2rem;
|
||||
--tracking-normal: -0.01em;
|
||||
--spacing: 0.25rem;
|
||||
|
||||
--shadow-2xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04);
|
||||
--shadow-xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04);
|
||||
--shadow-sm: 0px 2px 8px 0px rgb(0 0 0 / 0.06), 0px 1px 2px -1px rgb(0 0 0 / 0.06);
|
||||
--shadow: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 1px 2px -1px rgb(0 0 0 / 0.08);
|
||||
--shadow-md: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 2px 4px -1px rgb(0 0 0 / 0.08);
|
||||
--shadow-lg: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 4px 6px -1px rgb(0 0 0 / 0.08);
|
||||
--shadow-xl: 0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 8px 10px -1px rgb(0 0 0 / 0.08);
|
||||
--shadow-2xl: 0px 2px 8px 0px rgb(0 0 0 / 0.15);
|
||||
--shadow-2xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04);
|
||||
--shadow-xs: 0px 2px 4px 0px rgb(0 0 0 / 0.04);
|
||||
--shadow-sm:
|
||||
0px 2px 8px 0px rgb(0 0 0 / 0.06), 0px 1px 2px -1px rgb(0 0 0 / 0.06);
|
||||
--shadow:
|
||||
0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 1px 2px -1px rgb(0 0 0 / 0.08);
|
||||
--shadow-md:
|
||||
0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 2px 4px -1px rgb(0 0 0 / 0.08);
|
||||
--shadow-lg:
|
||||
0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 4px 6px -1px rgb(0 0 0 / 0.08);
|
||||
--shadow-xl:
|
||||
0px 2px 8px 0px rgb(0 0 0 / 0.08), 0px 8px 10px -1px rgb(0 0 0 / 0.08);
|
||||
--shadow-2xl: 0px 2px 8px 0px rgb(0 0 0 / 0.15);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* SiPher dark theme — primary brand identity */
|
||||
--background: var(--black);
|
||||
--foreground: var(--text);
|
||||
--card: #141414;
|
||||
--card-foreground: var(--text);
|
||||
--popover: var(--surface);
|
||||
--popover-foreground: var(--text);
|
||||
--primary: var(--acid);
|
||||
--primary-foreground: var(--void);
|
||||
--secondary: var(--muted);
|
||||
--secondary-foreground: var(--text);
|
||||
--muted: var(--muted);
|
||||
--muted-foreground: var(--subtle);
|
||||
--accent: var(--acid);
|
||||
--accent-foreground: var(--void);
|
||||
--destructive: var(--static);
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: var(--border);
|
||||
--input: var(--border);
|
||||
--ring: var(--acid);
|
||||
--chart-1: var(--acid);
|
||||
--chart-2: var(--signal);
|
||||
--chart-3: var(--static);
|
||||
--chart-4: var(--ghost);
|
||||
--chart-5: var(--subtle);
|
||||
--sidebar: var(--surface);
|
||||
--sidebar-foreground: var(--text);
|
||||
--sidebar-primary: var(--acid);
|
||||
--sidebar-primary-foreground: var(--void);
|
||||
--sidebar-accent: var(--muted);
|
||||
--sidebar-accent-foreground: var(--acid);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--acid);
|
||||
/* SiPher dark theme — primary brand identity */
|
||||
--background: var(--black);
|
||||
--foreground: var(--text);
|
||||
--card: #141414;
|
||||
--card-foreground: var(--text);
|
||||
--popover: var(--surface);
|
||||
--popover-foreground: var(--text);
|
||||
--primary: var(--acid);
|
||||
--primary-foreground: var(--void);
|
||||
--secondary: var(--muted);
|
||||
--secondary-foreground: var(--text);
|
||||
--muted: var(--muted);
|
||||
--muted-foreground: var(--subtle);
|
||||
--accent: var(--acid);
|
||||
--accent-foreground: var(--void);
|
||||
--destructive: var(--static);
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: var(--border);
|
||||
--input: var(--border);
|
||||
--ring: var(--acid);
|
||||
--chart-1: var(--acid);
|
||||
--chart-2: var(--signal);
|
||||
--chart-3: var(--static);
|
||||
--chart-4: var(--ghost);
|
||||
--chart-5: var(--subtle);
|
||||
--sidebar: var(--surface);
|
||||
--sidebar-foreground: var(--text);
|
||||
--sidebar-primary: var(--acid);
|
||||
--sidebar-primary-foreground: var(--void);
|
||||
--sidebar-accent: var(--muted);
|
||||
--sidebar-accent-foreground: var(--acid);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--acid);
|
||||
|
||||
--shadow-2xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25);
|
||||
--shadow-xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25);
|
||||
--shadow-sm: 0px 4px 12px 0px rgb(0 0 0 / 0.35), 0px 1px 2px -1px rgb(0 0 0 / 0.35);
|
||||
--shadow: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 1px 2px -1px rgb(0 0 0 / 0.4);
|
||||
--shadow-md: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 2px 4px -1px rgb(0 0 0 / 0.4);
|
||||
--shadow-lg: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 4px 6px -1px rgb(0 0 0 / 0.4);
|
||||
--shadow-xl: 0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 8px 10px -1px rgb(0 0 0 / 0.4);
|
||||
--shadow-2xl: 0px 4px 12px 0px rgb(0 0 0 / 0.6);
|
||||
--shadow-2xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25);
|
||||
--shadow-xs: 0px 4px 12px 0px rgb(0 0 0 / 0.25);
|
||||
--shadow-sm:
|
||||
0px 4px 12px 0px rgb(0 0 0 / 0.35), 0px 1px 2px -1px rgb(0 0 0 / 0.35);
|
||||
--shadow:
|
||||
0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 1px 2px -1px rgb(0 0 0 / 0.4);
|
||||
--shadow-md:
|
||||
0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 2px 4px -1px rgb(0 0 0 / 0.4);
|
||||
--shadow-lg:
|
||||
0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 4px 6px -1px rgb(0 0 0 / 0.4);
|
||||
--shadow-xl:
|
||||
0px 4px 12px 0px rgb(0 0 0 / 0.4), 0px 8px 10px -1px rgb(0 0 0 / 0.4);
|
||||
--shadow-2xl: 0px 4px 12px 0px rgb(0 0 0 / 0.6);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Brand palette — use as bg-acid, text-signal, etc. */
|
||||
--color-acid: var(--acid);
|
||||
--color-static: var(--static);
|
||||
--color-ghost: var(--ghost);
|
||||
--color-void: var(--void);
|
||||
--color-signal: var(--signal);
|
||||
--color-surface: var(--surface);
|
||||
--color-dim: var(--dim);
|
||||
--color-subtle: var(--subtle);
|
||||
/* Brand palette — use as bg-acid, text-signal, etc. */
|
||||
--color-acid: var(--acid);
|
||||
--color-static: var(--static);
|
||||
--color-ghost: var(--ghost);
|
||||
--color-void: var(--void);
|
||||
--color-signal: var(--signal);
|
||||
--color-surface: var(--surface);
|
||||
--color-dim: var(--dim);
|
||||
--color-subtle: var(--subtle);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-display: var(--font-display);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-display: var(--font-display);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
|
||||
/* Section labels — Space Mono, uppercase, letter-spacing */
|
||||
.section-label {
|
||||
@apply font-mono text-[10px] text-muted-foreground uppercase tracking-[0.2em];
|
||||
}
|
||||
/* Section labels — Space Mono, uppercase, letter-spacing */
|
||||
.section-label {
|
||||
@apply font-mono text-[10px] text-muted-foreground uppercase tracking-[0.2em];
|
||||
}
|
||||
|
||||
/* Display typography — Bebas Neue for headings */
|
||||
.font-display {
|
||||
font-family: var(--font-display), sans-serif;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
/* Display typography — Bebas Neue for headings */
|
||||
.font-display {
|
||||
font-family: var(--font-display), sans-serif;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +1,95 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
Login with your Apple or Google account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<Button variant="outline" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with Apple
|
||||
</Button>
|
||||
<Button variant="outline" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with Google
|
||||
</Button>
|
||||
</Field>
|
||||
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card">
|
||||
Or continue with
|
||||
</FieldSeparator>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" type="password" required />
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit">Login</Button>
|
||||
<FieldDescription className="text-center">
|
||||
Don't have an account? <a href="#">Sign up</a>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FieldDescription className="px-6 text-center">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
Login with your Apple or Google account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<Button variant="outline" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with Apple
|
||||
</Button>
|
||||
<Button variant="outline" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with Google
|
||||
</Button>
|
||||
</Field>
|
||||
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card">
|
||||
Or continue with
|
||||
</FieldSeparator>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" type="password" required />
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit">Login</Button>
|
||||
<FieldDescription className="text-center">
|
||||
Don't have an account? <a href="#">Sign up</a>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FieldDescription className="px-6 text-center">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,64 +1,64 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
|
|||
|
|
@ -3,90 +3,85 @@ import * as React from "react"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,158 +1,159 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,257 +1,249 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-inset:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent,
|
||||
DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,248 +1,244 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col *:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"*:data-[slot=field-label]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col @md/field-group:flex-row @md/field-group:items-center *:w-full @md/field-group:*:w-auto [&>.sr-only]:w-auto",
|
||||
"@md/field-group:*:data-[slot=field-label]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-sm leading-normal font-normal text-muted-foreground group-has-data-[orientation=horizontal]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
Field, FieldContent, FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup, FieldLabel, FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet, FieldTitle
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,167 +1,163 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import type { Label as LabelPrimitive } from "radix-ui"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from "react"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot.Root
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Slot.Root
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-sm text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-sm text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
Form, FormControl,
|
||||
FormDescription, FormField, FormItem,
|
||||
FormLabel, FormMessage, useFormField
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,19 @@ import * as React from "react"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
|
|
|
|||
|
|
@ -1,40 +1,40 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
|
|
|
|||
|
|
@ -12,9 +12,17 @@ import minioClient from "./plugins/server/storage/minio.client";
|
|||
const isTest = process.env.NODE_ENV === "test";
|
||||
const emailService: EmailService | undefined = isTest ? undefined : new EmailService();
|
||||
|
||||
const federationKeysExist = process.env.FEDERATION_PUBLIC_KEY && process.env.FEDERATION_PRIVATE_KEY;
|
||||
const federationKeysExist =
|
||||
process.env.FEDERATION_PUBLIC_KEY &&
|
||||
process.env.FEDERATION_PRIVATE_KEY &&
|
||||
process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY &&
|
||||
process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY;
|
||||
if (!federationKeysExist) {
|
||||
throw new Error("FEDERATION_PUBLIC_KEY and FEDERATION_PRIVATE_KEY must be set, please run `bun run keygen` to generate them.");
|
||||
throw new Error(
|
||||
"All federation keys must be set (FEDERATION_PUBLIC_KEY, FEDERATION_PRIVATE_KEY, " +
|
||||
"FEDERATION_ENCRYPTION_PUBLIC_KEY, FEDERATION_ENCRYPTION_PRIVATE_KEY). " +
|
||||
"Run `bun run keygen` to generate them.",
|
||||
);
|
||||
}
|
||||
|
||||
const bAuth = betterAuth({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Pool } from "pg";
|
|||
import * as schema from "./schema";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL!,
|
||||
connectionString: process.env.DATABASE_URL!,
|
||||
});
|
||||
|
||||
const db = drizzle({ client: pool, schema });
|
||||
|
|
|
|||
|
|
@ -1,285 +1,289 @@
|
|||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
username: text("username").unique(),
|
||||
displayUsername: text("display_username"),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
isPrivate: boolean("is_private").default(false),
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
username: text("username").unique(),
|
||||
displayUsername: text("display_username"),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
isPrivate: boolean("is_private").default(false),
|
||||
});
|
||||
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const account = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const verification = pgTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const twoFactor = pgTable(
|
||||
"two_factor",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
index("twoFactor_userId_idx").on(table.userId),
|
||||
],
|
||||
"two_factor",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
index("twoFactor_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const posts = pgTable("posts", {
|
||||
id: text("id").primaryKey(),
|
||||
content: jsonb("content").notNull(),
|
||||
authorId: text("author_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
published: timestamp("published").notNull(),
|
||||
isLocal: boolean("is_local").default(false).notNull(),
|
||||
isPrivate: boolean("is_private").default(false),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
content: jsonb("content").notNull(),
|
||||
authorId: text("author_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
published: timestamp("published").notNull(),
|
||||
isLocal: boolean("is_local").default(false).notNull(),
|
||||
isPrivate: boolean("is_private").default(false),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const follows = pgTable("follows", {
|
||||
id: text("id").primaryKey(),
|
||||
followerId: text("follower_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
followingId: text("following_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accepted: boolean("accepted").default(false).notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
followerId: text("follower_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
followingId: text("following_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accepted: boolean("accepted").default(false).notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const deliveryJobs = pgTable("delivery_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
targetUrl: text("target_url").notNull(),
|
||||
payload: text("payload").notNull(),
|
||||
attempts: integer("attempts").default(0).notNull(),
|
||||
lastAttemptedAt: timestamp("last_attempted_at"),
|
||||
nextAttemptAt: timestamp("next_attempt_at"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
targetUrl: text("target_url").notNull(),
|
||||
payload: text("payload").notNull(),
|
||||
attempts: integer("attempts").default(0).notNull(),
|
||||
lastAttemptedAt: timestamp("last_attempted_at"),
|
||||
nextAttemptAt: timestamp("next_attempt_at"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const mutes = pgTable("mutes", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
mutedUserId: text("muted_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
mutedUserId: text("muted_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const blocks = pgTable("blocks", {
|
||||
id: text("id").primaryKey(),
|
||||
blockerId: text("blocker_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
blockedUserId: text("blocked_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
blockerId: text("blocker_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
blockedUserId: text("blocked_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const serverRegistry = pgTable(
|
||||
"server_registry",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
url: text("url").notNull().unique(),
|
||||
publicKey: text("public_key").notNull().unique(),
|
||||
lastSeen: timestamp("last_seen").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
isHealthy: boolean("is_healthy").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("serverRegistry_publicKey_uidx").on(table.publicKey),
|
||||
index("serverRegistry_lastSeen_idx").on(table.lastSeen),
|
||||
],
|
||||
"server_registry",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
url: text("url").notNull().unique(),
|
||||
publicKey: text("public_key").notNull().unique(),
|
||||
encryptionPublicKey: text("encryption_public_key").notNull().unique(),
|
||||
lastSeen: timestamp("last_seen").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
isHealthy: boolean("is_healthy").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("serverRegistry_publicKey_uidx").on(table.publicKey),
|
||||
uniqueIndex("serverRegistry_encryptionPublicKey_uidx").on(
|
||||
table.encryptionPublicKey,
|
||||
),
|
||||
index("serverRegistry_lastSeen_idx").on(table.lastSeen),
|
||||
],
|
||||
);
|
||||
|
||||
export const rotateChallengeTokens = pgTable(
|
||||
"rotate_challenge_tokens",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
oldKeyToken: text("old_key_token").notNull(),
|
||||
newKeyToken: text("new_key_token").notNull().unique(),
|
||||
newPublicKey: text("new_public_key").notNull(),
|
||||
serverUrl: text("server_url").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
attemptsLeft: integer("attempts_left").default(3).notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("rotateChallengeTokens_newKeyToken_uidx").on(table.newKeyToken),
|
||||
index("rotateChallengeTokens_serverUrl_idx").on(table.serverUrl),
|
||||
],
|
||||
"rotate_challenge_tokens",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
signingOldToken: text("signing_old_token").notNull(),
|
||||
signingNewToken: text("signing_new_token").notNull(),
|
||||
encryptionOldToken: text("encryption_old_token").notNull(),
|
||||
encryptionNewToken: text("encryption_new_token").notNull(),
|
||||
newSigningPublicKey: text("new_signing_public_key").notNull(),
|
||||
newEncryptionPublicKey: text("new_encryption_public_key").notNull(),
|
||||
serverUrl: text("server_url").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
attemptsLeft: integer("attempts_left").default(3).notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
},
|
||||
(table) => [index("rotateChallengeTokens_serverUrl_idx").on(table.serverUrl)],
|
||||
);
|
||||
|
||||
export const blacklistedServers = pgTable(
|
||||
"blacklisted_servers",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
serverUrl: text("server_url").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
reason: text("reason").notNull(),
|
||||
},
|
||||
(table) => [index("blacklistedServers_serverUrl_idx").on(table.serverUrl)],
|
||||
"blacklisted_servers",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
serverUrl: text("server_url").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
reason: text("reason").notNull(),
|
||||
},
|
||||
(table) => [index("blacklistedServers_serverUrl_idx").on(table.serverUrl)],
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
twoFactors: many(twoFactor),
|
||||
postss: many(posts),
|
||||
followss: many(follows),
|
||||
mutess: many(mutes),
|
||||
blockss: many(blocks),
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
twoFactors: many(twoFactor),
|
||||
postss: many(posts),
|
||||
followss: many(follows),
|
||||
mutess: many(mutes),
|
||||
blockss: many(blocks),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const postsRelations = relations(posts, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [posts.authorId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [posts.authorId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const followsFollowerIdRelations = relations(follows, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [follows.followerId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [follows.followerId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const followsFollowingIdRelations = relations(follows, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [follows.followingId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [follows.followingId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const mutesUserIdRelations = relations(mutes, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [mutes.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [mutes.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const mutesMutedUserIdRelations = relations(mutes, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [mutes.mutedUserId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [mutes.mutedUserId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const blocksBlockerIdRelations = relations(blocks, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [blocks.blockerId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [blocks.blockerId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const blocksBlockedUserIdRelations = relations(blocks, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [blocks.blockedUserId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [blocks.blockedUserId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,32 +1,43 @@
|
|||
import Bun from "bun";
|
||||
import forge from "node-forge";
|
||||
import nacl from "tweetnacl";
|
||||
|
||||
export async function generateKeyPair() {
|
||||
|
||||
// Check if .env file exists
|
||||
if (!Bun.file(".env.local").exists()) { throw new Error("No .env.local file found"); }
|
||||
|
||||
const keypair = forge.pki.rsa.generateKeyPair(4096);
|
||||
const keys = {
|
||||
publicKey: forge.pki.publicKeyToPem(keypair.publicKey),
|
||||
privateKey: forge.pki.privateKeyToPem(keypair.privateKey)
|
||||
const envFile = Bun.file(".env.local");
|
||||
if (!await envFile.exists()) {
|
||||
throw new Error("No .env.local file found");
|
||||
}
|
||||
|
||||
// Sanity check to make sure the keys are not already in the file
|
||||
const env = await Bun.file(".env.local").text();
|
||||
if (env.includes("FEDERATION_PUBLIC_KEY") && env.includes("FEDERATION_PRIVATE_KEY")) {
|
||||
throw new Error("Keys already exist in .env.local file, if you wish to regenerate them, please delete the keys and run the command again");
|
||||
const signing = nacl.sign.keyPair();
|
||||
const encryption = nacl.box.keyPair();
|
||||
|
||||
const env = await envFile.text();
|
||||
if (
|
||||
env.includes("FEDERATION_PUBLIC_KEY") ||
|
||||
env.includes("FEDERATION_PRIVATE_KEY") ||
|
||||
env.includes("FEDERATION_ENCRYPTION_PUBLIC_KEY") ||
|
||||
env.includes("FEDERATION_ENCRYPTION_PRIVATE_KEY")
|
||||
) {
|
||||
throw new Error(
|
||||
"Federation keys already exist in .env.local. Delete them first if you want to regenerate.",
|
||||
);
|
||||
}
|
||||
|
||||
// Read the .env.local file and append the keys to the file without overwriting the existing keys
|
||||
// Escape newlines for .env format (single-line value that expands to PEM when loaded)
|
||||
const publicKey = keys.publicKey.replace(/\r?\n/g, "\\n");
|
||||
const privateKey = keys.privateKey.replace(/\r?\n/g, "\\n");
|
||||
const separator = "#------------- SERVER KEYS -------------\n# DO NOT EDIT THIS SECTION, THIS IS AUTOMATICALLY GENERATED BY THE SERVER\n#DO NOT PUBLISH THE PRIVATE KEY TO THE PUBLIC!";
|
||||
await Bun.write(".env.local", `${env}\n\n${separator}\n\nFEDERATION_PUBLIC_KEY="${publicKey}"\nFEDERATION_PRIVATE_KEY="${privateKey}"`);
|
||||
const signingPublicKey = Buffer.from(signing.publicKey).toString("base64");
|
||||
const signingPrivateKey = Buffer.from(signing.secretKey).toString("base64");
|
||||
const encryptionPublicKey = Buffer.from(encryption.publicKey).toString("base64");
|
||||
const encryptionPrivateKey = Buffer.from(encryption.secretKey).toString("base64");
|
||||
|
||||
console.log("Keys generated successfully");
|
||||
return keys;
|
||||
const block = [
|
||||
"",
|
||||
"# Federation keys (Ed25519 signing + X25519 encryption)",
|
||||
`FEDERATION_PUBLIC_KEY="${signingPublicKey}"`,
|
||||
`FEDERATION_PRIVATE_KEY="${signingPrivateKey}"`,
|
||||
`FEDERATION_ENCRYPTION_PUBLIC_KEY="${encryptionPublicKey}"`,
|
||||
`FEDERATION_ENCRYPTION_PRIVATE_KEY="${encryptionPrivateKey}"`,
|
||||
].join("\n");
|
||||
|
||||
await Bun.write(".env.local", env + block);
|
||||
console.log("Federation keys generated and written to .env.local");
|
||||
}
|
||||
|
||||
generateKeyPair();
|
||||
|
|
@ -1,49 +1,86 @@
|
|||
import forge from "node-forge";
|
||||
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
||||
import nacl from "tweetnacl";
|
||||
|
||||
export function encryptPayload(payload: string, recipientPublicKey: string) {
|
||||
const pub = forge.pki.publicKeyFromPem(recipientPublicKey);
|
||||
return forge.util.encode64(
|
||||
pub.encrypt(
|
||||
forge.util.encodeUtf8(payload),
|
||||
"RSA-OAEP"
|
||||
)
|
||||
)
|
||||
export interface EncryptedEnvelope {
|
||||
ephemeralPublicKey: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
authTag: string;
|
||||
}
|
||||
|
||||
export function decryptPayload(payload: string, privateKey: string) {
|
||||
const priv = forge.pki.privateKeyFromPem(privateKey);
|
||||
function toBase64(buf: Uint8Array): string {
|
||||
return Buffer.from(buf).toString("base64");
|
||||
}
|
||||
|
||||
function fromBase64(str: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(str, "base64"));
|
||||
}
|
||||
|
||||
function deriveAesKey(sharedSecret: Uint8Array): Buffer {
|
||||
return Buffer.from(
|
||||
hkdfSync("sha256", sharedSecret, Buffer.from("sipher-federation-v1-salt"), "sipher-federation-v1-aes", 32)
|
||||
);
|
||||
}
|
||||
|
||||
export function signMessage(message: string, ed25519SecretKey: Uint8Array): string {
|
||||
const msgBytes = new TextEncoder().encode(message);
|
||||
const sig = nacl.sign.detached(msgBytes, ed25519SecretKey);
|
||||
return toBase64(sig);
|
||||
}
|
||||
|
||||
export function verifySignature(message: string, signature: string, ed25519PublicKey: Uint8Array): boolean {
|
||||
try {
|
||||
return forge.util.decodeUtf8(
|
||||
priv.decrypt(
|
||||
forge.util.decode64(payload),
|
||||
"RSA-OAEP"
|
||||
)
|
||||
)
|
||||
const msgBytes = new TextEncoder().encode(message);
|
||||
const sigBytes = fromBase64(signature);
|
||||
return nacl.sign.detached.verify(msgBytes, sigBytes, ed25519PublicKey);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptPayload(plaintext: string, recipientX25519PublicKey: Uint8Array): EncryptedEnvelope {
|
||||
try {
|
||||
const ephemeral = nacl.box.keyPair();
|
||||
const sharedPoint = nacl.box.before(recipientX25519PublicKey, ephemeral.secretKey);
|
||||
const aesKey = deriveAesKey(sharedPoint);
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", aesKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
ephemeralPublicKey: toBase64(ephemeral.publicKey),
|
||||
iv: toBase64(iv),
|
||||
ciphertext: toBase64(encrypted),
|
||||
authTag: toBase64(authTag),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt payload", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyChallenge(
|
||||
challenge: string,
|
||||
signedChallenge: string,
|
||||
publicKeyPem: string
|
||||
): boolean {
|
||||
export function decryptPayload(envelope: EncryptedEnvelope, ownX25519SecretKey: Uint8Array): string {
|
||||
try {
|
||||
const pub = forge.pki.publicKeyFromPem(publicKeyPem)
|
||||
const md = forge.md.sha256.create()
|
||||
md.update(challenge, 'utf8')
|
||||
const sig = forge.util.decode64(signedChallenge)
|
||||
return pub.verify(md.digest().bytes(), sig)
|
||||
} catch {
|
||||
return false
|
||||
const ephemeralPub = fromBase64(envelope.ephemeralPublicKey);
|
||||
const sharedPoint = nacl.box.before(ephemeralPub, ownX25519SecretKey);
|
||||
const aesKey = deriveAesKey(sharedPoint);
|
||||
const iv = fromBase64(envelope.iv);
|
||||
const ciphertext = fromBase64(envelope.ciphertext);
|
||||
const authTag = fromBase64(envelope.authTag);
|
||||
|
||||
const decipher = createDecipheriv("aes-256-gcm", aesKey, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
} catch (error) {
|
||||
console.error("If you're trying to rotate keys, then your old keys are invalid and doesn't match the keys that the other federation has. You'll have to contact that federation in order to rotate your keys.")
|
||||
console.error("If you're not trying to rotate keys, then you're either doing something wrong or the other federation shouldn't be trusted anymore. Most likely the first.")
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function signChallenge(challenge: string, privateKeyPem: string): string {
|
||||
const priv = forge.pki.privateKeyFromPem(privateKeyPem)
|
||||
const md = forge.md.sha256.create()
|
||||
md.update(challenge, 'utf8')
|
||||
return forge.util.encode64(priv.sign(md.digest().bytes()))
|
||||
import { createHash } from "node:crypto";
|
||||
export function fingerprintKey(keyBase64: string): string {
|
||||
const hash = createHash("sha256").update(fromBase64(keyBase64)).digest("hex");
|
||||
return hash;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { pixelBasedPreset } from "@react-email/tailwind";
|
||||
import type { TailwindConfig } from "@react-email/tailwind";
|
||||
import { pixelBasedPreset } from "@react-email/tailwind";
|
||||
|
||||
/**
|
||||
* React Email Tailwind config matching globals.css design tokens.
|
||||
|
|
|
|||
|
|
@ -98,7 +98,20 @@ export const sipherSocialClientPlugin = () => {
|
|||
});
|
||||
}
|
||||
|
||||
console.log("Resolved content:", resolvedContent);
|
||||
const { data, error } = await $fetch<{
|
||||
postId: string;
|
||||
}>("/social/posts", {
|
||||
method: "POST",
|
||||
body: {
|
||||
content: resolvedContent,
|
||||
},
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error("Failed to create post");
|
||||
}
|
||||
|
||||
return data.postId;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ export const federation = () => {
|
|||
unique: true,
|
||||
index: true
|
||||
},
|
||||
encryptionPublicKey: {
|
||||
type: "string",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
lastSeen: {
|
||||
type: "date",
|
||||
required: true,
|
||||
|
|
@ -42,18 +48,32 @@ export const federation = () => {
|
|||
},
|
||||
rotateChallengeTokens: {
|
||||
fields: {
|
||||
oldKeyToken: {
|
||||
signingOldToken: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
newKeyToken: {
|
||||
signingNewToken: {
|
||||
type: "string",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
index: false
|
||||
},
|
||||
newPublicKey: {
|
||||
encryptionOldToken: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
encryptionNewToken: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
newSigningPublicKey: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
newEncryptionPublicKey: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx"
|
|||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { config } from 'dotenv'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
|
||||
import next from 'next'
|
||||
import { Server } from 'socket.io'
|
||||
|
||||
config({ path: '.env.local' })
|
||||
const port = parseInt(process.env.PORT || '3000', 10)
|
||||
|
|
@ -8,10 +9,19 @@ const dev = process.env.NODE_ENV !== 'production'
|
|||
const app = next({ dev })
|
||||
const handle = app.getRequestHandler()
|
||||
|
||||
app.prepare().then(() => {
|
||||
createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
app.prepare().then(async () => {
|
||||
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
handle(req, res)
|
||||
}).listen(port)
|
||||
})
|
||||
|
||||
const io = new Server(server)
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('join-firehose', () => socket.join('firehose'))
|
||||
socket.on('leave-firehose', () => socket.leave('firehose'))
|
||||
})
|
||||
|
||||
server.listen(port)
|
||||
|
||||
console.log(
|
||||
`> Server listening at http://localhost:${port} as ${dev ? 'development' : process.env.NODE_ENV
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
* After fixing the vulnerabilitie, the tests were updated to match the new behavior.
|
||||
* -----
|
||||
*/
|
||||
import { encryptPayload, fingerprintKey } from "@/lib/federation/keytools"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import forge from "node-forge"
|
||||
import http from "node:http"
|
||||
import {
|
||||
clearTables,
|
||||
|
|
@ -27,33 +27,28 @@ import {
|
|||
|
||||
const BASE = "http://localhost:3000"
|
||||
|
||||
function encryptPayload(payload: string, recipientPublicKeyPem: string) {
|
||||
const pub = forge.pki.publicKeyFromPem(recipientPublicKeyPem)
|
||||
return forge.util.encode64(
|
||||
pub.encrypt(forge.util.encodeUtf8(payload), "RSA-OAEP"),
|
||||
function getOwnEncryptionPublicKey(): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!, "base64"))
|
||||
}
|
||||
|
||||
function buildBadEnvelope() {
|
||||
return encryptPayload(
|
||||
JSON.stringify({
|
||||
signingOldSignature: "wrong",
|
||||
signingNewSignature: "wrong",
|
||||
encryptionOldPlaintext: "wrong",
|
||||
encryptionNewPlaintext: "wrong",
|
||||
}),
|
||||
getOwnEncryptionPublicKey(),
|
||||
)
|
||||
}
|
||||
|
||||
function fingerprintKey(pem: string): string {
|
||||
const md = forge.md.sha256.create()
|
||||
md.update(pem, "utf8")
|
||||
return md.digest().toHex()
|
||||
}
|
||||
|
||||
function generate4096Keypair() {
|
||||
const kp = forge.pki.rsa.generateKeyPair(4096)
|
||||
return {
|
||||
publicKey: forge.pki.publicKeyToPem(kp.publicKey),
|
||||
privateKey: forge.pki.privateKeyToPem(kp.privateKey),
|
||||
}
|
||||
}
|
||||
|
||||
function createTrapServer(fakePublicKey: string) {
|
||||
function createTrapServer(fakePublicKey: string, fakeEncryptionPublicKey: string) {
|
||||
const hits: { method: string; url: string }[] = []
|
||||
const server = http.createServer((req, res) => {
|
||||
hits.push({ method: req.method!, url: req.url! })
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ publicKey: fakePublicKey }))
|
||||
res.end(JSON.stringify({ publicKey: fakePublicKey, encryptionPublicKey: fakeEncryptionPublicKey }))
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -75,8 +70,8 @@ test.afterEach(async () => { await clearTables() })
|
|||
// ---------------------------------------------------------------------------
|
||||
test.describe("SSRF protection", () => {
|
||||
test("REGISTER rejects loopback URLs", async ({ request }) => {
|
||||
const { publicKey: fakePub } = generateKeypair()
|
||||
const trap = createTrapServer(fakePub)
|
||||
const keys = generateKeypair()
|
||||
const trap = createTrapServer(keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
const port = await trap.start()
|
||||
|
||||
try {
|
||||
|
|
@ -84,7 +79,8 @@ test.describe("SSRF protection", () => {
|
|||
data: {
|
||||
method: "REGISTER",
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
publicKey: fakePub,
|
||||
publicKey: keys.signingPublicKey,
|
||||
encryptionPublicKey: keys.encryptionPublicKey,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -92,7 +88,6 @@ test.describe("SSRF protection", () => {
|
|||
const body = await res.json()
|
||||
expect(body.error).toMatch(/blocked/i)
|
||||
|
||||
// The trap server should NOT have been hit
|
||||
expect(trap.hits.length).toBe(0)
|
||||
} finally {
|
||||
await trap.stop()
|
||||
|
|
@ -107,9 +102,14 @@ test.describe("SSRF protection", () => {
|
|||
]
|
||||
|
||||
for (const url of internalUrls) {
|
||||
const { publicKey } = generateKeypair()
|
||||
const keys = generateKeypair()
|
||||
const res = await request.post(`${BASE}/discover`, {
|
||||
data: { method: "REGISTER", url, publicKey },
|
||||
data: {
|
||||
method: "REGISTER",
|
||||
url,
|
||||
publicKey: keys.signingPublicKey,
|
||||
encryptionPublicKey: keys.encryptionPublicKey,
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status()).toBe(400)
|
||||
|
|
@ -119,25 +119,25 @@ test.describe("SSRF protection", () => {
|
|||
})
|
||||
|
||||
test("DISCOVER rejects stored internal URLs", async ({ request }) => {
|
||||
const { publicKey: maliciousPub } = generateKeypair()
|
||||
await seedServer("http://127.0.0.1:9999", maliciousPub)
|
||||
const keys = generateKeypair()
|
||||
await seedServer("http://127.0.0.1:9999", keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
|
||||
// Build a valid signature using the fingerprint approach
|
||||
const signaturePayload = JSON.stringify({
|
||||
publicKeyFingerprint: fingerprintKey(maliciousPub),
|
||||
const envelopePayload = JSON.stringify({
|
||||
publicKeyFingerprint: fingerprintKey(keys.signingPublicKey),
|
||||
encryptionPublicKeyFingerprint: fingerprintKey(keys.encryptionPublicKey),
|
||||
url: "http://127.0.0.1:9999",
|
||||
})
|
||||
const signature = encryptPayload(signaturePayload, process.env.FEDERATION_PUBLIC_KEY!)
|
||||
const envelope = encryptPayload(envelopePayload, getOwnEncryptionPublicKey())
|
||||
|
||||
const res = await request.post(`${BASE}/discover`, {
|
||||
data: {
|
||||
method: "DISCOVER",
|
||||
publicKey: maliciousPub,
|
||||
signature,
|
||||
publicKey: keys.signingPublicKey,
|
||||
encryptionPublicKey: keys.encryptionPublicKey,
|
||||
envelope,
|
||||
},
|
||||
})
|
||||
|
||||
// Should be blocked rather than fetching the internal URL
|
||||
expect(res.status()).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toMatch(/blocked/i)
|
||||
|
|
@ -149,7 +149,6 @@ test.describe("SSRF protection", () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
test.describe("Blacklist enforcement (fixed)", () => {
|
||||
async function blacklistServer(serverUrl: string, request: any) {
|
||||
// Seed a challenge with 1 attempt left
|
||||
await seedChallenge({
|
||||
serverUrl,
|
||||
attemptsLeft: 1,
|
||||
|
|
@ -160,8 +159,7 @@ test.describe("Blacklist enforcement (fixed)", () => {
|
|||
await request.post(`${BASE}/discover/rotate/confirm`, {
|
||||
data: {
|
||||
serverUrl,
|
||||
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
envelope: buildBadEnvelope(),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -169,8 +167,7 @@ test.describe("Blacklist enforcement (fixed)", () => {
|
|||
await request.post(`${BASE}/discover/rotate/confirm`, {
|
||||
data: {
|
||||
serverUrl,
|
||||
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
envelope: buildBadEnvelope(),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -179,14 +176,18 @@ test.describe("Blacklist enforcement (fixed)", () => {
|
|||
}
|
||||
|
||||
test("blacklisted server is rejected by rotate/init", async ({ request }) => {
|
||||
const { publicKey: oldPub } = generateKeypair()
|
||||
const oldKeys = generateKeypair()
|
||||
const serverUrl = "https://blacklisted-server.example"
|
||||
await seedServer(serverUrl, oldPub)
|
||||
await seedServer(serverUrl, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
|
||||
await blacklistServer(serverUrl, request as any)
|
||||
|
||||
const { publicKey: newPub } = generate4096Keypair()
|
||||
const newKeys = generateKeypair()
|
||||
const initRes = await request.post(`${BASE}/discover/rotate/init`, {
|
||||
data: { url: serverUrl, newPublicKey: newPub },
|
||||
data: {
|
||||
url: serverUrl,
|
||||
newSigningPublicKey: newKeys.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys.encryptionPublicKey,
|
||||
},
|
||||
})
|
||||
expect(initRes.status()).toBe(403)
|
||||
const body = await initRes.json()
|
||||
|
|
@ -195,15 +196,14 @@ test.describe("Blacklist enforcement (fixed)", () => {
|
|||
|
||||
test("blacklisted server is rejected by rotate/confirm", async ({ request }) => {
|
||||
const serverUrl = "https://blacklisted-confirm.example"
|
||||
const { publicKey } = generateKeypair()
|
||||
await seedServer(serverUrl, publicKey)
|
||||
const keys = generateKeypair()
|
||||
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
await blacklistServer(serverUrl, request as any)
|
||||
|
||||
const confirmRes = await request.post(`${BASE}/discover/rotate/confirm`, {
|
||||
data: {
|
||||
serverUrl,
|
||||
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
envelope: buildBadEnvelope(),
|
||||
},
|
||||
})
|
||||
expect(confirmRes.status()).toBe(403)
|
||||
|
|
@ -218,8 +218,8 @@ test.describe("Blacklist enforcement (fixed)", () => {
|
|||
test.describe("Race condition fixed on rotate/confirm", () => {
|
||||
test("concurrent requests are serialised by the row lock", async () => {
|
||||
const serverUrl = "https://race-target.example"
|
||||
const { publicKey } = generateKeypair()
|
||||
await seedServer(serverUrl, publicKey)
|
||||
const keys = generateKeypair()
|
||||
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
|
||||
await seedChallenge({
|
||||
serverUrl,
|
||||
|
|
@ -229,8 +229,7 @@ test.describe("Race condition fixed on rotate/confirm", () => {
|
|||
|
||||
const payload = JSON.stringify({
|
||||
serverUrl,
|
||||
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
envelope: buildBadEnvelope(),
|
||||
})
|
||||
|
||||
const fire = () =>
|
||||
|
|
@ -247,10 +246,6 @@ test.describe("Race condition fixed on rotate/confirm", () => {
|
|||
const blacklisted = statuses.filter((s) => s === 403).length
|
||||
const notFound = statuses.filter((s) => s === 404).length
|
||||
|
||||
// With the transaction lock, exactly 1 request should process the
|
||||
// mismatch (400), then the next sees attemptsLeft=0 and blacklists (403),
|
||||
// and the rest find no challenge (404) or hit the blacklist check.
|
||||
// The key point: no more than 1 mismatch (400) should get through.
|
||||
expect(mismatch).toBeLessThanOrEqual(1)
|
||||
expect(mismatch + blacklisted + notFound).toBe(statuses.length)
|
||||
})
|
||||
|
|
@ -262,59 +257,66 @@ test.describe("Race condition fixed on rotate/confirm", () => {
|
|||
test.describe("Challenge deduplication (fixed)", () => {
|
||||
test("second init is rejected while a challenge is pending", async ({ request }) => {
|
||||
const serverUrl = "https://dedup-target.example"
|
||||
const { publicKey } = generateKeypair()
|
||||
await seedServer(serverUrl, publicKey)
|
||||
const keys = generateKeypair()
|
||||
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
|
||||
const { publicKey: newPub1 } = generate4096Keypair()
|
||||
const { publicKey: newPub2 } = generate4096Keypair()
|
||||
const newKeys1 = generateKeypair()
|
||||
const newKeys2 = generateKeypair()
|
||||
|
||||
const res1 = await request.post(`${BASE}/discover/rotate/init`, {
|
||||
data: { url: serverUrl, newPublicKey: newPub1 },
|
||||
data: {
|
||||
url: serverUrl,
|
||||
newSigningPublicKey: newKeys1.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys1.encryptionPublicKey,
|
||||
},
|
||||
})
|
||||
expect(res1.status()).toBe(200)
|
||||
|
||||
// Second init while the first is still active → 409
|
||||
const res2 = await request.post(`${BASE}/discover/rotate/init`, {
|
||||
data: { url: serverUrl, newPublicKey: newPub2 },
|
||||
data: {
|
||||
url: serverUrl,
|
||||
newSigningPublicKey: newKeys2.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys2.encryptionPublicKey,
|
||||
},
|
||||
})
|
||||
expect(res2.status()).toBe(409)
|
||||
const body = await res2.json()
|
||||
expect(body.error).toMatch(/already pending/i)
|
||||
|
||||
// Only one challenge exists
|
||||
const challenges = await getChallengesByServerUrl(serverUrl)
|
||||
expect(challenges.length).toBe(1)
|
||||
})
|
||||
|
||||
test("init succeeds after the previous challenge expires", async ({ request }) => {
|
||||
const serverUrl = "https://dedup-expire.example"
|
||||
const { publicKey } = generateKeypair()
|
||||
await seedServer(serverUrl, publicKey)
|
||||
const keys = generateKeypair()
|
||||
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
|
||||
// Seed an already-expired challenge directly
|
||||
await seedChallenge({
|
||||
serverUrl,
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
})
|
||||
|
||||
const { publicKey: newPub } = generate4096Keypair()
|
||||
const newKeys = generateKeypair()
|
||||
const res = await request.post(`${BASE}/discover/rotate/init`, {
|
||||
data: { url: serverUrl, newPublicKey: newPub },
|
||||
data: {
|
||||
url: serverUrl,
|
||||
newSigningPublicKey: newKeys.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys.encryptionPublicKey,
|
||||
},
|
||||
})
|
||||
expect(res.status()).toBe(200)
|
||||
|
||||
// Old challenge was replaced, only new one exists
|
||||
const challenges = await getChallengesByServerUrl(serverUrl)
|
||||
expect(challenges.length).toBe(1)
|
||||
expect(challenges[0].newPublicKey).toBe(newPub)
|
||||
expect(challenges[0].newSigningPublicKey).toBe(newKeys.signingPublicKey)
|
||||
})
|
||||
|
||||
test("blacklisted server cannot reset attempts via new init", async ({ request }) => {
|
||||
const serverUrl = "https://reset-blocked.example"
|
||||
const { publicKey } = generateKeypair()
|
||||
await seedServer(serverUrl, publicKey)
|
||||
const keys = generateKeypair()
|
||||
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
|
||||
// Exhaust attempts → get blacklisted
|
||||
await seedChallenge({
|
||||
serverUrl,
|
||||
attemptsLeft: 1,
|
||||
|
|
@ -323,102 +325,109 @@ test.describe("Challenge deduplication (fixed)", () => {
|
|||
await request.post(`${BASE}/discover/rotate/confirm`, {
|
||||
data: {
|
||||
serverUrl,
|
||||
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
envelope: buildBadEnvelope(),
|
||||
},
|
||||
})
|
||||
await request.post(`${BASE}/discover/rotate/confirm`, {
|
||||
data: {
|
||||
serverUrl,
|
||||
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
envelope: buildBadEnvelope(),
|
||||
},
|
||||
})
|
||||
|
||||
const bl = await getBlacklistedServer(serverUrl)
|
||||
expect(bl).toBeDefined()
|
||||
|
||||
// Try init → blocked by blacklist check
|
||||
const { publicKey: freshPub } = generate4096Keypair()
|
||||
const freshKeys = generateKeypair()
|
||||
const initRes = await request.post(`${BASE}/discover/rotate/init`, {
|
||||
data: { url: serverUrl, newPublicKey: freshPub },
|
||||
data: {
|
||||
url: serverUrl,
|
||||
newSigningPublicKey: freshKeys.signingPublicKey,
|
||||
newEncryptionPublicKey: freshKeys.encryptionPublicKey,
|
||||
},
|
||||
})
|
||||
expect(initRes.status()).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Signature validation — field values must match the request
|
||||
// 5. Envelope validation — field values must match the request
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("Signature validation (fixed)", () => {
|
||||
test("signature with mismatched publicKey fingerprint is rejected", async ({ request }) => {
|
||||
const { publicKey: peerPub } = generateKeypair()
|
||||
await seedServer("https://sig-test.example", peerPub)
|
||||
test.describe("Envelope validation (fixed)", () => {
|
||||
test("envelope with mismatched publicKey fingerprint is rejected", async ({ request }) => {
|
||||
const keys = generateKeypair()
|
||||
await seedServer("https://sig-test.example", keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
|
||||
// Encrypt a signature where the fingerprint doesn't match
|
||||
const badSignature = encryptPayload(
|
||||
JSON.stringify({ publicKeyFingerprint: "wrong-fingerprint", url: "https://sig-test.example" }),
|
||||
process.env.FEDERATION_PUBLIC_KEY!,
|
||||
const badEnvelope = encryptPayload(
|
||||
JSON.stringify({
|
||||
publicKeyFingerprint: "wrong-fingerprint",
|
||||
encryptionPublicKeyFingerprint: fingerprintKey(keys.encryptionPublicKey),
|
||||
url: "https://sig-test.example",
|
||||
}),
|
||||
getOwnEncryptionPublicKey(),
|
||||
)
|
||||
|
||||
const res = await request.post(`${BASE}/discover`, {
|
||||
data: {
|
||||
method: "DISCOVER",
|
||||
publicKey: peerPub,
|
||||
signature: badSignature,
|
||||
publicKey: keys.signingPublicKey,
|
||||
encryptionPublicKey: keys.encryptionPublicKey,
|
||||
envelope: badEnvelope,
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status()).toBe(400)
|
||||
})
|
||||
|
||||
test("signature with placeholder values is rejected", async ({ request }) => {
|
||||
const { publicKey: peerPub } = generateKeypair()
|
||||
await seedServer("https://sig-test2.example", peerPub)
|
||||
test("envelope with placeholder values is rejected", async ({ request }) => {
|
||||
const keys = generateKeypair()
|
||||
await seedServer("https://sig-test2.example", keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
|
||||
// The old bypass: { publicKey: "x", url: "y" }, now invalid
|
||||
const forgerySignature = encryptPayload(
|
||||
const forgeryEnvelope = encryptPayload(
|
||||
JSON.stringify({ publicKey: "x", url: "y" }),
|
||||
process.env.FEDERATION_PUBLIC_KEY!,
|
||||
getOwnEncryptionPublicKey(),
|
||||
)
|
||||
|
||||
const res = await request.post(`${BASE}/discover`, {
|
||||
data: {
|
||||
method: "DISCOVER",
|
||||
publicKey: peerPub,
|
||||
signature: forgerySignature,
|
||||
publicKey: keys.signingPublicKey,
|
||||
encryptionPublicKey: keys.encryptionPublicKey,
|
||||
envelope: forgeryEnvelope,
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status()).toBe(400)
|
||||
})
|
||||
|
||||
test("signature with correct fingerprint passes validation", async ({ request }) => {
|
||||
const { publicKey: peerPub } = generateKeypair()
|
||||
const trap = createTrapServer(peerPub)
|
||||
test("envelope with correct fingerprints passes validation", async ({ request }) => {
|
||||
const keys = generateKeypair()
|
||||
const trap = createTrapServer(keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
const port = await trap.start()
|
||||
const peerUrl = `http://127.0.0.1:${port}`
|
||||
|
||||
try {
|
||||
await seedServer(peerUrl, peerPub)
|
||||
await seedServer(peerUrl, keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
|
||||
const validSignature = encryptPayload(
|
||||
const validEnvelope = encryptPayload(
|
||||
JSON.stringify({
|
||||
publicKeyFingerprint: fingerprintKey(peerPub),
|
||||
publicKeyFingerprint: fingerprintKey(keys.signingPublicKey),
|
||||
encryptionPublicKeyFingerprint: fingerprintKey(keys.encryptionPublicKey),
|
||||
url: peerUrl,
|
||||
}),
|
||||
process.env.FEDERATION_PUBLIC_KEY!,
|
||||
getOwnEncryptionPublicKey(),
|
||||
)
|
||||
|
||||
const res = await request.post(`${BASE}/discover`, {
|
||||
data: {
|
||||
method: "DISCOVER",
|
||||
publicKey: peerPub,
|
||||
signature: validSignature,
|
||||
publicKey: keys.signingPublicKey,
|
||||
encryptionPublicKey: keys.encryptionPublicKey,
|
||||
envelope: validEnvelope,
|
||||
},
|
||||
})
|
||||
|
||||
// The signature is valid, but the stored URL is internal → blocked by SSRF guard
|
||||
// Envelope is valid, but the stored URL is internal → blocked by SSRF guard
|
||||
expect(res.status()).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toMatch(/blocked/i)
|
||||
|
|
@ -433,10 +442,10 @@ test.describe("Signature validation (fixed)", () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
test.describe("Information disclosure", () => {
|
||||
test("GET /discover only returns url and isHealthy for peers", async ({ request }) => {
|
||||
const { publicKey: peerPub1 } = generateKeypair()
|
||||
const { publicKey: peerPub2 } = generateKeypair()
|
||||
await seedServer("https://peer-one.example", peerPub1)
|
||||
await seedServer("https://peer-two.example", peerPub2)
|
||||
const keys1 = generateKeypair()
|
||||
const keys2 = generateKeypair()
|
||||
await seedServer("https://peer-one.example", keys1.signingPublicKey, keys1.encryptionPublicKey)
|
||||
await seedServer("https://peer-two.example", keys2.signingPublicKey, keys2.encryptionPublicKey)
|
||||
|
||||
const res = await request.get(`${BASE}/discover`)
|
||||
expect(res.status()).toBe(200)
|
||||
|
|
@ -448,12 +457,10 @@ test.describe("Information disclosure", () => {
|
|||
for (const peer of body.peers) {
|
||||
expect(peer.url).toBeDefined()
|
||||
expect(peer.isHealthy).toBeDefined()
|
||||
// Internal fields must NOT be exposed
|
||||
expect(peer.id).toBeUndefined()
|
||||
expect(peer.createdAt).toBeUndefined()
|
||||
expect(peer.updatedAt).toBeUndefined()
|
||||
expect(peer.lastSeen).toBeUndefined()
|
||||
expect(peer.isHealthy).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ test("discover server", async ({ request, page }) => {
|
|||
method: "REGISTER",
|
||||
url: new URL(url).toString(),
|
||||
publicKey: process.env.FEDERATION_PUBLIC_KEY!,
|
||||
encryptionPublicKey: process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!,
|
||||
}
|
||||
})
|
||||
const status = response.status()
|
||||
|
|
@ -27,12 +28,14 @@ test("discover server", async ({ request, page }) => {
|
|||
debug("response body: %o", body);
|
||||
expect(status).toBe(200)
|
||||
expect(body).toMatchObject({ message: "Server registered successfully" })
|
||||
expect(body.echo).toBeInstanceOf(Object) // We can't assert the exact object because the echo is generated by the server
|
||||
expect(body.echo).toBeInstanceOf(Object)
|
||||
|
||||
// Insert the server echo into our database
|
||||
await insertServerEcho("http://192.168.3.26:3000", body.echo.publicKey as string);
|
||||
await insertServerEcho(
|
||||
"http://192.168.3.26:3000",
|
||||
body.echo.publicKey as string,
|
||||
body.echo.encryptionPublicKey as string,
|
||||
);
|
||||
|
||||
// check on the database itself if the server was registered
|
||||
const server = await getServerByUrl("http://192.168.3.26:3000");
|
||||
expect(server).toBeDefined()
|
||||
expect(server?.publicKey).toBe(body.echo.publicKey as string)
|
||||
|
|
|
|||
|
|
@ -2,21 +2,25 @@
|
|||
import db from "@/lib/db";
|
||||
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import forge from "node-forge";
|
||||
import nacl from "tweetnacl";
|
||||
|
||||
export function generateKeypair() {
|
||||
const keypair = forge.pki.rsa.generateKeyPair(2048);
|
||||
const signing = nacl.sign.keyPair();
|
||||
const encryption = nacl.box.keyPair();
|
||||
return {
|
||||
publicKey: forge.pki.publicKeyToPem(keypair.publicKey),
|
||||
privateKey: forge.pki.privateKeyToPem(keypair.privateKey),
|
||||
signingPublicKey: Buffer.from(signing.publicKey).toString("base64"),
|
||||
signingSecretKey: Buffer.from(signing.secretKey).toString("base64"),
|
||||
encryptionPublicKey: Buffer.from(encryption.publicKey).toString("base64"),
|
||||
encryptionSecretKey: Buffer.from(encryption.secretKey).toString("base64"),
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedServer(url: string, publicKey: string) {
|
||||
export async function seedServer(url: string, publicKey: string, encryptionPublicKey: string) {
|
||||
await db.insert(serverRegistry).values({
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
publicKey,
|
||||
encryptionPublicKey,
|
||||
lastSeen: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
|
@ -25,13 +29,16 @@ export async function seedServer(url: string, publicKey: string) {
|
|||
}
|
||||
|
||||
export async function seedChallenge(overrides?: Partial<typeof rotateChallengeTokens.$inferInsert>) {
|
||||
const { publicKey: defaultNewPublicKey } = generateKeypair()
|
||||
const keys = generateKeypair()
|
||||
const defaults = {
|
||||
id: crypto.randomUUID(),
|
||||
serverUrl: "https://test-server.com",
|
||||
oldKeyToken: crypto.randomUUID(),
|
||||
newKeyToken: crypto.randomUUID(),
|
||||
newPublicKey: defaultNewPublicKey,
|
||||
signingOldToken: crypto.randomUUID(),
|
||||
signingNewToken: crypto.randomUUID(),
|
||||
encryptionOldToken: crypto.randomUUID(),
|
||||
encryptionNewToken: crypto.randomUUID(),
|
||||
newSigningPublicKey: keys.signingPublicKey,
|
||||
newEncryptionPublicKey: keys.encryptionPublicKey,
|
||||
attemptsLeft: 3,
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 5),
|
||||
|
|
@ -53,11 +60,12 @@ export async function clearRotateChallengeTokens() {
|
|||
return await db.delete(rotateChallengeTokens)
|
||||
}
|
||||
|
||||
export async function insertServerEcho(url: string, publicKey: string) {
|
||||
export async function insertServerEcho(url: string, publicKey: string, encryptionPublicKey: string) {
|
||||
await db.insert(serverRegistry).values({
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
publicKey,
|
||||
encryptionPublicKey,
|
||||
lastSeen: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -2,19 +2,23 @@
|
|||
* Tests the key rotation flow.
|
||||
*
|
||||
* This test covers:
|
||||
* - Missing challenge
|
||||
* - Expired challenge
|
||||
* - Wrong challenge plaintext
|
||||
* - Init endpoint: validation, not-found, duplicate challenge
|
||||
* - Missing challenge on confirm
|
||||
* - Expired challenge on confirm
|
||||
* - Wrong challenge proofs (full init → confirm flow)
|
||||
* - Blacklists server after too many failed attempts
|
||||
* - Confirms valid challenge and rotates key
|
||||
* - Full init → confirm happy path that rotates both keys
|
||||
*/
|
||||
import { expect, test } from "@playwright/test"
|
||||
import createDebug from "debug"
|
||||
import forge from "node-forge"
|
||||
import { clearTables, generateKeypair, seedChallenge, seedServer } from "./helpers/db"
|
||||
import type { EncryptedEnvelope } from "@/lib/federation/keytools"
|
||||
import { decryptPayload, encryptPayload, signMessage } from "@/lib/federation/keytools"
|
||||
import { clearTables, generateKeypair, getServerByUrl, seedChallenge, seedServer } from "./helpers/db"
|
||||
|
||||
const debug = createDebug("test:key")
|
||||
|
||||
const SERVER_URL = "https://test-server.com"
|
||||
|
||||
test.beforeEach(async ({ }, testInfo) => {
|
||||
debug("beforeEach – clearing tables for: %s", testInfo.title)
|
||||
await clearTables()
|
||||
|
|
@ -24,119 +28,252 @@ test.afterEach(async ({ }, testInfo) => {
|
|||
await clearTables()
|
||||
})
|
||||
|
||||
function encryptPayload(payload: string, recipientPublicKey: string) {
|
||||
const pub = forge.pki.publicKeyFromPem(recipientPublicKey);
|
||||
return forge.util.encode64(
|
||||
pub.encrypt(
|
||||
forge.util.encodeUtf8(payload),
|
||||
"RSA-OAEP"
|
||||
)
|
||||
function getOwnEncryptionPublicKey(): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!, "base64"))
|
||||
}
|
||||
|
||||
function buildBadEnvelope() {
|
||||
return encryptPayload(
|
||||
JSON.stringify({
|
||||
signingOldSignature: "wrong",
|
||||
signingNewSignature: "wrong",
|
||||
encryptionOldPlaintext: "wrong",
|
||||
encryptionNewPlaintext: "wrong",
|
||||
}),
|
||||
getOwnEncryptionPublicKey(),
|
||||
)
|
||||
}
|
||||
|
||||
test("rejects missing challenge", async ({ request }) => {
|
||||
debug("test: rejects missing challenge – posting with unknown serverUrl")
|
||||
const res = await request.post("/discover/rotate/confirm", {
|
||||
interface InitChallenges {
|
||||
signingOldChallenge: string
|
||||
signingNewChallenge: string
|
||||
encryptionOldChallenge: EncryptedEnvelope
|
||||
encryptionNewChallenge: EncryptedEnvelope
|
||||
}
|
||||
|
||||
function solveInitChallenges(
|
||||
challenges: InitChallenges,
|
||||
oldKeys: ReturnType<typeof generateKeypair>,
|
||||
newKeys: ReturnType<typeof generateKeypair>,
|
||||
) {
|
||||
const oldSigningSecret = new Uint8Array(Buffer.from(oldKeys.signingSecretKey, "base64"))
|
||||
const newSigningSecret = new Uint8Array(Buffer.from(newKeys.signingSecretKey, "base64"))
|
||||
const oldEncSecret = new Uint8Array(Buffer.from(oldKeys.encryptionSecretKey, "base64"))
|
||||
const newEncSecret = new Uint8Array(Buffer.from(newKeys.encryptionSecretKey, "base64"))
|
||||
|
||||
return {
|
||||
signingOldSignature: signMessage(challenges.signingOldChallenge, oldSigningSecret),
|
||||
signingNewSignature: signMessage(challenges.signingNewChallenge, newSigningSecret),
|
||||
encryptionOldPlaintext: decryptPayload(challenges.encryptionOldChallenge, oldEncSecret),
|
||||
encryptionNewPlaintext: decryptPayload(challenges.encryptionNewChallenge, newEncSecret),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rotate/init tests
|
||||
// ---------------------------------------------------------------------------
|
||||
test("init rejects unregistered server", async ({ request }) => {
|
||||
const newKeys = generateKeypair()
|
||||
const res = await request.post("/discover/rotate/init", {
|
||||
data: {
|
||||
serverUrl: "https://ghost-server.com",
|
||||
signedOldChallenge: "fake",
|
||||
signedNewChallenge: "fake",
|
||||
url: "https://unknown-server.com",
|
||||
newSigningPublicKey: newKeys.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys.encryptionPublicKey,
|
||||
}
|
||||
})
|
||||
debug("test: rejects missing challenge – status %d", res.status())
|
||||
expect(res.status()).toBe(404)
|
||||
})
|
||||
|
||||
test("rejects expired challenge", async ({ request }) => {
|
||||
debug("test: rejects expired challenge – seeding expired challenge")
|
||||
test("init rejects same keys as currently registered", async ({ request }) => {
|
||||
const keys = generateKeypair()
|
||||
await seedServer(SERVER_URL, keys.signingPublicKey, keys.encryptionPublicKey)
|
||||
const res = await request.post("/discover/rotate/init", {
|
||||
data: {
|
||||
url: SERVER_URL,
|
||||
newSigningPublicKey: keys.signingPublicKey,
|
||||
newEncryptionPublicKey: keys.encryptionPublicKey,
|
||||
}
|
||||
})
|
||||
expect(res.status()).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ error: /already registered/i })
|
||||
})
|
||||
|
||||
test("init issues 4 challenges", async ({ request }) => {
|
||||
const oldKeys = generateKeypair()
|
||||
const newKeys = generateKeypair()
|
||||
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
|
||||
|
||||
const res = await request.post("/discover/rotate/init", {
|
||||
data: {
|
||||
url: SERVER_URL,
|
||||
newSigningPublicKey: newKeys.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys.encryptionPublicKey,
|
||||
}
|
||||
})
|
||||
expect(res.status()).toBe(200)
|
||||
|
||||
const body = await res.json()
|
||||
expect(body.signingOldChallenge).toBeDefined()
|
||||
expect(body.signingNewChallenge).toBeDefined()
|
||||
expect(body.encryptionOldChallenge).toBeDefined()
|
||||
expect(body.encryptionOldChallenge.ephemeralPublicKey).toBeDefined()
|
||||
expect(body.encryptionNewChallenge).toBeDefined()
|
||||
expect(body.encryptionNewChallenge.ephemeralPublicKey).toBeDefined()
|
||||
})
|
||||
|
||||
test("init rejects duplicate while challenge is pending", async ({ request }) => {
|
||||
const oldKeys = generateKeypair()
|
||||
const newKeys1 = generateKeypair()
|
||||
const newKeys2 = generateKeypair()
|
||||
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
|
||||
|
||||
const res1 = await request.post("/discover/rotate/init", {
|
||||
data: {
|
||||
url: SERVER_URL,
|
||||
newSigningPublicKey: newKeys1.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys1.encryptionPublicKey,
|
||||
}
|
||||
})
|
||||
expect(res1.status()).toBe(200)
|
||||
|
||||
const res2 = await request.post("/discover/rotate/init", {
|
||||
data: {
|
||||
url: SERVER_URL,
|
||||
newSigningPublicKey: newKeys2.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys2.encryptionPublicKey,
|
||||
}
|
||||
})
|
||||
expect(res2.status()).toBe(409)
|
||||
expect(await res2.json()).toMatchObject({ error: /already pending/i })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rotate/confirm tests
|
||||
// ---------------------------------------------------------------------------
|
||||
test("confirm rejects missing challenge", async ({ request }) => {
|
||||
const res = await request.post("/discover/rotate/confirm", {
|
||||
data: {
|
||||
serverUrl: "https://ghost-server.com",
|
||||
envelope: buildBadEnvelope(),
|
||||
}
|
||||
})
|
||||
expect(res.status()).toBe(404)
|
||||
})
|
||||
|
||||
test("confirm rejects expired challenge", async ({ request }) => {
|
||||
await seedChallenge({ expiresAt: new Date(Date.now() - 1000) })
|
||||
const res = await request.post("/discover/rotate/confirm", {
|
||||
data: {
|
||||
serverUrl: "https://test-server.com",
|
||||
signedOldChallenge: "fake",
|
||||
signedNewChallenge: "fake",
|
||||
serverUrl: SERVER_URL,
|
||||
envelope: buildBadEnvelope(),
|
||||
}
|
||||
})
|
||||
debug("test: rejects expired challenge – status %d", res.status())
|
||||
expect(res.status()).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ error: /expired/ })
|
||||
})
|
||||
|
||||
test("rejects wrong challenge plaintext", async ({ request }) => {
|
||||
debug("test: rejects wrong challenge plaintext – seeding valid challenge")
|
||||
await seedChallenge()
|
||||
debug("test: rejects wrong challenge plaintext – posting with incorrect plaintexts")
|
||||
const res = await request.post("/discover/rotate/confirm", {
|
||||
test("confirm rejects wrong proofs (init → confirm)", async ({ request }) => {
|
||||
const oldKeys = generateKeypair()
|
||||
const newKeys = generateKeypair()
|
||||
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
|
||||
|
||||
debug("test: wrong proofs – calling init")
|
||||
const initRes = await request.post("/discover/rotate/init", {
|
||||
data: {
|
||||
serverUrl: "https://test-server.com",
|
||||
// encrypt wrong plaintexts with your server's public key
|
||||
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
url: SERVER_URL,
|
||||
newSigningPublicKey: newKeys.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys.encryptionPublicKey,
|
||||
}
|
||||
})
|
||||
debug("test: rejects wrong challenge plaintext – status %d", res.status())
|
||||
expect(res.status()).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ error: /mismatch/ })
|
||||
expect(initRes.status()).toBe(200)
|
||||
|
||||
debug("test: wrong proofs – confirming with garbage proofs")
|
||||
const confirmRes = await request.post("/discover/rotate/confirm", {
|
||||
data: {
|
||||
serverUrl: SERVER_URL,
|
||||
envelope: buildBadEnvelope(),
|
||||
}
|
||||
})
|
||||
expect(confirmRes.status()).toBe(400)
|
||||
expect(await confirmRes.json()).toMatchObject({ error: /failed/i })
|
||||
})
|
||||
|
||||
test("blacklists server after too many failed attempts", async ({ request }) => {
|
||||
debug("test: blacklists server after too many failed attempts – seeding server and challenge (attemptsLeft=3)")
|
||||
await seedServer("https://test-server.com", process.env.FEDERATION_PUBLIC_KEY!)
|
||||
await seedChallenge({ expiresAt: new Date(Date.now() + 1000 * 60) })
|
||||
test("confirm blacklists after too many failed attempts", async ({ request }) => {
|
||||
const oldKeys = generateKeypair()
|
||||
const newKeys = generateKeypair()
|
||||
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
|
||||
|
||||
// 3 wrong attempts exhaust attemptsLeft (3 → 0), each returning 400 mismatch
|
||||
for (let i = 0; i < 3; i++) {
|
||||
debug("test: blacklists server after too many failed attempts – wrong attempt %d/3", i + 1)
|
||||
const res = await request.post("/discover/rotate/confirm", {
|
||||
data: {
|
||||
serverUrl: "https://test-server.com",
|
||||
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
}
|
||||
})
|
||||
debug("test: blacklists server after too many failed attempts – status %d", res.status())
|
||||
expect(res.status()).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ error: /mismatch/ })
|
||||
}
|
||||
|
||||
// 4th attempt: attemptsLeft is now 0, server gets blacklisted
|
||||
debug("test: blacklists server after too many failed attempts – 4th attempt should trigger blacklist (403)")
|
||||
const finalRes = await request.post("/discover/rotate/confirm", {
|
||||
debug("test: blacklists – calling init")
|
||||
const initRes = await request.post("/discover/rotate/init", {
|
||||
data: {
|
||||
serverUrl: "https://test-server.com",
|
||||
signedOldChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
signedNewChallenge: encryptPayload("wrong", process.env.FEDERATION_PUBLIC_KEY!),
|
||||
url: SERVER_URL,
|
||||
newSigningPublicKey: newKeys.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys.encryptionPublicKey,
|
||||
}
|
||||
})
|
||||
expect(initRes.status()).toBe(200)
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
debug("test: blacklists – wrong attempt %d/3", i + 1)
|
||||
const res = await request.post("/discover/rotate/confirm", {
|
||||
data: {
|
||||
serverUrl: SERVER_URL,
|
||||
envelope: buildBadEnvelope(),
|
||||
}
|
||||
})
|
||||
expect(res.status()).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ error: /failed/i })
|
||||
}
|
||||
|
||||
debug("test: blacklists – 4th attempt triggers blacklist")
|
||||
const finalRes = await request.post("/discover/rotate/confirm", {
|
||||
data: {
|
||||
serverUrl: SERVER_URL,
|
||||
envelope: buildBadEnvelope(),
|
||||
}
|
||||
})
|
||||
debug("test: blacklists server after too many failed attempts – final status %d", finalRes.status())
|
||||
expect(finalRes.status()).toBe(403)
|
||||
expect(await finalRes.json()).toMatchObject({ error: /blacklisted/ })
|
||||
})
|
||||
|
||||
test("confirms valid challenge and rotates key", async ({ request }) => {
|
||||
debug("test: confirms valid challenge – generating old and new keypairs")
|
||||
// SB's old keypair — what is currently registered
|
||||
const { publicKey: oldPublicKey } = generateKeypair()
|
||||
// SB's new keypair — what SB wants to rotate to
|
||||
const { publicKey: newPublicKey } = generateKeypair()
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full init → confirm happy path
|
||||
// ---------------------------------------------------------------------------
|
||||
test("full rotation flow: init → solve → confirm rotates both keys", async ({ request }) => {
|
||||
const oldKeys = generateKeypair()
|
||||
const newKeys = generateKeypair()
|
||||
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
|
||||
|
||||
debug("test: confirms valid challenge – seeding server and challenge")
|
||||
await seedServer("https://test-server.com", oldPublicKey)
|
||||
const challenge = await seedChallenge({ newPublicKey })
|
||||
|
||||
// Simulate SB: re-encrypt the plaintext tokens with SA's public key
|
||||
debug("test: confirms valid challenge – re-encrypting tokens with SA public key")
|
||||
const signedOldChallenge = encryptPayload(challenge.oldKeyToken, process.env.FEDERATION_PUBLIC_KEY!)
|
||||
const signedNewChallenge = encryptPayload(challenge.newKeyToken, process.env.FEDERATION_PUBLIC_KEY!)
|
||||
|
||||
const res = await request.post("/discover/rotate/confirm", {
|
||||
debug("test: full flow – calling init")
|
||||
const initRes = await request.post("/discover/rotate/init", {
|
||||
data: {
|
||||
serverUrl: "https://test-server.com",
|
||||
signedOldChallenge,
|
||||
signedNewChallenge,
|
||||
url: SERVER_URL,
|
||||
newSigningPublicKey: newKeys.signingPublicKey,
|
||||
newEncryptionPublicKey: newKeys.encryptionPublicKey,
|
||||
}
|
||||
})
|
||||
debug("test: confirms valid challenge – status %d", res.status())
|
||||
expect(res.status()).toBe(200)
|
||||
expect(await res.json()).toMatchObject({ message: /confirmed/ })
|
||||
expect(initRes.status()).toBe(200)
|
||||
const challenges: InitChallenges = await initRes.json()
|
||||
|
||||
debug("test: full flow – solving challenges")
|
||||
const proofs = solveInitChallenges(challenges, oldKeys, newKeys)
|
||||
|
||||
debug("test: full flow – building proof envelope encrypted with SA's X25519 key")
|
||||
const envelope = encryptPayload(JSON.stringify(proofs), getOwnEncryptionPublicKey())
|
||||
|
||||
debug("test: full flow – confirming")
|
||||
const confirmRes = await request.post("/discover/rotate/confirm", {
|
||||
data: {
|
||||
serverUrl: SERVER_URL,
|
||||
envelope,
|
||||
}
|
||||
})
|
||||
expect(confirmRes.status()).toBe(200)
|
||||
expect(await confirmRes.json()).toMatchObject({ message: /confirmed/ })
|
||||
|
||||
debug("test: full flow – verifying keys were rotated in DB")
|
||||
const server = await getServerByUrl(SERVER_URL)
|
||||
expect(server).toBeDefined()
|
||||
expect(server!.publicKey).toBe(newKeys.signingPublicKey)
|
||||
expect(server!.encryptionPublicKey).toBe(newKeys.encryptionPublicKey)
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue