feat: added initial server package
ec148ac7
16 file(s) · +1374 −16
| 58 | 58 | "typescript": "^5", |
|
| 59 | 59 | }, |
|
| 60 | 60 | }, |
|
| 61 | + | "packages/server": { |
|
| 62 | + | "name": "@sequoia/server", |
|
| 63 | + | "version": "0.1.0", |
|
| 64 | + | "dependencies": { |
|
| 65 | + | "@atproto-labs/handle-resolver": "^0.1.5", |
|
| 66 | + | "@atproto/api": "^0.13.21", |
|
| 67 | + | "@atproto/jwk-jose": "^0.1.3", |
|
| 68 | + | "@atproto/oauth-client": "^0.3.3", |
|
| 69 | + | "hono": "^4.7.4", |
|
| 70 | + | }, |
|
| 71 | + | }, |
|
| 61 | 72 | }, |
|
| 62 | 73 | "packages": { |
|
| 63 | 74 | "@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=="], |
|
| 64 | 75 | ||
| 65 | - | "@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=="], |
|
| 76 | + | "@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=="], |
|
| 66 | 77 | ||
| 67 | 78 | "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], |
|
| 68 | 79 | ||
| 69 | 80 | "@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=="], |
|
| 70 | 81 | ||
| 71 | - | "@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=="], |
|
| 82 | + | "@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=="], |
|
| 72 | 83 | ||
| 73 | 84 | "@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=="], |
|
| 74 | 85 | ||
| 75 | - | "@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=="], |
|
| 86 | + | "@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=="], |
|
| 76 | 87 | ||
| 77 | 88 | "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], |
|
| 78 | 89 | ||
| 79 | - | "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], |
|
| 90 | + | "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.2.0", "", {}, "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA=="], |
|
| 80 | 91 | ||
| 81 | - | "@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=="], |
|
| 92 | + | "@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=="], |
|
| 82 | 93 | ||
| 83 | - | "@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=="], |
|
| 94 | + | "@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=="], |
|
| 84 | 95 | ||
| 85 | 96 | "@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=="], |
|
| 86 | 97 | ||
| 87 | - | "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], |
|
| 98 | + | "@atproto/did": ["@atproto/did@0.1.5", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-8+1D08QdGE5TF0bB0vV8HLVrVZJeLNITpRTUVEoABNMRaUS7CoYSVb0+JNQDeJIVmqMjOL8dOjvCUDkp3gEaGQ=="], |
|
| 88 | 99 | ||
| 89 | 100 | "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], |
|
| 90 | 101 | ||
| 96 | 107 | ||
| 97 | 108 | "@atproto/lex-json": ["@atproto/lex-json@0.0.12", "", { "dependencies": { "@atproto/lex-data": "^0.0.12", "tslib": "^2.8.1" } }, "sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA=="], |
|
| 98 | 109 | ||
| 99 | - | "@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=="], |
|
| 110 | + | "@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=="], |
|
| 100 | 111 | ||
| 101 | - | "@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=="], |
|
| 112 | + | "@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=="], |
|
| 102 | 113 | ||
| 103 | 114 | "@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=="], |
|
| 104 | 115 | ||
| 105 | - | "@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=="], |
|
| 116 | + | "@atproto/oauth-types": ["@atproto/oauth-types@0.2.8", "", { "dependencies": { "@atproto/jwk": "0.2.0", "zod": "^3.23.8" } }, "sha512-xcYI2JmhrWwscePDoaKeDawVCCZkcvBqrBFMpMk4gf/OujH0pNSKBD/aWsayc6WvujVbTqwrG2hwPLfRqzJbwg=="], |
|
| 106 | 117 | ||
| 107 | - | "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], |
|
| 118 | + | "@atproto/syntax": ["@atproto/syntax@0.3.4", "", {}, "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg=="], |
|
| 108 | 119 | ||
| 109 | - | "@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], |
|
| 120 | + | "@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="], |
|
| 110 | 121 | ||
| 111 | 122 | "@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=="], |
|
| 112 | 123 | ||
| 533 | 544 | "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA=="], |
|
| 534 | 545 | ||
| 535 | 546 | "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="], |
|
| 547 | + | ||
| 548 | + | "@sequoia/server": ["@sequoia/server@workspace:packages/server"], |
|
| 536 | 549 | ||
| 537 | 550 | "@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=="], |
|
| 538 | 551 | ||
| 1648 | 1661 | ||
| 1649 | 1662 | "@atproto-labs/fetch-node/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], |
|
| 1650 | 1663 | ||
| 1651 | - | "@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=="], |
|
| 1664 | + | "@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=="], |
|
| 1665 | + | ||
| 1666 | + | "@atproto-labs/handle-resolver-node/@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], |
|
| 1667 | + | ||
| 1668 | + | "@atproto-labs/identity-resolver/@atproto/syntax": ["@atproto/syntax@0.4.0", "", {}, "sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA=="], |
|
| 1669 | + | ||
| 1670 | + | "@atproto/common-web/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], |
|
| 1671 | + | ||
| 1672 | + | "@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], |
|
| 1673 | + | ||
| 1674 | + | "@atproto/oauth-client/@atproto/jwk": ["@atproto/jwk@0.2.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-foOxExbw04XCaoLaGdv9BQj0Ac7snZsk6IpQjOsjYatf+i62Pi9bUkZ0MAoA75HPk8ZmKoDnbA60uBMmiOPPHQ=="], |
|
| 1675 | + | ||
| 1676 | + | "@atproto/oauth-client/@atproto/xrpc": ["@atproto/xrpc@0.7.0", "", { "dependencies": { "@atproto/lexicon": "^0.4.11", "zod": "^3.23.8" } }, "sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw=="], |
|
| 1677 | + | ||
| 1678 | + | "@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=="], |
|
| 1679 | + | ||
| 1680 | + | "@atproto/oauth-client-node/@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], |
|
| 1681 | + | ||
| 1682 | + | "@atproto/oauth-client-node/@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], |
|
| 1652 | 1683 | ||
| 1653 | 1684 | "@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=="], |
|
| 1654 | 1685 | ||
| 1655 | 1686 | "@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=="], |
|
| 1687 | + | ||
| 1688 | + | "@atproto/oauth-types/@atproto/jwk": ["@atproto/jwk@0.2.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-foOxExbw04XCaoLaGdv9BQj0Ac7snZsk6IpQjOsjYatf+i62Pi9bUkZ0MAoA75HPk8ZmKoDnbA60uBMmiOPPHQ=="], |
|
| 1656 | 1689 | ||
| 1657 | 1690 | "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], |
|
| 1658 | 1691 | ||
| 1710 | 1743 | ||
| 1711 | 1744 | "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], |
|
| 1712 | 1745 | ||
| 1746 | + | "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=="], |
|
| 1747 | + | ||
| 1748 | + | "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=="], |
|
| 1749 | + | ||
| 1750 | + | "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=="], |
|
| 1751 | + | ||
| 1713 | 1752 | "eval/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], |
|
| 1714 | 1753 | ||
| 1715 | 1754 | "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 | 1783 | ||
| 1745 | 1784 | "vocs/hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], |
|
| 1746 | 1785 | ||
| 1747 | - | "@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=="], |
|
| 1786 | + | "@atproto-labs/handle-resolver-node/@atproto-labs/handle-resolver/@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], |
|
| 1787 | + | ||
| 1788 | + | "@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=="], |
|
| 1789 | + | ||
| 1790 | + | "@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=="], |
|
| 1748 | 1791 | ||
| 1749 | - | "@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=="], |
|
| 1792 | + | "@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=="], |
|
| 1793 | + | ||
| 1794 | + | "@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=="], |
|
| 1795 | + | ||
| 1796 | + | "@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=="], |
|
| 1797 | + | ||
| 1798 | + | "@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=="], |
|
| 1750 | 1799 | ||
| 1751 | 1800 | "@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=="], |
|
| 1752 | 1801 | ||
| 1816 | 1865 | ||
| 1817 | 1866 | "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], |
|
| 1818 | 1867 | ||
| 1868 | + | "docs/@atproto-labs/handle-resolver/@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], |
|
| 1869 | + | ||
| 1870 | + | "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=="], |
|
| 1871 | + | ||
| 1872 | + | "docs/@atproto-labs/handle-resolver/@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], |
|
| 1873 | + | ||
| 1874 | + | "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=="], |
|
| 1875 | + | ||
| 1876 | + | "docs/@atproto/api/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], |
|
| 1877 | + | ||
| 1878 | + | "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=="], |
|
| 1879 | + | ||
| 1880 | + | "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=="], |
|
| 1881 | + | ||
| 1882 | + | "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=="], |
|
| 1883 | + | ||
| 1884 | + | "docs/@atproto/oauth-client/@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], |
|
| 1885 | + | ||
| 1886 | + | "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=="], |
|
| 1887 | + | ||
| 1888 | + | "docs/@atproto/oauth-client/@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], |
|
| 1889 | + | ||
| 1890 | + | "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=="], |
|
| 1891 | + | ||
| 1892 | + | "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=="], |
|
| 1893 | + | ||
| 1819 | 1894 | "eval/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], |
|
| 1820 | 1895 | ||
| 1821 | 1896 | "hast-util-from-dom/hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], |
|
| 1825 | 1900 | "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], |
|
| 1826 | 1901 | ||
| 1827 | 1902 | "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=="], |
|
| 1903 | + | ||
| 1904 | + | "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=="], |
|
| 1905 | + | ||
| 1906 | + | "sequoia-cli/@atproto/api/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], |
|
| 1907 | + | ||
| 1908 | + | "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=="], |
|
| 1828 | 1909 | ||
| 1829 | 1910 | "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], |
|
| 1830 | 1911 | ||
| 1878 | 1959 | ||
| 1879 | 1960 | "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], |
|
| 1880 | 1961 | ||
| 1962 | + | "@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=="], |
|
| 1963 | + | ||
| 1964 | + | "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=="], |
|
| 1965 | + | ||
| 1966 | + | "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=="], |
|
| 1967 | + | ||
| 1881 | 1968 | "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=="], |
|
| 1882 | 1969 | ||
| 1883 | 1970 | "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=="], |
|
| 1971 | + | ||
| 1972 | + | "@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=="], |
|
| 1973 | + | ||
| 1974 | + | "@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=="], |
|
| 1975 | + | ||
| 1976 | + | "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=="], |
|
| 1977 | + | ||
| 1978 | + | "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=="], |
|
| 1979 | + | ||
| 1980 | + | "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=="], |
|
| 1981 | + | ||
| 1982 | + | "docs/@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], |
|
| 1983 | + | ||
| 1984 | + | "@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=="], |
|
| 1985 | + | ||
| 1986 | + | "@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=="], |
|
| 1987 | + | ||
| 1988 | + | "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=="], |
|
| 1989 | + | ||
| 1990 | + | "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=="], |
|
| 1884 | 1991 | } |
|
| 1885 | 1992 | } |
|
| 13 | 13 | "build:docs": "cd docs && bun run build", |
|
| 14 | 14 | "build:cli": "cd packages/cli && bun run build", |
|
| 15 | 15 | "deploy:docs": "cd docs && bun run deploy", |
|
| 16 | - | "deploy:cli": "cd packages/cli && bun run deploy" |
|
| 16 | + | "deploy:cli": "cd packages/cli && bun run deploy", |
|
| 17 | + | "dev:server": "cd packages/server && bun run dev", |
|
| 18 | + | "start:server": "cd packages/server && bun run start" |
|
| 17 | 19 | }, |
|
| 18 | 20 | "devDependencies": { |
|
| 19 | 21 | "@types/bun": "latest", |
| 1 | + | CLIENT_URL=https://your-domain.com |
|
| 2 | + | CLIENT_NAME=Sequoia |
|
| 3 | + | PORT=3000 |
|
| 4 | + | REDIS_URL=redis://redis:6379 |
|
| 5 | + | ||
| 6 | + | # Theme overrides (optional) |
|
| 7 | + | # THEME_ACCENT_COLOR=#3A5A40 |
|
| 8 | + | # THEME_BG_COLOR=#F5F3EF |
|
| 9 | + | # THEME_FG_COLOR=#2C2C2C |
|
| 10 | + | # THEME_BORDER_COLOR=#D5D1C8 |
|
| 11 | + | # THEME_ERROR_COLOR=#8B3A3A |
|
| 12 | + | # THEME_BORDER_RADIUS=6px |
|
| 13 | + | # THEME_FONT_FAMILY=system-ui, sans-serif |
|
| 14 | + | # THEME_DARK_BG_COLOR=#1A1A1A |
|
| 15 | + | # THEME_DARK_FG_COLOR=#E5E5E5 |
|
| 16 | + | # THEME_DARK_BORDER_COLOR=#3A3A3A |
|
| 17 | + | # THEME_DARK_ERROR_COLOR=#E57373 |
|
| 18 | + | ||
| 19 | + | # Path to a custom CSS file for full theme control (optional) |
|
| 20 | + | # THEME_CSS_PATH=/app/theme.css |
| 1 | + | FROM oven/bun:1 AS install |
|
| 2 | + | WORKDIR /app |
|
| 3 | + | COPY package.json bun.lock* ./ |
|
| 4 | + | RUN bun install --frozen-lockfile || bun install |
|
| 5 | + | ||
| 6 | + | FROM oven/bun:1 |
|
| 7 | + | WORKDIR /app |
|
| 8 | + | COPY --from=install /app/node_modules ./node_modules |
|
| 9 | + | COPY package.json ./ |
|
| 10 | + | COPY src ./src |
|
| 11 | + | ||
| 12 | + | ENV PORT=3000 |
|
| 13 | + | EXPOSE ${PORT} |
|
| 14 | + | ||
| 15 | + | ENTRYPOINT ["bun", "run", "src/index.ts"] |
| 1 | + | # Sequoia Server |
|
| 2 | + | ||
| 3 | + | 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. |
|
| 4 | + | ||
| 5 | + | ## Quickstart |
|
| 6 | + | ||
| 7 | + | ### Docker (recommended) |
|
| 8 | + | ||
| 9 | + | ```bash |
|
| 10 | + | cp .env.example .env |
|
| 11 | + | # Edit .env — at minimum set CLIENT_URL to your public URL |
|
| 12 | + | docker compose up |
|
| 13 | + | ``` |
|
| 14 | + | ||
| 15 | + | ### Local development |
|
| 16 | + | ||
| 17 | + | Requires [Bun](https://bun.sh) and a running Redis instance. |
|
| 18 | + | ||
| 19 | + | ```bash |
|
| 20 | + | bun install |
|
| 21 | + | CLIENT_URL=http://localhost:3000 bun run dev |
|
| 22 | + | ``` |
|
| 23 | + | ||
| 24 | + | ## How it works |
|
| 25 | + | ||
| 26 | + | 1. A user visits `/subscribe?publicationUri=at://...` and enters their Bluesky handle |
|
| 27 | + | 2. The server initiates an AT Protocol OAuth flow — the user authorizes on Bluesky |
|
| 28 | + | 3. After callback, the server creates a `site.standard.graph.subscription` record in the user's repo |
|
| 29 | + | 4. The [sequoia-subscribe](https://github.com/standard-schema/sequoia) web component can point to this server for the full flow |
|
| 30 | + | ||
| 31 | + | ### Routes |
|
| 32 | + | ||
| 33 | + | | Route | Method | Description | |
|
| 34 | + | |-------|--------|-------------| |
|
| 35 | + | | `/api/health` | GET | Health check | |
|
| 36 | + | | `/oauth/client-metadata.json` | GET | OAuth client metadata | |
|
| 37 | + | | `/oauth/login?handle=` | GET | Start OAuth flow | |
|
| 38 | + | | `/oauth/callback` | GET | OAuth callback | |
|
| 39 | + | | `/oauth/logout` | POST | Revoke session | |
|
| 40 | + | | `/oauth/status` | GET | Check auth status | |
|
| 41 | + | | `/subscribe` | GET | Subscribe page (HTML) | |
|
| 42 | + | | `/subscribe` | POST | Subscribe via API (JSON) | |
|
| 43 | + | | `/subscribe/check` | GET | Check subscription status | |
|
| 44 | + | | `/subscribe/login` | POST | Handle form submission | |
|
| 45 | + | ||
| 46 | + | ## Configuration |
|
| 47 | + | ||
| 48 | + | | Variable | Required | Default | Description | |
|
| 49 | + | |----------|----------|---------|-------------| |
|
| 50 | + | | `CLIENT_URL` | Yes | — | Public URL of this server (used for OAuth redirects) | |
|
| 51 | + | | `CLIENT_NAME` | No | `Sequoia` | Name shown on Bluesky OAuth consent screen | |
|
| 52 | + | | `PORT` | No | `3000` | Server port | |
|
| 53 | + | | `REDIS_URL` | No | `redis://localhost:6379` | Redis connection URL | |
|
| 54 | + | ||
| 55 | + | ### Theming |
|
| 56 | + | ||
| 57 | + | The subscribe pages use CSS custom properties that can be overridden via environment variables: |
|
| 58 | + | ||
| 59 | + | | Variable | Default | |
|
| 60 | + | |----------|---------| |
|
| 61 | + | | `THEME_ACCENT_COLOR` | `#3A5A40` | |
|
| 62 | + | | `THEME_BG_COLOR` | `#F5F3EF` | |
|
| 63 | + | | `THEME_FG_COLOR` | `#2C2C2C` | |
|
| 64 | + | | `THEME_BORDER_COLOR` | `#D5D1C8` | |
|
| 65 | + | | `THEME_ERROR_COLOR` | `#8B3A3A` | |
|
| 66 | + | | `THEME_BORDER_RADIUS` | `6px` | |
|
| 67 | + | | `THEME_FONT_FAMILY` | `system-ui, sans-serif` | |
|
| 68 | + | | `THEME_DARK_BG_COLOR` | `#1A1A1A` | |
|
| 69 | + | | `THEME_DARK_FG_COLOR` | `#E5E5E5` | |
|
| 70 | + | | `THEME_DARK_BORDER_COLOR` | `#3A3A3A` | |
|
| 71 | + | | `THEME_DARK_ERROR_COLOR` | `#E57373` | |
|
| 72 | + | ||
| 73 | + | 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. |
|
| 74 | + | ||
| 75 | + | ## Deployment |
|
| 76 | + | ||
| 77 | + | The included `Dockerfile` produces a minimal image: |
|
| 78 | + | ||
| 79 | + | ```bash |
|
| 80 | + | docker build -t sequoia-server . |
|
| 81 | + | docker run -p 3000:3000 \ |
|
| 82 | + | -e CLIENT_URL=https://your-domain.com \ |
|
| 83 | + | -e REDIS_URL=redis://your-redis:6379 \ |
|
| 84 | + | sequoia-server |
|
| 85 | + | ``` |
|
| 86 | + | ||
| 87 | + | Or use `docker-compose.yml` which bundles Redis: |
|
| 88 | + | ||
| 89 | + | ```bash |
|
| 90 | + | docker compose up -d |
|
| 91 | + | ``` |
|
| 92 | + | ||
| 93 | + | Place behind a reverse proxy (Caddy, nginx, Traefik) for TLS. |
| 1 | + | services: |
|
| 2 | + | server: |
|
| 3 | + | build: . |
|
| 4 | + | ports: |
|
| 5 | + | - "${PORT:-3000}:${PORT:-3000}" |
|
| 6 | + | environment: |
|
| 7 | + | - CLIENT_URL=${CLIENT_URL} |
|
| 8 | + | - CLIENT_NAME=${CLIENT_NAME:-Sequoia} |
|
| 9 | + | - PORT=${PORT:-3000} |
|
| 10 | + | - REDIS_URL=redis://redis:6379 |
|
| 11 | + | - THEME_ACCENT_COLOR=${THEME_ACCENT_COLOR:-} |
|
| 12 | + | - THEME_BG_COLOR=${THEME_BG_COLOR:-} |
|
| 13 | + | - THEME_FG_COLOR=${THEME_FG_COLOR:-} |
|
| 14 | + | - THEME_BORDER_COLOR=${THEME_BORDER_COLOR:-} |
|
| 15 | + | - THEME_ERROR_COLOR=${THEME_ERROR_COLOR:-} |
|
| 16 | + | - THEME_BORDER_RADIUS=${THEME_BORDER_RADIUS:-} |
|
| 17 | + | - THEME_FONT_FAMILY=${THEME_FONT_FAMILY:-} |
|
| 18 | + | - THEME_DARK_BG_COLOR=${THEME_DARK_BG_COLOR:-} |
|
| 19 | + | - THEME_DARK_FG_COLOR=${THEME_DARK_FG_COLOR:-} |
|
| 20 | + | - THEME_DARK_BORDER_COLOR=${THEME_DARK_BORDER_COLOR:-} |
|
| 21 | + | - THEME_DARK_ERROR_COLOR=${THEME_DARK_ERROR_COLOR:-} |
|
| 22 | + | - THEME_CSS_PATH=${THEME_CSS_PATH:-} |
|
| 23 | + | depends_on: |
|
| 24 | + | - redis |
|
| 25 | + | ||
| 26 | + | redis: |
|
| 27 | + | image: redis:7 |
|
| 28 | + | volumes: |
|
| 29 | + | - redis-data:/data |
|
| 30 | + | ||
| 31 | + | volumes: |
|
| 32 | + | redis-data: |
| 1 | + | { |
|
| 2 | + | "name": "sequoia-server", |
|
| 3 | + | "version": "0.0.1", |
|
| 4 | + | "private": true, |
|
| 5 | + | "type": "module", |
|
| 6 | + | "scripts": { |
|
| 7 | + | "dev": "bun --watch src/index.ts", |
|
| 8 | + | "start": "bun run src/index.ts" |
|
| 9 | + | }, |
|
| 10 | + | "dependencies": { |
|
| 11 | + | "@atproto/api": "^0.13.21", |
|
| 12 | + | "@atproto/jwk-jose": "^0.1.3", |
|
| 13 | + | "@atproto/oauth-client": "^0.3.3", |
|
| 14 | + | "@atproto-labs/handle-resolver": "^0.1.5", |
|
| 15 | + | "hono": "^4.7.4" |
|
| 16 | + | } |
|
| 17 | + | } |
| 1 | + | export interface Env { |
|
| 2 | + | CLIENT_URL: string; |
|
| 3 | + | CLIENT_NAME: string; |
|
| 4 | + | PORT: number; |
|
| 5 | + | REDIS_URL: string; |
|
| 6 | + | } |
|
| 7 | + | ||
| 8 | + | export function loadEnv(): Env { |
|
| 9 | + | const CLIENT_URL = process.env.CLIENT_URL; |
|
| 10 | + | if (!CLIENT_URL) { |
|
| 11 | + | throw new Error("CLIENT_URL environment variable is required"); |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | return { |
|
| 15 | + | CLIENT_URL: CLIENT_URL.replace(/\/+$/, ""), |
|
| 16 | + | CLIENT_NAME: process.env.CLIENT_NAME || "Sequoia", |
|
| 17 | + | PORT: Number(process.env.PORT) || 3000, |
|
| 18 | + | REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379", |
|
| 19 | + | }; |
|
| 20 | + | } |
| 1 | + | import { Hono } from "hono"; |
|
| 2 | + | import { cors } from "hono/cors"; |
|
| 3 | + | import { RedisClient } from "bun"; |
|
| 4 | + | import { loadEnv } from "./env"; |
|
| 5 | + | import type { Env } from "./env"; |
|
| 6 | + | import auth from "./routes/auth"; |
|
| 7 | + | import subscribe from "./routes/subscribe"; |
|
| 8 | + | ||
| 9 | + | const env = loadEnv(); |
|
| 10 | + | ||
| 11 | + | const redis = new RedisClient(env.REDIS_URL); |
|
| 12 | + | ||
| 13 | + | type Variables = { env: Env; redis: typeof redis }; |
|
| 14 | + | ||
| 15 | + | const app = new Hono<{ Variables: Variables }>(); |
|
| 16 | + | ||
| 17 | + | // Inject env and redis into all routes |
|
| 18 | + | app.use("*", async (c, next) => { |
|
| 19 | + | c.set("env", env); |
|
| 20 | + | c.set("redis", redis); |
|
| 21 | + | await next(); |
|
| 22 | + | }); |
|
| 23 | + | ||
| 24 | + | // Health check |
|
| 25 | + | app.get("/api/health", (c) => c.json({ status: "ok" })); |
|
| 26 | + | ||
| 27 | + | // OAuth routes |
|
| 28 | + | app.route("/oauth", auth); |
|
| 29 | + | ||
| 30 | + | // Subscribe routes with CORS |
|
| 31 | + | app.use( |
|
| 32 | + | "/subscribe/*", |
|
| 33 | + | cors({ |
|
| 34 | + | origin: (origin) => origin, |
|
| 35 | + | credentials: true, |
|
| 36 | + | }), |
|
| 37 | + | ); |
|
| 38 | + | app.use( |
|
| 39 | + | "/subscribe", |
|
| 40 | + | cors({ |
|
| 41 | + | origin: (origin) => origin, |
|
| 42 | + | credentials: true, |
|
| 43 | + | }), |
|
| 44 | + | ); |
|
| 45 | + | app.route("/subscribe", subscribe); |
|
| 46 | + | ||
| 47 | + | console.log(`Sequoia server listening on port ${env.PORT}`); |
|
| 48 | + | ||
| 49 | + | export default { |
|
| 50 | + | port: env.PORT, |
|
| 51 | + | fetch: app.fetch, |
|
| 52 | + | }; |
| 1 | + | import { JoseKey } from "@atproto/jwk-jose"; |
|
| 2 | + | import { OAuthClient } from "@atproto/oauth-client"; |
|
| 3 | + | import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; |
|
| 4 | + | import type { RedisClient } from "bun"; |
|
| 5 | + | import { createStateStore, createSessionStore } from "./redis-stores"; |
|
| 6 | + | ||
| 7 | + | export const OAUTH_SCOPE = |
|
| 8 | + | "atproto repo:site.standard.graph.subscription?action=create&action=delete"; |
|
| 9 | + | ||
| 10 | + | export function createOAuthClient( |
|
| 11 | + | redis: RedisClient, |
|
| 12 | + | clientUrl: string, |
|
| 13 | + | clientName = "Sequoia", |
|
| 14 | + | ) { |
|
| 15 | + | const clientId = `${clientUrl}/oauth/client-metadata.json`; |
|
| 16 | + | const redirectUri = `${clientUrl}/oauth/callback`; |
|
| 17 | + | ||
| 18 | + | const dohEndpoint = |
|
| 19 | + | process.env.DOH_ENDPOINT || "https://cloudflare-dns.com/dns-query"; |
|
| 20 | + | ||
| 21 | + | return new OAuthClient({ |
|
| 22 | + | responseMode: "query", |
|
| 23 | + | handleResolver: new AtprotoDohHandleResolver({ dohEndpoint }), |
|
| 24 | + | clientMetadata: { |
|
| 25 | + | client_id: clientId, |
|
| 26 | + | client_name: clientName, |
|
| 27 | + | client_uri: clientUrl, |
|
| 28 | + | redirect_uris: [redirectUri], |
|
| 29 | + | grant_types: ["authorization_code", "refresh_token"], |
|
| 30 | + | response_types: ["code"], |
|
| 31 | + | scope: OAUTH_SCOPE, |
|
| 32 | + | token_endpoint_auth_method: "none", |
|
| 33 | + | application_type: "web", |
|
| 34 | + | dpop_bound_access_tokens: true, |
|
| 35 | + | }, |
|
| 36 | + | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @atproto Key class mismatch across packages |
|
| 37 | + | runtimeImplementation: { |
|
| 38 | + | createKey: (algs: string[]) => JoseKey.generate(algs) as any, |
|
| 39 | + | getRandomValues: (length: number) => |
|
| 40 | + | crypto.getRandomValues(new Uint8Array(length)), |
|
| 41 | + | digest: async (data: Uint8Array, { name }: { name: string }) => { |
|
| 42 | + | const buf = await crypto.subtle.digest( |
|
| 43 | + | name.replace("sha", "SHA-"), |
|
| 44 | + | new Uint8Array(data), |
|
| 45 | + | ); |
|
| 46 | + | return new Uint8Array(buf); |
|
| 47 | + | }, |
|
| 48 | + | requestLock: <T>(_name: string, fn: () => T | PromiseLike<T>) => fn(), |
|
| 49 | + | }, |
|
| 50 | + | stateStore: createStateStore(redis), |
|
| 51 | + | sessionStore: createSessionStore(redis), |
|
| 52 | + | }); |
|
| 53 | + | } |
| 1 | + | import { JoseKey } from "@atproto/jwk-jose"; |
|
| 2 | + | import type { |
|
| 3 | + | Key, |
|
| 4 | + | InternalStateData, |
|
| 5 | + | SessionStore, |
|
| 6 | + | StateStore, |
|
| 7 | + | } from "@atproto/oauth-client"; |
|
| 8 | + | import { RedisClient } from "bun"; |
|
| 9 | + | ||
| 10 | + | type SerializedStateData = Omit<InternalStateData, "dpopKey"> & { |
|
| 11 | + | dpopJwk: Record<string, unknown>; |
|
| 12 | + | }; |
|
| 13 | + | ||
| 14 | + | type SerializedSession = Omit<Parameters<SessionStore["set"]>[1], "dpopKey"> & { |
|
| 15 | + | dpopJwk: Record<string, unknown>; |
|
| 16 | + | }; |
|
| 17 | + | ||
| 18 | + | function serializeKey(key: Key): Record<string, unknown> { |
|
| 19 | + | const jwk = key.privateJwk; |
|
| 20 | + | if (!jwk) throw new Error("Private DPoP JWK is missing"); |
|
| 21 | + | return jwk as Record<string, unknown>; |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | async function deserializeKey(jwk: Record<string, unknown>): Promise<Key> { |
|
| 25 | + | return JoseKey.fromJWK(jwk) as unknown as Key; |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | export function createStateStore(redis: RedisClient, ttl = 600): StateStore { |
|
| 29 | + | return { |
|
| 30 | + | async set(key, { dpopKey, ...rest }) { |
|
| 31 | + | const data: SerializedStateData = { |
|
| 32 | + | ...rest, |
|
| 33 | + | dpopJwk: serializeKey(dpopKey), |
|
| 34 | + | }; |
|
| 35 | + | const redisKey = `oauth_state:${key}`; |
|
| 36 | + | await redis.set(redisKey, JSON.stringify(data)); |
|
| 37 | + | await redis.expire(redisKey, ttl); |
|
| 38 | + | }, |
|
| 39 | + | async get(key) { |
|
| 40 | + | const raw = await redis.get(`oauth_state:${key}`); |
|
| 41 | + | if (!raw) return undefined; |
|
| 42 | + | const { dpopJwk, ...rest }: SerializedStateData = JSON.parse(raw); |
|
| 43 | + | const dpopKey = await deserializeKey(dpopJwk); |
|
| 44 | + | return { ...rest, dpopKey }; |
|
| 45 | + | }, |
|
| 46 | + | async del(key) { |
|
| 47 | + | await redis.del(`oauth_state:${key}`); |
|
| 48 | + | }, |
|
| 49 | + | }; |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | export function createSessionStore( |
|
| 53 | + | redis: RedisClient, |
|
| 54 | + | ttl = 60 * 60 * 24 * 14, |
|
| 55 | + | ): SessionStore { |
|
| 56 | + | return { |
|
| 57 | + | async set(sub, { dpopKey, ...rest }) { |
|
| 58 | + | const data: SerializedSession = { |
|
| 59 | + | ...rest, |
|
| 60 | + | dpopJwk: serializeKey(dpopKey), |
|
| 61 | + | }; |
|
| 62 | + | const redisKey = `oauth_session:${sub}`; |
|
| 63 | + | await redis.set(redisKey, JSON.stringify(data)); |
|
| 64 | + | await redis.expire(redisKey, ttl); |
|
| 65 | + | }, |
|
| 66 | + | async get(sub) { |
|
| 67 | + | const raw = await redis.get(`oauth_session:${sub}`); |
|
| 68 | + | if (!raw) return undefined; |
|
| 69 | + | const { dpopJwk, ...rest }: SerializedSession = JSON.parse(raw); |
|
| 70 | + | const dpopKey = await deserializeKey(dpopJwk); |
|
| 71 | + | return { ...rest, dpopKey }; |
|
| 72 | + | }, |
|
| 73 | + | async del(sub) { |
|
| 74 | + | await redis.del(`oauth_session:${sub}`); |
|
| 75 | + | }, |
|
| 76 | + | }; |
|
| 77 | + | } |
| 1 | + | import type { Context } from "hono"; |
|
| 2 | + | import { deleteCookie, getCookie, setCookie } from "hono/cookie"; |
|
| 3 | + | ||
| 4 | + | const SESSION_COOKIE_NAME = "session_id"; |
|
| 5 | + | const RETURN_TO_COOKIE_NAME = "login_return_to"; |
|
| 6 | + | const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds |
|
| 7 | + | const RETURN_TO_TTL = 600; // 10 minutes in seconds |
|
| 8 | + | ||
| 9 | + | function baseCookieOptions(clientUrl: string) { |
|
| 10 | + | const isLocalhost = clientUrl.includes("localhost"); |
|
| 11 | + | const hostname = new URL(clientUrl).hostname; |
|
| 12 | + | return { |
|
| 13 | + | httpOnly: true as const, |
|
| 14 | + | sameSite: "Lax" as const, |
|
| 15 | + | path: "/", |
|
| 16 | + | ...(isLocalhost ? {} : { domain: `.${hostname}`, secure: true }), |
|
| 17 | + | }; |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | /** |
|
| 21 | + | * Get DID from session cookie |
|
| 22 | + | */ |
|
| 23 | + | export function getSessionDid(c: Context): string | null { |
|
| 24 | + | const value = getCookie(c, SESSION_COOKIE_NAME); |
|
| 25 | + | return value ? decodeURIComponent(value) : null; |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | /** |
|
| 29 | + | * Set session cookie with the user's DID |
|
| 30 | + | */ |
|
| 31 | + | export function setSessionCookie( |
|
| 32 | + | c: Context, |
|
| 33 | + | did: string, |
|
| 34 | + | clientUrl: string, |
|
| 35 | + | ): void { |
|
| 36 | + | setCookie(c, SESSION_COOKIE_NAME, encodeURIComponent(did), { |
|
| 37 | + | ...baseCookieOptions(clientUrl), |
|
| 38 | + | maxAge: SESSION_TTL, |
|
| 39 | + | }); |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | /** |
|
| 43 | + | * Clear session cookie |
|
| 44 | + | */ |
|
| 45 | + | export function clearSessionCookie(c: Context, clientUrl: string): void { |
|
| 46 | + | deleteCookie(c, SESSION_COOKIE_NAME, baseCookieOptions(clientUrl)); |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /** |
|
| 50 | + | * Get the post-OAuth return-to URL from the short-lived cookie |
|
| 51 | + | */ |
|
| 52 | + | export function getReturnToCookie(c: Context): string | null { |
|
| 53 | + | const value = getCookie(c, RETURN_TO_COOKIE_NAME); |
|
| 54 | + | return value ? decodeURIComponent(value) : null; |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | /** |
|
| 58 | + | * Set a short-lived cookie that redirects back after OAuth completes |
|
| 59 | + | */ |
|
| 60 | + | export function setReturnToCookie( |
|
| 61 | + | c: Context, |
|
| 62 | + | returnTo: string, |
|
| 63 | + | clientUrl: string, |
|
| 64 | + | ): void { |
|
| 65 | + | setCookie(c, RETURN_TO_COOKIE_NAME, encodeURIComponent(returnTo), { |
|
| 66 | + | ...baseCookieOptions(clientUrl), |
|
| 67 | + | maxAge: RETURN_TO_TTL, |
|
| 68 | + | }); |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | /** |
|
| 72 | + | * Clear the return-to cookie |
|
| 73 | + | */ |
|
| 74 | + | export function clearReturnToCookie(c: Context, clientUrl: string): void { |
|
| 75 | + | deleteCookie(c, RETURN_TO_COOKIE_NAME, baseCookieOptions(clientUrl)); |
|
| 76 | + | } |
| 1 | + | import { existsSync, readFileSync } from "fs"; |
|
| 2 | + | ||
| 3 | + | interface ThemeVars { |
|
| 4 | + | fgColor: string; |
|
| 5 | + | bgColor: string; |
|
| 6 | + | accentColor: string; |
|
| 7 | + | borderColor: string; |
|
| 8 | + | errorColor: string; |
|
| 9 | + | borderRadius: string; |
|
| 10 | + | fontFamily: string; |
|
| 11 | + | darkBgColor: string; |
|
| 12 | + | darkFgColor: string; |
|
| 13 | + | darkBorderColor: string; |
|
| 14 | + | darkErrorColor: string; |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | function getThemeVars(): ThemeVars { |
|
| 18 | + | return { |
|
| 19 | + | fgColor: process.env.THEME_FG_COLOR || "#2C2C2C", |
|
| 20 | + | bgColor: process.env.THEME_BG_COLOR || "#F5F3EF", |
|
| 21 | + | accentColor: process.env.THEME_ACCENT_COLOR || "#3A5A40", |
|
| 22 | + | borderColor: process.env.THEME_BORDER_COLOR || "#D5D1C8", |
|
| 23 | + | errorColor: process.env.THEME_ERROR_COLOR || "#8B3A3A", |
|
| 24 | + | borderRadius: process.env.THEME_BORDER_RADIUS || "6px", |
|
| 25 | + | fontFamily: process.env.THEME_FONT_FAMILY || "system-ui, sans-serif", |
|
| 26 | + | darkBgColor: process.env.THEME_DARK_BG_COLOR || "#1A1A1A", |
|
| 27 | + | darkFgColor: process.env.THEME_DARK_FG_COLOR || "#E5E5E5", |
|
| 28 | + | darkBorderColor: process.env.THEME_DARK_BORDER_COLOR || "#3A3A3A", |
|
| 29 | + | darkErrorColor: process.env.THEME_DARK_ERROR_COLOR || "#E57373", |
|
| 30 | + | }; |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | function getCustomCss(): string { |
|
| 34 | + | const cssPath = process.env.THEME_CSS_PATH; |
|
| 35 | + | if (!cssPath) return ""; |
|
| 36 | + | try { |
|
| 37 | + | if (existsSync(cssPath)) { |
|
| 38 | + | return readFileSync(cssPath, "utf-8"); |
|
| 39 | + | } |
|
| 40 | + | } catch { |
|
| 41 | + | console.warn(`Failed to read custom CSS file: ${cssPath}`); |
|
| 42 | + | } |
|
| 43 | + | return ""; |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | export function generateStyleBlock(): string { |
|
| 47 | + | const t = getThemeVars(); |
|
| 48 | + | const customCss = getCustomCss(); |
|
| 49 | + | ||
| 50 | + | return `<style> |
|
| 51 | + | :root { |
|
| 52 | + | --sequoia-fg-color: ${t.fgColor}; |
|
| 53 | + | --sequoia-bg-color: ${t.bgColor}; |
|
| 54 | + | --sequoia-accent-color: ${t.accentColor}; |
|
| 55 | + | --sequoia-border-color: ${t.borderColor}; |
|
| 56 | + | --sequoia-error-color: ${t.errorColor}; |
|
| 57 | + | --sequoia-border-radius: ${t.borderRadius}; |
|
| 58 | + | --sequoia-font-family: ${t.fontFamily}; |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | @media (prefers-color-scheme: dark) { |
|
| 62 | + | :root { |
|
| 63 | + | --sequoia-fg-color: ${t.darkFgColor}; |
|
| 64 | + | --sequoia-bg-color: ${t.darkBgColor}; |
|
| 65 | + | --sequoia-border-color: ${t.darkBorderColor}; |
|
| 66 | + | --sequoia-error-color: ${t.darkErrorColor}; |
|
| 67 | + | } |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | * { box-sizing: border-box; margin: 0; padding: 0; } |
|
| 71 | + | ||
| 72 | + | body { |
|
| 73 | + | font-family: var(--sequoia-font-family); |
|
| 74 | + | background: var(--sequoia-bg-color); |
|
| 75 | + | color: var(--sequoia-fg-color); |
|
| 76 | + | line-height: 1.6; |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | .page-container { |
|
| 80 | + | max-width: 480px; |
|
| 81 | + | margin: 4rem auto; |
|
| 82 | + | padding: 0 1.25rem; |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | h1 { |
|
| 86 | + | font-size: 1.75rem; |
|
| 87 | + | font-weight: 700; |
|
| 88 | + | margin-bottom: 0.75rem; |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | p { margin-bottom: 1rem; } |
|
| 92 | + | ||
| 93 | + | a { |
|
| 94 | + | color: var(--sequoia-accent-color); |
|
| 95 | + | text-decoration: underline; |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | a:hover { text-decoration: none; } |
|
| 99 | + | ||
| 100 | + | form { display: flex; flex-direction: column; } |
|
| 101 | + | ||
| 102 | + | input[type="text"] { |
|
| 103 | + | padding: 0.5rem 0.75rem; |
|
| 104 | + | border: 1px solid var(--sequoia-border-color); |
|
| 105 | + | border-radius: var(--sequoia-border-radius); |
|
| 106 | + | margin-bottom: 1.25rem; |
|
| 107 | + | width: 100%; |
|
| 108 | + | font-size: 1rem; |
|
| 109 | + | font-family: inherit; |
|
| 110 | + | background: var(--sequoia-bg-color); |
|
| 111 | + | color: var(--sequoia-fg-color); |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | input[type="text"]:focus { |
|
| 115 | + | border-color: var(--sequoia-accent-color); |
|
| 116 | + | outline: 2px solid var(--sequoia-accent-color); |
|
| 117 | + | outline-offset: 2px; |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | button { |
|
| 121 | + | padding: 0.625rem 1.25rem; |
|
| 122 | + | background: var(--sequoia-accent-color); |
|
| 123 | + | color: #fff; |
|
| 124 | + | border: none; |
|
| 125 | + | border-radius: var(--sequoia-border-radius); |
|
| 126 | + | font-size: 1rem; |
|
| 127 | + | font-family: inherit; |
|
| 128 | + | font-weight: 600; |
|
| 129 | + | cursor: pointer; |
|
| 130 | + | transition: opacity 0.15s; |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | button:hover { opacity: 0.9; } |
|
| 134 | + | ||
| 135 | + | button:focus-visible { |
|
| 136 | + | outline: 2px solid var(--sequoia-accent-color); |
|
| 137 | + | outline-offset: 2px; |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | table { |
|
| 141 | + | width: 100%; |
|
| 142 | + | border-collapse: collapse; |
|
| 143 | + | table-layout: fixed; |
|
| 144 | + | margin-top: 1rem; |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | td { |
|
| 148 | + | padding: 0.5rem 0.75rem; |
|
| 149 | + | border-bottom: 1px solid var(--sequoia-border-color); |
|
| 150 | + | vertical-align: top; |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | td:first-child { |
|
| 154 | + | width: 7rem; |
|
| 155 | + | font-weight: 600; |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | td:last-child { overflow: hidden; } |
|
| 159 | + | ||
| 160 | + | td code { |
|
| 161 | + | font-size: 0.85rem; |
|
| 162 | + | word-break: break-all; |
|
| 163 | + | } |
|
| 164 | + | ||
| 165 | + | td div { |
|
| 166 | + | overflow-x: auto; |
|
| 167 | + | white-space: nowrap; |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | .error { color: var(--sequoia-error-color); } |
|
| 171 | + | ${customCss ? `\n /* Custom CSS */\n ${customCss}` : ""} |
|
| 172 | + | </style>`; |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | export function page(body: string, headExtra = ""): string { |
|
| 176 | + | return `<!DOCTYPE html> |
|
| 177 | + | <html lang="en"> |
|
| 178 | + | <head> |
|
| 179 | + | <meta charset="UTF-8" /> |
|
| 180 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 181 | + | <title>Sequoia · Subscribe</title> |
|
| 182 | + | ${generateStyleBlock()} |
|
| 183 | + | ${headExtra} |
|
| 184 | + | </head> |
|
| 185 | + | <body> |
|
| 186 | + | <div class="page-container"> |
|
| 187 | + | ${body} |
|
| 188 | + | </div> |
|
| 189 | + | </body> |
|
| 190 | + | </html>`; |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | export function escapeHtml(text: string): string { |
|
| 194 | + | return text |
|
| 195 | + | .replace(/&/g, "&") |
|
| 196 | + | .replace(/</g, "<") |
|
| 197 | + | .replace(/>/g, ">") |
|
| 198 | + | .replace(/"/g, """); |
|
| 199 | + | } |
| 1 | + | import { Hono } from "hono"; |
|
| 2 | + | import type { RedisClient } from "bun"; |
|
| 3 | + | import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client"; |
|
| 4 | + | import { |
|
| 5 | + | getSessionDid, |
|
| 6 | + | setSessionCookie, |
|
| 7 | + | clearSessionCookie, |
|
| 8 | + | getReturnToCookie, |
|
| 9 | + | clearReturnToCookie, |
|
| 10 | + | } from "../lib/session"; |
|
| 11 | + | import type { Env } from "../env"; |
|
| 12 | + | ||
| 13 | + | type Variables = { env: Env; redis: RedisClient }; |
|
| 14 | + | ||
| 15 | + | const auth = new Hono<{ Variables: Variables }>(); |
|
| 16 | + | ||
| 17 | + | // OAuth client metadata endpoint |
|
| 18 | + | auth.get("/client-metadata.json", (c) => { |
|
| 19 | + | const env = c.get("env"); |
|
| 20 | + | const clientId = `${env.CLIENT_URL}/oauth/client-metadata.json`; |
|
| 21 | + | const redirectUri = `${env.CLIENT_URL}/oauth/callback`; |
|
| 22 | + | ||
| 23 | + | return c.json({ |
|
| 24 | + | client_id: clientId, |
|
| 25 | + | client_name: env.CLIENT_NAME, |
|
| 26 | + | client_uri: env.CLIENT_URL, |
|
| 27 | + | redirect_uris: [redirectUri], |
|
| 28 | + | grant_types: ["authorization_code", "refresh_token"], |
|
| 29 | + | response_types: ["code"], |
|
| 30 | + | scope: OAUTH_SCOPE, |
|
| 31 | + | token_endpoint_auth_method: "none", |
|
| 32 | + | application_type: "web", |
|
| 33 | + | dpop_bound_access_tokens: true, |
|
| 34 | + | }); |
|
| 35 | + | }); |
|
| 36 | + | ||
| 37 | + | // Start OAuth login flow |
|
| 38 | + | auth.get("/login", async (c) => { |
|
| 39 | + | const env = c.get("env"); |
|
| 40 | + | const redis = c.get("redis"); |
|
| 41 | + | ||
| 42 | + | try { |
|
| 43 | + | const handle = c.req.query("handle"); |
|
| 44 | + | if (!handle) { |
|
| 45 | + | return c.redirect(`${env.CLIENT_URL}/?error=missing_handle`); |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 49 | + | const authUrl = await client.authorize(handle, { |
|
| 50 | + | scope: OAUTH_SCOPE, |
|
| 51 | + | }); |
|
| 52 | + | ||
| 53 | + | return c.redirect(authUrl.toString()); |
|
| 54 | + | } catch (error) { |
|
| 55 | + | console.error("Login error:", error); |
|
| 56 | + | return c.redirect(`${env.CLIENT_URL}/?error=login_failed`); |
|
| 57 | + | } |
|
| 58 | + | }); |
|
| 59 | + | ||
| 60 | + | // OAuth callback handler |
|
| 61 | + | auth.get("/callback", async (c) => { |
|
| 62 | + | const env = c.get("env"); |
|
| 63 | + | const redis = c.get("redis"); |
|
| 64 | + | ||
| 65 | + | try { |
|
| 66 | + | const params = new URLSearchParams(c.req.url.split("?")[1] || ""); |
|
| 67 | + | ||
| 68 | + | if (params.get("error")) { |
|
| 69 | + | const error = params.get("error"); |
|
| 70 | + | console.error("OAuth error:", error, params.get("error_description")); |
|
| 71 | + | return c.redirect( |
|
| 72 | + | `${env.CLIENT_URL}/?error=${encodeURIComponent(error!)}`, |
|
| 73 | + | ); |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 77 | + | const { session } = await client.callback(params); |
|
| 78 | + | ||
| 79 | + | // Resolve handle from DID |
|
| 80 | + | let handle: string | undefined; |
|
| 81 | + | try { |
|
| 82 | + | const identity = await client.identityResolver.resolve(session.did); |
|
| 83 | + | handle = identity.handle; |
|
| 84 | + | } catch { |
|
| 85 | + | // Handle resolution is best-effort |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | // Store handle in Redis alongside the session for quick lookup |
|
| 89 | + | if (handle) { |
|
| 90 | + | const key = `oauth_handle:${session.did}`; |
|
| 91 | + | await redis.set(key, handle); |
|
| 92 | + | await redis.expire(key, 60 * 60 * 24 * 14); |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | setSessionCookie(c, session.did, env.CLIENT_URL); |
|
| 96 | + | ||
| 97 | + | // If a subscribe flow set a return URL before initiating OAuth, honor it |
|
| 98 | + | const returnTo = getReturnToCookie(c); |
|
| 99 | + | clearReturnToCookie(c, env.CLIENT_URL); |
|
| 100 | + | ||
| 101 | + | return c.redirect(returnTo ?? `${env.CLIENT_URL}/`); |
|
| 102 | + | } catch (error) { |
|
| 103 | + | console.error("Callback error:", error); |
|
| 104 | + | return c.redirect(`${env.CLIENT_URL}/?error=callback_failed`); |
|
| 105 | + | } |
|
| 106 | + | }); |
|
| 107 | + | ||
| 108 | + | // Logout endpoint |
|
| 109 | + | auth.post("/logout", async (c) => { |
|
| 110 | + | const env = c.get("env"); |
|
| 111 | + | const redis = c.get("redis"); |
|
| 112 | + | const did = getSessionDid(c); |
|
| 113 | + | ||
| 114 | + | if (did) { |
|
| 115 | + | try { |
|
| 116 | + | const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 117 | + | await client.revoke(did); |
|
| 118 | + | } catch (error) { |
|
| 119 | + | console.error("Revoke error:", error); |
|
| 120 | + | } |
|
| 121 | + | await redis.del(`oauth_handle:${did}`); |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | clearSessionCookie(c, env.CLIENT_URL); |
|
| 125 | + | return c.json({ success: true }); |
|
| 126 | + | }); |
|
| 127 | + | ||
| 128 | + | // Check auth status |
|
| 129 | + | auth.get("/status", async (c) => { |
|
| 130 | + | const env = c.get("env"); |
|
| 131 | + | const redis = c.get("redis"); |
|
| 132 | + | const did = getSessionDid(c); |
|
| 133 | + | ||
| 134 | + | if (!did) { |
|
| 135 | + | return c.json({ authenticated: false }); |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | try { |
|
| 139 | + | const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 140 | + | const session = await client.restore(did); |
|
| 141 | + | ||
| 142 | + | const handle = await redis.get(`oauth_handle:${session.did}`); |
|
| 143 | + | ||
| 144 | + | return c.json({ |
|
| 145 | + | authenticated: true, |
|
| 146 | + | did: session.did, |
|
| 147 | + | handle: handle || undefined, |
|
| 148 | + | }); |
|
| 149 | + | } catch (error) { |
|
| 150 | + | console.error("Session restore failed:", error); |
|
| 151 | + | clearSessionCookie(c, env.CLIENT_URL); |
|
| 152 | + | return c.json({ authenticated: false }); |
|
| 153 | + | } |
|
| 154 | + | }); |
|
| 155 | + | ||
| 156 | + | export default auth; |
| 1 | + | import { Agent } from "@atproto/api"; |
|
| 2 | + | import { Hono } from "hono"; |
|
| 3 | + | import type { RedisClient } from "bun"; |
|
| 4 | + | import { createOAuthClient } from "../lib/oauth-client"; |
|
| 5 | + | import { getSessionDid, setReturnToCookie } from "../lib/session"; |
|
| 6 | + | import { page, escapeHtml } from "../lib/theme"; |
|
| 7 | + | import type { Env } from "../env"; |
|
| 8 | + | ||
| 9 | + | type Variables = { env: Env; redis: RedisClient }; |
|
| 10 | + | ||
| 11 | + | const subscribe = new Hono<{ Variables: Variables }>(); |
|
| 12 | + | ||
| 13 | + | const COLLECTION = "site.standard.graph.subscription"; |
|
| 14 | + | const REDIRECT_DELAY_SECONDS = 5; |
|
| 15 | + | ||
| 16 | + | // ============================================================================ |
|
| 17 | + | // Helpers |
|
| 18 | + | // ============================================================================ |
|
| 19 | + | ||
| 20 | + | function withReturnToParam( |
|
| 21 | + | returnTo: string | undefined, |
|
| 22 | + | key: string, |
|
| 23 | + | value: string, |
|
| 24 | + | ): string | undefined { |
|
| 25 | + | if (!returnTo) return undefined; |
|
| 26 | + | try { |
|
| 27 | + | const url = new URL(returnTo); |
|
| 28 | + | url.searchParams.set(key, value); |
|
| 29 | + | return url.toString(); |
|
| 30 | + | } catch { |
|
| 31 | + | return returnTo; |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | async function findExistingSubscription( |
|
| 36 | + | agent: Agent, |
|
| 37 | + | did: string, |
|
| 38 | + | publicationUri: string, |
|
| 39 | + | ): Promise<string | null> { |
|
| 40 | + | let cursor: string | undefined; |
|
| 41 | + | ||
| 42 | + | do { |
|
| 43 | + | const result = await agent.com.atproto.repo.listRecords({ |
|
| 44 | + | repo: did, |
|
| 45 | + | collection: COLLECTION, |
|
| 46 | + | limit: 100, |
|
| 47 | + | cursor, |
|
| 48 | + | }); |
|
| 49 | + | ||
| 50 | + | for (const record of result.data.records) { |
|
| 51 | + | const value = record.value as { publication?: string }; |
|
| 52 | + | if (value.publication === publicationUri) { |
|
| 53 | + | return record.uri; |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | cursor = result.data.cursor; |
|
| 58 | + | } while (cursor); |
|
| 59 | + | ||
| 60 | + | return null; |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | // ============================================================================ |
|
| 64 | + | // POST /subscribe |
|
| 65 | + | // ============================================================================ |
|
| 66 | + | ||
| 67 | + | subscribe.post("/", async (c) => { |
|
| 68 | + | const env = c.get("env"); |
|
| 69 | + | const redis = c.get("redis"); |
|
| 70 | + | ||
| 71 | + | let publicationUri: string; |
|
| 72 | + | try { |
|
| 73 | + | const body = await c.req.json<{ publicationUri?: string }>(); |
|
| 74 | + | publicationUri = body.publicationUri ?? ""; |
|
| 75 | + | } catch { |
|
| 76 | + | return c.json({ error: "Invalid JSON body" }, 400); |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | if (!publicationUri || !publicationUri.startsWith("at://")) { |
|
| 80 | + | return c.json({ error: "Missing or invalid publicationUri" }, 400); |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | const did = getSessionDid(c); |
|
| 84 | + | if (!did) { |
|
| 85 | + | const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; |
|
| 86 | + | return c.json({ authenticated: false, subscribeUrl }, 401); |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | try { |
|
| 90 | + | const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 91 | + | const session = await client.restore(did); |
|
| 92 | + | const agent = new Agent(session); |
|
| 93 | + | ||
| 94 | + | const existingUri = await findExistingSubscription( |
|
| 95 | + | agent, |
|
| 96 | + | did, |
|
| 97 | + | publicationUri, |
|
| 98 | + | ); |
|
| 99 | + | if (existingUri) { |
|
| 100 | + | return c.json({ |
|
| 101 | + | subscribed: true, |
|
| 102 | + | existing: true, |
|
| 103 | + | recordUri: existingUri, |
|
| 104 | + | }); |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | const result = await agent.com.atproto.repo.createRecord({ |
|
| 108 | + | repo: did, |
|
| 109 | + | collection: COLLECTION, |
|
| 110 | + | record: { |
|
| 111 | + | $type: COLLECTION, |
|
| 112 | + | publication: publicationUri, |
|
| 113 | + | }, |
|
| 114 | + | }); |
|
| 115 | + | ||
| 116 | + | return c.json({ |
|
| 117 | + | subscribed: true, |
|
| 118 | + | existing: false, |
|
| 119 | + | recordUri: result.data.uri, |
|
| 120 | + | }); |
|
| 121 | + | } catch (error) { |
|
| 122 | + | console.error("Subscribe POST error:", error); |
|
| 123 | + | const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; |
|
| 124 | + | return c.json({ authenticated: false, subscribeUrl }, 401); |
|
| 125 | + | } |
|
| 126 | + | }); |
|
| 127 | + | ||
| 128 | + | // ============================================================================ |
|
| 129 | + | // GET /subscribe |
|
| 130 | + | // ============================================================================ |
|
| 131 | + | ||
| 132 | + | subscribe.get("/", async (c) => { |
|
| 133 | + | const env = c.get("env"); |
|
| 134 | + | const redis = c.get("redis"); |
|
| 135 | + | ||
| 136 | + | const publicationUri = c.req.query("publicationUri"); |
|
| 137 | + | const action = c.req.query("action"); |
|
| 138 | + | ||
| 139 | + | if (action && action !== "unsubscribe") { |
|
| 140 | + | return c.html(renderError(`Unsupported action: ${action}`), 400); |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | if (!publicationUri || !publicationUri.startsWith("at://")) { |
|
| 144 | + | return c.html(renderError("Missing or invalid publication URI."), 400); |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | const referer = c.req.header("referer"); |
|
| 148 | + | const returnTo = |
|
| 149 | + | c.req.query("returnTo") ?? |
|
| 150 | + | (referer && !referer.includes("/subscribe") ? referer : undefined); |
|
| 151 | + | ||
| 152 | + | const did = getSessionDid(c); |
|
| 153 | + | if (!did) { |
|
| 154 | + | return c.html( |
|
| 155 | + | renderHandleForm(publicationUri, returnTo, undefined, action), |
|
| 156 | + | ); |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | try { |
|
| 160 | + | const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 161 | + | const session = await client.restore(did); |
|
| 162 | + | const agent = new Agent(session); |
|
| 163 | + | ||
| 164 | + | if (action === "unsubscribe") { |
|
| 165 | + | const existingUri = await findExistingSubscription( |
|
| 166 | + | agent, |
|
| 167 | + | did, |
|
| 168 | + | publicationUri, |
|
| 169 | + | ); |
|
| 170 | + | if (existingUri) { |
|
| 171 | + | const rkey = existingUri.split("/").pop()!; |
|
| 172 | + | await agent.com.atproto.repo.deleteRecord({ |
|
| 173 | + | repo: did, |
|
| 174 | + | collection: COLLECTION, |
|
| 175 | + | rkey, |
|
| 176 | + | }); |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | let cleanReturnTo = returnTo; |
|
| 180 | + | if (cleanReturnTo) { |
|
| 181 | + | try { |
|
| 182 | + | const rtUrl = new URL(cleanReturnTo); |
|
| 183 | + | rtUrl.searchParams.delete("sequoia_did"); |
|
| 184 | + | cleanReturnTo = rtUrl.toString(); |
|
| 185 | + | } catch { |
|
| 186 | + | // keep as-is |
|
| 187 | + | } |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | return c.html( |
|
| 191 | + | renderSuccess( |
|
| 192 | + | publicationUri, |
|
| 193 | + | null, |
|
| 194 | + | "Unsubscribed", |
|
| 195 | + | existingUri |
|
| 196 | + | ? "You've successfully unsubscribed!" |
|
| 197 | + | : "You weren't subscribed to this publication.", |
|
| 198 | + | withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), |
|
| 199 | + | ), |
|
| 200 | + | ); |
|
| 201 | + | } |
|
| 202 | + | ||
| 203 | + | const existingUri = await findExistingSubscription( |
|
| 204 | + | agent, |
|
| 205 | + | did, |
|
| 206 | + | publicationUri, |
|
| 207 | + | ); |
|
| 208 | + | const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); |
|
| 209 | + | ||
| 210 | + | if (existingUri) { |
|
| 211 | + | return c.html( |
|
| 212 | + | renderSuccess( |
|
| 213 | + | publicationUri, |
|
| 214 | + | existingUri, |
|
| 215 | + | "Subscribed", |
|
| 216 | + | "You're already subscribed to this publication.", |
|
| 217 | + | returnToWithDid, |
|
| 218 | + | ), |
|
| 219 | + | ); |
|
| 220 | + | } |
|
| 221 | + | ||
| 222 | + | const result = await agent.com.atproto.repo.createRecord({ |
|
| 223 | + | repo: did, |
|
| 224 | + | collection: COLLECTION, |
|
| 225 | + | record: { |
|
| 226 | + | $type: COLLECTION, |
|
| 227 | + | publication: publicationUri, |
|
| 228 | + | }, |
|
| 229 | + | }); |
|
| 230 | + | ||
| 231 | + | return c.html( |
|
| 232 | + | renderSuccess( |
|
| 233 | + | publicationUri, |
|
| 234 | + | result.data.uri, |
|
| 235 | + | "Subscribed", |
|
| 236 | + | "You've successfully subscribed!", |
|
| 237 | + | returnToWithDid, |
|
| 238 | + | ), |
|
| 239 | + | ); |
|
| 240 | + | } catch (error) { |
|
| 241 | + | console.error("Subscribe GET error:", error); |
|
| 242 | + | return c.html( |
|
| 243 | + | renderHandleForm( |
|
| 244 | + | publicationUri, |
|
| 245 | + | returnTo, |
|
| 246 | + | "Session expired. Please sign in again.", |
|
| 247 | + | action, |
|
| 248 | + | ), |
|
| 249 | + | ); |
|
| 250 | + | } |
|
| 251 | + | }); |
|
| 252 | + | ||
| 253 | + | // ============================================================================ |
|
| 254 | + | // GET /subscribe/check |
|
| 255 | + | // ============================================================================ |
|
| 256 | + | ||
| 257 | + | subscribe.get("/check", async (c) => { |
|
| 258 | + | const env = c.get("env"); |
|
| 259 | + | const redis = c.get("redis"); |
|
| 260 | + | ||
| 261 | + | const publicationUri = c.req.query("publicationUri"); |
|
| 262 | + | ||
| 263 | + | if (!publicationUri || !publicationUri.startsWith("at://")) { |
|
| 264 | + | return c.json({ error: "Missing or invalid publicationUri" }, 400); |
|
| 265 | + | } |
|
| 266 | + | ||
| 267 | + | const did = getSessionDid(c) ?? c.req.query("did") ?? null; |
|
| 268 | + | if (!did || !did.startsWith("did:")) { |
|
| 269 | + | return c.json({ authenticated: false }, 401); |
|
| 270 | + | } |
|
| 271 | + | ||
| 272 | + | try { |
|
| 273 | + | const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 274 | + | const session = await client.restore(did); |
|
| 275 | + | const agent = new Agent(session); |
|
| 276 | + | const recordUri = await findExistingSubscription( |
|
| 277 | + | agent, |
|
| 278 | + | did, |
|
| 279 | + | publicationUri, |
|
| 280 | + | ); |
|
| 281 | + | return recordUri |
|
| 282 | + | ? c.json({ subscribed: true, recordUri }) |
|
| 283 | + | : c.json({ subscribed: false }); |
|
| 284 | + | } catch { |
|
| 285 | + | return c.json({ authenticated: false }, 401); |
|
| 286 | + | } |
|
| 287 | + | }); |
|
| 288 | + | ||
| 289 | + | // ============================================================================ |
|
| 290 | + | // POST /subscribe/login |
|
| 291 | + | // ============================================================================ |
|
| 292 | + | ||
| 293 | + | subscribe.post("/login", async (c) => { |
|
| 294 | + | const env = c.get("env"); |
|
| 295 | + | ||
| 296 | + | const body = await c.req.parseBody(); |
|
| 297 | + | const handle = (body["handle"] as string | undefined)?.trim(); |
|
| 298 | + | const publicationUri = body["publicationUri"] as string | undefined; |
|
| 299 | + | const formReturnTo = (body["returnTo"] as string | undefined) || undefined; |
|
| 300 | + | const formAction = (body["action"] as string | undefined) || undefined; |
|
| 301 | + | ||
| 302 | + | if (!handle || !publicationUri) { |
|
| 303 | + | return c.html( |
|
| 304 | + | renderError("Missing handle or publication URI."), |
|
| 305 | + | 400, |
|
| 306 | + | ); |
|
| 307 | + | } |
|
| 308 | + | ||
| 309 | + | const returnTo = |
|
| 310 | + | `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` + |
|
| 311 | + | (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + |
|
| 312 | + | (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); |
|
| 313 | + | setReturnToCookie(c, returnTo, env.CLIENT_URL); |
|
| 314 | + | ||
| 315 | + | return c.redirect( |
|
| 316 | + | `${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, |
|
| 317 | + | ); |
|
| 318 | + | }); |
|
| 319 | + | ||
| 320 | + | // ============================================================================ |
|
| 321 | + | // HTML rendering |
|
| 322 | + | // ============================================================================ |
|
| 323 | + | ||
| 324 | + | function renderHandleForm( |
|
| 325 | + | publicationUri: string, |
|
| 326 | + | returnTo?: string, |
|
| 327 | + | error?: string, |
|
| 328 | + | action?: string, |
|
| 329 | + | ): string { |
|
| 330 | + | const errorHtml = error |
|
| 331 | + | ? `<p class="error">${escapeHtml(error)}</p>` |
|
| 332 | + | : ""; |
|
| 333 | + | const returnToInput = returnTo |
|
| 334 | + | ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` |
|
| 335 | + | : ""; |
|
| 336 | + | const actionInput = action |
|
| 337 | + | ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` |
|
| 338 | + | : ""; |
|
| 339 | + | ||
| 340 | + | return page(` |
|
| 341 | + | <h1>Subscribe on Bluesky</h1> |
|
| 342 | + | <p>Enter your Bluesky handle to subscribe to this publication.</p> |
|
| 343 | + | ${errorHtml} |
|
| 344 | + | <form method="POST" action="/subscribe/login"> |
|
| 345 | + | <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> |
|
| 346 | + | ${returnToInput} |
|
| 347 | + | ${actionInput} |
|
| 348 | + | <input |
|
| 349 | + | type="text" |
|
| 350 | + | name="handle" |
|
| 351 | + | placeholder="you.bsky.social" |
|
| 352 | + | autocomplete="username" |
|
| 353 | + | required |
|
| 354 | + | autofocus |
|
| 355 | + | /> |
|
| 356 | + | <button type="submit">Continue on Bluesky</button> |
|
| 357 | + | </form> |
|
| 358 | + | `); |
|
| 359 | + | } |
|
| 360 | + | ||
| 361 | + | function renderSuccess( |
|
| 362 | + | publicationUri: string, |
|
| 363 | + | recordUri: string | null, |
|
| 364 | + | heading: string, |
|
| 365 | + | msg: string, |
|
| 366 | + | returnTo?: string, |
|
| 367 | + | ): string { |
|
| 368 | + | const escapedPublicationUri = escapeHtml(publicationUri); |
|
| 369 | + | const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; |
|
| 370 | + | ||
| 371 | + | const redirectHtml = returnTo |
|
| 372 | + | ? `<p id="redirect-msg">Redirecting to <a href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> |
|
| 373 | + | <script> |
|
| 374 | + | (function(){ |
|
| 375 | + | var secs = ${REDIRECT_DELAY_SECONDS}; |
|
| 376 | + | var el = document.getElementById('countdown'); |
|
| 377 | + | var iv = setInterval(function(){ |
|
| 378 | + | secs--; |
|
| 379 | + | if (el) el.textContent = String(secs); |
|
| 380 | + | if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } |
|
| 381 | + | }, 1000); |
|
| 382 | + | })(); |
|
| 383 | + | </script>` |
|
| 384 | + | : ""; |
|
| 385 | + | const headExtra = returnTo |
|
| 386 | + | ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` |
|
| 387 | + | : ""; |
|
| 388 | + | ||
| 389 | + | return page( |
|
| 390 | + | ` |
|
| 391 | + | <h1>${escapeHtml(heading)}</h1> |
|
| 392 | + | <p>${msg}</p> |
|
| 393 | + | ${redirectHtml} |
|
| 394 | + | <table> |
|
| 395 | + | <colgroup><col style="width:7rem;"><col></colgroup> |
|
| 396 | + | <tbody> |
|
| 397 | + | <tr> |
|
| 398 | + | <td>Publication</td> |
|
| 399 | + | <td> |
|
| 400 | + | <div><code><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div> |
|
| 401 | + | </td> |
|
| 402 | + | </tr> |
|
| 403 | + | ${ |
|
| 404 | + | recordUri |
|
| 405 | + | ? `<tr> |
|
| 406 | + | <td>Record</td> |
|
| 407 | + | <td> |
|
| 408 | + | <div><code><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> |
|
| 409 | + | </td> |
|
| 410 | + | </tr>` |
|
| 411 | + | : "" |
|
| 412 | + | } |
|
| 413 | + | </tbody> |
|
| 414 | + | </table> |
|
| 415 | + | `, |
|
| 416 | + | headExtra, |
|
| 417 | + | ); |
|
| 418 | + | } |
|
| 419 | + | ||
| 420 | + | function renderError(message: string): string { |
|
| 421 | + | return page( |
|
| 422 | + | `<h1>Error</h1><p class="error">${escapeHtml(message)}</p>`, |
|
| 423 | + | ); |
|
| 424 | + | } |
|
| 425 | + | ||
| 426 | + | export default subscribe; |
| 1 | + | { |
|
| 2 | + | "compilerOptions": { |
|
| 3 | + | "target": "ESNext", |
|
| 4 | + | "module": "ESNext", |
|
| 5 | + | "moduleResolution": "bundler", |
|
| 6 | + | "strict": true, |
|
| 7 | + | "esModuleInterop": true, |
|
| 8 | + | "skipLibCheck": true, |
|
| 9 | + | "outDir": "dist", |
|
| 10 | + | "rootDir": "src" |
|
| 11 | + | }, |
|
| 12 | + | "include": ["src"] |
|
| 13 | + | } |