mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: add atproto oauth login and callback

+666 -13
+15 -3
nuxt.config.ts
··· 16 16 githubWebhookSecret: '', 17 17 cronSecret: '', 18 18 workerBudgetMs: '', 19 + encryptionKey: '', 20 + atprotoPrivateJwk: '', 21 + public: { 22 + publicURL: '', 23 + }, 19 24 }, 20 25 typescript: { 21 26 nodeTsConfig: { ··· 39 44 crons: [ 40 45 { 41 46 path: '/api/jobs/run', 42 - schedule: '* * * * *' 47 + schedule: '* * * * *', 43 48 }, 44 - ] 49 + ], 50 + }, 51 + }, 52 + typescript: { 53 + tsConfig: { 54 + // Pull dotted directories like `server/routes/.well-known/` into the 55 + // server tsconfig; TypeScript's default include glob skips them. 56 + include: ['../server/**/.*/**/*'], 45 57 }, 46 - } 58 + }, 47 59 }, 48 60 compatibilityDate: '2024-04-03', 49 61 // Ensure that any HTML validation errors are treated as build errors
+7
package.json
··· 20 20 "db:generate": "drizzle-kit generate", 21 21 "db:migrate": "drizzle-kit migrate", 22 22 "db:studio": "drizzle-kit studio", 23 + "gen:jwk": "node scripts/gen-jwk.ts", 24 + "gen:encryption-key": "node -e \"console.log(require('node:crypto').randomBytes(32).toString('base64'))\"", 25 + "gen:cron-secret": "node -e \"console.log(require('node:crypto').randomBytes(32).toString('base64url'))\"", 23 26 "test:types": "vue-tsc -b --noEmit", 24 27 "test": "vp test", 25 28 "test:watch": "vp test watch", ··· 31 34 "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'" 32 35 }, 33 36 "dependencies": { 37 + "@atproto/api": "^0.19.11", 38 + "@atproto/jwk-jose": "^0.1.11", 39 + "@atproto/oauth-client-node": "^0.3.17", 34 40 "@neondatabase/serverless": "^1.1.0", 41 + "@noble/ciphers": "^2.2.0", 35 42 "@nuxt/fonts": "^0.14.0", 36 43 "@nuxt/image": "^2.0.0", 37 44 "@nuxt/scripts": "^1.0.6",
+308 -10
pnpm-lock.yaml
··· 12 12 13 13 .: 14 14 dependencies: 15 + '@atproto/api': 16 + specifier: ^0.19.11 17 + version: 0.19.11 18 + '@atproto/jwk-jose': 19 + specifier: ^0.1.11 20 + version: 0.1.11 21 + '@atproto/oauth-client-node': 22 + specifier: ^0.3.17 23 + version: 0.3.17 15 24 '@neondatabase/serverless': 16 25 specifier: ^1.1.0 17 26 version: 1.1.0 27 + '@noble/ciphers': 28 + specifier: ^2.2.0 29 + version: 2.2.0 18 30 '@nuxt/fonts': 19 31 specifier: ^0.14.0 20 32 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 50 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) 39 51 nuxt-og-image: 40 52 specifier: ^6.4.11 41 - version: 6.4.11(73738ea48627a6be1d31778aaae04f8e) 53 + version: 6.4.11(51cc4797704473e6ff9b1d290d94c09f) 42 54 rolldown: 43 55 specifier: ^1.0.0-rc.18 44 56 version: 1.0.0-rc.18 ··· 100 112 101 113 packages: 102 114 115 + '@atproto-labs/did-resolver@0.2.6': 116 + resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} 117 + 118 + '@atproto-labs/fetch-node@0.2.0': 119 + resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} 120 + engines: {node: '>=18.7.0'} 121 + 122 + '@atproto-labs/fetch@0.2.3': 123 + resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} 124 + 125 + '@atproto-labs/handle-resolver-node@0.1.25': 126 + resolution: {integrity: sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==} 127 + engines: {node: '>=18.7.0'} 128 + 129 + '@atproto-labs/handle-resolver@0.3.6': 130 + resolution: {integrity: sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==} 131 + 132 + '@atproto-labs/identity-resolver@0.3.6': 133 + resolution: {integrity: sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==} 134 + 135 + '@atproto-labs/pipe@0.1.1': 136 + resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} 137 + 138 + '@atproto-labs/simple-store-memory@0.1.4': 139 + resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} 140 + 141 + '@atproto-labs/simple-store@0.3.0': 142 + resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} 143 + 144 + '@atproto/api@0.19.11': 145 + resolution: {integrity: sha512-7V4Sg6hcv/UxoXobjfvy/Ox2ioKQtZ3DzbsiFndYCcBfsZ5GO8rNEroHPq3hT0CFBJK1NAD6JfOtTBN2z267Xg==} 146 + 147 + '@atproto/common-web@0.4.21': 148 + resolution: {integrity: sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==} 149 + 150 + '@atproto/did@0.3.0': 151 + resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} 152 + 153 + '@atproto/jwk-jose@0.1.11': 154 + resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} 155 + 156 + '@atproto/jwk-webcrypto@0.2.0': 157 + resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} 158 + 159 + '@atproto/jwk@0.6.0': 160 + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} 161 + 162 + '@atproto/lex-data@0.0.15': 163 + resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==} 164 + 165 + '@atproto/lex-json@0.0.16': 166 + resolution: {integrity: sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==} 167 + 168 + '@atproto/lexicon@0.6.2': 169 + resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==} 170 + 171 + '@atproto/oauth-client-node@0.3.17': 172 + resolution: {integrity: sha512-67LNuKAlC35Exe7CB5S0QCAnEqr6fKV9Nvp64jAHFof1N+Vc9Ltt1K9oekE5Ctf7dvpGByrHRF0noUw9l9sWLA==} 173 + engines: {node: '>=18.7.0'} 174 + 175 + '@atproto/oauth-client@0.6.1': 176 + resolution: {integrity: sha512-QTLbEFyv7EJuwJf4A8IZnsylK5wwrzrSsxy0INcZf9zktPVQvgckWhSvbfK8alp60M+rwWfQQAlodcCF4WB78A==} 177 + 178 + '@atproto/oauth-types@0.6.3': 179 + resolution: {integrity: sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==} 180 + 181 + '@atproto/syntax@0.5.4': 182 + resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==} 183 + 184 + '@atproto/xrpc@0.7.7': 185 + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} 186 + 103 187 '@babel/code-frame@7.29.0': 104 188 resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} 105 189 engines: {node: '>=6.9.0'} ··· 1158 1242 '@neondatabase/serverless@1.1.0': 1159 1243 resolution: {integrity: sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==} 1160 1244 engines: {node: '>=19.0.0'} 1245 + 1246 + '@noble/ciphers@2.2.0': 1247 + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} 1248 + engines: {node: '>= 20.19.0'} 1161 1249 1162 1250 '@nodelib/fs.scandir@2.1.5': 1163 1251 resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} ··· 2997 3085 peerDependencies: 2998 3086 postcss: ^8.1.0 2999 3087 3088 + await-lock@2.2.2: 3089 + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 3090 + 3000 3091 b4a@1.8.1: 3001 3092 resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} 3002 3093 peerDependencies: ··· 3204 3295 3205 3296 cookie-es@3.1.1: 3206 3297 resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} 3298 + 3299 + core-js@3.49.0: 3300 + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} 3207 3301 3208 3302 core-util-is@1.0.3: 3209 3303 resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} ··· 3945 4039 ioredis@5.10.1: 3946 4040 resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} 3947 4041 engines: {node: '>=12.22.0'} 4042 + 4043 + ipaddr.js@2.4.0: 4044 + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} 4045 + engines: {node: '>= 10'} 3948 4046 3949 4047 ipx@3.1.1: 3950 4048 resolution: {integrity: sha512-7Xnt54Dco7uYkfdAw0r2vCly3z0rSaVhEXMzPvl3FndsTVm5p26j+PO+gyinkYmcsEUvX2Rh7OGK7KzYWRu6BA==} ··· 4032 4130 resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} 4033 4131 engines: {node: '>=20'} 4034 4132 4133 + iso-datestring-validator@2.2.2: 4134 + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} 4135 + 4035 4136 istanbul-lib-coverage@3.2.2: 4036 4137 resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} 4037 4138 engines: {node: '>=8'} ··· 4050 4151 jiti@2.6.1: 4051 4152 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 4052 4153 hasBin: true 4154 + 4155 + jose@5.10.0: 4156 + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} 4053 4157 4054 4158 js-beautify@1.15.4: 4055 4159 resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} ··· 4330 4434 4331 4435 muggle-string@0.4.1: 4332 4436 resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} 4437 + 4438 + multiformats@9.9.0: 4439 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 4333 4440 4334 4441 nano-staged@1.0.2: 4335 4442 resolution: {integrity: sha512-Fytar3zHLY99nlMfqPPbraxZodqQAHPpdPRyYaplL+lB9DCR6pUrafxbG+Btz4+7fO5Rm/+DO4ZeDO/nLSUMhw==} ··· 5199 5306 resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} 5200 5307 engines: {node: '>=14.0.0'} 5201 5308 5309 + tlds@1.261.0: 5310 + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} 5311 + hasBin: true 5312 + 5202 5313 to-regex-range@5.0.1: 5203 5314 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 5204 5315 engines: {node: '>=8.0'} ··· 5241 5352 ufo@1.6.4: 5242 5353 resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} 5243 5354 5355 + uint8arrays@3.0.0: 5356 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 5357 + 5244 5358 ultrahtml@1.6.0: 5245 5359 resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} 5246 5360 ··· 5252 5366 5253 5367 undici-types@7.19.2: 5254 5368 resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} 5369 + 5370 + undici@6.25.0: 5371 + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} 5372 + engines: {node: '>=18.17'} 5255 5373 5256 5374 unenv@2.0.0-rc.24: 5257 5375 resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} ··· 5259 5377 unhead@2.1.13: 5260 5378 resolution: {integrity: sha512-jO9M1sI6b2h/1KpIu4Jeu+ptumLmUKboRRLxys5pYHFeT+lqTzfNHbYUX9bxVDhC1FBszAGuWcUVlmvIPsah8Q==} 5261 5379 5380 + unicode-segmenter@0.14.5: 5381 + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} 5382 + 5262 5383 unicorn-magic@0.3.0: 5263 5384 resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} 5264 5385 engines: {node: '>=18'} ··· 5649 5770 resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} 5650 5771 engines: {node: '>= 14'} 5651 5772 5773 + zod@3.25.76: 5774 + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 5775 + 5652 5776 snapshots: 5653 5777 5778 + '@atproto-labs/did-resolver@0.2.6': 5779 + dependencies: 5780 + '@atproto-labs/fetch': 0.2.3 5781 + '@atproto-labs/pipe': 0.1.1 5782 + '@atproto-labs/simple-store': 0.3.0 5783 + '@atproto-labs/simple-store-memory': 0.1.4 5784 + '@atproto/did': 0.3.0 5785 + zod: 3.25.76 5786 + 5787 + '@atproto-labs/fetch-node@0.2.0': 5788 + dependencies: 5789 + '@atproto-labs/fetch': 0.2.3 5790 + '@atproto-labs/pipe': 0.1.1 5791 + ipaddr.js: 2.4.0 5792 + undici: 6.25.0 5793 + 5794 + '@atproto-labs/fetch@0.2.3': 5795 + dependencies: 5796 + '@atproto-labs/pipe': 0.1.1 5797 + 5798 + '@atproto-labs/handle-resolver-node@0.1.25': 5799 + dependencies: 5800 + '@atproto-labs/fetch-node': 0.2.0 5801 + '@atproto-labs/handle-resolver': 0.3.6 5802 + '@atproto/did': 0.3.0 5803 + 5804 + '@atproto-labs/handle-resolver@0.3.6': 5805 + dependencies: 5806 + '@atproto-labs/simple-store': 0.3.0 5807 + '@atproto-labs/simple-store-memory': 0.1.4 5808 + '@atproto/did': 0.3.0 5809 + zod: 3.25.76 5810 + 5811 + '@atproto-labs/identity-resolver@0.3.6': 5812 + dependencies: 5813 + '@atproto-labs/did-resolver': 0.2.6 5814 + '@atproto-labs/handle-resolver': 0.3.6 5815 + 5816 + '@atproto-labs/pipe@0.1.1': {} 5817 + 5818 + '@atproto-labs/simple-store-memory@0.1.4': 5819 + dependencies: 5820 + '@atproto-labs/simple-store': 0.3.0 5821 + lru-cache: 10.4.3 5822 + 5823 + '@atproto-labs/simple-store@0.3.0': {} 5824 + 5825 + '@atproto/api@0.19.11': 5826 + dependencies: 5827 + '@atproto/common-web': 0.4.21 5828 + '@atproto/lexicon': 0.6.2 5829 + '@atproto/syntax': 0.5.4 5830 + '@atproto/xrpc': 0.7.7 5831 + await-lock: 2.2.2 5832 + multiformats: 9.9.0 5833 + tlds: 1.261.0 5834 + zod: 3.25.76 5835 + 5836 + '@atproto/common-web@0.4.21': 5837 + dependencies: 5838 + '@atproto/lex-data': 0.0.15 5839 + '@atproto/lex-json': 0.0.16 5840 + '@atproto/syntax': 0.5.4 5841 + zod: 3.25.76 5842 + 5843 + '@atproto/did@0.3.0': 5844 + dependencies: 5845 + zod: 3.25.76 5846 + 5847 + '@atproto/jwk-jose@0.1.11': 5848 + dependencies: 5849 + '@atproto/jwk': 0.6.0 5850 + jose: 5.10.0 5851 + 5852 + '@atproto/jwk-webcrypto@0.2.0': 5853 + dependencies: 5854 + '@atproto/jwk': 0.6.0 5855 + '@atproto/jwk-jose': 0.1.11 5856 + zod: 3.25.76 5857 + 5858 + '@atproto/jwk@0.6.0': 5859 + dependencies: 5860 + multiformats: 9.9.0 5861 + zod: 3.25.76 5862 + 5863 + '@atproto/lex-data@0.0.15': 5864 + dependencies: 5865 + multiformats: 9.9.0 5866 + tslib: 2.8.1 5867 + uint8arrays: 3.0.0 5868 + unicode-segmenter: 0.14.5 5869 + 5870 + '@atproto/lex-json@0.0.16': 5871 + dependencies: 5872 + '@atproto/lex-data': 0.0.15 5873 + tslib: 2.8.1 5874 + 5875 + '@atproto/lexicon@0.6.2': 5876 + dependencies: 5877 + '@atproto/common-web': 0.4.21 5878 + '@atproto/syntax': 0.5.4 5879 + iso-datestring-validator: 2.2.2 5880 + multiformats: 9.9.0 5881 + zod: 3.25.76 5882 + 5883 + '@atproto/oauth-client-node@0.3.17': 5884 + dependencies: 5885 + '@atproto-labs/did-resolver': 0.2.6 5886 + '@atproto-labs/handle-resolver-node': 0.1.25 5887 + '@atproto-labs/simple-store': 0.3.0 5888 + '@atproto/did': 0.3.0 5889 + '@atproto/jwk': 0.6.0 5890 + '@atproto/jwk-jose': 0.1.11 5891 + '@atproto/jwk-webcrypto': 0.2.0 5892 + '@atproto/oauth-client': 0.6.1 5893 + '@atproto/oauth-types': 0.6.3 5894 + 5895 + '@atproto/oauth-client@0.6.1': 5896 + dependencies: 5897 + '@atproto-labs/did-resolver': 0.2.6 5898 + '@atproto-labs/fetch': 0.2.3 5899 + '@atproto-labs/handle-resolver': 0.3.6 5900 + '@atproto-labs/identity-resolver': 0.3.6 5901 + '@atproto-labs/simple-store': 0.3.0 5902 + '@atproto-labs/simple-store-memory': 0.1.4 5903 + '@atproto/did': 0.3.0 5904 + '@atproto/jwk': 0.6.0 5905 + '@atproto/oauth-types': 0.6.3 5906 + '@atproto/xrpc': 0.7.7 5907 + core-js: 3.49.0 5908 + multiformats: 9.9.0 5909 + zod: 3.25.76 5910 + 5911 + '@atproto/oauth-types@0.6.3': 5912 + dependencies: 5913 + '@atproto/did': 0.3.0 5914 + '@atproto/jwk': 0.6.0 5915 + zod: 3.25.76 5916 + 5917 + '@atproto/syntax@0.5.4': 5918 + dependencies: 5919 + tslib: 2.8.1 5920 + 5921 + '@atproto/xrpc@0.7.7': 5922 + dependencies: 5923 + '@atproto/lexicon': 0.6.2 5924 + zod: 3.25.76 5925 + 5654 5926 '@babel/code-frame@7.29.0': 5655 5927 dependencies: 5656 5928 '@babel/helper-validator-identifier': 7.28.5 ··· 6422 6694 optional: true 6423 6695 6424 6696 '@neondatabase/serverless@1.1.0': {} 6697 + 6698 + '@noble/ciphers@2.2.0': {} 6425 6699 6426 6700 '@nodelib/fs.scandir@2.1.5': 6427 6701 dependencies: ··· 8116 8390 postcss: 8.5.13 8117 8391 postcss-value-parser: 4.2.0 8118 8392 8393 + await-lock@2.2.2: {} 8394 + 8119 8395 b4a@1.8.1: {} 8120 8396 8121 8397 balanced-match@1.0.2: {} ··· 8305 8581 8306 8582 cookie-es@3.1.1: {} 8307 8583 8584 + core-js@3.49.0: {} 8585 + 8308 8586 core-util-is@1.0.3: {} 8309 8587 8310 8588 crc-32@1.2.2: {} ··· 9064 9342 transitivePeerDependencies: 9065 9343 - supports-color 9066 9344 9345 + ipaddr.js@2.4.0: {} 9346 + 9067 9347 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): 9068 9348 dependencies: 9069 9349 '@fastify/accept-negotiator': 2.0.1 ··· 9162 9442 9163 9443 isexe@4.0.0: {} 9164 9444 9445 + iso-datestring-validator@2.2.2: {} 9446 + 9165 9447 istanbul-lib-coverage@3.2.2: {} 9166 9448 9167 9449 istanbul-lib-report@3.0.1: ··· 9183 9465 9184 9466 jiti@2.6.1: {} 9185 9467 9468 + jose@5.10.0: {} 9469 + 9186 9470 js-beautify@1.15.4: 9187 9471 dependencies: 9188 9472 config-chain: 1.1.13 ··· 9434 9718 9435 9719 muggle-string@0.4.1: {} 9436 9720 9721 + multiformats@9.9.0: {} 9722 + 9437 9723 nano-staged@1.0.2: {} 9438 9724 9439 9725 nanoid@3.3.12: {} ··· 9586 9872 dependencies: 9587 9873 boolbase: 1.0.0 9588 9874 9589 - nuxt-og-image@6.4.11(73738ea48627a6be1d31778aaae04f8e): 9875 + nuxt-og-image@6.4.11(51cc4797704473e6ff9b1d290d94c09f): 9590 9876 dependencies: 9591 9877 '@clack/prompts': 1.3.0 9592 9878 '@nuxt/kit': 4.4.4(magicast@0.5.2) ··· 9602 9888 magic-string: 0.30.21 9603 9889 magicast: 0.5.2 9604 9890 mocked-exports: 0.1.1 9605 - nuxt-site-config: 4.0.8(63185d4d07eff04c3532e23607595514) 9606 - nuxtseo-shared: 5.1.3(722a2202af61d2736787650a24b9e3d3) 9891 + nuxt-site-config: 4.0.8(6ac414940de2d45d3b2692e1d867e261) 9892 + nuxtseo-shared: 5.1.3(cdaeb7588bb59f0294d3d21d524df9cd) 9607 9893 nypm: 0.6.6 9608 9894 ofetch: 1.5.1 9609 9895 ohash: 2.0.11 ··· 9645 9931 - magicast 9646 9932 - vue 9647 9933 9648 - nuxt-site-config@4.0.8(63185d4d07eff04c3532e23607595514): 9934 + nuxt-site-config@4.0.8(6ac414940de2d45d3b2692e1d867e261): 9649 9935 dependencies: 9650 9936 '@nuxt/kit': 4.4.4(magicast@0.5.2) 9651 9937 h3: 1.15.11 9652 9938 nuxt-site-config-kit: 4.0.8(magicast@0.5.2)(vue@3.5.33(typescript@6.0.3)) 9653 - nuxtseo-shared: 5.1.3(722a2202af61d2736787650a24b9e3d3) 9939 + nuxtseo-shared: 5.1.3(cdaeb7588bb59f0294d3d21d524df9cd) 9654 9940 pathe: 2.0.3 9655 9941 pkg-types: 2.3.1 9656 9942 site-config-stack: 4.0.8(vue@3.5.33(typescript@6.0.3)) ··· 9798 10084 - xml2js 9799 10085 - yaml 9800 10086 9801 - nuxtseo-shared@5.1.3(722a2202af61d2736787650a24b9e3d3): 10087 + nuxtseo-shared@5.1.3(cdaeb7588bb59f0294d3d21d524df9cd): 9802 10088 dependencies: 9803 10089 '@clack/prompts': 1.3.0 9804 10090 '@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 10103 ufo: 1.6.4 9818 10104 vue: 3.5.33(typescript@6.0.3) 9819 10105 optionalDependencies: 9820 - nuxt-site-config: 4.0.8(63185d4d07eff04c3532e23607595514) 10106 + nuxt-site-config: 4.0.8(6ac414940de2d45d3b2692e1d867e261) 10107 + zod: 3.25.76 9821 10108 transitivePeerDependencies: 9822 10109 - magicast 9823 10110 - vite ··· 10678 10965 10679 10966 tinyrainbow@3.1.0: {} 10680 10967 10968 + tlds@1.261.0: {} 10969 + 10681 10970 to-regex-range@5.0.1: 10682 10971 dependencies: 10683 10972 is-number: 7.0.0 ··· 10688 10977 10689 10978 tr46@0.0.3: {} 10690 10979 10691 - tslib@2.8.1: 10692 - optional: true 10980 + tslib@2.8.1: {} 10693 10981 10694 10982 tsx@4.21.0: 10695 10983 dependencies: ··· 10712 11000 10713 11001 ufo@1.6.4: {} 10714 11002 11003 + uint8arrays@3.0.0: 11004 + dependencies: 11005 + multiformats: 9.9.0 11006 + 10715 11007 ultrahtml@1.6.0: {} 10716 11008 10717 11009 uncrypto@0.1.3: {} ··· 10725 11017 10726 11018 undici-types@7.19.2: {} 10727 11019 11020 + undici@6.25.0: {} 11021 + 10728 11022 unenv@2.0.0-rc.24: 10729 11023 dependencies: 10730 11024 pathe: 2.0.3 ··· 10732 11026 unhead@2.1.13: 10733 11027 dependencies: 10734 11028 hookable: 6.1.1 11029 + 11030 + unicode-segmenter@0.14.5: {} 10735 11031 10736 11032 unicorn-magic@0.3.0: {} 10737 11033 ··· 11150 11446 archiver-utils: 5.0.2 11151 11447 compress-commons: 6.0.2 11152 11448 readable-stream: 4.7.0 11449 + 11450 + zod@3.25.76: {}
+15
scripts/gen-jwk.ts
··· 1 + /** 2 + * Generate an ES256 JWK private key for AT Proto OAuth client signing. 3 + * Print the JSON-encoded private JWK on stdout. Add it to your env as 4 + * `NUXT_ATPROTO_PRIVATE_JWK`. The public half is exposed at 5 + * /.well-known/jwks.json by the running app. 6 + * 7 + * Usage: 8 + * pnpm gen:jwk > .jwk.json 9 + * NUXT_ATPROTO_PRIVATE_JWK="$(cat .jwk.json)" pnpm dev 10 + */ 11 + import { JoseKey } from '@atproto/jwk-jose' 12 + 13 + const key = await JoseKey.generate(['ES256'], crypto.randomUUID().slice(0, 8)) 14 + process.stdout.write(JSON.stringify(key.privateJwk)) 15 + process.stdout.write('\n')
+27
server/api/atproto/callback.get.ts
··· 1 + import { userIdentity } from '~~/server/db/schema' 2 + 3 + export default defineEventHandler(async event => { 4 + const url = getRequestURL(event) 5 + const params = url.searchParams 6 + 7 + const client = await useOAuthClient() 8 + const { session, state } = await client.callback(params) 9 + 10 + const installationId = state ? Number(state) : NaN 11 + if (!Number.isFinite(installationId)) { 12 + throw createError({ statusCode: 400, statusMessage: 'invalid state (missing installation id)' }) 13 + } 14 + 15 + const db = useDb() 16 + await db.insert(userIdentity).values({ 17 + did: session.did, 18 + handle: null, // resolved separately; we don't have it from the session blob 19 + installationId, 20 + updatedAt: new Date(), 21 + }).onConflictDoUpdate({ 22 + target: userIdentity.did, 23 + set: { installationId, updatedAt: new Date() }, 24 + }) 25 + 26 + await sendRedirect(event, '/dashboard', 302) 27 + })
+27
server/api/atproto/login.get.ts
··· 1 + export default defineEventHandler(async event => { 2 + const query = getQuery(event) 3 + const handleRaw = query.handle 4 + const installationIdRaw = query.installationId 5 + 6 + if (typeof handleRaw !== 'string' || !handleRaw.trim()) { 7 + throw createError({ statusCode: 400, statusMessage: 'handle is required' }) 8 + } 9 + if (typeof installationIdRaw !== 'string' || !/^\d+$/.test(installationIdRaw)) { 10 + throw createError({ statusCode: 400, statusMessage: 'installationId is required' }) 11 + } 12 + 13 + const handle = handleRaw.trim() 14 + const installationId = installationIdRaw 15 + 16 + const client = await useOAuthClient() 17 + 18 + // Round-trip the installation id via OAuth `state`. The library wraps and 19 + // signs `state` itself (PKCE + state CSRF protection are handled internally), 20 + // so this is safe to use as an opaque link key. 21 + const url = await client.authorize(handle, { 22 + state: installationId, 23 + scope: 'atproto transition:generic', 24 + }) 25 + 26 + await sendRedirect(event, url.toString(), 302) 27 + })
+5
server/routes/.well-known/atproto-client-metadata.json.get.ts
··· 1 + export default defineEventHandler(async () => { 2 + const client = await useOAuthClient() 3 + // The library builds the canonical client_metadata document for us. 4 + return client.clientMetadata 5 + })
+5
server/routes/.well-known/jwks.json.get.ts
··· 1 + export default defineEventHandler(async () => { 2 + const client = await useOAuthClient() 3 + // Public half only; private keys never leave the server. 4 + return client.jwks 5 + })
+133
server/utils/atproto-oauth.ts
··· 1 + import { JoseKey } from '@atproto/jwk-jose' 2 + import { 3 + type NodeOAuthClientOptions, 4 + type NodeSavedSession, 5 + type NodeSavedSessionStore, 6 + type NodeSavedState, 7 + type NodeSavedStateStore, 8 + NodeOAuthClient, 9 + } from '@atproto/oauth-client-node' 10 + import { sql } from 'drizzle-orm' 11 + import { atprotoSession, atprotoState } from '../db/schema' 12 + import { useDb } from './db' 13 + import { decrypt, encrypt } from './encryption' 14 + 15 + let cachedClient: NodeOAuthClient | undefined 16 + 17 + /** 18 + * Build the AT Proto OAuth client. The client metadata is constructed from 19 + * runtime config so a single deploy can serve different `client_id`s by 20 + * environment (loopback dev vs prod). 21 + * 22 + * The state and session stores wrap the OAuth library's required interface and 23 + * encrypt the values at rest with `encryption.ts` (xchacha20poly1305). The 24 + * values contain access tokens, refresh tokens, and the user's DPoP private 25 + * key — a DB read with no encryption would be account takeover. 26 + */ 27 + export async function useOAuthClient(): Promise<NodeOAuthClient> { 28 + if (cachedClient) return cachedClient 29 + 30 + const config = useRuntimeConfig() 31 + const publicURL = config.public.publicURL?.replace(/\/$/, '') 32 + if (!publicURL) { 33 + throw new Error('NUXT_PUBLIC_URL is not set') 34 + } 35 + 36 + const privateJwkRaw = config.atprotoPrivateJwk 37 + if (!privateJwkRaw) { 38 + throw new Error('NUXT_ATPROTO_PRIVATE_JWK is not set (run `pnpm gen:jwk` to create one)') 39 + } 40 + const key = await JoseKey.fromImportable(privateJwkRaw) 41 + 42 + const isLoopback = publicURL.startsWith('http://127.0.0.1') || publicURL.startsWith('http://localhost') 43 + const clientId = isLoopback 44 + // Loopback dev: spec-defined synthetic client_id; no metadata fetched by PDS. 45 + ? `http://localhost?redirect_uri=${encodeURIComponent(`${publicURL}/api/atproto/callback`)}&scope=${encodeURIComponent('atproto transition:generic')}` 46 + : `${publicURL}/.well-known/atproto-client-metadata.json` 47 + 48 + const options: NodeOAuthClientOptions = { 49 + clientMetadata: { 50 + client_id: clientId, 51 + client_name: 'synchub.to', 52 + client_uri: publicURL, 53 + redirect_uris: [`${publicURL}/api/atproto/callback`], 54 + grant_types: ['authorization_code', 'refresh_token'], 55 + response_types: ['code'], 56 + scope: 'atproto transition:generic', 57 + application_type: 'web', 58 + token_endpoint_auth_method: 'private_key_jwt', 59 + token_endpoint_auth_signing_alg: 'ES256', 60 + dpop_bound_access_tokens: true, 61 + jwks_uri: `${publicURL}/.well-known/jwks.json`, 62 + }, 63 + keyset: [key], 64 + stateStore: makeStateStore(), 65 + sessionStore: makeSessionStore(), 66 + // Note: no requestLock supplied. Multi-instance deployments can race on 67 + // concurrent token refreshes; see PLAN.md "Deferred / follow-ups". 68 + } 69 + 70 + cachedClient = new NodeOAuthClient(options) 71 + return cachedClient 72 + } 73 + 74 + function makeStateStore(): NodeSavedStateStore { 75 + return { 76 + async set(key: string, value: NodeSavedState) { 77 + const { ciphertext, nonce } = encrypt(JSON.stringify(value)) 78 + const db = useDb() 79 + await db.insert(atprotoState).values({ 80 + key, 81 + valueCiphertext: ciphertext, 82 + valueNonce: nonce, 83 + }).onConflictDoUpdate({ 84 + target: atprotoState.key, 85 + set: { valueCiphertext: ciphertext, valueNonce: nonce }, 86 + }) 87 + }, 88 + async get(key: string) { 89 + const db = useDb() 90 + const rows = await db.select().from(atprotoState).where(sql`${atprotoState.key} = ${key}`) 91 + if (rows.length === 0) return undefined 92 + const row = rows[0]! 93 + return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedState 94 + }, 95 + async del(key: string) { 96 + const db = useDb() 97 + await db.delete(atprotoState).where(sql`${atprotoState.key} = ${key}`) 98 + }, 99 + } 100 + } 101 + 102 + function makeSessionStore(): NodeSavedSessionStore { 103 + return { 104 + async set(sub: string, value: NodeSavedSession) { 105 + const { ciphertext, nonce } = encrypt(JSON.stringify(value)) 106 + const db = useDb() 107 + await db.insert(atprotoSession).values({ 108 + sub, 109 + valueCiphertext: ciphertext, 110 + valueNonce: nonce, 111 + }).onConflictDoUpdate({ 112 + target: atprotoSession.sub, 113 + set: { valueCiphertext: ciphertext, valueNonce: nonce, updatedAt: new Date() }, 114 + }) 115 + }, 116 + async get(sub: string) { 117 + const db = useDb() 118 + const rows = await db.select().from(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) 119 + if (rows.length === 0) return undefined 120 + const row = rows[0]! 121 + return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedSession 122 + }, 123 + async del(sub: string) { 124 + const db = useDb() 125 + await db.delete(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) 126 + }, 127 + } 128 + } 129 + 130 + /** Test hook: drop the cached client. */ 131 + export function clearOAuthClientCache() { 132 + cachedClient = undefined 133 + }
+49
server/utils/encryption.ts
··· 1 + import crypto from 'node:crypto' 2 + import { xchacha20poly1305 } from '@noble/ciphers/chacha.js' 3 + 4 + /** 5 + * Authenticated encryption with a key from runtime config (`NUXT_ENCRYPTION_KEY`, 6 + * base64-encoded 32 bytes). Used to wrap anything sensitive at the app layer 7 + * before it lands in the DB: AT Proto session blobs, SSH private keys. 8 + * 9 + * The KEK is held only in env. If it's lost, every encrypted row becomes 10 + * unreadable. KEK rotation is a future concern \u2014 see PLAN.md. 11 + */ 12 + const NONCE_BYTES = 24 13 + let cachedKey: Uint8Array | undefined 14 + 15 + function getKey(): Uint8Array { 16 + if (cachedKey) return cachedKey 17 + // Read process.env directly rather than via useRuntimeConfig() so this helper 18 + // is callable from outside a Nitro request context (e.g. tests, scripts). 19 + // Nuxt's runtime config still declares the var for documentation; the env 20 + // name is the same. 21 + const raw = process.env.NUXT_ENCRYPTION_KEY 22 + if (!raw) { 23 + throw new Error('NUXT_ENCRYPTION_KEY is not set (expected base64-encoded 32 bytes)') 24 + } 25 + const decoded = Buffer.from(raw, 'base64') 26 + if (decoded.length !== 32) { 27 + throw new Error(`NUXT_ENCRYPTION_KEY must decode to 32 bytes, got ${decoded.length}`) 28 + } 29 + cachedKey = new Uint8Array(decoded) 30 + return cachedKey 31 + } 32 + 33 + export function encrypt(plaintext: string): { ciphertext: Buffer, nonce: Buffer } { 34 + const nonce = crypto.randomBytes(NONCE_BYTES) 35 + const cipher = xchacha20poly1305(getKey(), new Uint8Array(nonce)) 36 + const ciphertext = cipher.encrypt(new TextEncoder().encode(plaintext)) 37 + return { ciphertext: Buffer.from(ciphertext), nonce } 38 + } 39 + 40 + export function decrypt(ciphertext: Buffer, nonce: Buffer): string { 41 + const cipher = xchacha20poly1305(getKey(), new Uint8Array(nonce)) 42 + const plaintext = cipher.decrypt(new Uint8Array(ciphertext)) 43 + return new TextDecoder().decode(plaintext) 44 + } 45 + 46 + /** Test/utility hook: drop the cached key so the next call re-reads runtime config. */ 47 + export function clearEncryptionKeyCache() { 48 + cachedKey = undefined 49 + }
+75
test/unit/encryption.spec.ts
··· 1 + import crypto from 'node:crypto' 2 + import { afterEach, beforeEach, describe, expect, it } from 'vitest' 3 + import { clearEncryptionKeyCache, decrypt, encrypt } from '../../server/utils/encryption' 4 + 5 + const ORIGINAL_ENV = process.env.NUXT_ENCRYPTION_KEY 6 + 7 + describe('encryption', () => { 8 + beforeEach(() => { 9 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 10 + clearEncryptionKeyCache() 11 + }) 12 + 13 + afterEach(() => { 14 + if (ORIGINAL_ENV === undefined) delete process.env.NUXT_ENCRYPTION_KEY 15 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENV 16 + clearEncryptionKeyCache() 17 + }) 18 + 19 + it('round-trips a string', () => { 20 + const { ciphertext, nonce } = encrypt('hello world') 21 + expect(decrypt(ciphertext, nonce)).toBe('hello world') 22 + }) 23 + 24 + it('round-trips JSON-shaped data', () => { 25 + const payload = JSON.stringify({ did: 'did:plc:foo', token: 'abc.def.ghi' }) 26 + const { ciphertext, nonce } = encrypt(payload) 27 + expect(JSON.parse(decrypt(ciphertext, nonce))).toEqual({ 28 + did: 'did:plc:foo', 29 + token: 'abc.def.ghi', 30 + }) 31 + }) 32 + 33 + it('produces different ciphertext for the same input (random nonce)', () => { 34 + const a = encrypt('same plaintext') 35 + const b = encrypt('same plaintext') 36 + expect(a.ciphertext.equals(b.ciphertext)).toBe(false) 37 + expect(a.nonce.equals(b.nonce)).toBe(false) 38 + }) 39 + 40 + it('throws on tampered ciphertext', () => { 41 + const { ciphertext, nonce } = encrypt('hello') 42 + const tampered = Buffer.from(ciphertext) 43 + tampered[0] = tampered[0] ^ 0xFF 44 + expect(() => decrypt(tampered, nonce)).toThrow(/invalid tag|auth/i) 45 + }) 46 + 47 + it('throws on tampered nonce', () => { 48 + const { ciphertext, nonce } = encrypt('hello') 49 + const tampered = Buffer.from(nonce) 50 + tampered[0] = tampered[0] ^ 0xFF 51 + expect(() => decrypt(ciphertext, tampered)).toThrow(/invalid tag|auth/i) 52 + }) 53 + 54 + it('throws when the key changes between encrypt and decrypt', () => { 55 + const { ciphertext, nonce } = encrypt('hello') 56 + 57 + // Rotate the key. 58 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 59 + clearEncryptionKeyCache() 60 + 61 + expect(() => decrypt(ciphertext, nonce)).toThrow(/invalid tag|auth/i) 62 + }) 63 + 64 + it('throws on wrong key length', () => { 65 + process.env.NUXT_ENCRYPTION_KEY = Buffer.from('too short').toString('base64') 66 + clearEncryptionKeyCache() 67 + expect(() => encrypt('hello')).toThrow(/32 bytes/) 68 + }) 69 + 70 + it('throws when the key is missing', () => { 71 + delete process.env.NUXT_ENCRYPTION_KEY 72 + clearEncryptionKeyCache() 73 + expect(() => encrypt('hello')).toThrow(/NUXT_ENCRYPTION_KEY/) 74 + }) 75 + })