feat: implement server discovery and key rotation functionality
- Added new routes for server discovery and key rotation, including challenge issuance and confirmation processes. - Introduced database schema for managing server registrations and rotation challenges. - Implemented encryption and decryption utilities for secure communication between servers. - Updated package dependencies and added new client and server plugins for social features. - Enhanced user management with additional fields and relations in the database schema.
This commit is contained in:
parent
b1b80dd75b
commit
ea172050a6
23 changed files with 1313 additions and 139 deletions
43
bun.lock
43
bun.lock
|
|
@ -14,11 +14,12 @@
|
|||
"bullmq": "^5.70.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"debug": "^4.4.3",
|
||||
"dexie": "^4.3.0",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^12.35.0",
|
||||
"framer-motion": "^12.35.2",
|
||||
"http-signature": "^1.4.0",
|
||||
"ioredis": "^5.10.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
|
|
@ -26,7 +27,7 @@
|
|||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-forge": "^1.3.3",
|
||||
"nodemailer": "^8.0.1",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
|
|
@ -42,7 +43,9 @@
|
|||
"@react-email/preview-server": "5.2.9",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/node": "^20.19.37",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^19.2.14",
|
||||
|
|
@ -52,7 +55,7 @@
|
|||
"cross-env": "^10.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"react-email": "5.2.9",
|
||||
"shadcn": "^3.8.5",
|
||||
"shadcn": "^4.0.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
|
@ -620,7 +623,13 @@
|
|||
|
||||
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="],
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
|
@ -922,7 +931,7 @@
|
|||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.35.0", "", { "dependencies": { "motion-dom": "^12.35.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ=="],
|
||||
"framer-motion": ["framer-motion@12.35.2", "", { "dependencies": { "motion-dom": "^12.35.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
|
|
@ -1166,7 +1175,7 @@
|
|||
|
||||
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.35.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA=="],
|
||||
"motion-dom": ["motion-dom@12.35.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
|
||||
|
||||
|
|
@ -1208,7 +1217,7 @@
|
|||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="],
|
||||
"nodemailer": ["nodemailer@8.0.2", "", {}, "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
|
|
@ -1406,7 +1415,7 @@
|
|||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shadcn": ["shadcn@3.8.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-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="],
|
||||
"shadcn": ["shadcn@4.0.2", "", { "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-U6BxywcVt5Vy+iLdjoyWTPcuz2RQNad09tjJsW8hyKtxlx7VAvzHIvoNxvXN+gxWt/HX8kt/cVNk8AXcmPUaNQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -1520,7 +1529,7 @@
|
|||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
|
|
@ -1634,6 +1643,8 @@
|
|||
|
||||
"@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=="],
|
||||
|
|
@ -1776,10 +1787,20 @@
|
|||
|
||||
"@prisma/config/c12/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"bullmq/ioredis/@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
|
@ -1838,6 +1859,8 @@
|
|||
|
||||
"drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"engine.io/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"giget/nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -16,6 +16,7 @@
|
|||
"dev": "cross-env NODE_ENV=development tsx src/server.ts",
|
||||
"email:dev": "cross-env NODE_ENV=development email dev --dir src/lib/mail/templates --port 3001",
|
||||
"test": "cross-env NODE_ENV=test playwright test",
|
||||
"test:key": "cross-env NODE_ENV=test playwright test tests/key.test.ts",
|
||||
"build": "next build",
|
||||
"start": "cross-env NODE_ENV=production node src/server.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
|
|
@ -33,11 +34,12 @@
|
|||
"bullmq": "^5.70.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"debug": "^4.4.3",
|
||||
"dexie": "^4.3.0",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^12.35.0",
|
||||
"framer-motion": "^12.35.2",
|
||||
"http-signature": "^1.4.0",
|
||||
"ioredis": "^5.10.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
|
|
@ -45,7 +47,7 @@
|
|||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-forge": "^1.3.3",
|
||||
"nodemailer": "^8.0.1",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
|
|
@ -61,7 +63,9 @@
|
|||
"@react-email/preview-server": "5.2.9",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/node": "^20.19.37",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^19.2.14",
|
||||
|
|
@ -71,7 +75,7 @@
|
|||
"cross-env": "^10.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"react-email": "5.2.9",
|
||||
"shadcn": "^3.8.5",
|
||||
"shadcn": "^4.0.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
|
|
|||
133
src/app/discover/rotate/confirm/route.ts
Normal file
133
src/app/discover/rotate/confirm/route.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import db from "@/lib/db";
|
||||
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
|
||||
import { decryptPayload } from "@/lib/federation/keytools";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import createDebug from "debug";
|
||||
|
||||
const debug = createDebug("app:discover:rotate:confirm");
|
||||
|
||||
/**
|
||||
* Confirms a key rotation challenge issued by /discover/rotate/init.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
debug("POST /discover/rotate/confirm – confirmation request for %s", body?.serverUrl);
|
||||
|
||||
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(),
|
||||
}).safeParse(body);
|
||||
|
||||
if (!validated.success) {
|
||||
debug("POST /discover/rotate/confirm – validation failed: %o", validated.error.message);
|
||||
return NextResponse.json({ error: validated.error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
debug("POST /discover/rotate/confirm – fetching pending challenge for %s", validated.data.serverUrl);
|
||||
const [challenge] = await db.select().from(rotateChallengeTokens)
|
||||
.where(eq(rotateChallengeTokens.serverUrl, validated.data.serverUrl));
|
||||
|
||||
if (!challenge) {
|
||||
debug("POST /discover/rotate/confirm – no pending challenge found");
|
||||
return NextResponse.json({ error: "No pending rotation challenge found for this server." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (challenge.expiresAt < new Date()) {
|
||||
debug("POST /discover/rotate/confirm – challenge expired at %s", challenge.expiresAt.toISOString());
|
||||
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, challenge.id));
|
||||
return NextResponse.json({ error: "Challenge token has expired." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (challenge.attemptsLeft <= 0) {
|
||||
debug("POST /discover/rotate/confirm – no attempts left, blacklisting %s", challenge.serverUrl);
|
||||
await db.insert(blacklistedServers).values({
|
||||
id: crypto.randomUUID(),
|
||||
serverUrl: challenge.serverUrl,
|
||||
reason: "Too many failed attempts to confirm key rotation challenge",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, challenge.id));
|
||||
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;
|
||||
try {
|
||||
decryptedOld = decryptPayload(validated.data.signedOldChallenge, process.env.FEDERATION_PRIVATE_KEY!);
|
||||
decryptedNew = decryptPayload(validated.data.signedNewChallenge, process.env.FEDERATION_PRIVATE_KEY!);
|
||||
} catch {
|
||||
debug("POST /discover/rotate/confirm – decryption failed, decrementing attempts");
|
||||
await db.update(rotateChallengeTokens).set({
|
||||
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
|
||||
}).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.`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Both plaintexts must match their stored tokens.
|
||||
// A mismatch on oldKeyChallenge means the requester does not hold the registered private key.
|
||||
// A mismatch on newKeyChallenge means the requester does not actually own the new key.
|
||||
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",
|
||||
);
|
||||
await db.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.`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Both challenges passed:
|
||||
// — SA holds the old private key (they are who they claim to be)
|
||||
// — SA holds the new private key (they own the key they want to rotate to)
|
||||
// — SA knows our public key (they fetched our identity to re-encrypt)
|
||||
debug("POST /discover/rotate/confirm – both challenges passed, rotating key for %s", challenge.serverUrl);
|
||||
await db.update(serverRegistry).set({
|
||||
publicKey: challenge.newPublicKey,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(serverRegistry.url, challenge.serverUrl));
|
||||
|
||||
await db.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, challenge.id));
|
||||
|
||||
debug("POST /discover/rotate/confirm – key rotation complete for %s", challenge.serverUrl);
|
||||
return NextResponse.json({ message: "Key rotation confirmed successfully." });
|
||||
}
|
||||
96
src/app/discover/rotate/init/route.ts
Normal file
96
src/app/discover/rotate/init/route.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import db from "@/lib/db";
|
||||
import { 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) => {
|
||||
try {
|
||||
const pub = forge.pki.publicKeyFromPem(key);
|
||||
return pub.n.bitLength() >= 4096;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { message: "Invalid public key" });
|
||||
|
||||
const schema = z.object({
|
||||
url: z.url(),
|
||||
newPublicKey: publicKeySchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
debug("POST /discover/rotate/init – rotation request for %s", body?.url);
|
||||
|
||||
const validated = schema.safeParse(body);
|
||||
if (!validated.success) {
|
||||
debug("POST /discover/rotate/init – validation failed: %o", validated.error.message);
|
||||
return NextResponse.json({ error: validated.error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
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) {
|
||||
debug("POST /discover/rotate/init – server not found");
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
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);
|
||||
|
||||
await db.insert(rotateChallengeTokens).values({
|
||||
id: crypto.randomUUID(),
|
||||
oldKeyToken: oldKeyPlaintext,
|
||||
newKeyToken: newKeyPlaintext,
|
||||
newPublicKey: validated.data.newPublicKey,
|
||||
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 });
|
||||
}
|
||||
137
src/app/discover/route.ts
Normal file
137
src/app/discover/route.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import db from "@/lib/db";
|
||||
import { serverRegistry } from "@/lib/db/schema";
|
||||
import { decryptPayload } 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");
|
||||
|
||||
export async function GET() {
|
||||
debug("GET /discover – fetching healthy peers");
|
||||
const peers = await db.select().from(serverRegistry).where(eq(serverRegistry.isHealthy, true));
|
||||
debug("GET /discover – found %d peer(s)", peers.length);
|
||||
|
||||
return NextResponse.json({
|
||||
url: process.env.NEXT_PUBLIC_APP_URL,
|
||||
publicKey: process.env.FEDERATION_PUBLIC_KEY,
|
||||
peers
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertServer(url: string, publicKey: string) {
|
||||
return await db.insert(serverRegistry).values({
|
||||
id: crypto.randomUUID(),
|
||||
url: url,
|
||||
publicKey: publicKey,
|
||||
lastSeen: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isHealthy: true,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
|
||||
const publicKeySchema = z.string().refine((key) => {
|
||||
try {
|
||||
const pub = forge.pki.publicKeyFromPem(key);
|
||||
return pub.n.bitLength() >= 4096;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { message: "Invalid public key" });
|
||||
|
||||
const schema = z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z.literal("DISCOVER"),
|
||||
publicKey: publicKeySchema,
|
||||
signature: z.string().refine((signature) => {
|
||||
try {
|
||||
const sig = decryptPayload(signature, process.env.FEDERATION_PRIVATE_KEY!);
|
||||
const data = JSON.parse(sig);
|
||||
return data.publicKey != null && data.url != null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { message: "Invalid signature" }),
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal("REGISTER"),
|
||||
url: z.url(),
|
||||
publicKey: publicKeySchema,
|
||||
})
|
||||
]);
|
||||
|
||||
async function discoverServer(validated: Extract<z.infer<typeof schema>, { method: "DISCOVER" }>) {
|
||||
debug("DISCOVER – looking up server by public key");
|
||||
const server = await db.select().from(serverRegistry).where(eq(serverRegistry.publicKey, validated.publicKey));
|
||||
if (server.length === 0) {
|
||||
debug("DISCOVER – server not found");
|
||||
return NextResponse.json({ error: "Server not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const confirmations = {
|
||||
sameKeyOnServer: false,
|
||||
sameKeyOnFetch: false,
|
||||
}
|
||||
|
||||
if (server[0].publicKey === validated.publicKey) confirmations.sameKeyOnServer = true;
|
||||
debug("DISCOVER – fetching public key from federation server %s", server[0].url);
|
||||
const federationResponse = await (await fetch(server[0].url + "/discover")).json();
|
||||
if (federationResponse.publicKey === validated.publicKey) confirmations.sameKeyOnFetch = true;
|
||||
|
||||
debug("DISCOVER – confirmations: %o", confirmations);
|
||||
return NextResponse.json(confirmations);
|
||||
}
|
||||
|
||||
async function registerServer(validated: Extract<z.infer<typeof schema>, { method: "REGISTER" }>) {
|
||||
debug("REGISTER – fetching /discover from %s to validate server", validated.url);
|
||||
const response = await (await fetch(validated.url + "/discover")).json();
|
||||
if (!response.publicKey) {
|
||||
debug("REGISTER – remote server returned no public key");
|
||||
return NextResponse.json({ error: "Invalid server" }, { status: 400 });
|
||||
} else if (response.publicKey !== validated.publicKey) {
|
||||
debug("REGISTER – public key mismatch: provided vs fetched");
|
||||
return NextResponse.json({ error: "Invalid public key" }, { 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 });
|
||||
}
|
||||
|
||||
debug("REGISTER – upserting server %s", validated.url);
|
||||
await upsertServer(validated.url.toString(), validated.publicKey);
|
||||
|
||||
debug("REGISTER – server registered successfully");
|
||||
return NextResponse.json({
|
||||
message: "Server registered successfully", echo: {
|
||||
url: process.env.NEXT_PUBLIC_APP_URL,
|
||||
publicKey: process.env.FEDERATION_PUBLIC_KEY,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
debug("POST /discover – method: %s", body?.method);
|
||||
|
||||
const validated = schema.safeParse(body);
|
||||
|
||||
if (!validated.success) {
|
||||
debug("POST /discover – validation failed: %o", validated.error.message);
|
||||
return NextResponse.json({ error: validated.error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
switch (validated.data.method) {
|
||||
case "DISCOVER":
|
||||
return await discoverServer(validated.data);
|
||||
case "REGISTER":
|
||||
return await registerServer(validated.data);
|
||||
default:
|
||||
return NextResponse.json({ error: "Invalid method" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { twoFactorClient, usernameClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { sipherSocialClientPlugin } from "./plugins/client/social";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
fetchOptions: {},
|
||||
plugins: [
|
||||
usernameClient(),
|
||||
twoFactorClient(),
|
||||
sipherSocialClientPlugin(),
|
||||
]
|
||||
})
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { federation } from "@/plugins/server/federation";
|
||||
import { sipherSocial } from '@/plugins/server/social';
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
import { bearer, haveIBeenPwned, testUtils, twoFactor, username } from "better-auth/plugins";
|
||||
import { bearer, haveIBeenPwned, openAPI, testUtils, twoFactor, username } from "better-auth/plugins";
|
||||
import db from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import EmailService from "./mail";
|
||||
|
|
@ -54,11 +56,24 @@ export const auth = betterAuth({
|
|||
twoFactor(),
|
||||
bearer(),
|
||||
haveIBeenPwned(),
|
||||
sipherSocial(),
|
||||
federation(),
|
||||
openAPI(),
|
||||
testUtils() // TODO: Add a conditional plugin for test utils in development
|
||||
],
|
||||
// This is disabled by default, but I'll keep this here for ease of mind.
|
||||
// You never know when companies will change their minds and decide to start tracking you.
|
||||
telemetry: {
|
||||
enabled: false
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
isPrivate: {
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
required: false,
|
||||
index: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1,5 +1,14 @@
|
|||
import { relations } from "drizzle-orm";
|
||||
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
|
|
@ -15,6 +24,7 @@ export const user = pgTable("user", {
|
|||
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(
|
||||
|
|
@ -92,10 +102,116 @@ export const twoFactor = pgTable(
|
|||
],
|
||||
);
|
||||
|
||||
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),
|
||||
],
|
||||
);
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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),
|
||||
],
|
||||
);
|
||||
|
||||
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)],
|
||||
);
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
|
|
@ -118,3 +234,52 @@ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
|||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const postsRelations = relations(posts, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [posts.authorId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const followsFollowerIdRelations = relations(follows, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [follows.followerId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const followsFollowingIdRelations = relations(follows, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [follows.followingId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const mutesUserIdRelations = relations(mutes, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [mutes.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const mutesMutedUserIdRelations = relations(mutes, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [mutes.mutedUserId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const blocksBlockerIdRelations = relations(blocks, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [blocks.blockerId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const blocksBlockedUserIdRelations = relations(blocks, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [blocks.blockedUserId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
import { relations } from "drizzle-orm";
|
||||
import { boolean, index, pgTable, text, timestamp } 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),
|
||||
});
|
||||
|
||||
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)],
|
||||
);
|
||||
|
||||
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)],
|
||||
);
|
||||
|
||||
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)],
|
||||
);
|
||||
|
||||
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),
|
||||
],
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
twoFactors: many(twoFactor),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
30
src/lib/federation/keygen.ts
Normal file
30
src/lib/federation/keygen.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import Bun from "bun";
|
||||
import forge from "node-forge";
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// 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\nDO 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}"`);
|
||||
|
||||
console.log("Keys generated successfully");
|
||||
return keys;
|
||||
}
|
||||
49
src/lib/federation/keytools.ts
Normal file
49
src/lib/federation/keytools.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import forge from "node-forge";
|
||||
|
||||
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 function decryptPayload(payload: string, privateKey: string) {
|
||||
const priv = forge.pki.privateKeyFromPem(privateKey);
|
||||
try {
|
||||
return forge.util.decodeUtf8(
|
||||
priv.decrypt(
|
||||
forge.util.decode64(payload),
|
||||
"RSA-OAEP"
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt payload", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyChallenge(
|
||||
challenge: string,
|
||||
signedChallenge: string,
|
||||
publicKeyPem: string
|
||||
): boolean {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
26
src/lib/plugins/client/social.ts
Normal file
26
src/lib/plugins/client/social.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { BetterAuthClientPlugin } from "better-auth/client";
|
||||
import { z } from "zod";
|
||||
import { postContentSchema } from "../server/helpers/social/social";
|
||||
import type { sipherSocial } from "../server/social";
|
||||
|
||||
type SipherSocialPlugin = typeof sipherSocial;
|
||||
|
||||
export const sipherSocialClientPlugin = () => {
|
||||
return {
|
||||
id: "sipher-social",
|
||||
$InferServerPlugin: {} as ReturnType<SipherSocialPlugin>,
|
||||
getActions($fetch, $store, options) {
|
||||
return {
|
||||
createPost: async (content: z.infer<typeof postContentSchema>) => {
|
||||
const response = await $fetch("/social/posts", {
|
||||
method: "POST",
|
||||
body: {
|
||||
content,
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
},
|
||||
} satisfies BetterAuthClientPlugin;
|
||||
};
|
||||
105
src/lib/plugins/server/federation.ts
Normal file
105
src/lib/plugins/server/federation.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { BetterAuthPlugin } from "better-auth";
|
||||
|
||||
export const federation = () => {
|
||||
return {
|
||||
id: "sipher-federation",
|
||||
schema: {
|
||||
serverRegistry: {
|
||||
fields: {
|
||||
url: {
|
||||
type: "string",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: false
|
||||
},
|
||||
publicKey: {
|
||||
type: "string",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
lastSeen: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
isHealthy: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
index: false
|
||||
}
|
||||
}
|
||||
},
|
||||
rotateChallengeTokens: {
|
||||
fields: {
|
||||
oldKeyToken: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
newKeyToken: {
|
||||
type: "string",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
newPublicKey: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
serverUrl: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
attemptsLeft: {
|
||||
type: "number",
|
||||
required: true,
|
||||
index: false,
|
||||
defaultValue: 3
|
||||
},
|
||||
expiresAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
}
|
||||
}
|
||||
},
|
||||
blacklistedServers: {
|
||||
fields: {
|
||||
serverUrl: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies BetterAuthPlugin;
|
||||
}
|
||||
17
src/lib/plugins/server/helpers/social/endpoints/blocks.ts
Normal file
17
src/lib/plugins/server/helpers/social/endpoints/blocks.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createAuthEndpoint } from "better-auth/api"
|
||||
import { z } from "zod"
|
||||
|
||||
export const createBlock = createAuthEndpoint("/social/blocks", {
|
||||
method: "POST",
|
||||
}, async (context) => { })
|
||||
|
||||
export const deleteBlock = createAuthEndpoint("/social/blocks/:id", {
|
||||
method: "DELETE",
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
}, async (context) => { })
|
||||
|
||||
export const getBlocks = createAuthEndpoint("/social/blocks", {
|
||||
method: "GET",
|
||||
}, async (context) => { })
|
||||
21
src/lib/plugins/server/helpers/social/endpoints/follows.ts
Normal file
21
src/lib/plugins/server/helpers/social/endpoints/follows.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createAuthEndpoint } from "better-auth/api"
|
||||
import { z } from "zod"
|
||||
|
||||
export const followUser = createAuthEndpoint("/social/follows", {
|
||||
method: "POST",
|
||||
}, async (context) => { })
|
||||
|
||||
export const unfollowUser = createAuthEndpoint("/social/follows/:id", {
|
||||
method: "DELETE",
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
}, async (context) => { })
|
||||
|
||||
export const getFollows = createAuthEndpoint("/social/follows/following", {
|
||||
method: "GET",
|
||||
}, async (context) => { })
|
||||
|
||||
export const getFollowers = createAuthEndpoint("/social/follows/followers", {
|
||||
method: "GET",
|
||||
}, async (context) => { })
|
||||
7
src/lib/plugins/server/helpers/social/endpoints/index.ts
Normal file
7
src/lib/plugins/server/helpers/social/endpoints/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createBlock, deleteBlock, getBlocks } from "./blocks";
|
||||
import { followUser, getFollowers, getFollows, unfollowUser } from "./follows";
|
||||
import { createMute, deleteMute, getMutes } from "./mutes";
|
||||
import { createPost, getPost } from "./posts";
|
||||
|
||||
export { createBlock, createMute, createPost, deleteBlock, deleteMute, followUser, getBlocks, getFollowers, getFollows, getMutes, getPost, unfollowUser };
|
||||
|
||||
18
src/lib/plugins/server/helpers/social/endpoints/mutes.ts
Normal file
18
src/lib/plugins/server/helpers/social/endpoints/mutes.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createAuthEndpoint } from "better-auth/api"
|
||||
import { z } from "zod"
|
||||
|
||||
export const createMute = createAuthEndpoint("/social/mutes", {
|
||||
method: "POST",
|
||||
}, async (context) => { })
|
||||
|
||||
export const deleteMute = createAuthEndpoint("/social/mutes/:id", {
|
||||
method: "DELETE",
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
}, async (context) => { })
|
||||
|
||||
export const getMutes = createAuthEndpoint("/social/mutes", {
|
||||
method: "GET",
|
||||
}, async (context) => { })
|
||||
|
||||
25
src/lib/plugins/server/helpers/social/endpoints/posts.ts
Normal file
25
src/lib/plugins/server/helpers/social/endpoints/posts.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
|
||||
import { z } from "zod";
|
||||
import { postContentSchema } from "../social";
|
||||
|
||||
export const createPost = createAuthEndpoint("/social/posts", {
|
||||
method: "POST",
|
||||
body: postContentSchema,
|
||||
}, async (context) => {
|
||||
const content = context.body;
|
||||
const user = getSessionFromCtx(context)
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
console.log(content);
|
||||
return context.json({ message: "Hello, world!" }, { status: 200 });
|
||||
});
|
||||
|
||||
export const getPost = createAuthEndpoint("/social/posts/:id", {
|
||||
method: "GET",
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
}, async (context) => { })
|
||||
219
src/lib/plugins/server/helpers/social/social.ts
Normal file
219
src/lib/plugins/server/helpers/social/social.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { BetterAuthPluginDBSchema } from "better-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
const postContentBlockSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("text"),
|
||||
value: z.string().min(1, "Text content cannot be empty"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("image"),
|
||||
url: z.url("Image must be a valid URL"),
|
||||
index: z.number().min(0, "Index must be a positive number"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("video"),
|
||||
url: z.url("Video must be a valid URL"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("audio"),
|
||||
url: z.url("Audio must be a valid URL"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("link"),
|
||||
url: z.url("Link must be a valid URL"),
|
||||
}),
|
||||
], { error: 'Block "type" must be one of: text, image, video, audio, link' });
|
||||
|
||||
export const postContentSchema = z
|
||||
.array(postContentBlockSchema, { error: "Post content must be an array of blocks" })
|
||||
.min(1, "Post must contain at least one content block");
|
||||
|
||||
export default {
|
||||
posts: {
|
||||
fields: {
|
||||
content: {
|
||||
type: "json",
|
||||
required: true,
|
||||
index: false,
|
||||
transform: {
|
||||
output: (value) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof value === "string" ? JSON.parse(value) : value;
|
||||
} catch {
|
||||
throw new Error("Post content is not valid JSON");
|
||||
}
|
||||
|
||||
const validated = postContentSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
const issues = validated.error.issues
|
||||
.map((i) => `[${i.path.join(".")}] ${i.message}`)
|
||||
.join("; ");
|
||||
throw new Error(`Invalid post content: ${issues}`);
|
||||
}
|
||||
|
||||
return validated.data;
|
||||
}
|
||||
}
|
||||
},
|
||||
authorId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false,
|
||||
references: {
|
||||
model: "user",
|
||||
field: "id"
|
||||
}
|
||||
},
|
||||
published: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false,
|
||||
},
|
||||
// "isLocal" will be used to determine if the post should only exist
|
||||
// on the local server or if it should be propagated to other servers
|
||||
isLocal: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
index: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
// "isPrivate" will be used to determine if the post should be visible only for the user's followers
|
||||
isPrivate: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
index: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
}
|
||||
}
|
||||
},
|
||||
follows: {
|
||||
fields: {
|
||||
followerId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false,
|
||||
references: {
|
||||
model: "user",
|
||||
field: "id"
|
||||
}
|
||||
},
|
||||
followingId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false,
|
||||
references: {
|
||||
model: "user",
|
||||
field: "id"
|
||||
}
|
||||
},
|
||||
accepted: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
index: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
}
|
||||
}
|
||||
},
|
||||
deliveryJobs: {
|
||||
fields: {
|
||||
targetUrl: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
// This could be encrypted, so we're not using a transform function to check for validity
|
||||
payload: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false
|
||||
},
|
||||
attempts: {
|
||||
type: "number",
|
||||
required: true,
|
||||
index: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
lastAttemptedAt: {
|
||||
type: "date",
|
||||
required: false,
|
||||
index: false,
|
||||
},
|
||||
nextAttemptAt: {
|
||||
type: "date",
|
||||
required: false,
|
||||
index: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
}
|
||||
}
|
||||
},
|
||||
mutes: {
|
||||
fields: {
|
||||
userId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false,
|
||||
references: {
|
||||
model: "user",
|
||||
field: "id"
|
||||
}
|
||||
},
|
||||
mutedUserId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false,
|
||||
references: {
|
||||
model: "user",
|
||||
field: "id"
|
||||
}
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
}
|
||||
}
|
||||
},
|
||||
blocks: {
|
||||
fields: {
|
||||
blockerId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false,
|
||||
references: {
|
||||
model: "user",
|
||||
field: "id"
|
||||
}
|
||||
},
|
||||
blockedUserId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
index: false,
|
||||
references: {
|
||||
model: "user",
|
||||
field: "id"
|
||||
}
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
index: false
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies BetterAuthPluginDBSchema
|
||||
14
src/lib/plugins/server/social.ts
Normal file
14
src/lib/plugins/server/social.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { BetterAuthPlugin } from "better-auth";
|
||||
|
||||
import * as socialEndpoints from "./helpers/social/endpoints";
|
||||
import socialSchema from "./helpers/social/social";
|
||||
|
||||
export const sipherSocial = () => {
|
||||
return {
|
||||
id: "sipher-social",
|
||||
schema: socialSchema,
|
||||
endpoints: {
|
||||
...socialEndpoints,
|
||||
}
|
||||
} satisfies BetterAuthPlugin;
|
||||
}
|
||||
46
tests/helpers/db.ts
Normal file
46
tests/helpers/db.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// tests/helpers/db.ts
|
||||
import db from "@/lib/db";
|
||||
import { rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
|
||||
import forge from "node-forge";
|
||||
|
||||
export function generateKeypair() {
|
||||
const keypair = forge.pki.rsa.generateKeyPair(2048);
|
||||
return {
|
||||
publicKey: forge.pki.publicKeyToPem(keypair.publicKey),
|
||||
privateKey: forge.pki.privateKeyToPem(keypair.privateKey),
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedServer(url: string, publicKey: string) {
|
||||
await db.insert(serverRegistry).values({
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
publicKey,
|
||||
lastSeen: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isHealthy: true,
|
||||
}).onConflictDoNothing()
|
||||
}
|
||||
|
||||
export async function seedChallenge(overrides?: Partial<typeof rotateChallengeTokens.$inferInsert>) {
|
||||
const { publicKey: defaultNewPublicKey } = generateKeypair()
|
||||
const defaults = {
|
||||
id: crypto.randomUUID(),
|
||||
serverUrl: "https://test-server.com",
|
||||
oldKeyToken: crypto.randomUUID(),
|
||||
newKeyToken: crypto.randomUUID(),
|
||||
newPublicKey: defaultNewPublicKey,
|
||||
attemptsLeft: 3,
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 5),
|
||||
}
|
||||
const row = { ...defaults, ...overrides }
|
||||
await db.insert(rotateChallengeTokens).values(row)
|
||||
return row
|
||||
}
|
||||
|
||||
export async function clearTables() {
|
||||
await db.delete(rotateChallengeTokens)
|
||||
await db.delete(serverRegistry)
|
||||
}
|
||||
142
tests/key.test.ts
Normal file
142
tests/key.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Tests the key rotation flow.
|
||||
*
|
||||
* This test covers:
|
||||
* - Missing challenge
|
||||
* - Expired challenge
|
||||
* - Wrong challenge plaintext
|
||||
* - Blacklists server after too many failed attempts
|
||||
* - Confirms valid challenge and rotates key
|
||||
*/
|
||||
import { expect, test } from "@playwright/test"
|
||||
import createDebug from "debug"
|
||||
import forge from "node-forge"
|
||||
import { clearTables, generateKeypair, seedChallenge, seedServer } from "./helpers/db"
|
||||
|
||||
const debug = createDebug("test:key")
|
||||
|
||||
test.beforeEach(async ({ }, testInfo) => {
|
||||
debug("beforeEach – clearing tables for: %s", testInfo.title)
|
||||
await clearTables()
|
||||
})
|
||||
test.afterEach(async ({ }, testInfo) => {
|
||||
debug("afterEach – clearing tables after: %s", testInfo.title)
|
||||
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"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
test("rejects missing challenge", async ({ request }) => {
|
||||
debug("test: rejects missing challenge – posting with unknown serverUrl")
|
||||
const res = await request.post("/discover/rotate/confirm", {
|
||||
data: {
|
||||
serverUrl: "https://ghost-server.com",
|
||||
signedOldChallenge: "fake",
|
||||
signedNewChallenge: "fake",
|
||||
}
|
||||
})
|
||||
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")
|
||||
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",
|
||||
}
|
||||
})
|
||||
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", {
|
||||
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!),
|
||||
}
|
||||
})
|
||||
debug("test: rejects wrong challenge plaintext – status %d", res.status())
|
||||
expect(res.status()).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ error: /mismatch/ })
|
||||
})
|
||||
|
||||
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) })
|
||||
|
||||
// 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", {
|
||||
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 – 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()
|
||||
|
||||
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", {
|
||||
data: {
|
||||
serverUrl: "https://test-server.com",
|
||||
signedOldChallenge,
|
||||
signedNewChallenge,
|
||||
}
|
||||
})
|
||||
debug("test: confirms valid challenge – status %d", res.status())
|
||||
expect(res.status()).toBe(200)
|
||||
expect(await res.json()).toMatchObject({ message: /confirmed/ })
|
||||
})
|
||||
|
|
@ -26,9 +26,9 @@
|
|||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@app": [
|
||||
"./src/app"
|
||||
]
|
||||
"@/plugins/*": [
|
||||
"./src/lib/plugins/*"
|
||||
],
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue