diff --git a/nuxt.config.ts b/nuxt.config.ts index c0a8fee..91c804e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -16,6 +16,11 @@ export default defineNuxtConfig({ githubWebhookSecret: '', cronSecret: '', workerBudgetMs: '', + encryptionKey: '', + atprotoPrivateJwk: '', + public: { + publicURL: '', + }, }, typescript: { nodeTsConfig: { @@ -39,11 +44,18 @@ export default defineNuxtConfig({ crons: [ { path: '/api/jobs/run', - schedule: '* * * * *' + schedule: '* * * * *', }, - ] + ], + }, + }, + typescript: { + tsConfig: { + // Pull dotted directories like `server/routes/.well-known/` into the + // server tsconfig; TypeScript's default include glob skips them. + include: ['../server/**/.*/**/*'], }, - } + }, }, compatibilityDate: '2024-04-03', // Ensure that any HTML validation errors are treated as build errors diff --git a/package.json b/package.json index fe29f81..1370826 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", + "gen:jwk": "node scripts/gen-jwk.ts", + "gen:encryption-key": "node -e \"console.log(require('node:crypto').randomBytes(32).toString('base64'))\"", + "gen:cron-secret": "node -e \"console.log(require('node:crypto').randomBytes(32).toString('base64url'))\"", "test:types": "vue-tsc -b --noEmit", "test": "vp test", "test:watch": "vp test watch", @@ -31,7 +34,11 @@ "test:browser:update": "docker run --rm --network host -v $(pwd):/work/ -v /tmp/playwright-node-modules:/work/node_modules -w /work/ -it mcr.microsoft.com/playwright:v1.59.1-noble bash -c 'corepack enable && pnpm i && pnpm playwright test test/browser --update-snapshots'" }, "dependencies": { + "@atproto/api": "^0.19.11", + "@atproto/jwk-jose": "^0.1.11", + "@atproto/oauth-client-node": "^0.3.17", "@neondatabase/serverless": "^1.1.0", + "@noble/ciphers": "^2.2.0", "@nuxt/fonts": "^0.14.0", "@nuxt/image": "^2.0.0", "@nuxt/scripts": "^1.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52a132a..2e59243 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,9 +12,21 @@ importers: .: dependencies: + '@atproto/api': + specifier: ^0.19.11 + version: 0.19.11 + '@atproto/jwk-jose': + specifier: ^0.1.11 + version: 0.1.11 + '@atproto/oauth-client-node': + specifier: ^0.3.17 + version: 0.3.17 '@neondatabase/serverless': specifier: ^1.1.0 version: 1.1.0 + '@noble/ciphers': + specifier: ^2.2.0 + version: 2.2.0 '@nuxt/fonts': specifier: ^0.14.0 version: 0.14.0(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.4)) @@ -38,7 +50,7 @@ importers: version: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@25.6.0)(@vue/compiler-sfc@3.5.33)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(esbuild@0.28.0)(eslint@10.3.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(oxlint@1.61.0(oxlint-tsgolint@0.22.0))(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.21.0)(typescript@6.0.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.4))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.4) nuxt-og-image: specifier: ^6.4.11 - version: 6.4.11(73738ea48627a6be1d31778aaae04f8e) + version: 6.4.11(51cc4797704473e6ff9b1d290d94c09f) rolldown: specifier: ^1.0.0-rc.18 version: 1.0.0-rc.18 @@ -100,6 +112,78 @@ importers: packages: + '@atproto-labs/did-resolver@0.2.6': + resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} + + '@atproto-labs/fetch-node@0.2.0': + resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} + engines: {node: '>=18.7.0'} + + '@atproto-labs/fetch@0.2.3': + resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} + + '@atproto-labs/handle-resolver-node@0.1.25': + resolution: {integrity: sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==} + engines: {node: '>=18.7.0'} + + '@atproto-labs/handle-resolver@0.3.6': + resolution: {integrity: sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==} + + '@atproto-labs/identity-resolver@0.3.6': + resolution: {integrity: sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==} + + '@atproto-labs/pipe@0.1.1': + resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} + + '@atproto-labs/simple-store-memory@0.1.4': + resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} + + '@atproto-labs/simple-store@0.3.0': + resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} + + '@atproto/api@0.19.11': + resolution: {integrity: sha512-7V4Sg6hcv/UxoXobjfvy/Ox2ioKQtZ3DzbsiFndYCcBfsZ5GO8rNEroHPq3hT0CFBJK1NAD6JfOtTBN2z267Xg==} + + '@atproto/common-web@0.4.21': + resolution: {integrity: sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==} + + '@atproto/did@0.3.0': + resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} + + '@atproto/jwk-jose@0.1.11': + resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} + + '@atproto/jwk-webcrypto@0.2.0': + resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} + + '@atproto/jwk@0.6.0': + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} + + '@atproto/lex-data@0.0.15': + resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==} + + '@atproto/lex-json@0.0.16': + resolution: {integrity: sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==} + + '@atproto/lexicon@0.6.2': + resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==} + + '@atproto/oauth-client-node@0.3.17': + resolution: {integrity: sha512-67LNuKAlC35Exe7CB5S0QCAnEqr6fKV9Nvp64jAHFof1N+Vc9Ltt1K9oekE5Ctf7dvpGByrHRF0noUw9l9sWLA==} + engines: {node: '>=18.7.0'} + + '@atproto/oauth-client@0.6.1': + resolution: {integrity: sha512-QTLbEFyv7EJuwJf4A8IZnsylK5wwrzrSsxy0INcZf9zktPVQvgckWhSvbfK8alp60M+rwWfQQAlodcCF4WB78A==} + + '@atproto/oauth-types@0.6.3': + resolution: {integrity: sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==} + + '@atproto/syntax@0.5.4': + resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==} + + '@atproto/xrpc@0.7.7': + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1159,6 +1243,10 @@ packages: resolution: {integrity: sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==} engines: {node: '>=19.0.0'} + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2997,6 +3085,9 @@ packages: peerDependencies: postcss: ^8.1.0 + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -3205,6 +3296,9 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3946,6 +4040,10 @@ packages: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + ipx@3.1.1: resolution: {integrity: sha512-7Xnt54Dco7uYkfdAw0r2vCly3z0rSaVhEXMzPvl3FndsTVm5p26j+PO+gyinkYmcsEUvX2Rh7OGK7KzYWRu6BA==} hasBin: true @@ -4032,6 +4130,9 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} + iso-datestring-validator@2.2.2: + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -4051,6 +4152,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -4331,6 +4435,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + nano-staged@1.0.2: resolution: {integrity: sha512-Fytar3zHLY99nlMfqPPbraxZodqQAHPpdPRyYaplL+lB9DCR6pUrafxbG+Btz4+7fO5Rm/+DO4ZeDO/nLSUMhw==} engines: {node: ^22 || >= 24} @@ -5199,6 +5306,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5241,6 +5352,9 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uint8arrays@3.0.0: + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} + ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} @@ -5253,12 +5367,19 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} unhead@2.1.13: resolution: {integrity: sha512-jO9M1sI6b2h/1KpIu4Jeu+ptumLmUKboRRLxys5pYHFeT+lqTzfNHbYUX9bxVDhC1FBszAGuWcUVlmvIPsah8Q==} + unicode-segmenter@0.14.5: + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -5649,8 +5770,159 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: + '@atproto-labs/did-resolver@0.2.6': + dependencies: + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/pipe': 0.1.1 + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + zod: 3.25.76 + + '@atproto-labs/fetch-node@0.2.0': + dependencies: + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/pipe': 0.1.1 + ipaddr.js: 2.4.0 + undici: 6.25.0 + + '@atproto-labs/fetch@0.2.3': + dependencies: + '@atproto-labs/pipe': 0.1.1 + + '@atproto-labs/handle-resolver-node@0.1.25': + dependencies: + '@atproto-labs/fetch-node': 0.2.0 + '@atproto-labs/handle-resolver': 0.3.6 + '@atproto/did': 0.3.0 + + '@atproto-labs/handle-resolver@0.3.6': + dependencies: + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + zod: 3.25.76 + + '@atproto-labs/identity-resolver@0.3.6': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/handle-resolver': 0.3.6 + + '@atproto-labs/pipe@0.1.1': {} + + '@atproto-labs/simple-store-memory@0.1.4': + dependencies: + '@atproto-labs/simple-store': 0.3.0 + lru-cache: 10.4.3 + + '@atproto-labs/simple-store@0.3.0': {} + + '@atproto/api@0.19.11': + dependencies: + '@atproto/common-web': 0.4.21 + '@atproto/lexicon': 0.6.2 + '@atproto/syntax': 0.5.4 + '@atproto/xrpc': 0.7.7 + await-lock: 2.2.2 + multiformats: 9.9.0 + tlds: 1.261.0 + zod: 3.25.76 + + '@atproto/common-web@0.4.21': + dependencies: + '@atproto/lex-data': 0.0.15 + '@atproto/lex-json': 0.0.16 + '@atproto/syntax': 0.5.4 + zod: 3.25.76 + + '@atproto/did@0.3.0': + dependencies: + zod: 3.25.76 + + '@atproto/jwk-jose@0.1.11': + dependencies: + '@atproto/jwk': 0.6.0 + jose: 5.10.0 + + '@atproto/jwk-webcrypto@0.2.0': + dependencies: + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + zod: 3.25.76 + + '@atproto/jwk@0.6.0': + dependencies: + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/lex-data@0.0.15': + dependencies: + multiformats: 9.9.0 + tslib: 2.8.1 + uint8arrays: 3.0.0 + unicode-segmenter: 0.14.5 + + '@atproto/lex-json@0.0.16': + dependencies: + '@atproto/lex-data': 0.0.15 + tslib: 2.8.1 + + '@atproto/lexicon@0.6.2': + dependencies: + '@atproto/common-web': 0.4.21 + '@atproto/syntax': 0.5.4 + iso-datestring-validator: 2.2.2 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/oauth-client-node@0.3.17': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/handle-resolver-node': 0.1.25 + '@atproto-labs/simple-store': 0.3.0 + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + '@atproto/jwk-webcrypto': 0.2.0 + '@atproto/oauth-client': 0.6.1 + '@atproto/oauth-types': 0.6.3 + + '@atproto/oauth-client@0.6.1': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/handle-resolver': 0.3.6 + '@atproto-labs/identity-resolver': 0.3.6 + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + '@atproto/oauth-types': 0.6.3 + '@atproto/xrpc': 0.7.7 + core-js: 3.49.0 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/oauth-types@0.6.3': + dependencies: + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + zod: 3.25.76 + + '@atproto/syntax@0.5.4': + dependencies: + tslib: 2.8.1 + + '@atproto/xrpc@0.7.7': + dependencies: + '@atproto/lexicon': 0.6.2 + zod: 3.25.76 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6423,6 +6695,8 @@ snapshots: '@neondatabase/serverless@1.1.0': {} + '@noble/ciphers@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8116,6 +8390,8 @@ snapshots: postcss: 8.5.13 postcss-value-parser: 4.2.0 + await-lock@2.2.2: {} + b4a@1.8.1: {} balanced-match@1.0.2: {} @@ -8305,6 +8581,8 @@ snapshots: cookie-es@3.1.1: {} + core-js@3.49.0: {} + core-util-is@1.0.3: {} crc-32@1.2.2: {} @@ -9064,6 +9342,8 @@ snapshots: transitivePeerDependencies: - supports-color + ipaddr.js@2.4.0: {} + ipx@3.1.1(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(ioredis@5.10.1)(srvx@0.11.15): dependencies: '@fastify/accept-negotiator': 2.0.1 @@ -9162,6 +9442,8 @@ snapshots: isexe@4.0.0: {} + iso-datestring-validator@2.2.2: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -9183,6 +9465,8 @@ snapshots: jiti@2.6.1: {} + jose@5.10.0: {} + js-beautify@1.15.4: dependencies: config-chain: 1.1.13 @@ -9434,6 +9718,8 @@ snapshots: muggle-string@0.4.1: {} + multiformats@9.9.0: {} + nano-staged@1.0.2: {} nanoid@3.3.12: {} @@ -9586,7 +9872,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt-og-image@6.4.11(73738ea48627a6be1d31778aaae04f8e): + nuxt-og-image@6.4.11(51cc4797704473e6ff9b1d290d94c09f): dependencies: '@clack/prompts': 1.3.0 '@nuxt/kit': 4.4.4(magicast@0.5.2) @@ -9602,8 +9888,8 @@ snapshots: magic-string: 0.30.21 magicast: 0.5.2 mocked-exports: 0.1.1 - nuxt-site-config: 4.0.8(63185d4d07eff04c3532e23607595514) - nuxtseo-shared: 5.1.3(722a2202af61d2736787650a24b9e3d3) + nuxt-site-config: 4.0.8(6ac414940de2d45d3b2692e1d867e261) + nuxtseo-shared: 5.1.3(cdaeb7588bb59f0294d3d21d524df9cd) nypm: 0.6.6 ofetch: 1.5.1 ohash: 2.0.11 @@ -9645,12 +9931,12 @@ snapshots: - magicast - vue - nuxt-site-config@4.0.8(63185d4d07eff04c3532e23607595514): + nuxt-site-config@4.0.8(6ac414940de2d45d3b2692e1d867e261): dependencies: '@nuxt/kit': 4.4.4(magicast@0.5.2) h3: 1.15.11 nuxt-site-config-kit: 4.0.8(magicast@0.5.2)(vue@3.5.33(typescript@6.0.3)) - nuxtseo-shared: 5.1.3(722a2202af61d2736787650a24b9e3d3) + nuxtseo-shared: 5.1.3(cdaeb7588bb59f0294d3d21d524df9cd) pathe: 2.0.3 pkg-types: 2.3.1 site-config-stack: 4.0.8(vue@3.5.33(typescript@6.0.3)) @@ -9798,7 +10084,7 @@ snapshots: - xml2js - yaml - nuxtseo-shared@5.1.3(722a2202af61d2736787650a24b9e3d3): + nuxtseo-shared@5.1.3(cdaeb7588bb59f0294d3d21d524df9cd): dependencies: '@clack/prompts': 1.3.0 '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.2)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.4)) @@ -9817,7 +10103,8 @@ snapshots: ufo: 1.6.4 vue: 3.5.33(typescript@6.0.3) optionalDependencies: - nuxt-site-config: 4.0.8(63185d4d07eff04c3532e23607595514) + nuxt-site-config: 4.0.8(6ac414940de2d45d3b2692e1d867e261) + zod: 3.25.76 transitivePeerDependencies: - magicast - vite @@ -10678,6 +10965,8 @@ snapshots: tinyrainbow@3.1.0: {} + tlds@1.261.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -10688,8 +10977,7 @@ snapshots: tr46@0.0.3: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.21.0: dependencies: @@ -10712,6 +11000,10 @@ snapshots: ufo@1.6.4: {} + uint8arrays@3.0.0: + dependencies: + multiformats: 9.9.0 + ultrahtml@1.6.0: {} uncrypto@0.1.3: {} @@ -10725,6 +11017,8 @@ snapshots: undici-types@7.19.2: {} + undici@6.25.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -10733,6 +11027,8 @@ snapshots: dependencies: hookable: 6.1.1 + unicode-segmenter@0.14.5: {} + unicorn-magic@0.3.0: {} unicorn-magic@0.4.0: {} @@ -11150,3 +11446,5 @@ snapshots: archiver-utils: 5.0.2 compress-commons: 6.0.2 readable-stream: 4.7.0 + + zod@3.25.76: {} diff --git a/scripts/gen-jwk.ts b/scripts/gen-jwk.ts new file mode 100644 index 0000000..a7831ba --- /dev/null +++ b/scripts/gen-jwk.ts @@ -0,0 +1,15 @@ +/** + * Generate an ES256 JWK private key for AT Proto OAuth client signing. + * Print the JSON-encoded private JWK on stdout. Add it to your env as + * `NUXT_ATPROTO_PRIVATE_JWK`. The public half is exposed at + * /.well-known/jwks.json by the running app. + * + * Usage: + * pnpm gen:jwk > .jwk.json + * NUXT_ATPROTO_PRIVATE_JWK="$(cat .jwk.json)" pnpm dev + */ +import { JoseKey } from '@atproto/jwk-jose' + +const key = await JoseKey.generate(['ES256'], crypto.randomUUID().slice(0, 8)) +process.stdout.write(JSON.stringify(key.privateJwk)) +process.stdout.write('\n') diff --git a/server/api/atproto/callback.get.ts b/server/api/atproto/callback.get.ts new file mode 100644 index 0000000..dfced56 --- /dev/null +++ b/server/api/atproto/callback.get.ts @@ -0,0 +1,27 @@ +import { userIdentity } from '~~/server/db/schema' + +export default defineEventHandler(async event => { + const url = getRequestURL(event) + const params = url.searchParams + + const client = await useOAuthClient() + const { session, state } = await client.callback(params) + + const installationId = state ? Number(state) : NaN + if (!Number.isFinite(installationId)) { + throw createError({ statusCode: 400, statusMessage: 'invalid state (missing installation id)' }) + } + + const db = useDb() + await db.insert(userIdentity).values({ + did: session.did, + handle: null, // resolved separately; we don't have it from the session blob + installationId, + updatedAt: new Date(), + }).onConflictDoUpdate({ + target: userIdentity.did, + set: { installationId, updatedAt: new Date() }, + }) + + await sendRedirect(event, '/dashboard', 302) +}) diff --git a/server/api/atproto/login.get.ts b/server/api/atproto/login.get.ts new file mode 100644 index 0000000..3cad7b7 --- /dev/null +++ b/server/api/atproto/login.get.ts @@ -0,0 +1,27 @@ +export default defineEventHandler(async event => { + const query = getQuery(event) + const handleRaw = query.handle + const installationIdRaw = query.installationId + + if (typeof handleRaw !== 'string' || !handleRaw.trim()) { + throw createError({ statusCode: 400, statusMessage: 'handle is required' }) + } + if (typeof installationIdRaw !== 'string' || !/^\d+$/.test(installationIdRaw)) { + throw createError({ statusCode: 400, statusMessage: 'installationId is required' }) + } + + const handle = handleRaw.trim() + const installationId = installationIdRaw + + const client = await useOAuthClient() + + // Round-trip the installation id via OAuth `state`. The library wraps and + // signs `state` itself (PKCE + state CSRF protection are handled internally), + // so this is safe to use as an opaque link key. + const url = await client.authorize(handle, { + state: installationId, + scope: 'atproto transition:generic', + }) + + await sendRedirect(event, url.toString(), 302) +}) diff --git a/server/routes/.well-known/atproto-client-metadata.json.get.ts b/server/routes/.well-known/atproto-client-metadata.json.get.ts new file mode 100644 index 0000000..3398af7 --- /dev/null +++ b/server/routes/.well-known/atproto-client-metadata.json.get.ts @@ -0,0 +1,5 @@ +export default defineEventHandler(async () => { + const client = await useOAuthClient() + // The library builds the canonical client_metadata document for us. + return client.clientMetadata +}) diff --git a/server/routes/.well-known/jwks.json.get.ts b/server/routes/.well-known/jwks.json.get.ts new file mode 100644 index 0000000..1177b51 --- /dev/null +++ b/server/routes/.well-known/jwks.json.get.ts @@ -0,0 +1,5 @@ +export default defineEventHandler(async () => { + const client = await useOAuthClient() + // Public half only; private keys never leave the server. + return client.jwks +}) diff --git a/server/utils/atproto-oauth.ts b/server/utils/atproto-oauth.ts new file mode 100644 index 0000000..bf60668 --- /dev/null +++ b/server/utils/atproto-oauth.ts @@ -0,0 +1,133 @@ +import { JoseKey } from '@atproto/jwk-jose' +import { + type NodeOAuthClientOptions, + type NodeSavedSession, + type NodeSavedSessionStore, + type NodeSavedState, + type NodeSavedStateStore, + NodeOAuthClient, +} from '@atproto/oauth-client-node' +import { sql } from 'drizzle-orm' +import { atprotoSession, atprotoState } from '../db/schema' +import { useDb } from './db' +import { decrypt, encrypt } from './encryption' + +let cachedClient: NodeOAuthClient | undefined + +/** + * Build the AT Proto OAuth client. The client metadata is constructed from + * runtime config so a single deploy can serve different `client_id`s by + * environment (loopback dev vs prod). + * + * The state and session stores wrap the OAuth library's required interface and + * encrypt the values at rest with `encryption.ts` (xchacha20poly1305). The + * values contain access tokens, refresh tokens, and the user's DPoP private + * key — a DB read with no encryption would be account takeover. + */ +export async function useOAuthClient(): Promise { + if (cachedClient) return cachedClient + + const config = useRuntimeConfig() + const publicURL = config.public.publicURL?.replace(/\/$/, '') + if (!publicURL) { + throw new Error('NUXT_PUBLIC_URL is not set') + } + + const privateJwkRaw = config.atprotoPrivateJwk + if (!privateJwkRaw) { + throw new Error('NUXT_ATPROTO_PRIVATE_JWK is not set (run `pnpm gen:jwk` to create one)') + } + const key = await JoseKey.fromImportable(privateJwkRaw) + + const isLoopback = publicURL.startsWith('http://127.0.0.1') || publicURL.startsWith('http://localhost') + const clientId = isLoopback + // Loopback dev: spec-defined synthetic client_id; no metadata fetched by PDS. + ? `http://localhost?redirect_uri=${encodeURIComponent(`${publicURL}/api/atproto/callback`)}&scope=${encodeURIComponent('atproto transition:generic')}` + : `${publicURL}/.well-known/atproto-client-metadata.json` + + const options: NodeOAuthClientOptions = { + clientMetadata: { + client_id: clientId, + client_name: 'synchub.to', + client_uri: publicURL, + redirect_uris: [`${publicURL}/api/atproto/callback`], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'atproto transition:generic', + application_type: 'web', + token_endpoint_auth_method: 'private_key_jwt', + token_endpoint_auth_signing_alg: 'ES256', + dpop_bound_access_tokens: true, + jwks_uri: `${publicURL}/.well-known/jwks.json`, + }, + keyset: [key], + stateStore: makeStateStore(), + sessionStore: makeSessionStore(), + // Note: no requestLock supplied. Multi-instance deployments can race on + // concurrent token refreshes; see PLAN.md "Deferred / follow-ups". + } + + cachedClient = new NodeOAuthClient(options) + return cachedClient +} + +function makeStateStore(): NodeSavedStateStore { + return { + async set(key: string, value: NodeSavedState) { + const { ciphertext, nonce } = encrypt(JSON.stringify(value)) + const db = useDb() + await db.insert(atprotoState).values({ + key, + valueCiphertext: ciphertext, + valueNonce: nonce, + }).onConflictDoUpdate({ + target: atprotoState.key, + set: { valueCiphertext: ciphertext, valueNonce: nonce }, + }) + }, + async get(key: string) { + const db = useDb() + const rows = await db.select().from(atprotoState).where(sql`${atprotoState.key} = ${key}`) + if (rows.length === 0) return undefined + const row = rows[0]! + return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedState + }, + async del(key: string) { + const db = useDb() + await db.delete(atprotoState).where(sql`${atprotoState.key} = ${key}`) + }, + } +} + +function makeSessionStore(): NodeSavedSessionStore { + return { + async set(sub: string, value: NodeSavedSession) { + const { ciphertext, nonce } = encrypt(JSON.stringify(value)) + const db = useDb() + await db.insert(atprotoSession).values({ + sub, + valueCiphertext: ciphertext, + valueNonce: nonce, + }).onConflictDoUpdate({ + target: atprotoSession.sub, + set: { valueCiphertext: ciphertext, valueNonce: nonce, updatedAt: new Date() }, + }) + }, + async get(sub: string) { + const db = useDb() + const rows = await db.select().from(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) + if (rows.length === 0) return undefined + const row = rows[0]! + return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedSession + }, + async del(sub: string) { + const db = useDb() + await db.delete(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) + }, + } +} + +/** Test hook: drop the cached client. */ +export function clearOAuthClientCache() { + cachedClient = undefined +} diff --git a/server/utils/encryption.ts b/server/utils/encryption.ts new file mode 100644 index 0000000..d30f72b --- /dev/null +++ b/server/utils/encryption.ts @@ -0,0 +1,49 @@ +import crypto from 'node:crypto' +import { xchacha20poly1305 } from '@noble/ciphers/chacha.js' + +/** + * Authenticated encryption with a key from runtime config (`NUXT_ENCRYPTION_KEY`, + * base64-encoded 32 bytes). Used to wrap anything sensitive at the app layer + * before it lands in the DB: AT Proto session blobs, SSH private keys. + * + * The KEK is held only in env. If it's lost, every encrypted row becomes + * unreadable. KEK rotation is a future concern \u2014 see PLAN.md. + */ +const NONCE_BYTES = 24 +let cachedKey: Uint8Array | undefined + +function getKey(): Uint8Array { + if (cachedKey) return cachedKey + // Read process.env directly rather than via useRuntimeConfig() so this helper + // is callable from outside a Nitro request context (e.g. tests, scripts). + // Nuxt's runtime config still declares the var for documentation; the env + // name is the same. + const raw = process.env.NUXT_ENCRYPTION_KEY + if (!raw) { + throw new Error('NUXT_ENCRYPTION_KEY is not set (expected base64-encoded 32 bytes)') + } + const decoded = Buffer.from(raw, 'base64') + if (decoded.length !== 32) { + throw new Error(`NUXT_ENCRYPTION_KEY must decode to 32 bytes, got ${decoded.length}`) + } + cachedKey = new Uint8Array(decoded) + return cachedKey +} + +export function encrypt(plaintext: string): { ciphertext: Buffer, nonce: Buffer } { + const nonce = crypto.randomBytes(NONCE_BYTES) + const cipher = xchacha20poly1305(getKey(), new Uint8Array(nonce)) + const ciphertext = cipher.encrypt(new TextEncoder().encode(plaintext)) + return { ciphertext: Buffer.from(ciphertext), nonce } +} + +export function decrypt(ciphertext: Buffer, nonce: Buffer): string { + const cipher = xchacha20poly1305(getKey(), new Uint8Array(nonce)) + const plaintext = cipher.decrypt(new Uint8Array(ciphertext)) + return new TextDecoder().decode(plaintext) +} + +/** Test/utility hook: drop the cached key so the next call re-reads runtime config. */ +export function clearEncryptionKeyCache() { + cachedKey = undefined +} diff --git a/test/unit/encryption.spec.ts b/test/unit/encryption.spec.ts new file mode 100644 index 0000000..ba53a3a --- /dev/null +++ b/test/unit/encryption.spec.ts @@ -0,0 +1,75 @@ +import crypto from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { clearEncryptionKeyCache, decrypt, encrypt } from '../../server/utils/encryption' + +const ORIGINAL_ENV = process.env.NUXT_ENCRYPTION_KEY + +describe('encryption', () => { + beforeEach(() => { + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') + clearEncryptionKeyCache() + }) + + afterEach(() => { + if (ORIGINAL_ENV === undefined) delete process.env.NUXT_ENCRYPTION_KEY + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENV + clearEncryptionKeyCache() + }) + + it('round-trips a string', () => { + const { ciphertext, nonce } = encrypt('hello world') + expect(decrypt(ciphertext, nonce)).toBe('hello world') + }) + + it('round-trips JSON-shaped data', () => { + const payload = JSON.stringify({ did: 'did:plc:foo', token: 'abc.def.ghi' }) + const { ciphertext, nonce } = encrypt(payload) + expect(JSON.parse(decrypt(ciphertext, nonce))).toEqual({ + did: 'did:plc:foo', + token: 'abc.def.ghi', + }) + }) + + it('produces different ciphertext for the same input (random nonce)', () => { + const a = encrypt('same plaintext') + const b = encrypt('same plaintext') + expect(a.ciphertext.equals(b.ciphertext)).toBe(false) + expect(a.nonce.equals(b.nonce)).toBe(false) + }) + + it('throws on tampered ciphertext', () => { + const { ciphertext, nonce } = encrypt('hello') + const tampered = Buffer.from(ciphertext) + tampered[0] = tampered[0] ^ 0xFF + expect(() => decrypt(tampered, nonce)).toThrow(/invalid tag|auth/i) + }) + + it('throws on tampered nonce', () => { + const { ciphertext, nonce } = encrypt('hello') + const tampered = Buffer.from(nonce) + tampered[0] = tampered[0] ^ 0xFF + expect(() => decrypt(ciphertext, tampered)).toThrow(/invalid tag|auth/i) + }) + + it('throws when the key changes between encrypt and decrypt', () => { + const { ciphertext, nonce } = encrypt('hello') + + // Rotate the key. + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') + clearEncryptionKeyCache() + + expect(() => decrypt(ciphertext, nonce)).toThrow(/invalid tag|auth/i) + }) + + it('throws on wrong key length', () => { + process.env.NUXT_ENCRYPTION_KEY = Buffer.from('too short').toString('base64') + clearEncryptionKeyCache() + expect(() => encrypt('hello')).toThrow(/32 bytes/) + }) + + it('throws when the key is missing', () => { + delete process.env.NUXT_ENCRYPTION_KEY + clearEncryptionKeyCache() + expect(() => encrypt('hello')).toThrow(/NUXT_ENCRYPTION_KEY/) + }) +})