From ec148ac761aab080244eaa9be9c2ca6a8527370a Mon Sep 17 00:00:00 2001 From: Steve Date: Thu, 05 Mar 2026 03:15:45 +0000 Subject: [PATCH] feat: added initial server package --- bun.lock | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- package.json | 4 +++- packages/server/.env.example | 20 ++++++++++++++++++++ packages/server/Dockerfile | 15 +++++++++++++++ packages/server/README.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server/docker-compose.yml | 32 ++++++++++++++++++++++++++++++++ packages/server/package.json | 17 +++++++++++++++++ packages/server/tsconfig.json | 13 +++++++++++++ packages/server/src/env.ts | 20 ++++++++++++++++++++ packages/server/src/index.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server/src/lib/oauth-client.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server/src/lib/redis-stores.ts | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server/src/lib/session.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server/src/lib/theme.ts | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server/src/routes/auth.ts | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/server/src/routes/subscribe.ts | 426 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 16 file(s) changed, 1374 insertion(s)(+), 16 deletion(s)(-) diff --git a/bun.lock b/bun.lock --- a/bun.lock +++ b/bun.lock @@ -58,33 +58,44 @@ "typescript": "^5", }, }, + "packages/server": { + "name": "@sequoia/server", + "version": "0.1.0", + "dependencies": { + "@atproto-labs/handle-resolver": "^0.1.5", + "@atproto/api": "^0.13.21", + "@atproto/jwk-jose": "^0.1.3", + "@atproto/oauth-client": "^0.3.3", + "hono": "^4.7.4", + }, + }, }, "packages": { "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@atproto-labs/did-resolver": ["@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.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.1.13", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.2.0", "@atproto-labs/simple-store-memory": "0.1.3", "@atproto/did": "0.1.5", "zod": "^3.23.8" } }, "sha512-DG3YNaCKc6PAIv1Gsz3E1Kufw2t14OBxe4LdKK7KKLCNoex51hm+A5yMevShe3BSll+QosqWYIEgkPSc5xBoGQ=="], "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], - "@atproto-labs/handle-resolver": ["@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.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.1.8", "", { "dependencies": { "@atproto-labs/simple-store": "0.2.0", "@atproto-labs/simple-store-memory": "0.1.3", "@atproto/did": "0.1.5", "zod": "^3.23.8" } }, "sha512-Y0ckccoCGDo/3g4thPkgp9QcORmc+qqEaCBCYCZYtfLIQp4775u22wd+4fyEyJP4DqoReKacninkICgRGfs3dQ=="], "@atproto-labs/handle-resolver-node": ["@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" } }, "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw=="], - "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.1.18", "", { "dependencies": { "@atproto-labs/did-resolver": "0.1.13", "@atproto-labs/handle-resolver": "0.1.8", "@atproto/syntax": "0.4.0" } }, "sha512-DArYXP1hzZJIBcojun0CWEF+TjAhlGKcVq/RwLiGfY1mKq2yPjCiXyHj+5L0+z9jBSZiAB7L65JgcjI2+MFiRg=="], "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], - "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.2.0", "", {}, "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA=="], - "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.3", "", { "dependencies": { "@atproto-labs/simple-store": "0.2.0", "lru-cache": "^10.2.0" } }, "sha512-jkitT9+AtU+0b28DoN92iURLaCt/q/q4yX8q6V+9LSwYlUTqKoj/5NFKvF7x6EBuG+gpUdlcycbH7e60gjOhRQ=="], - "@atproto/api": ["@atproto/api@0.19.0", "", { "dependencies": { "@atproto/common-web": "^0.4.17", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-7u/EGgkIj4bbslGer2RMQPtMWCPvREcpH0mVagaf5om+NcPzUIZeIacWKANVv95BdMJ7jlcHS7xrkEMPmg2dFw=="], + "@atproto/api": ["@atproto/api@0.13.35", "", { "dependencies": { "@atproto/common-web": "^0.4.0", "@atproto/lexicon": "^0.4.6", "@atproto/syntax": "^0.3.2", "@atproto/xrpc": "^0.6.8", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g=="], "@atproto/common-web": ["@atproto/common-web@0.4.17", "", { "dependencies": { "@atproto/lex-data": "^0.0.12", "@atproto/lex-json": "^0.0.12", "@atproto/syntax": "^0.4.3", "zod": "^3.23.8" } }, "sha512-sfxD8NGxyoxhxmM9EUshEFbWcJ3+JHEOZF4Quk6HsCh1UxpHBmLabT/vEsAkDWl+C/8U0ine0+c/gHyE/OZiQQ=="], - "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], + "@atproto/did": ["@atproto/did@0.1.5", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-8+1D08QdGE5TF0bB0vV8HLVrVZJeLNITpRTUVEoABNMRaUS7CoYSVb0+JNQDeJIVmqMjOL8dOjvCUDkp3gEaGQ=="], "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], @@ -96,17 +107,17 @@ "@atproto/lex-json": ["@atproto/lex-json@0.0.12", "", { "dependencies": { "@atproto/lex-data": "^0.0.12", "tslib": "^2.8.1" } }, "sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA=="], - "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], + "@atproto/lexicon": ["@atproto/lexicon@0.4.14", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/syntax": "^0.4.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ=="], - "@atproto/oauth-client": ["@atproto/oauth-client@0.6.0", "", { "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", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q=="], + "@atproto/oauth-client": ["@atproto/oauth-client@0.3.22", "", { "dependencies": { "@atproto-labs/did-resolver": "0.1.13", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.1.8", "@atproto-labs/identity-resolver": "0.1.18", "@atproto-labs/simple-store": "0.2.0", "@atproto-labs/simple-store-memory": "0.1.3", "@atproto/did": "0.1.5", "@atproto/jwk": "0.2.0", "@atproto/oauth-types": "0.2.8", "@atproto/xrpc": "0.7.0", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-IJYkUSGGklV7tQ0S2+5smh8Xmu5MwfxBUNXMtqiooeU2nj+UcNk3/b0nE4MS05JNfwh2BXgHv3P8hrhVG2+RAA=="], "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "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.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="], - "@atproto/oauth-types": ["@atproto/oauth-types@0.6.3", "", { "dependencies": { "@atproto/did": "^0.3.0", "@atproto/jwk": "^0.6.0", "zod": "^3.23.8" } }, "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng=="], + "@atproto/oauth-types": ["@atproto/oauth-types@0.2.8", "", { "dependencies": { "@atproto/jwk": "0.2.0", "zod": "^3.23.8" } }, "sha512-xcYI2JmhrWwscePDoaKeDawVCCZkcvBqrBFMpMk4gf/OujH0pNSKBD/aWsayc6WvujVbTqwrG2hwPLfRqzJbwg=="], - "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], + "@atproto/syntax": ["@atproto/syntax@0.3.4", "", {}, "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg=="], - "@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], + "@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="], "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], @@ -533,6 +544,8 @@ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="], + + "@sequoia/server": ["@sequoia/server@workspace:packages/server"], "@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], @@ -1648,11 +1661,31 @@ "@atproto-labs/fetch-node/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], - "@atproto/lexicon/@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], + "@atproto-labs/handle-resolver-node/@atproto-labs/handle-resolver": ["@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.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], + + "@atproto-labs/handle-resolver-node/@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], + + "@atproto-labs/identity-resolver/@atproto/syntax": ["@atproto/syntax@0.4.0", "", {}, "sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA=="], + + "@atproto/common-web/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], + + "@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], + + "@atproto/oauth-client/@atproto/jwk": ["@atproto/jwk@0.2.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-foOxExbw04XCaoLaGdv9BQj0Ac7snZsk6IpQjOsjYatf+i62Pi9bUkZ0MAoA75HPk8ZmKoDnbA60uBMmiOPPHQ=="], + + "@atproto/oauth-client/@atproto/xrpc": ["@atproto/xrpc@0.7.0", "", { "dependencies": { "@atproto/lexicon": "^0.4.11", "zod": "^3.23.8" } }, "sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw=="], + + "@atproto/oauth-client-node/@atproto-labs/did-resolver": ["@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.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], + + "@atproto/oauth-client-node/@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], + + "@atproto/oauth-client-node/@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], "@atproto/oauth-client-node/@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "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.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], "@atproto/oauth-client-node/@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], + + "@atproto/oauth-types/@atproto/jwk": ["@atproto/jwk@0.2.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-foOxExbw04XCaoLaGdv9BQj0Ac7snZsk6IpQjOsjYatf+i62Pi9bUkZ0MAoA75HPk8ZmKoDnbA60uBMmiOPPHQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1710,6 +1743,12 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "docs/@atproto-labs/handle-resolver": ["@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.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], + + "docs/@atproto/api": ["@atproto/api@0.19.0", "", { "dependencies": { "@atproto/common-web": "^0.4.17", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-7u/EGgkIj4bbslGer2RMQPtMWCPvREcpH0mVagaf5om+NcPzUIZeIacWKANVv95BdMJ7jlcHS7xrkEMPmg2dFw=="], + + "docs/@atproto/oauth-client": ["@atproto/oauth-client@0.6.0", "", { "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", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q=="], + "eval/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], @@ -1744,9 +1783,19 @@ "vocs/hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], - "@atproto/lexicon/@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], + "@atproto-labs/handle-resolver-node/@atproto-labs/handle-resolver/@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], - "@atproto/lexicon/@atproto/common-web/@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], + "@atproto-labs/handle-resolver-node/@atproto-labs/handle-resolver/@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + + "@atproto/oauth-client-node/@atproto-labs/did-resolver/@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + + "@atproto/oauth-client-node/@atproto/oauth-client/@atproto-labs/handle-resolver": ["@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.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], + + "@atproto/oauth-client-node/@atproto/oauth-client/@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], + + "@atproto/oauth-client-node/@atproto/oauth-client/@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + + "@atproto/oauth-client-node/@atproto/oauth-client/@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], @@ -1816,6 +1865,32 @@ "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "docs/@atproto-labs/handle-resolver/@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], + + "docs/@atproto-labs/handle-resolver/@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + + "docs/@atproto-labs/handle-resolver/@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], + + "docs/@atproto/api/@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], + + "docs/@atproto/api/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], + + "docs/@atproto/api/@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], + + "docs/@atproto/oauth-client/@atproto-labs/did-resolver": ["@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.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], + + "docs/@atproto/oauth-client/@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], + + "docs/@atproto/oauth-client/@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], + + "docs/@atproto/oauth-client/@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + + "docs/@atproto/oauth-client/@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], + + "docs/@atproto/oauth-client/@atproto/oauth-types": ["@atproto/oauth-types@0.6.3", "", { "dependencies": { "@atproto/did": "^0.3.0", "@atproto/jwk": "^0.6.0", "zod": "^3.23.8" } }, "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng=="], + + "docs/@atproto/oauth-client/@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], + "eval/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "hast-util-from-dom/hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -1825,6 +1900,12 @@ "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "sequoia-cli/@atproto/api/@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], + + "sequoia-cli/@atproto/api/@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], + + "sequoia-cli/@atproto/api/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], + + "sequoia-cli/@atproto/api/@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], @@ -1878,8 +1959,34 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@atproto/oauth-client-node/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], + + "docs/@atproto/api/@atproto/lexicon/@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], + + "docs/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], + "sequoia-cli/@atproto/api/@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], "sequoia-cli/@atproto/api/@atproto/common-web/@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], + + "@atproto/oauth-client-node/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], + + "@atproto/oauth-client-node/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], + + "docs/@atproto/api/@atproto/lexicon/@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], + + "docs/@atproto/api/@atproto/lexicon/@atproto/common-web/@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], + + "docs/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], + + "docs/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], + + "@atproto/oauth-client-node/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], + + "@atproto/oauth-client-node/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/common-web/@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], + + "docs/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], + + "docs/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/common-web/@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], } } diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "build:docs": "cd docs && bun run build", "build:cli": "cd packages/cli && bun run build", "deploy:docs": "cd docs && bun run deploy", - "deploy:cli": "cd packages/cli && bun run deploy" + "deploy:cli": "cd packages/cli && bun run deploy", + "dev:server": "cd packages/server && bun run dev", + "start:server": "cd packages/server && bun run start" }, "devDependencies": { "@types/bun": "latest", diff --git a/packages/server/.env.example b/packages/server/.env.example new file mode 100644 --- /dev/null +++ b/packages/server/.env.example @@ -0,0 +1,20 @@ +CLIENT_URL=https://your-domain.com +CLIENT_NAME=Sequoia +PORT=3000 +REDIS_URL=redis://redis:6379 + +# Theme overrides (optional) +# THEME_ACCENT_COLOR=#3A5A40 +# THEME_BG_COLOR=#F5F3EF +# THEME_FG_COLOR=#2C2C2C +# THEME_BORDER_COLOR=#D5D1C8 +# THEME_ERROR_COLOR=#8B3A3A +# THEME_BORDER_RADIUS=6px +# THEME_FONT_FAMILY=system-ui, sans-serif +# THEME_DARK_BG_COLOR=#1A1A1A +# THEME_DARK_FG_COLOR=#E5E5E5 +# THEME_DARK_BORDER_COLOR=#3A3A3A +# THEME_DARK_ERROR_COLOR=#E57373 + +# Path to a custom CSS file for full theme control (optional) +# THEME_CSS_PATH=/app/theme.css diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile new file mode 100644 --- /dev/null +++ b/packages/server/Dockerfile @@ -0,0 +1,15 @@ +FROM oven/bun:1 AS install +WORKDIR /app +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install + +FROM oven/bun:1 +WORKDIR /app +COPY --from=install /app/node_modules ./node_modules +COPY package.json ./ +COPY src ./src + +ENV PORT=3000 +EXPOSE ${PORT} + +ENTRYPOINT ["bun", "run", "src/index.ts"] diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1,93 @@ +# Sequoia Server + +Self-hostable AT Protocol OAuth and subscription server. Handles Bluesky login and manages `site.standard.graph.subscription` records on behalf of users. Built with Bun, Hono, and Redis. + +## Quickstart + +### Docker (recommended) + +```bash +cp .env.example .env +# Edit .env โ€” at minimum set CLIENT_URL to your public URL +docker compose up +``` + +### Local development + +Requires [Bun](https://bun.sh) and a running Redis instance. + +```bash +bun install +CLIENT_URL=http://localhost:3000 bun run dev +``` + +## How it works + +1. A user visits `/subscribe?publicationUri=at://...` and enters their Bluesky handle +2. The server initiates an AT Protocol OAuth flow โ€” the user authorizes on Bluesky +3. After callback, the server creates a `site.standard.graph.subscription` record in the user's repo +4. The [sequoia-subscribe](https://github.com/standard-schema/sequoia) web component can point to this server for the full flow + +### Routes + +| Route | Method | Description | +|-------|--------|-------------| +| `/api/health` | GET | Health check | +| `/oauth/client-metadata.json` | GET | OAuth client metadata | +| `/oauth/login?handle=` | GET | Start OAuth flow | +| `/oauth/callback` | GET | OAuth callback | +| `/oauth/logout` | POST | Revoke session | +| `/oauth/status` | GET | Check auth status | +| `/subscribe` | GET | Subscribe page (HTML) | +| `/subscribe` | POST | Subscribe via API (JSON) | +| `/subscribe/check` | GET | Check subscription status | +| `/subscribe/login` | POST | Handle form submission | + +## Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `CLIENT_URL` | Yes | โ€” | Public URL of this server (used for OAuth redirects) | +| `CLIENT_NAME` | No | `Sequoia` | Name shown on Bluesky OAuth consent screen | +| `PORT` | No | `3000` | Server port | +| `REDIS_URL` | No | `redis://localhost:6379` | Redis connection URL | + +### Theming + +The subscribe pages use CSS custom properties that can be overridden via environment variables: + +| Variable | Default | +|----------|---------| +| `THEME_ACCENT_COLOR` | `#3A5A40` | +| `THEME_BG_COLOR` | `#F5F3EF` | +| `THEME_FG_COLOR` | `#2C2C2C` | +| `THEME_BORDER_COLOR` | `#D5D1C8` | +| `THEME_ERROR_COLOR` | `#8B3A3A` | +| `THEME_BORDER_RADIUS` | `6px` | +| `THEME_FONT_FAMILY` | `system-ui, sans-serif` | +| `THEME_DARK_BG_COLOR` | `#1A1A1A` | +| `THEME_DARK_FG_COLOR` | `#E5E5E5` | +| `THEME_DARK_BORDER_COLOR` | `#3A3A3A` | +| `THEME_DARK_ERROR_COLOR` | `#E57373` | + +For full control, set `THEME_CSS_PATH` to a CSS file path (e.g. `/app/theme.css` mounted via Docker volume). It will be injected after the default styles. + +## Deployment + +The included `Dockerfile` produces a minimal image: + +```bash +docker build -t sequoia-server . +docker run -p 3000:3000 \ + -e CLIENT_URL=https://your-domain.com \ + -e REDIS_URL=redis://your-redis:6379 \ + sequoia-server +``` + +Or use `docker-compose.yml` which bundles Redis: + +```bash +docker compose up -d +``` + +Place behind a reverse proxy (Caddy, nginx, Traefik) for TLS. diff --git a/packages/server/docker-compose.yml b/packages/server/docker-compose.yml new file mode 100644 --- /dev/null +++ b/packages/server/docker-compose.yml @@ -0,0 +1,32 @@ +services: + server: + build: . + ports: + - "${PORT:-3000}:${PORT:-3000}" + environment: + - CLIENT_URL=${CLIENT_URL} + - CLIENT_NAME=${CLIENT_NAME:-Sequoia} + - PORT=${PORT:-3000} + - REDIS_URL=redis://redis:6379 + - THEME_ACCENT_COLOR=${THEME_ACCENT_COLOR:-} + - THEME_BG_COLOR=${THEME_BG_COLOR:-} + - THEME_FG_COLOR=${THEME_FG_COLOR:-} + - THEME_BORDER_COLOR=${THEME_BORDER_COLOR:-} + - THEME_ERROR_COLOR=${THEME_ERROR_COLOR:-} + - THEME_BORDER_RADIUS=${THEME_BORDER_RADIUS:-} + - THEME_FONT_FAMILY=${THEME_FONT_FAMILY:-} + - THEME_DARK_BG_COLOR=${THEME_DARK_BG_COLOR:-} + - THEME_DARK_FG_COLOR=${THEME_DARK_FG_COLOR:-} + - THEME_DARK_BORDER_COLOR=${THEME_DARK_BORDER_COLOR:-} + - THEME_DARK_ERROR_COLOR=${THEME_DARK_ERROR_COLOR:-} + - THEME_CSS_PATH=${THEME_CSS_PATH:-} + depends_on: + - redis + + redis: + image: redis:7 + volumes: + - redis-data:/data + +volumes: + redis-data: diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "sequoia-server", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@atproto/api": "^0.13.21", + "@atproto/jwk-jose": "^0.1.3", + "@atproto/oauth-client": "^0.3.3", + "@atproto-labs/handle-resolver": "^0.1.5", + "hono": "^4.7.4" + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts new file mode 100644 --- /dev/null +++ b/packages/server/src/env.ts @@ -0,0 +1,20 @@ +export interface Env { + CLIENT_URL: string; + CLIENT_NAME: string; + PORT: number; + REDIS_URL: string; +} + +export function loadEnv(): Env { + const CLIENT_URL = process.env.CLIENT_URL; + if (!CLIENT_URL) { + throw new Error("CLIENT_URL environment variable is required"); + } + + return { + CLIENT_URL: CLIENT_URL.replace(/\/+$/, ""), + CLIENT_NAME: process.env.CLIENT_NAME || "Sequoia", + PORT: Number(process.env.PORT) || 3000, + REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379", + }; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,52 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { RedisClient } from "bun"; +import { loadEnv } from "./env"; +import type { Env } from "./env"; +import auth from "./routes/auth"; +import subscribe from "./routes/subscribe"; + +const env = loadEnv(); + +const redis = new RedisClient(env.REDIS_URL); + +type Variables = { env: Env; redis: typeof redis }; + +const app = new Hono<{ Variables: Variables }>(); + +// Inject env and redis into all routes +app.use("*", async (c, next) => { + c.set("env", env); + c.set("redis", redis); + await next(); +}); + +// Health check +app.get("/api/health", (c) => c.json({ status: "ok" })); + +// OAuth routes +app.route("/oauth", auth); + +// Subscribe routes with CORS +app.use( + "/subscribe/*", + cors({ + origin: (origin) => origin, + credentials: true, + }), +); +app.use( + "/subscribe", + cors({ + origin: (origin) => origin, + credentials: true, + }), +); +app.route("/subscribe", subscribe); + +console.log(`Sequoia server listening on port ${env.PORT}`); + +export default { + port: env.PORT, + fetch: app.fetch, +}; diff --git a/packages/server/src/lib/oauth-client.ts b/packages/server/src/lib/oauth-client.ts new file mode 100644 --- /dev/null +++ b/packages/server/src/lib/oauth-client.ts @@ -0,0 +1,53 @@ +import { JoseKey } from "@atproto/jwk-jose"; +import { OAuthClient } from "@atproto/oauth-client"; +import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; +import type { RedisClient } from "bun"; +import { createStateStore, createSessionStore } from "./redis-stores"; + +export const OAUTH_SCOPE = + "atproto repo:site.standard.graph.subscription?action=create&action=delete"; + +export function createOAuthClient( + redis: RedisClient, + clientUrl: string, + clientName = "Sequoia", +) { + const clientId = `${clientUrl}/oauth/client-metadata.json`; + const redirectUri = `${clientUrl}/oauth/callback`; + + const dohEndpoint = + process.env.DOH_ENDPOINT || "https://cloudflare-dns.com/dns-query"; + + return new OAuthClient({ + responseMode: "query", + handleResolver: new AtprotoDohHandleResolver({ dohEndpoint }), + clientMetadata: { + client_id: clientId, + client_name: clientName, + client_uri: clientUrl, + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: OAUTH_SCOPE, + token_endpoint_auth_method: "none", + application_type: "web", + dpop_bound_access_tokens: true, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @atproto Key class mismatch across packages + runtimeImplementation: { + createKey: (algs: string[]) => JoseKey.generate(algs) as any, + getRandomValues: (length: number) => + crypto.getRandomValues(new Uint8Array(length)), + digest: async (data: Uint8Array, { name }: { name: string }) => { + const buf = await crypto.subtle.digest( + name.replace("sha", "SHA-"), + new Uint8Array(data), + ); + return new Uint8Array(buf); + }, + requestLock: (_name: string, fn: () => T | PromiseLike) => fn(), + }, + stateStore: createStateStore(redis), + sessionStore: createSessionStore(redis), + }); +} diff --git a/packages/server/src/lib/redis-stores.ts b/packages/server/src/lib/redis-stores.ts new file mode 100644 --- /dev/null +++ b/packages/server/src/lib/redis-stores.ts @@ -0,0 +1,77 @@ +import { JoseKey } from "@atproto/jwk-jose"; +import type { + Key, + InternalStateData, + SessionStore, + StateStore, +} from "@atproto/oauth-client"; +import { RedisClient } from "bun"; + +type SerializedStateData = Omit & { + dpopJwk: Record; +}; + +type SerializedSession = Omit[1], "dpopKey"> & { + dpopJwk: Record; +}; + +function serializeKey(key: Key): Record { + const jwk = key.privateJwk; + if (!jwk) throw new Error("Private DPoP JWK is missing"); + return jwk as Record; +} + +async function deserializeKey(jwk: Record): Promise { + return JoseKey.fromJWK(jwk) as unknown as Key; +} + +export function createStateStore(redis: RedisClient, ttl = 600): StateStore { + return { + async set(key, { dpopKey, ...rest }) { + const data: SerializedStateData = { + ...rest, + dpopJwk: serializeKey(dpopKey), + }; + const redisKey = `oauth_state:${key}`; + await redis.set(redisKey, JSON.stringify(data)); + await redis.expire(redisKey, ttl); + }, + async get(key) { + const raw = await redis.get(`oauth_state:${key}`); + if (!raw) return undefined; + const { dpopJwk, ...rest }: SerializedStateData = JSON.parse(raw); + const dpopKey = await deserializeKey(dpopJwk); + return { ...rest, dpopKey }; + }, + async del(key) { + await redis.del(`oauth_state:${key}`); + }, + }; +} + +export function createSessionStore( + redis: RedisClient, + ttl = 60 * 60 * 24 * 14, +): SessionStore { + return { + async set(sub, { dpopKey, ...rest }) { + const data: SerializedSession = { + ...rest, + dpopJwk: serializeKey(dpopKey), + }; + const redisKey = `oauth_session:${sub}`; + await redis.set(redisKey, JSON.stringify(data)); + await redis.expire(redisKey, ttl); + }, + async get(sub) { + const raw = await redis.get(`oauth_session:${sub}`); + if (!raw) return undefined; + const { dpopJwk, ...rest }: SerializedSession = JSON.parse(raw); + const dpopKey = await deserializeKey(dpopJwk); + return { ...rest, dpopKey }; + }, + async del(sub) { + await redis.del(`oauth_session:${sub}`); + }, + }; +} diff --git a/packages/server/src/lib/session.ts b/packages/server/src/lib/session.ts new file mode 100644 --- /dev/null +++ b/packages/server/src/lib/session.ts @@ -0,0 +1,76 @@ +import type { Context } from "hono"; +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; + +const SESSION_COOKIE_NAME = "session_id"; +const RETURN_TO_COOKIE_NAME = "login_return_to"; +const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds +const RETURN_TO_TTL = 600; // 10 minutes in seconds + +function baseCookieOptions(clientUrl: string) { + const isLocalhost = clientUrl.includes("localhost"); + const hostname = new URL(clientUrl).hostname; + return { + httpOnly: true as const, + sameSite: "Lax" as const, + path: "/", + ...(isLocalhost ? {} : { domain: `.${hostname}`, secure: true }), + }; +} + +/** + * Get DID from session cookie + */ +export function getSessionDid(c: Context): string | null { + const value = getCookie(c, SESSION_COOKIE_NAME); + return value ? decodeURIComponent(value) : null; +} + +/** + * Set session cookie with the user's DID + */ +export function setSessionCookie( + c: Context, + did: string, + clientUrl: string, +): void { + setCookie(c, SESSION_COOKIE_NAME, encodeURIComponent(did), { + ...baseCookieOptions(clientUrl), + maxAge: SESSION_TTL, + }); +} + +/** + * Clear session cookie + */ +export function clearSessionCookie(c: Context, clientUrl: string): void { + deleteCookie(c, SESSION_COOKIE_NAME, baseCookieOptions(clientUrl)); +} + +/** + * Get the post-OAuth return-to URL from the short-lived cookie + */ +export function getReturnToCookie(c: Context): string | null { + const value = getCookie(c, RETURN_TO_COOKIE_NAME); + return value ? decodeURIComponent(value) : null; +} + +/** + * Set a short-lived cookie that redirects back after OAuth completes + */ +export function setReturnToCookie( + c: Context, + returnTo: string, + clientUrl: string, +): void { + setCookie(c, RETURN_TO_COOKIE_NAME, encodeURIComponent(returnTo), { + ...baseCookieOptions(clientUrl), + maxAge: RETURN_TO_TTL, + }); +} + +/** + * Clear the return-to cookie + */ +export function clearReturnToCookie(c: Context, clientUrl: string): void { + deleteCookie(c, RETURN_TO_COOKIE_NAME, baseCookieOptions(clientUrl)); +} diff --git a/packages/server/src/lib/theme.ts b/packages/server/src/lib/theme.ts new file mode 100644 --- /dev/null +++ b/packages/server/src/lib/theme.ts @@ -0,0 +1,199 @@ +import { existsSync, readFileSync } from "fs"; + +interface ThemeVars { + fgColor: string; + bgColor: string; + accentColor: string; + borderColor: string; + errorColor: string; + borderRadius: string; + fontFamily: string; + darkBgColor: string; + darkFgColor: string; + darkBorderColor: string; + darkErrorColor: string; +} + +function getThemeVars(): ThemeVars { + return { + fgColor: process.env.THEME_FG_COLOR || "#2C2C2C", + bgColor: process.env.THEME_BG_COLOR || "#F5F3EF", + accentColor: process.env.THEME_ACCENT_COLOR || "#3A5A40", + borderColor: process.env.THEME_BORDER_COLOR || "#D5D1C8", + errorColor: process.env.THEME_ERROR_COLOR || "#8B3A3A", + borderRadius: process.env.THEME_BORDER_RADIUS || "6px", + fontFamily: process.env.THEME_FONT_FAMILY || "system-ui, sans-serif", + darkBgColor: process.env.THEME_DARK_BG_COLOR || "#1A1A1A", + darkFgColor: process.env.THEME_DARK_FG_COLOR || "#E5E5E5", + darkBorderColor: process.env.THEME_DARK_BORDER_COLOR || "#3A3A3A", + darkErrorColor: process.env.THEME_DARK_ERROR_COLOR || "#E57373", + }; +} + +function getCustomCss(): string { + const cssPath = process.env.THEME_CSS_PATH; + if (!cssPath) return ""; + try { + if (existsSync(cssPath)) { + return readFileSync(cssPath, "utf-8"); + } + } catch { + console.warn(`Failed to read custom CSS file: ${cssPath}`); + } + return ""; +} + +export function generateStyleBlock(): string { + const t = getThemeVars(); + const customCss = getCustomCss(); + + return ``; +} + +export function page(body: string, headExtra = ""): string { + return ` + + + + + Sequoia ยท Subscribe + ${generateStyleBlock()} + ${headExtra} + + +
+ ${body} +
+ +`; +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts new file mode 100644 --- /dev/null +++ b/packages/server/src/routes/auth.ts @@ -0,0 +1,156 @@ +import { Hono } from "hono"; +import type { RedisClient } from "bun"; +import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client"; +import { + getSessionDid, + setSessionCookie, + clearSessionCookie, + getReturnToCookie, + clearReturnToCookie, +} from "../lib/session"; +import type { Env } from "../env"; + +type Variables = { env: Env; redis: RedisClient }; + +const auth = new Hono<{ Variables: Variables }>(); + +// OAuth client metadata endpoint +auth.get("/client-metadata.json", (c) => { + const env = c.get("env"); + const clientId = `${env.CLIENT_URL}/oauth/client-metadata.json`; + const redirectUri = `${env.CLIENT_URL}/oauth/callback`; + + return c.json({ + client_id: clientId, + client_name: env.CLIENT_NAME, + client_uri: env.CLIENT_URL, + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: OAUTH_SCOPE, + token_endpoint_auth_method: "none", + application_type: "web", + dpop_bound_access_tokens: true, + }); +}); + +// Start OAuth login flow +auth.get("/login", async (c) => { + const env = c.get("env"); + const redis = c.get("redis"); + + try { + const handle = c.req.query("handle"); + if (!handle) { + return c.redirect(`${env.CLIENT_URL}/?error=missing_handle`); + } + + const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const authUrl = await client.authorize(handle, { + scope: OAUTH_SCOPE, + }); + + return c.redirect(authUrl.toString()); + } catch (error) { + console.error("Login error:", error); + return c.redirect(`${env.CLIENT_URL}/?error=login_failed`); + } +}); + +// OAuth callback handler +auth.get("/callback", async (c) => { + const env = c.get("env"); + const redis = c.get("redis"); + + try { + const params = new URLSearchParams(c.req.url.split("?")[1] || ""); + + if (params.get("error")) { + const error = params.get("error"); + console.error("OAuth error:", error, params.get("error_description")); + return c.redirect( + `${env.CLIENT_URL}/?error=${encodeURIComponent(error!)}`, + ); + } + + const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const { session } = await client.callback(params); + + // Resolve handle from DID + let handle: string | undefined; + try { + const identity = await client.identityResolver.resolve(session.did); + handle = identity.handle; + } catch { + // Handle resolution is best-effort + } + + // Store handle in Redis alongside the session for quick lookup + if (handle) { + const key = `oauth_handle:${session.did}`; + await redis.set(key, handle); + await redis.expire(key, 60 * 60 * 24 * 14); + } + + setSessionCookie(c, session.did, env.CLIENT_URL); + + // If a subscribe flow set a return URL before initiating OAuth, honor it + const returnTo = getReturnToCookie(c); + clearReturnToCookie(c, env.CLIENT_URL); + + return c.redirect(returnTo ?? `${env.CLIENT_URL}/`); + } catch (error) { + console.error("Callback error:", error); + return c.redirect(`${env.CLIENT_URL}/?error=callback_failed`); + } +}); + +// Logout endpoint +auth.post("/logout", async (c) => { + const env = c.get("env"); + const redis = c.get("redis"); + const did = getSessionDid(c); + + if (did) { + try { + const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + await client.revoke(did); + } catch (error) { + console.error("Revoke error:", error); + } + await redis.del(`oauth_handle:${did}`); + } + + clearSessionCookie(c, env.CLIENT_URL); + return c.json({ success: true }); +}); + +// Check auth status +auth.get("/status", async (c) => { + const env = c.get("env"); + const redis = c.get("redis"); + const did = getSessionDid(c); + + if (!did) { + return c.json({ authenticated: false }); + } + + try { + const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const session = await client.restore(did); + + const handle = await redis.get(`oauth_handle:${session.did}`); + + return c.json({ + authenticated: true, + did: session.did, + handle: handle || undefined, + }); + } catch (error) { + console.error("Session restore failed:", error); + clearSessionCookie(c, env.CLIENT_URL); + return c.json({ authenticated: false }); + } +}); + +export default auth; diff --git a/packages/server/src/routes/subscribe.ts b/packages/server/src/routes/subscribe.ts new file mode 100644 --- /dev/null +++ b/packages/server/src/routes/subscribe.ts @@ -0,0 +1,426 @@ +import { Agent } from "@atproto/api"; +import { Hono } from "hono"; +import type { RedisClient } from "bun"; +import { createOAuthClient } from "../lib/oauth-client"; +import { getSessionDid, setReturnToCookie } from "../lib/session"; +import { page, escapeHtml } from "../lib/theme"; +import type { Env } from "../env"; + +type Variables = { env: Env; redis: RedisClient }; + +const subscribe = new Hono<{ Variables: Variables }>(); + +const COLLECTION = "site.standard.graph.subscription"; +const REDIRECT_DELAY_SECONDS = 5; + +// ============================================================================ +// Helpers +// ============================================================================ + +function withReturnToParam( + returnTo: string | undefined, + key: string, + value: string, +): string | undefined { + if (!returnTo) return undefined; + try { + const url = new URL(returnTo); + url.searchParams.set(key, value); + return url.toString(); + } catch { + return returnTo; + } +} + +async function findExistingSubscription( + agent: Agent, + did: string, + publicationUri: string, +): Promise { + let cursor: string | undefined; + + do { + const result = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: COLLECTION, + limit: 100, + cursor, + }); + + for (const record of result.data.records) { + const value = record.value as { publication?: string }; + if (value.publication === publicationUri) { + return record.uri; + } + } + + cursor = result.data.cursor; + } while (cursor); + + return null; +} + +// ============================================================================ +// POST /subscribe +// ============================================================================ + +subscribe.post("/", async (c) => { + const env = c.get("env"); + const redis = c.get("redis"); + + let publicationUri: string; + try { + const body = await c.req.json<{ publicationUri?: string }>(); + publicationUri = body.publicationUri ?? ""; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (!publicationUri || !publicationUri.startsWith("at://")) { + return c.json({ error: "Missing or invalid publicationUri" }, 400); + } + + const did = getSessionDid(c); + if (!did) { + const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; + return c.json({ authenticated: false, subscribeUrl }, 401); + } + + try { + const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const session = await client.restore(did); + const agent = new Agent(session); + + const existingUri = await findExistingSubscription( + agent, + did, + publicationUri, + ); + if (existingUri) { + return c.json({ + subscribed: true, + existing: true, + recordUri: existingUri, + }); + } + + const result = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: COLLECTION, + record: { + $type: COLLECTION, + publication: publicationUri, + }, + }); + + return c.json({ + subscribed: true, + existing: false, + recordUri: result.data.uri, + }); + } catch (error) { + console.error("Subscribe POST error:", error); + const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; + return c.json({ authenticated: false, subscribeUrl }, 401); + } +}); + +// ============================================================================ +// GET /subscribe +// ============================================================================ + +subscribe.get("/", async (c) => { + const env = c.get("env"); + const redis = c.get("redis"); + + const publicationUri = c.req.query("publicationUri"); + const action = c.req.query("action"); + + if (action && action !== "unsubscribe") { + return c.html(renderError(`Unsupported action: ${action}`), 400); + } + + if (!publicationUri || !publicationUri.startsWith("at://")) { + return c.html(renderError("Missing or invalid publication URI."), 400); + } + + const referer = c.req.header("referer"); + const returnTo = + c.req.query("returnTo") ?? + (referer && !referer.includes("/subscribe") ? referer : undefined); + + const did = getSessionDid(c); + if (!did) { + return c.html( + renderHandleForm(publicationUri, returnTo, undefined, action), + ); + } + + try { + const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const session = await client.restore(did); + const agent = new Agent(session); + + if (action === "unsubscribe") { + const existingUri = await findExistingSubscription( + agent, + did, + publicationUri, + ); + if (existingUri) { + const rkey = existingUri.split("/").pop()!; + await agent.com.atproto.repo.deleteRecord({ + repo: did, + collection: COLLECTION, + rkey, + }); + } + + let cleanReturnTo = returnTo; + if (cleanReturnTo) { + try { + const rtUrl = new URL(cleanReturnTo); + rtUrl.searchParams.delete("sequoia_did"); + cleanReturnTo = rtUrl.toString(); + } catch { + // keep as-is + } + } + + return c.html( + renderSuccess( + publicationUri, + null, + "Unsubscribed", + existingUri + ? "You've successfully unsubscribed!" + : "You weren't subscribed to this publication.", + withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), + ), + ); + } + + const existingUri = await findExistingSubscription( + agent, + did, + publicationUri, + ); + const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); + + if (existingUri) { + return c.html( + renderSuccess( + publicationUri, + existingUri, + "Subscribed", + "You're already subscribed to this publication.", + returnToWithDid, + ), + ); + } + + const result = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: COLLECTION, + record: { + $type: COLLECTION, + publication: publicationUri, + }, + }); + + return c.html( + renderSuccess( + publicationUri, + result.data.uri, + "Subscribed", + "You've successfully subscribed!", + returnToWithDid, + ), + ); + } catch (error) { + console.error("Subscribe GET error:", error); + return c.html( + renderHandleForm( + publicationUri, + returnTo, + "Session expired. Please sign in again.", + action, + ), + ); + } +}); + +// ============================================================================ +// GET /subscribe/check +// ============================================================================ + +subscribe.get("/check", async (c) => { + const env = c.get("env"); + const redis = c.get("redis"); + + const publicationUri = c.req.query("publicationUri"); + + if (!publicationUri || !publicationUri.startsWith("at://")) { + return c.json({ error: "Missing or invalid publicationUri" }, 400); + } + + const did = getSessionDid(c) ?? c.req.query("did") ?? null; + if (!did || !did.startsWith("did:")) { + return c.json({ authenticated: false }, 401); + } + + try { + const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const session = await client.restore(did); + const agent = new Agent(session); + const recordUri = await findExistingSubscription( + agent, + did, + publicationUri, + ); + return recordUri + ? c.json({ subscribed: true, recordUri }) + : c.json({ subscribed: false }); + } catch { + return c.json({ authenticated: false }, 401); + } +}); + +// ============================================================================ +// POST /subscribe/login +// ============================================================================ + +subscribe.post("/login", async (c) => { + const env = c.get("env"); + + const body = await c.req.parseBody(); + const handle = (body["handle"] as string | undefined)?.trim(); + const publicationUri = body["publicationUri"] as string | undefined; + const formReturnTo = (body["returnTo"] as string | undefined) || undefined; + const formAction = (body["action"] as string | undefined) || undefined; + + if (!handle || !publicationUri) { + return c.html( + renderError("Missing handle or publication URI."), + 400, + ); + } + + const returnTo = + `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` + + (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + + (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); + setReturnToCookie(c, returnTo, env.CLIENT_URL); + + return c.redirect( + `${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, + ); +}); + +// ============================================================================ +// HTML rendering +// ============================================================================ + +function renderHandleForm( + publicationUri: string, + returnTo?: string, + error?: string, + action?: string, +): string { + const errorHtml = error + ? `

${escapeHtml(error)}

` + : ""; + const returnToInput = returnTo + ? `` + : ""; + const actionInput = action + ? `` + : ""; + + return page(` +

Subscribe on Bluesky

+

Enter your Bluesky handle to subscribe to this publication.

+ ${errorHtml} +
+ + ${returnToInput} + ${actionInput} + + +
+ `); +} + +function renderSuccess( + publicationUri: string, + recordUri: string | null, + heading: string, + msg: string, + returnTo?: string, +): string { + const escapedPublicationUri = escapeHtml(publicationUri); + const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; + + const redirectHtml = returnTo + ? `

Redirecting to ${escapedReturnTo} in ${REDIRECT_DELAY_SECONDS}\u00a0seconds\u2026

+ ` + : ""; + const headExtra = returnTo + ? `` + : ""; + + return page( + ` +

${escapeHtml(heading)}

+

${msg}

+ ${redirectHtml} + + + + + + + + ${ + recordUri + ? ` + + + ` + : "" + } + +
Publication + +
Record + +
+ `, + headExtra, + ); +} + +function renderError(message: string): string { + return page( + `

Error

${escapeHtml(message)}

`, + ); +} + +export default subscribe; -- tangled.sh