Scratch space for learning atproto app development

Merge pull request #41 from bluesky-social/msi/oauth-confidential-client

Confidential client implementation

+12 -7
.env.template
···
# Environment Configuration
-
NODE_ENV="development" # Options: 'development', 'production'
-
PORT="8080" # The port your server will listen on
-
HOST="localhost" # Hostname for the server
-
PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id.
-
DB_PATH=":memory:" # The SQLite database path. Leave as ":memory:" to use a temporary in-memory database.
+
NODE_ENV="development" # Options: 'development', 'production'
+
PORT="8080" # The port your server will listen on
+
DB_PATH=":memory:" # The SQLite database path. Set as ":memory:" to use a temporary in-memory database.
+
# PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id.
+
# LOG_LEVEL="info" # Options: 'fatal', 'error', 'warn', 'info', 'debug'
+
# PDS_URL="https://my.pds" # The default PDS for login and sign-ups
+
+
# Secrets below *MUST* be set in production
-
# Secrets
-
# Must set this in production. May be generated with `openssl rand -base64 33`
+
# May be generated with `openssl rand -base64 33`
# COOKIE_SECRET=""
+
+
# May be generated with `./bin/gen-jwk` (requires `npm install` once first)
+
# PRIVATE_KEYS='[{"kty":"EC","kid":"123",...}]'
+1
.gitignore
···
dist-ssr
*.local
.env
+
*.sqlite
# Editor directories and files
!.vscode/extensions.json
+7
.prettierrc
···
+
{
+
"trailingComma": "all",
+
"tabWidth": 2,
+
"semi": false,
+
"singleQuote": true,
+
"useTabs": false
+
}
+1 -1
.vscode/settings.json
···
{
"editor.formatOnSave": true,
-
"editor.defaultFormatter": "biomejs.biome",
+
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.organizeImports.biome": "explicit",
+42
README.md
···
npm run dev
# Navigate to http://localhost:8080
```
+
+
## Deploying
+
+
In production, you will need a private key to sign OAuth tokens request. Use the
+
following command to generate a new private key:
+
+
```sh
+
./bin/gen-jwk
+
```
+
+
The generated key must be added to the environment variables (`.env` file) in `PRIVATE_KEYS`.
+
+
```env
+
PRIVATE_KEYS='[{"kty":"EC","kid":"12",...}]'
+
```
+
+
> [!NOTE]
+
>
+
> The `PRIVATE_KEYS` can contain multiple keys. The first key in the array is
+
> the most recent one, and it will be used to sign new tokens. When a key is
+
> removed, all associated sessions will be invalidated.
+
+
Make sure to also set the `COOKIE_SECRET`, which is used to sign session
+
cookies, in your environment variables (`.env` file). You should use a random
+
string for this:
+
+
```sh
+
openssl rand -base64 33
+
```
+
+
Finally, set the `PUBLIC_URL` to the URL where your app will be accessible. This
+
will allow the authorization servers to download the app's public keys.
+
+
```env
+
PUBLIC_URL="https://your-app-url.com"
+
```
+
+
> [!NOTE]
+
>
+
> You can use services like [ngrok](https://ngrok.com/) to expose your local
+
> server to the internet for testing purposes. Just set the `PUBLIC_URL` to the
+
> ngrok URL.
+15
bin/gen-jwk
···
+
#!/usr/bin/env node
+
+
'use strict'
+
+
const { JoseKey } = require('@atproto/oauth-client-node')
+
+
async function main() {
+
const kid = Date.now().toString()
+
const key = await JoseKey.generate(['ES256'], kid)
+
const jwk = key.privateJwk
+
+
console.log(JSON.stringify(jwk))
+
}
+
+
main()
+306 -272
package-lock.json
···
"version": "0.0.1",
"license": "MIT",
"dependencies": {
-
"@atproto/api": "^0.13.4",
-
"@atproto/common": "^0.4.1",
-
"@atproto/identity": "^0.4.0",
-
"@atproto/lexicon": "^0.4.2",
-
"@atproto/oauth-client-node": "^0.2.2",
-
"@atproto/sync": "^0.1.4",
-
"@atproto/syntax": "^0.3.0",
-
"@atproto/xrpc-server": "^0.7.9",
+
"@atproto/api": "^0.15.6",
+
"@atproto/common": "^0.4.11",
+
"@atproto/identity": "^0.4.8",
+
"@atproto/lexicon": "^0.4.11",
+
"@atproto/oauth-client-node": "^0.3.1",
+
"@atproto/sync": "^0.1.26",
+
"@atproto/syntax": "^0.4.0",
+
"@atproto/xrpc-server": "^0.8.0",
"better-sqlite3": "^11.1.2",
"dotenv": "^16.4.5",
"envalid": "^8.0.0",
"express": "^4.19.2",
+
"http-terminator": "^3.2.0",
"iron-session": "^8.0.2",
"kysely": "^0.27.4",
"multiformats": "^9.9.0",
"pino": "^9.3.2",
-
"uhtml": "^4.5.9"
+
"uhtml": "^4.5.9",
+
"zod": "^3.25.67"
},
"devDependencies": {
"@atproto/lex-cli": "^0.4.1",
···
}
},
"node_modules/@atproto-labs/did-resolver": {
-
"version": "0.1.5",
-
"resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.5.tgz",
-
"integrity": "sha512-uoCb+P0N4du5NiZt6ohVEbSDdijXBJlQwSlWLHX0rUDtEVV+g3aEGe7jUW94lWpqQmRlQ5xcyd9owleMibNxZw==",
+
"version": "0.2.0",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.0.tgz",
+
"integrity": "sha512-y9GOx2gUETynDKmANnBrU5DTf+u0AwKBJpGns1vDDOYMdLdRCFIeYy3UH+TI8YOkcEazjgF5Q3m+LjwriE1KqQ==",
"license": "MIT",
"dependencies": {
-
"@atproto-labs/fetch": "0.1.1",
-
"@atproto-labs/pipe": "0.1.0",
-
"@atproto-labs/simple-store": "0.1.1",
-
"@atproto-labs/simple-store-memory": "0.1.1",
-
"@atproto/did": "0.1.3",
+
"@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"
}
},
"node_modules/@atproto-labs/fetch": {
-
"version": "0.1.1",
-
"resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.1.1.tgz",
-
"integrity": "sha512-X1zO1MDoJzEurbWXMAe1H8EZ995Xam/aXdxhGVrXmOMyPDuvBa1oxwh/kQNZRCKcMQUbiwkk+Jfq6ZkTuvGbww==",
+
"version": "0.2.3",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz",
+
"integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==",
"license": "MIT",
"dependencies": {
-
"@atproto-labs/pipe": "0.1.0"
-
},
-
"optionalDependencies": {
-
"zod": "^3.23.8"
+
"@atproto-labs/pipe": "0.1.1"
}
},
"node_modules/@atproto-labs/fetch-node": {
-
"version": "0.1.3",
-
"resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.1.3.tgz",
-
"integrity": "sha512-KX3ogPJt6dXNppWImQ9omfhrc8t73WrJaxHMphRAqQL8jXxKW5NBCTjSuwroBkJ1pj1aValBrc5NpdYu+H/9Qg==",
+
"version": "0.1.9",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.1.9.tgz",
+
"integrity": "sha512-8sHDDXZEzQptLu8ddUU/8U+THS6dumgPynVX0/1PjUYd4S/FWyPcz6yMIiVChTfzKnZvYRRz47+qvOKhydrHQw==",
"license": "MIT",
"dependencies": {
-
"@atproto-labs/fetch": "0.1.1",
-
"@atproto-labs/pipe": "0.1.0",
+
"@atproto-labs/fetch": "0.2.3",
+
"@atproto-labs/pipe": "0.1.1",
"ipaddr.js": "^2.1.0",
-
"psl": "^1.9.0",
"undici": "^6.14.1"
+
},
+
"engines": {
+
"node": ">=18.7.0"
}
},
"node_modules/@atproto-labs/fetch-node/node_modules/ipaddr.js": {
···
}
},
"node_modules/@atproto-labs/handle-resolver": {
-
"version": "0.1.4",
-
"resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.1.4.tgz",
-
"integrity": "sha512-tnGUD2mQ6c8xHs3eeVJgwYqM3FHoTZZbOcOGKqO1A5cuIG+gElwEhpWwpwX5LI7FF4J8eS9BOHLl3NFS7Q8QXg==",
+
"version": "0.3.0",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.0.tgz",
+
"integrity": "sha512-TREelvXB6P2eHxx6QjINRkBzUZu/aXWrdY9iN57shQe3C8rzsHNEHHuTVvRa33Hc7vFdQbZN0TnCgKveoyiL/A==",
"license": "MIT",
"dependencies": {
-
"@atproto-labs/simple-store": "0.1.1",
-
"@atproto-labs/simple-store-memory": "0.1.1",
-
"@atproto/did": "0.1.3",
+
"@atproto-labs/simple-store": "0.2.0",
+
"@atproto-labs/simple-store-memory": "0.1.3",
+
"@atproto/did": "0.1.5",
"zod": "^3.23.8"
}
},
"node_modules/@atproto-labs/handle-resolver-node": {
-
"version": "0.1.7",
-
"resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.7.tgz",
-
"integrity": "sha512-3pXUB8/twMPXUz+zMjSVTA5acxnizC7PF+EsjLKwirwVzLRrTcFQkyHXGTrdUfIQq+S1eLq7b6H7ZKqMOX9VQQ==",
+
"version": "0.1.18",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.18.tgz",
+
"integrity": "sha512-/qo14c3I+kagT1UWSp3lTIzwDetfkxvF3Y3VlX2NyQ2jHwgtIAJ81KFNqe7t82NpQDjWiM5h4bdjvdbFIh5djQ==",
"license": "MIT",
"dependencies": {
-
"@atproto-labs/fetch-node": "0.1.3",
-
"@atproto-labs/handle-resolver": "0.1.4",
-
"@atproto/did": "0.1.3"
+
"@atproto-labs/fetch-node": "0.1.9",
+
"@atproto-labs/handle-resolver": "0.3.0",
+
"@atproto/did": "0.1.5"
+
},
+
"engines": {
+
"node": ">=18.7.0"
}
},
"node_modules/@atproto-labs/identity-resolver": {
-
"version": "0.1.6",
-
"resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.1.6.tgz",
-
"integrity": "sha512-kq1yhpImGG1IUE8QEKj2IjSfNrkG2VailZRuiFLYdcszDEBDzr9HN3ElV42ebxhofuSFgKOCrYWJIUiLuXo6Uw==",
+
"version": "0.2.0",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.2.0.tgz",
+
"integrity": "sha512-X4UpU9qSgbuBVRXw0kpYqdVRtjNGezmaetyQIwWHNdUl1+ILu4GhinSk1MBXamzgg/07/BVCU0r4LRIPg2Wiow==",
"license": "MIT",
"dependencies": {
-
"@atproto-labs/did-resolver": "0.1.5",
-
"@atproto-labs/handle-resolver": "0.1.4",
-
"@atproto/syntax": "0.3.1"
+
"@atproto-labs/did-resolver": "0.2.0",
+
"@atproto-labs/handle-resolver": "0.3.0"
}
},
"node_modules/@atproto-labs/pipe": {
-
"version": "0.1.0",
-
"resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.0.tgz",
-
"integrity": "sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==",
+
"version": "0.1.1",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz",
+
"integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==",
"license": "MIT"
},
"node_modules/@atproto-labs/simple-store": {
-
"version": "0.1.1",
-
"resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz",
-
"integrity": "sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==",
+
"version": "0.2.0",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.2.0.tgz",
+
"integrity": "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA==",
"license": "MIT"
},
"node_modules/@atproto-labs/simple-store-memory": {
-
"version": "0.1.1",
-
"resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz",
-
"integrity": "sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==",
+
"version": "0.1.3",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.3.tgz",
+
"integrity": "sha512-jkitT9+AtU+0b28DoN92iURLaCt/q/q4yX8q6V+9LSwYlUTqKoj/5NFKvF7x6EBuG+gpUdlcycbH7e60gjOhRQ==",
"license": "MIT",
"dependencies": {
-
"@atproto-labs/simple-store": "0.1.1",
+
"@atproto-labs/simple-store": "0.2.0",
"lru-cache": "^10.2.0"
}
},
"node_modules/@atproto/api": {
-
"version": "0.13.6",
-
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.6.tgz",
-
"integrity": "sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==",
+
"version": "0.15.16",
+
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.15.16.tgz",
+
"integrity": "sha512-ZNBrzBg2l0lHreKik1lJn8lrhAktwlY8NUPBU/hO9dwjAnDHQTiSzNFZt65dp9djmqZ75sX/VJ+heNuaJBvnhQ==",
+
"license": "MIT",
"dependencies": {
-
"@atproto/common-web": "^0.3.0",
-
"@atproto/lexicon": "^0.4.1",
-
"@atproto/syntax": "^0.3.0",
-
"@atproto/xrpc": "^0.6.1",
+
"@atproto/common-web": "^0.4.2",
+
"@atproto/lexicon": "^0.4.11",
+
"@atproto/syntax": "^0.4.0",
+
"@atproto/xrpc": "^0.7.0",
"await-lock": "^2.2.2",
"multiformats": "^9.9.0",
-
"tlds": "^1.234.0"
+
"tlds": "^1.234.0",
+
"zod": "^3.23.8"
}
},
"node_modules/@atproto/common": {
-
"version": "0.4.7",
-
"resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.4.7.tgz",
-
"integrity": "sha512-C844ILV66sqHjQCJDb8tN/yZB2MBaLpZ1qptDT8zWRMx0uw7j/B6/EuN9R9a57Nj99Hhi93QkvQxOujURqpPeA==",
+
"version": "0.4.11",
+
"resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.4.11.tgz",
+
"integrity": "sha512-Knv0viYXNMfCdIE7jLUiWJKnnMfEwg+vz2epJQi8WOjqtqCFb3W/3Jn72ZiuovIfpdm13MaOiny6w2NErUQC6g==",
"license": "MIT",
"dependencies": {
-
"@atproto/common-web": "^0.3.2",
+
"@atproto/common-web": "^0.4.2",
"@ipld/dag-cbor": "^7.0.3",
"cbor-x": "^1.5.1",
"iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0",
"pino": "^8.21.0"
+
},
+
"engines": {
+
"node": ">=18.7.0"
}
},
"node_modules/@atproto/common-web": {
-
"version": "0.3.2",
-
"resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.2.tgz",
-
"integrity": "sha512-Vx0JtL1/CssJbFAb0UOdvTrkbUautsDfHNOXNTcX2vyPIxH9xOameSqLLunM1hZnOQbJwyjmQCt6TV+bhnanDg==",
+
"version": "0.4.2",
+
"resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.2.tgz",
+
"integrity": "sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==",
"license": "MIT",
"dependencies": {
"graphemer": "^1.4.0",
···
}
},
"node_modules/@atproto/crypto": {
-
"version": "0.4.3",
-
"resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.3.tgz",
-
"integrity": "sha512-YSSUAvkx+ldpXw97NXZWfLx/prgh5YJ2K0BCw51JCJmXSRp6KhhwvOm4J+K/s5hwpssyuDCVTXknyS4PHwaK5g==",
+
"version": "0.4.4",
+
"resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.4.tgz",
+
"integrity": "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.7.0",
"@noble/hashes": "^1.6.1",
"uint8arrays": "3.0.0"
+
},
+
"engines": {
+
"node": ">=18.7.0"
}
},
"node_modules/@atproto/did": {
-
"version": "0.1.3",
-
"resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.1.3.tgz",
-
"integrity": "sha512-ULD8Gw/KRRwLFZ2Z2L4DjmdOMrg8IYYlcjdSc+GQ2/QJSVnD2zaJJVTLd3vls121wGt/583rNaiZTT2DpBze4w==",
+
"version": "0.1.5",
+
"resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.1.5.tgz",
+
"integrity": "sha512-8+1D08QdGE5TF0bB0vV8HLVrVZJeLNITpRTUVEoABNMRaUS7CoYSVb0+JNQDeJIVmqMjOL8dOjvCUDkp3gEaGQ==",
"license": "MIT",
"dependencies": {
"zod": "^3.23.8"
}
},
"node_modules/@atproto/identity": {
-
"version": "0.4.2",
-
"resolved": "https://registry.npmjs.org/@atproto/identity/-/identity-0.4.2.tgz",
-
"integrity": "sha512-Z267XI84enuYQLV8hgDMVkGZqy8GtPI4PYVn1rz4YKwSaI+nGwADNtyK+ZZWFa0tTDKS6q6u4ae7B8RdrUlk8A==",
+
"version": "0.4.8",
+
"resolved": "https://registry.npmjs.org/@atproto/identity/-/identity-0.4.8.tgz",
+
"integrity": "sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA==",
+
"license": "MIT",
"dependencies": {
-
"@atproto/common-web": "^0.3.1",
-
"@atproto/crypto": "^0.4.1",
-
"axios": "^0.27.2"
+
"@atproto/common-web": "^0.4.2",
+
"@atproto/crypto": "^0.4.4"
+
},
+
"engines": {
+
"node": ">=18.7.0"
}
},
"node_modules/@atproto/jwk": {
-
"version": "0.1.1",
-
"resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz",
-
"integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==",
+
"version": "0.4.0",
+
"resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.4.0.tgz",
+
"integrity": "sha512-tvp4iZrzqEzKCeTOKz50/o6WdsZzOuWmWjF6On5QAp04fLwLpsFu2Hixgx/lA1KBO0O4sns7YSGcAqSSX6Rdog==",
+
"license": "MIT",
"dependencies": {
"multiformats": "^9.9.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/jwk-jose": {
-
"version": "0.1.2",
-
"resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz",
-
"integrity": "sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==",
+
"version": "0.1.9",
+
"resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.9.tgz",
+
"integrity": "sha512-HT9GcUe6htDxI5OSYXWdeS6QZ9lpuDDvJk508ppi8a48E/1f8eumoM0QhgbFRF9IKAnnFrtnZDOAvljQzFKwwQ==",
+
"license": "MIT",
"dependencies": {
-
"@atproto/jwk": "0.1.1",
+
"@atproto/jwk": "0.4.0",
"jose": "^5.2.0"
}
},
"node_modules/@atproto/jwk-webcrypto": {
-
"version": "0.1.2",
-
"resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.2.tgz",
-
"integrity": "sha512-vTBUbUZXh0GI+6KJiPGukmI4BQEHFAij8fJJ4WnReF/hefAs3ISZtrWZHGBebz+q2EcExYlnhhlmxvDzV7veGw==",
+
"version": "0.1.9",
+
"resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.9.tgz",
+
"integrity": "sha512-ecciePHT0JEDZNAbMKSkdqoBYsjvhwuVno0jsS600SZmuvi2fAMhGraDZ5ZOO5M0hHHBiDbN7Ar/qcnIwyoxsA==",
+
"license": "MIT",
"dependencies": {
-
"@atproto/jwk": "0.1.1",
-
"@atproto/jwk-jose": "0.1.2"
+
"@atproto/jwk": "0.4.0",
+
"@atproto/jwk-jose": "0.1.9",
+
"zod": "^3.23.8"
}
},
"node_modules/@atproto/lex-cli": {
···
"lex": "dist/index.js"
}
},
+
"node_modules/@atproto/lex-cli/node_modules/@atproto/syntax": {
+
"version": "0.3.4",
+
"resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.4.tgz",
+
"integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==",
+
"dev": true,
+
"license": "MIT"
+
},
"node_modules/@atproto/lexicon": {
-
"version": "0.4.5",
-
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.5.tgz",
-
"integrity": "sha512-fljWqMGKn+XWtTprBcS3F1hGBREnQYh6qYHv2sjENucc7REms1gtmZXSerB9N6pVeHVNOnXiILdukeAcic5OEw==",
+
"version": "0.4.11",
+
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.11.tgz",
+
"integrity": "sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw==",
"license": "MIT",
"dependencies": {
-
"@atproto/common-web": "^0.3.2",
-
"@atproto/syntax": "^0.3.1",
+
"@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"
}
},
"node_modules/@atproto/oauth-client": {
-
"version": "0.3.2",
-
"resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.3.2.tgz",
-
"integrity": "sha512-/HUlv5dnR1am4BQlVYSuevGf4mKJ5RMkElnum8lbwRDewKyzqHwdtJWeNcfcPFtDhUKg0U2pWfRv8ZZd6kk9dQ==",
+
"version": "0.4.2",
+
"resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.4.2.tgz",
+
"integrity": "sha512-wHRYcrh+iKQvMramYqE6PHs5Y/L2LYFzrEnyUMf83CjD3GYFwbSN5pwot6EFXONxRwuRjxpXsCSlFzZwx9YFvw==",
"license": "MIT",
"dependencies": {
-
"@atproto-labs/did-resolver": "0.1.5",
-
"@atproto-labs/fetch": "0.1.1",
-
"@atproto-labs/handle-resolver": "0.1.4",
-
"@atproto-labs/identity-resolver": "0.1.6",
-
"@atproto-labs/simple-store": "0.1.1",
-
"@atproto-labs/simple-store-memory": "0.1.1",
-
"@atproto/did": "0.1.3",
-
"@atproto/jwk": "0.1.1",
-
"@atproto/oauth-types": "0.2.1",
-
"@atproto/xrpc": "0.6.4",
+
"@atproto-labs/did-resolver": "0.2.0",
+
"@atproto-labs/fetch": "0.2.3",
+
"@atproto-labs/handle-resolver": "0.3.0",
+
"@atproto-labs/identity-resolver": "0.2.0",
+
"@atproto-labs/simple-store": "0.2.0",
+
"@atproto-labs/simple-store-memory": "0.1.3",
+
"@atproto/did": "0.1.5",
+
"@atproto/jwk": "0.4.0",
+
"@atproto/oauth-types": "0.3.1",
+
"@atproto/xrpc": "0.7.0",
"multiformats": "^9.9.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/oauth-client-node": {
-
"version": "0.2.2",
-
"resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.2.2.tgz",
-
"integrity": "sha512-IlO0ozTf+uDezfcdYU60U5gERDRc9DJgNRbm2IGEpHWBXEYBQlACQHlQ+yDGP8Ts3Xtfop2YXju8n+TdXdqeLQ==",
+
"version": "0.3.1",
+
"resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.3.1.tgz",
+
"integrity": "sha512-k37YC7Ke4+btX05oAqHqkkM8r2Ya/tssWANx7/GMwN3PXPP5PK1C/pkxJrGsN/hpjn3I4W9lVTOlC7nigEX7sw==",
"license": "MIT",
"dependencies": {
-
"@atproto-labs/did-resolver": "0.1.5",
-
"@atproto-labs/handle-resolver-node": "0.1.7",
-
"@atproto-labs/simple-store": "0.1.1",
-
"@atproto/did": "0.1.3",
-
"@atproto/jwk": "0.1.1",
-
"@atproto/jwk-jose": "0.1.2",
-
"@atproto/jwk-webcrypto": "0.1.2",
-
"@atproto/oauth-client": "0.3.2",
-
"@atproto/oauth-types": "0.2.1"
+
"@atproto-labs/did-resolver": "0.2.0",
+
"@atproto-labs/handle-resolver-node": "0.1.18",
+
"@atproto-labs/simple-store": "0.2.0",
+
"@atproto/did": "0.1.5",
+
"@atproto/jwk": "0.4.0",
+
"@atproto/jwk-jose": "0.1.9",
+
"@atproto/jwk-webcrypto": "0.1.9",
+
"@atproto/oauth-client": "0.4.2",
+
"@atproto/oauth-types": "0.3.1"
+
},
+
"engines": {
+
"node": ">=18.7.0"
}
},
"node_modules/@atproto/oauth-types": {
-
"version": "0.2.1",
-
"resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.2.1.tgz",
-
"integrity": "sha512-hDisUXzcq5KU1HMuCYZ8Kcz7BePl7V11bFjjgZvND3mdSphiyBpJ8MCNn3QzAa6cXpFo0w9PDcYMAlCCRZHdVw==",
+
"version": "0.3.1",
+
"resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.3.1.tgz",
+
"integrity": "sha512-l8ahtm74lmBOs5boi5q7mqzF2D37+cIYqVmbCrpexNeJfg2BXu0sBxREt0ADxP25Td9pX+u6FnefCOQtI/YAZw==",
"license": "MIT",
"dependencies": {
-
"@atproto/jwk": "0.1.1",
+
"@atproto/jwk": "0.4.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/repo": {
-
"version": "0.5.3",
-
"resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.5.3.tgz",
-
"integrity": "sha512-Lbp35SaK5149B9VnE6CVruo/iImNKQ49pPSR+5KuStHDCIyH0z/ynOrEJfpQjTzVu9kdio6bimo5zsl4F2fT2Q==",
+
"version": "0.8.2",
+
"resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.2.tgz",
+
"integrity": "sha512-lP0g5Uw3TUC2Tc7te8YKCpRoIhBYI+Uvn11fupGEaMcMjgLdYtB0Kc0AiqWXF42KqlBG9dAEoJITi2GRzDNHUg==",
+
"license": "MIT",
"dependencies": {
-
"@atproto/common": "^0.4.4",
-
"@atproto/common-web": "^0.3.1",
-
"@atproto/crypto": "^0.4.1",
-
"@atproto/lexicon": "^0.4.2",
-
"@ipld/car": "^3.2.3",
+
"@atproto/common": "^0.4.11",
+
"@atproto/common-web": "^0.4.2",
+
"@atproto/crypto": "^0.4.4",
+
"@atproto/lexicon": "^0.4.11",
"@ipld/dag-cbor": "^7.0.0",
"multiformats": "^9.9.0",
"uint8arrays": "3.0.0",
+
"varint": "^6.0.0",
"zod": "^3.23.8"
+
},
+
"engines": {
+
"node": ">=18.7.0"
}
},
"node_modules/@atproto/sync": {
-
"version": "0.1.4",
-
"resolved": "https://registry.npmjs.org/@atproto/sync/-/sync-0.1.4.tgz",
-
"integrity": "sha512-IKF7UKJ78tNhXUpow2/SyQ98UmT9RBZAjBI6n04ssJz1gOTW2XzEvdU0lIfqfpLc++0h7p4GfzzyyUNLgBxd0g==",
+
"version": "0.1.26",
+
"resolved": "https://registry.npmjs.org/@atproto/sync/-/sync-0.1.26.tgz",
+
"integrity": "sha512-bpUIajtPrE3RgFW8mIfrI4EM/LJ4JjQhI5fsqc78zCHZawuflpllf1aH70roDWWiskMWoiLWnVRxdYXdeEgbXA==",
+
"license": "MIT",
"dependencies": {
-
"@atproto/common": "^0.4.4",
-
"@atproto/identity": "^0.4.2",
-
"@atproto/lexicon": "^0.4.2",
-
"@atproto/repo": "^0.5.3",
-
"@atproto/syntax": "^0.3.0",
-
"@atproto/xrpc-server": "^0.7.1",
+
"@atproto/common": "^0.4.11",
+
"@atproto/identity": "^0.4.8",
+
"@atproto/lexicon": "^0.4.11",
+
"@atproto/repo": "^0.8.2",
+
"@atproto/syntax": "^0.4.0",
+
"@atproto/xrpc-server": "^0.8.0",
"multiformats": "^9.9.0",
-
"p-queue": "^6.6.2"
+
"p-queue": "^6.6.2",
+
"ws": "^8.12.0"
+
},
+
"engines": {
+
"node": ">=18.7.0"
}
},
"node_modules/@atproto/syntax": {
-
"version": "0.3.1",
-
"resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.1.tgz",
-
"integrity": "sha512-fzW0Mg1QUOVCWUD3RgEsDt6d1OZ6DdFmbKcDdbzUfh0t4rhtRAC05KbZYmxuMPWDAiJ4BbbQ5dkAc/mNypMXkw==",
+
"version": "0.4.0",
+
"resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.0.tgz",
+
"integrity": "sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==",
"license": "MIT"
},
"node_modules/@atproto/xrpc": {
-
"version": "0.6.4",
-
"resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.4.tgz",
-
"integrity": "sha512-9ZAJ8nsXTqC4XFyS0E1Wlg7bAvonhXQNQ3Ocs1L1LIwFLXvsw/4fNpIHXxvXvqTCVeyHLbImOnE9UiO1c/qIYA==",
+
"version": "0.7.0",
+
"resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.0.tgz",
+
"integrity": "sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw==",
"license": "MIT",
"dependencies": {
-
"@atproto/lexicon": "^0.4.3",
+
"@atproto/lexicon": "^0.4.11",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/xrpc-server": {
-
"version": "0.7.9",
-
"resolved": "https://registry.npmjs.org/@atproto/xrpc-server/-/xrpc-server-0.7.9.tgz",
-
"integrity": "sha512-x6CqV6KycIUyZs+J4V+wujc3R98QIkVRU4KmbUgAJ9AtJuTDnOOEbUFrNVVes45UfjJw4ztg021R0M2y0aI3fQ==",
+
"version": "0.8.0",
+
"resolved": "https://registry.npmjs.org/@atproto/xrpc-server/-/xrpc-server-0.8.0.tgz",
+
"integrity": "sha512-jDAEVHVhM4IvC0y491gXBuD4b1D9/XrM3HaEronRneAdNZ0qE0nsiJNqiHfQ6r4BvFdHnABM9KyHV9EQTvmxfg==",
"license": "MIT",
"dependencies": {
-
"@atproto/common": "^0.4.7",
-
"@atproto/crypto": "^0.4.3",
-
"@atproto/lexicon": "^0.4.5",
-
"@atproto/xrpc": "^0.6.7",
+
"@atproto/common": "^0.4.11",
+
"@atproto/crypto": "^0.4.4",
+
"@atproto/lexicon": "^0.4.11",
+
"@atproto/xrpc": "^0.7.0",
"cbor-x": "^1.5.1",
"express": "^4.17.2",
"http-errors": "^2.0.0",
···
"uint8arrays": "3.0.0",
"ws": "^8.12.0",
"zod": "^3.23.8"
-
}
-
},
-
"node_modules/@atproto/xrpc-server/node_modules/@atproto/xrpc": {
-
"version": "0.6.7",
-
"resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.7.tgz",
-
"integrity": "sha512-pbzZIONIskyGKxxG3s2wB7rQ2W1xu3ycfeYhKwk/E/ippeJFVxcof64iSC7f22+7JSKUJcxBeZ1piBB82vLj7g==",
-
"license": "MIT",
-
"dependencies": {
-
"@atproto/lexicon": "^0.4.5",
-
"zod": "^3.23.8"
+
},
+
"engines": {
+
"node": ">=18.7.0"
}
},
"node_modules/@cbor-extract/cbor-extract-darwin-arm64": {
···
"node": ">=18"
}
},
-
"node_modules/@ipld/car": {
-
"version": "3.2.4",
-
"resolved": "https://registry.npmjs.org/@ipld/car/-/car-3.2.4.tgz",
-
"integrity": "sha512-rezKd+jk8AsTGOoJKqzfjLJ3WVft7NZNH95f0pfPbicROvzTyvHCNy567HzSUd6gRXZ9im29z5ZEv9Hw49jSYw==",
-
"dependencies": {
-
"@ipld/dag-cbor": "^7.0.0",
-
"multiformats": "^9.5.4",
-
"varint": "^6.0.0"
-
}
-
},
"node_modules/@ipld/dag-cbor": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@ipld/dag-cbor/-/dag-cbor-7.0.3.tgz",
···
},
"node_modules/@noble/curves": {
-
"version": "1.8.1",
-
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.1.tgz",
-
"integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==",
+
"version": "1.9.2",
+
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.2.tgz",
+
"integrity": "sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g==",
"license": "MIT",
"dependencies": {
-
"@noble/hashes": "1.7.1"
+
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
···
},
"node_modules/@noble/hashes": {
-
"version": "1.7.1",
-
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz",
-
"integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==",
+
"version": "1.8.0",
+
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
···
"node": ">=8"
},
-
"node_modules/asynckit": {
-
"version": "0.4.0",
-
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
-
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
···
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
-
},
-
"node_modules/axios": {
-
"version": "0.27.2",
-
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
-
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
-
"dependencies": {
-
"follow-redirects": "^1.14.9",
-
"form-data": "^4.0.0"
-
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
···
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true
},
-
"node_modules/combined-stream": {
-
"version": "1.0.8",
-
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
-
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-
"dependencies": {
-
"delayed-stream": "~1.0.0"
-
},
-
"engines": {
-
"node": ">= 0.8"
-
}
-
},
"node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
···
"url": "https://github.com/sponsors/ljharb"
},
-
"node_modules/delayed-stream": {
-
"version": "1.0.0",
-
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+
"node_modules/delay": {
+
"version": "5.0.0",
+
"resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz",
+
"integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==",
+
"license": "MIT",
"engines": {
-
"node": ">=0.4.0"
+
"node": ">=10"
+
},
+
"funding": {
+
"url": "https://github.com/sponsors/sindresorhus"
},
"node_modules/depd": {
···
"node": ">=8.6.0"
},
+
"node_modules/fast-printf": {
+
"version": "1.6.10",
+
"resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz",
+
"integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==",
+
"license": "BSD-3-Clause",
+
"engines": {
+
"node": ">=10.0"
+
}
+
},
"node_modules/fast-redact": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
···
"node": ">= 0.8"
},
-
"node_modules/follow-redirects": {
-
"version": "1.15.8",
-
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.8.tgz",
-
"integrity": "sha512-xgrmBhBToVKay1q2Tao5LI26B83UhrB/vM1avwVSDzt8rx3rO6AizBAaF46EgksTVr+rFTQaqZZ9MVBfUe4nig==",
-
"funding": [
-
{
-
"type": "individual",
-
"url": "https://github.com/sponsors/RubenVerborgh"
-
}
-
],
-
"engines": {
-
"node": ">=4.0"
-
},
-
"peerDependenciesMeta": {
-
"debug": {
-
"optional": true
-
}
-
}
-
},
"node_modules/foreground-child": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
···
"url": "https://github.com/sponsors/isaacs"
},
-
"node_modules/form-data": {
-
"version": "4.0.0",
-
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
-
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
-
"dependencies": {
-
"asynckit": "^0.4.0",
-
"combined-stream": "^1.0.8",
-
"mime-types": "^2.1.12"
-
},
-
"engines": {
-
"node": ">= 6"
-
}
-
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
···
},
"engines": {
"node": ">= 0.8"
+
}
+
},
+
"node_modules/http-terminator": {
+
"version": "3.2.0",
+
"resolved": "https://registry.npmjs.org/http-terminator/-/http-terminator-3.2.0.tgz",
+
"integrity": "sha512-JLjck1EzPaWjsmIf8bziM3p9fgR1Y3JoUKAkyYEbZmFrIvJM6I8vVJfBGWlEtV9IWOvzNnaTtjuwZeBY2kwB4g==",
+
"license": "BSD-3-Clause",
+
"dependencies": {
+
"delay": "^5.0.0",
+
"p-wait-for": "^3.2.0",
+
"roarr": "^7.0.4",
+
"type-fest": "^2.3.3"
+
},
+
"engines": {
+
"node": ">=14"
},
"node_modules/human-signals": {
···
"node": ">=8"
},
+
"node_modules/p-wait-for": {
+
"version": "3.2.0",
+
"resolved": "https://registry.npmjs.org/p-wait-for/-/p-wait-for-3.2.0.tgz",
+
"integrity": "sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==",
+
"license": "MIT",
+
"dependencies": {
+
"p-timeout": "^3.0.0"
+
},
+
"engines": {
+
"node": ">=8"
+
},
+
"funding": {
+
"url": "https://github.com/sponsors/sindresorhus"
+
}
+
},
"node_modules/package-json-from-dist": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
···
"node": ">= 0.10"
},
-
"node_modules/psl": {
-
"version": "1.13.0",
-
"resolved": "https://registry.npmjs.org/psl/-/psl-1.13.0.tgz",
-
"integrity": "sha512-BFwmFXiJoFqlUpZ5Qssolv15DMyc84gTBds1BjsV1BfXEo1UyyD7GsmN67n7J77uRhoSNW1AXtXKPLcBFQn9Aw==",
-
"license": "MIT",
-
"dependencies": {
-
"punycode": "^2.3.1"
-
}
-
},
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
···
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+
"dev": true,
"engines": {
"node": ">=6"
···
"node_modules/rate-limiter-flexible": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.4.2.tgz",
-
"integrity": "sha512-rMATGGOdO1suFyf/mI5LYhts71g1sbdhmd6YvdiXO2gJnd42Tt6QS4JUKJKSWVVkMtBacm6l40FR7Trjo6Iruw=="
+
"integrity": "sha512-rMATGGOdO1suFyf/mI5LYhts71g1sbdhmd6YvdiXO2gJnd42Tt6QS4JUKJKSWVVkMtBacm6l40FR7Trjo6Iruw==",
+
"license": "ISC"
},
"node_modules/raw-body": {
"version": "2.5.2",
···
"url": "https://github.com/sponsors/isaacs"
},
+
"node_modules/roarr": {
+
"version": "7.21.1",
+
"resolved": "https://registry.npmjs.org/roarr/-/roarr-7.21.1.tgz",
+
"integrity": "sha512-3niqt5bXFY1InKU8HKWqqYTYjtrBaxBMnXELXCXUYgtNYGUtZM5rB46HIC430AyacL95iEniGf7RgqsesykLmQ==",
+
"license": "BSD-3-Clause",
+
"dependencies": {
+
"fast-printf": "^1.6.9",
+
"safe-stable-stringify": "^2.4.3",
+
"semver-compare": "^1.0.0"
+
},
+
"engines": {
+
"node": ">=18.0"
+
}
+
},
"node_modules/rollup": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.2.tgz",
···
"node": ">=10"
},
+
"node_modules/semver-compare": {
+
"version": "1.0.0",
+
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
+
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
+
"license": "MIT"
+
},
"node_modules/send": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
···
"node": "*"
},
+
"node_modules/type-fest": {
+
"version": "2.19.0",
+
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+
"license": "(MIT OR CC0-1.0)",
+
"engines": {
+
"node": ">=12.20"
+
},
+
"funding": {
+
"url": "https://github.com/sponsors/sindresorhus"
+
}
+
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
···
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="
},
"node_modules/undici": {
-
"version": "6.21.0",
-
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz",
-
"integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==",
+
"version": "6.21.3",
+
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
+
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
···
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
-
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="
+
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
+
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
···
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
-
"version": "8.18.0",
-
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
-
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+
"version": "8.18.2",
+
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
+
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
+
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
···
},
"node_modules/zod": {
-
"version": "3.23.8",
-
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
-
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
+
"version": "3.25.67",
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
+
"integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==",
+
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+10 -9
package.json
···
"clean": "rimraf dist coverage"
},
"dependencies": {
-
"@atproto/api": "^0.13.4",
-
"@atproto/common": "^0.4.1",
-
"@atproto/identity": "^0.4.0",
-
"@atproto/lexicon": "^0.4.2",
-
"@atproto/oauth-client-node": "^0.2.2",
-
"@atproto/sync": "^0.1.4",
-
"@atproto/syntax": "^0.3.0",
-
"@atproto/xrpc-server": "^0.7.9",
+
"@atproto/api": "^0.15.6",
+
"@atproto/common": "^0.4.11",
+
"@atproto/identity": "^0.4.8",
+
"@atproto/lexicon": "^0.4.11",
+
"@atproto/oauth-client-node": "^0.3.1",
+
"@atproto/sync": "^0.1.26",
+
"@atproto/xrpc-server": "^0.8.0",
"better-sqlite3": "^11.1.2",
"dotenv": "^16.4.5",
"envalid": "^8.0.0",
"express": "^4.19.2",
+
"http-terminator": "^3.2.0",
"iron-session": "^8.0.2",
"kysely": "^0.27.4",
"multiformats": "^9.9.0",
"pino": "^9.3.2",
-
"uhtml": "^4.5.9"
+
"uhtml": "^4.5.9",
+
"zod": "^3.25.67"
},
"devDependencies": {
"@atproto/lex-cli": "^0.4.1",
+58 -20
src/auth/client.ts
···
-
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
import {
+
Keyset,
+
JoseKey,
+
atprotoLoopbackClientMetadata,
+
NodeOAuthClient,
+
OAuthClientMetadataInput,
+
} from '@atproto/oauth-client-node'
+
import assert from 'node:assert'
+
import type { Database } from '#/db'
-
import { env } from '#/lib/env'
+
import { env } from '#/env'
import { SessionStore, StateStore } from './storage'
-
export const createClient = async (db: Database) => {
-
const publicUrl = env.PUBLIC_URL
-
const url = publicUrl || `http://127.0.0.1:${env.PORT}`
-
const enc = encodeURIComponent
+
export async function createOAuthClient(db: Database) {
+
// Confidential client require a keyset accessible on the internet. Non
+
// internet clients (e.g. development) cannot expose a keyset on the internet
+
// so they can't be private..
+
const keyset =
+
env.PUBLIC_URL && env.PRIVATE_KEYS
+
? new Keyset(
+
await Promise.all(
+
env.PRIVATE_KEYS.map((jwk) => JoseKey.fromJWK(jwk)),
+
),
+
)
+
: undefined
+
+
assert(
+
!env.PUBLIC_URL || keyset?.size,
+
'ATProto requires backend clients to be confidential. Make sure to set the PRIVATE_KEYS environment variable.',
+
)
+
+
// If a keyset is defined (meaning the client is confidential). Let's make
+
// sure it has a private key for signing. Note: findPrivateKey will throw if
+
// the keyset does not contain a suitable private key.
+
const pk = keyset?.findPrivateKey({ use: 'sig' })
+
+
const clientMetadata: OAuthClientMetadataInput = env.PUBLIC_URL
+
? {
+
client_name: 'Statusphere Example App',
+
client_id: `${env.PUBLIC_URL}/oauth-client-metadata.json`,
+
jwks_uri: `${env.PUBLIC_URL}/.well-known/jwks.json`,
+
redirect_uris: [`${env.PUBLIC_URL}/oauth/callback`],
+
scope: 'atproto transition:generic',
+
grant_types: ['authorization_code', 'refresh_token'],
+
response_types: ['code'],
+
application_type: 'web',
+
token_endpoint_auth_method: pk ? 'private_key_jwt' : 'none',
+
token_endpoint_auth_signing_alg: pk ? pk.alg : undefined,
+
dpop_bound_access_tokens: true,
+
}
+
: atprotoLoopbackClientMetadata(
+
`http://localhost?${new URLSearchParams([
+
['redirect_uri', `http://127.0.0.1:${env.PORT}/oauth/callback`],
+
['scope', `atproto transition:generic`],
+
])}`,
+
)
+
return new NodeOAuthClient({
-
clientMetadata: {
-
client_name: 'AT Protocol Express App',
-
client_id: publicUrl
-
? `${url}/client-metadata.json`
-
: `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc('atproto transition:generic')}`,
-
client_uri: url,
-
redirect_uris: [`${url}/oauth/callback`],
-
scope: 'atproto transition:generic',
-
grant_types: ['authorization_code', 'refresh_token'],
-
response_types: ['code'],
-
application_type: 'web',
-
token_endpoint_auth_method: 'none',
-
dpop_bound_access_tokens: true,
-
},
+
keyset,
+
clientMetadata,
stateStore: new StateStore(db),
sessionStore: new SessionStore(db),
+
plcDirectoryUrl: env.PLC_URL,
+
handleResolver: env.PDS_URL,
})
}
+46
src/context.ts
···
+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
import { Firehose } from '@atproto/sync'
+
import { pino } from 'pino'
+
+
import { createOAuthClient } from '#/auth/client'
+
import { createDb, Database, migrateToLatest } from '#/db'
+
import { createIngester } from '#/ingester'
+
import { env } from '#/env'
+
import {
+
BidirectionalResolver,
+
createBidirectionalResolver,
+
} from '#/id-resolver'
+
+
/**
+
* Application state passed to the router and elsewhere
+
*/
+
export type AppContext = {
+
db: Database
+
ingester: Firehose
+
logger: pino.Logger
+
oauthClient: NodeOAuthClient
+
resolver: BidirectionalResolver
+
destroy: () => Promise<void>
+
}
+
+
export async function createAppContext(): Promise<AppContext> {
+
const db = createDb(env.DB_PATH)
+
await migrateToLatest(db)
+
const oauthClient = await createOAuthClient(db)
+
const ingester = createIngester(db)
+
const logger = pino({ name: 'server', level: env.LOG_LEVEL })
+
const resolver = createBidirectionalResolver(oauthClient)
+
+
return {
+
db,
+
ingester,
+
logger,
+
oauthClient,
+
resolver,
+
+
async destroy() {
+
await ingester.destroy()
+
await db.destroy()
+
},
+
}
+
}
+25
src/env.ts
···
+
import dotenv from 'dotenv'
+
import { cleanEnv, port, str, testOnly, url } from 'envalid'
+
import { envalidJsonWebKeys as keys } from '#/lib/jwk'
+
+
dotenv.config()
+
+
export const env = cleanEnv(process.env, {
+
NODE_ENV: str({
+
devDefault: testOnly('test'),
+
choices: ['development', 'production', 'test'],
+
}),
+
PORT: port({ devDefault: testOnly(3000) }),
+
PUBLIC_URL: url({ default: undefined }),
+
DB_PATH: str({ devDefault: ':memory:' }),
+
COOKIE_SECRET: str({ devDefault: '00000000000000000000000000000000' }),
+
PRIVATE_KEYS: keys({ default: undefined }),
+
LOG_LEVEL: str({
+
devDefault: 'debug',
+
default: 'info',
+
choices: ['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'],
+
}),
+
PDS_URL: url({ default: undefined }),
+
PLC_URL: url({ default: undefined }),
+
FIREHOSE_URL: url({ default: undefined }),
+
})
+24 -29
src/id-resolver.ts
···
-
import { IdResolver, MemoryCache } from '@atproto/identity'
-
-
const HOUR = 60e3 * 60
-
const DAY = HOUR * 24
-
-
-
export function createIdResolver() {
-
return new IdResolver({
-
didCache: new MemoryCache(HOUR, DAY),
-
})
-
}
+
import { OAuthClient } from '@atproto/oauth-client-node'
export interface BidirectionalResolver {
-
resolveDidToHandle(did: string): Promise<string>
-
resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>
+
resolveDidToHandle(did: string): Promise<string | undefined>
+
resolveDidsToHandles(
+
dids: string[],
+
): Promise<Record<string, string | undefined>>
}
-
export function createBidirectionalResolver(resolver: IdResolver) {
+
export function createBidirectionalResolver({
+
identityResolver,
+
}: OAuthClient): BidirectionalResolver {
return {
-
async resolveDidToHandle(did: string): Promise<string> {
-
const didDoc = await resolver.did.resolveAtprotoData(did)
-
const resolvedHandle = await resolver.handle.resolve(didDoc.handle)
-
if (resolvedHandle === did) {
-
return didDoc.handle
+
async resolveDidToHandle(did: string): Promise<string | undefined> {
+
try {
+
const { handle } = await identityResolver.resolve(did)
+
if (handle) return handle
+
} catch {
+
// Ignore
}
-
return did
},
async resolveDidsToHandles(
-
dids: string[]
-
): Promise<Record<string, string>> {
-
const didHandleMap: Record<string, string> = {}
-
const resolves = await Promise.all(
-
dids.map((did) => this.resolveDidToHandle(did).catch((_) => did))
+
dids: string[],
+
): Promise<Record<string, string | undefined>> {
+
const uniqueDids = [...new Set(dids)]
+
+
return Object.fromEntries(
+
await Promise.all(
+
uniqueDids.map((did) =>
+
this.resolveDidToHandle(did).then((handle) => [did, handle]),
+
),
+
),
)
-
for (let i = 0; i < dids.length; i++) {
-
didHandleMap[dids[i]] = resolves[i]
-
}
-
return didHandleMap
},
}
}
+24 -91
src/index.ts
···
-
import events from 'node:events'
-
import type http from 'node:http'
-
import express, { type Express } from 'express'
-
import { pino } from 'pino'
-
import type { OAuthClient } from '@atproto/oauth-client-node'
-
import { Firehose } from '@atproto/sync'
+
import { once } from 'node:events'
-
import { createDb, migrateToLatest } from '#/db'
-
import { env } from '#/lib/env'
-
import { createIngester } from '#/ingester'
+
import { createAppContext } from '#/context'
+
import { env } from '#/env'
+
import { startServer } from '#/lib/http'
+
import { run } from '#/lib/process'
import { createRouter } from '#/routes'
-
import { createClient } from '#/auth/client'
-
import { createBidirectionalResolver, createIdResolver, BidirectionalResolver } from '#/id-resolver'
-
import type { Database } from '#/db'
-
import { IdResolver, MemoryCache } from '@atproto/identity'
-
// Application state passed to the router and elsewhere
-
export type AppContext = {
-
db: Database
-
ingester: Firehose
-
logger: pino.Logger
-
oauthClient: OAuthClient
-
resolver: BidirectionalResolver
-
}
+
run(async (killSignal) => {
+
// Create the application context
+
const ctx = await createAppContext()
-
export class Server {
-
constructor(
-
public app: express.Application,
-
public server: http.Server,
-
public ctx: AppContext
-
) {}
+
// Create the HTTP router
+
const router = createRouter(ctx)
-
static async create() {
-
const { NODE_ENV, HOST, PORT, DB_PATH } = env
-
const logger = pino({ name: 'server start' })
+
// Start the HTTP server
+
const { terminate } = await startServer(router, { port: env.PORT })
-
// Set up the SQLite database
-
const db = createDb(DB_PATH)
-
await migrateToLatest(db)
+
const url = env.PUBLIC_URL || `http://localhost:${env.PORT}`
+
ctx.logger.info(`Server (${env.NODE_ENV}) running at ${url}`)
-
// Create the atproto utilities
-
const oauthClient = await createClient(db)
-
const baseIdResolver = createIdResolver()
-
const ingester = createIngester(db, baseIdResolver)
-
const resolver = createBidirectionalResolver(baseIdResolver)
-
const ctx = {
-
db,
-
ingester,
-
logger,
-
oauthClient,
-
resolver,
-
}
+
// Subscribe to events on the firehose
+
ctx.ingester.start()
-
// Subscribe to events on the firehose
-
ingester.start()
+
// Wait for a termination signal
+
if (!killSignal.aborted) await once(killSignal, 'abort')
+
ctx.logger.info(`Signal received, shutting down...`)
-
// Create our server
-
const app: Express = express()
-
app.set('trust proxy', true)
+
// Gracefully shutdown the http server
+
await terminate()
-
// Routes & middlewares
-
const router = createRouter(ctx)
-
app.use(express.json())
-
app.use(express.urlencoded({ extended: true }))
-
app.use(router)
-
app.use((_req, res) => res.sendStatus(404))
-
-
// Bind our server to the port
-
const server = app.listen(env.PORT)
-
await events.once(server, 'listening')
-
logger.info(`Server (${NODE_ENV}) running on port http://${HOST}:${PORT}`)
-
-
return new Server(app, server, ctx)
-
}
-
-
async close() {
-
this.ctx.logger.info('sigint received, shutting down')
-
await this.ctx.ingester.destroy()
-
return new Promise<void>((resolve) => {
-
this.server.close(() => {
-
this.ctx.logger.info('server closed')
-
resolve()
-
})
-
})
-
}
-
}
-
-
const run = async () => {
-
const server = await Server.create()
-
-
const onCloseSignal = async () => {
-
setTimeout(() => process.exit(1), 10000).unref() // Force shutdown after 10s
-
await server.close()
-
process.exit()
-
}
-
-
process.on('SIGINT', onCloseSignal)
-
process.on('SIGTERM', onCloseSignal)
-
}
-
-
run()
+
// Gracefully shutdown the application context
+
await ctx.destroy()
+
})
+32 -11
src/ingester.ts
···
-
import pino from 'pino'
-
import { IdResolver } from '@atproto/identity'
-
import { Firehose } from '@atproto/sync'
import type { Database } from '#/db'
import * as Status from '#/lexicon/types/xyz/statusphere/status'
+
import { IdResolver, MemoryCache } from '@atproto/identity'
+
import { Event, Firehose } from '@atproto/sync'
+
import pino from 'pino'
+
import { env } from './env'
-
export function createIngester(db: Database, idResolver: IdResolver) {
-
const logger = pino({ name: 'firehose ingestion' })
+
const HOUR = 60e3 * 60
+
const DAY = HOUR * 24
+
+
export function createIngester(db: Database) {
+
const logger = pino({ name: 'firehose', level: env.LOG_LEVEL })
return new Firehose({
-
idResolver,
-
handleEvent: async (evt) => {
+
filterCollections: ['xyz.statusphere.status'],
+
handleEvent: async (evt: Event) => {
// Watch for write events
if (evt.event === 'create' || evt.event === 'update') {
const now = new Date()
···
Status.isRecord(record) &&
Status.validateRecord(record).success
) {
+
logger.debug(
+
{ uri: evt.uri.toString(), status: record.status },
+
'ingesting status',
+
)
+
// Store the status in our SQLite
await db
.insertInto('status')
···
oc.column('uri').doUpdateSet({
status: record.status,
indexedAt: now.toISOString(),
-
})
+
}),
)
.execute()
}
···
evt.event === 'delete' &&
evt.collection === 'xyz.statusphere.status'
) {
+
logger.debug(
+
{ uri: evt.uri.toString(), did: evt.did },
+
'deleting status',
+
)
+
// Remove the status from our SQLite
-
await db.deleteFrom('status').where('uri', '=', evt.uri.toString()).execute()
+
await db
+
.deleteFrom('status')
+
.where('uri', '=', evt.uri.toString())
+
.execute()
}
},
-
onError: (err) => {
+
onError: (err: unknown) => {
logger.error({ err }, 'error on firehose ingestion')
},
-
filterCollections: ['xyz.statusphere.status'],
excludeIdentity: true,
excludeAccount: true,
+
service: env.FIREHOSE_URL,
+
idResolver: new IdResolver({
+
plcUrl: env.PLC_URL,
+
didCache: new MemoryCache(HOUR, DAY),
+
}),
})
}
-16
src/lib/env.ts
···
-
import dotenv from 'dotenv'
-
import { cleanEnv, host, port, str, testOnly } from 'envalid'
-
-
dotenv.config()
-
-
export const env = cleanEnv(process.env, {
-
NODE_ENV: str({
-
devDefault: testOnly('test'),
-
choices: ['development', 'production', 'test'],
-
}),
-
HOST: host({ devDefault: testOnly('localhost') }),
-
PORT: port({ devDefault: testOnly(3000) }),
-
PUBLIC_URL: str({}),
-
DB_PATH: str({ devDefault: ':memory:' }),
-
COOKIE_SECRET: str({ devDefault: '00000000000000000000000000000000' }),
-
})
+59
src/lib/http.ts
···
+
import { Request, Response } from 'express'
+
import { createHttpTerminator } from 'http-terminator'
+
import { once } from 'node:events'
+
import type {
+
IncomingMessage,
+
RequestListener,
+
ServerResponse,
+
} from 'node:http'
+
import { createServer } from 'node:http'
+
+
export type NextFunction = (err?: unknown) => void
+
+
export type Middleware<
+
Req extends IncomingMessage = IncomingMessage,
+
Res extends ServerResponse = ServerResponse,
+
> = (req: Req, res: Res, next: NextFunction) => void
+
+
export type Handler<
+
Req extends IncomingMessage = IncomingMessage,
+
Res extends ServerResponse = ServerResponse,
+
> = (req: Req, res: Res) => unknown | Promise<unknown>
+
/**
+
* Wraps a request handler middleware to ensure that `next` is called if it
+
* throws or returns a promise that rejects.
+
*/
+
export function handler<
+
Req extends IncomingMessage = Request,
+
Res extends ServerResponse = Response,
+
>(fn: Handler<Req, Res>): Middleware<Req, Res> {
+
return async (req, res, next) => {
+
try {
+
await fn(req, res)
+
} catch (err) {
+
next(err)
+
}
+
}
+
}
+
+
/**
+
* Create an HTTP server with the provided request listener, ensuring that it
+
* can bind the listening port, and returns a termination function that allows
+
* graceful termination of HTTP connections.
+
*/
+
export async function startServer(
+
requestListener: RequestListener,
+
{
+
port,
+
gracefulTerminationTimeout,
+
}: { port?: number; gracefulTerminationTimeout?: number } = {},
+
) {
+
const server = createServer(requestListener)
+
const { terminate } = createHttpTerminator({
+
gracefulTerminationTimeout,
+
server,
+
})
+
server.listen(port)
+
await once(server, 'listening')
+
return { server, terminate }
+
}
+17
src/lib/jwk.ts
···
+
import { Jwk, jwkValidator } from '@atproto/oauth-client-node'
+
import { makeValidator } from 'envalid'
+
import { z } from 'zod'
+
+
export type JsonWebKey = Jwk & { kid: string }
+
+
const jsonWebKeySchema = z.intersection(
+
jwkValidator,
+
z.object({ kid: z.string().nonempty() }),
+
) satisfies z.ZodType<JsonWebKey>
+
+
const jsonWebKeysSchema = z.array(jsonWebKeySchema).nonempty()
+
+
export const envalidJsonWebKeys = makeValidator((input) => {
+
const value = JSON.parse(input)
+
return jsonWebKeysSchema.parse(value)
+
})
+24
src/lib/process.ts
···
+
const SIGNALS = ['SIGINT', 'SIGTERM'] as const
+
+
/**
+
* Runs a function with an abort signal that will be triggered when the process
+
* receives a termination signal.
+
*/
+
export async function run<F extends (signal: AbortSignal) => Promise<void>>(
+
fn: F,
+
): Promise<void> {
+
const killController = new AbortController()
+
+
const abort = (signal?: string) => {
+
for (const sig of SIGNALS) process.off(sig, abort)
+
killController.abort(signal)
+
}
+
+
for (const sig of SIGNALS) process.on(sig, abort)
+
+
try {
+
await fn(killController.signal)
+
} finally {
+
abort()
+
}
+
}
+4
src/lib/util.ts
···
+
export function ifString<T>(value: T): (T & string) | undefined {
+
if (typeof value === 'string') return value
+
return undefined
+
}
+3 -4
src/pages/home.ts
···
const TODAY = new Date().toDateString()
-
const STATUS_OPTIONS = [
+
export const STATUS_OPTIONS = [
'👍',
'👎',
'💙',
'🥹',
'😧',
-
'😤',
'🙃',
'😉',
'😎',
···
type Props = {
statuses: Status[]
-
didHandleMap: Record<string, string>
+
didHandleMap: Record<string, string | undefined>
profile?: { displayName?: string }
myStatus?: Status
}
···
value="${status}"
>
${status}
-
</button>`
+
</button>`,
)}
</form>
${statuses.map((status, i) => {
+14 -6
src/pages/login.ts
···
+
import { env } from '#/env'
import { html } from '../lib/view'
import { shell } from './shell'
···
}
function content({ error }: Props) {
+
const signupService =
+
!env.PDS_URL || env.PDS_URL === 'https://bsky.social'
+
? 'Bluesky'
+
: new URL(env.PDS_URL).hostname
+
return html`<div id="root">
<div id="header">
<h1>Statusphere</h1>
···
<form action="/login" method="post" class="login-form">
<input
type="text"
-
name="handle"
+
name="input"
placeholder="Enter your handle (eg alice.bsky.social)"
required
/>
+
<button type="submit">Log in</button>
-
${error ? html`<p>Error: <i>${error}</i></p>` : undefined}
</form>
-
<div class="signup-cta">
-
Don't have an account on the Atmosphere?
-
<a href="https://bsky.app">Sign up for Bluesky</a> to create one now!
-
</div>
+
+
<a href="/signup" class="button signup-cta">
+
Login or Sign up with a ${signupService} account
+
</a>
+
+
${error ? html`<p>Error: <i>${error}</i></p>` : undefined}
</div>
</div>`
}
+28 -9
src/pages/public/styles.css
···
Josh's Custom CSS Reset
https://www.joshwcomeau.com/css/custom-css-reset/
*/
-
*, *::before, *::after {
+
*,
+
*::before,
+
*::after {
box-sizing: border-box;
}
* {
···
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
-
img, picture, video, canvas, svg {
+
img,
+
picture,
+
video,
+
canvas,
+
svg {
display: block;
max-width: 100%;
}
-
input, button, textarea, select {
+
input,
+
button,
+
textarea,
+
select {
font: inherit;
}
-
p, h1, h2, h3, h4, h5, h6 {
+
p,
+
h1,
+
h2,
+
h3,
+
h4,
+
h5,
+
h6 {
overflow-wrap: break-word;
}
-
#root, #__next {
+
#root,
+
#__next {
isolation: isolate;
}
/*
Common components
*/
-
button, .button {
+
button,
+
.button {
display: inline-block;
border: 0;
background-color: var(--primary-500);
···
cursor: pointer;
text-decoration: none;
}
-
button:hover, .button:hover {
+
button:hover,
+
.button:hover {
background: var(--primary-400);
}
···
.signup-cta {
text-align: center;
-
text-wrap: balance;
+
width: 100%;
+
display: block;
margin-top: 1rem;
-
}
+
}
+156 -83
src/routes.ts
···
-
import assert from 'node:assert'
-
import path from 'node:path'
-
import type { IncomingMessage, ServerResponse } from 'node:http'
-
import { OAuthResolverError } from '@atproto/oauth-client-node'
-
import { isValidHandle } from '@atproto/syntax'
+
import { Agent } from '@atproto/api'
import { TID } from '@atproto/common'
-
import { Agent } from '@atproto/api'
-
import express from 'express'
+
import { OAuthResolverError } from '@atproto/oauth-client-node'
+
import express, { Request, Response } from 'express'
import { getIronSession } from 'iron-session'
-
import type { AppContext } from '#/index'
+
import type {
+
IncomingMessage,
+
RequestListener,
+
ServerResponse,
+
} from 'node:http'
+
import path from 'node:path'
+
+
import type { AppContext } from '#/context'
+
import { env } from '#/env'
+
import * as Profile from '#/lexicon/types/app/bsky/actor/profile'
+
import * as Status from '#/lexicon/types/xyz/statusphere/status'
+
import { handler } from '#/lib/http'
+
import { ifString } from '#/lib/util'
+
import { page } from '#/lib/view'
import { home } from '#/pages/home'
import { login } from '#/pages/login'
-
import { env } from '#/lib/env'
-
import { page } from '#/lib/view'
-
import * as Status from '#/lexicon/types/xyz/statusphere/status'
-
import * as Profile from '#/lexicon/types/app/bsky/actor/profile'
-
type Session = { did: string }
+
// Max age, in seconds, for static routes and assets
+
const MAX_AGE = env.NODE_ENV === 'production' ? 60 : 0
-
// Helper function for defining routes
-
const handler =
-
(fn: express.Handler) =>
-
async (
-
req: express.Request,
-
res: express.Response,
-
next: express.NextFunction
-
) => {
-
try {
-
await fn(req, res, next)
-
} catch (err) {
-
next(err)
-
}
-
}
+
type Session = { did?: string }
// Helper function to get the Atproto Agent for the active session
async function getSessionAgent(
req: IncomingMessage,
-
res: ServerResponse<IncomingMessage>,
-
ctx: AppContext
+
res: ServerResponse,
+
ctx: AppContext,
) {
+
res.setHeader('Vary', 'Cookie')
+
const session = await getIronSession<Session>(req, res, {
cookieName: 'sid',
password: env.COOKIE_SECRET,
})
if (!session.did) return null
+
+
// This page is dynamic and should not be cached publicly
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, private`)
+
try {
const oauthSession = await ctx.oauthClient.restore(session.did)
return oauthSession ? new Agent(oauthSession) : null
···
}
}
-
export const createRouter = (ctx: AppContext) => {
-
const router = express.Router()
+
export const createRouter = (ctx: AppContext): RequestListener => {
+
const router = express()
// Static assets
-
router.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
+
router.use(
+
'/public',
+
express.static(path.join(__dirname, 'pages', 'public'), {
+
maxAge: MAX_AGE * 1000,
+
}),
+
)
// OAuth metadata
router.get(
-
'/client-metadata.json',
-
handler((_req, res) => {
-
return res.json(ctx.oauthClient.clientMetadata)
-
})
+
'/oauth-client-metadata.json',
+
handler((req, res) => {
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
+
res.json(ctx.oauthClient.clientMetadata)
+
}),
+
)
+
+
// Public keys
+
router.get(
+
'/.well-known/jwks.json',
+
handler((req, res) => {
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
+
res.json(ctx.oauthClient.jwks)
+
}),
)
// OAuth callback to complete session creation
router.get(
'/oauth/callback',
handler(async (req, res) => {
+
res.setHeader('cache-control', 'no-store')
+
const params = new URLSearchParams(req.originalUrl.split('?')[1])
try {
-
const { session } = await ctx.oauthClient.callback(params)
-
const clientSession = await getIronSession<Session>(req, res, {
+
// Load the session cookie
+
const session = await getIronSession<Session>(req, res, {
cookieName: 'sid',
password: env.COOKIE_SECRET,
})
-
assert(!clientSession.did, 'session already exists')
-
clientSession.did = session.did
-
await clientSession.save()
+
+
// If the user is already signed in, destroy the old credentials
+
if (session.did) {
+
try {
+
const oauthSession = await ctx.oauthClient.restore(session.did)
+
if (oauthSession) oauthSession.signOut()
+
} catch (err) {
+
ctx.logger.warn({ err }, 'oauth restore failed')
+
}
+
}
+
+
// Complete the OAuth flow
+
const oauth = await ctx.oauthClient.callback(params)
+
+
// Update the session cookie
+
session.did = oauth.session.did
+
+
await session.save()
} catch (err) {
ctx.logger.error({ err }, 'oauth callback failed')
-
return res.redirect('/?error')
}
+
return res.redirect('/')
-
})
+
}),
)
// Login page
router.get(
'/login',
-
handler(async (_req, res) => {
-
return res.type('html').send(page(login({})))
-
})
+
handler(async (req, res) => {
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
+
res.type('html').send(page(login({})))
+
}),
)
// Login handler
router.post(
'/login',
+
express.urlencoded(),
handler(async (req, res) => {
-
// Validate
-
const handle = req.body?.handle
-
if (typeof handle !== 'string' || !isValidHandle(handle)) {
-
return res.type('html').send(page(login({ error: 'invalid handle' })))
-
}
+
// Never store this route
+
res.setHeader('cache-control', 'no-store')
// Initiate the OAuth flow
try {
-
const url = await ctx.oauthClient.authorize(handle, {
+
// Validate input: can be a handle, a DID or a service URL (PDS).
+
const input = ifString(req.body.input)
+
if (!input) {
+
throw new Error('Invalid input')
+
}
+
+
// Initiate the OAuth flow
+
const url = await ctx.oauthClient.authorize(input, {
scope: 'atproto transition:generic',
})
-
return res.redirect(url.toString())
+
+
res.redirect(url.toString())
} catch (err) {
ctx.logger.error({ err }, 'oauth authorize failed')
-
return res.type('html').send(
+
+
const error = err instanceof Error ? err.message : 'unexpected error'
+
+
return res.type('html').send(page(login({ error })))
+
}
+
}),
+
)
+
+
// Signup
+
router.get(
+
'/signup',
+
handler(async (req, res) => {
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
+
+
try {
+
const service = env.PDS_URL ?? 'https://bsky.social'
+
const url = await ctx.oauthClient.authorize(service, {
+
scope: 'atproto transition:generic',
+
})
+
res.redirect(url.toString())
+
} catch (err) {
+
ctx.logger.error({ err }, 'oauth authorize failed')
+
res.type('html').send(
page(
login({
error:
err instanceof OAuthResolverError
? err.message
: "couldn't initiate login",
-
})
-
)
+
}),
+
),
)
}
-
})
+
}),
)
// Logout handler
router.post(
'/logout',
handler(async (req, res) => {
+
// Never store this route
+
res.setHeader('cache-control', 'no-store')
+
const session = await getIronSession<Session>(req, res, {
cookieName: 'sid',
password: env.COOKIE_SECRET,
})
-
await session.destroy()
+
+
// Revoke credentials on the server
+
if (session.did) {
+
try {
+
const oauthSession = await ctx.oauthClient.restore(session.did)
+
if (oauthSession) await oauthSession.signOut()
+
} catch (err) {
+
ctx.logger.warn({ err }, 'Failed to revoke credentials')
+
}
+
}
+
+
session.destroy()
+
return res.redirect('/')
-
})
+
}),
)
// Homepage
···
// Map user DIDs to their domain-name handles
const didHandleMap = await ctx.resolver.resolveDidsToHandles(
-
statuses.map((s) => s.authorDid)
+
statuses.map((s) => s.authorDid),
)
if (!agent) {
···
}
// Fetch additional information about the logged-in user
-
const profileResponse = await agent.com.atproto.repo.getRecord({
-
repo: agent.assertDid,
-
collection: 'app.bsky.actor.profile',
-
rkey: 'self',
-
}).catch(() => undefined);
+
const profileResponse = await agent.com.atproto.repo
+
.getRecord({
+
repo: agent.assertDid,
+
collection: 'app.bsky.actor.profile',
+
rkey: 'self',
+
})
+
.catch(() => undefined)
-
const profileRecord = profileResponse?.data;
+
const profileRecord = profileResponse?.data
-
const profile = profileRecord &&
+
const profile =
+
profileRecord &&
Profile.isRecord(profileRecord.value) &&
Profile.validateRecord(profileRecord.value).success
? profileRecord.value
: {}
// Serve the logged-in view
-
return res.type('html').send(
-
page(
-
home({
-
statuses,
-
didHandleMap,
-
profile,
-
myStatus,
-
})
-
)
-
)
-
})
+
res
+
.type('html')
+
.send(page(home({ statuses, didHandleMap, profile, myStatus })))
+
}),
)
// "Set status" handler
router.post(
'/status',
+
express.urlencoded(),
handler(async (req, res) => {
// If the user is signed in, get an agent which communicates with their server
const agent = await getSessionAgent(req, res, ctx)
···
.send('<h1>Error: Session required</h1>')
}
-
// Construct & validate their status record
-
const rkey = TID.nextStr()
+
// Construct their status record
const record = {
$type: 'xyz.statusphere.status',
status: req.body?.status,
createdAt: new Date().toISOString(),
}
+
+
// Make sure the record generated from the input is valid
if (!Status.validateRecord(record).success) {
return res
.status(400)
···
const res = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection: 'xyz.statusphere.status',
-
rkey,
+
rkey: TID.nextStr(),
record,
validate: false,
})
···
} catch (err) {
ctx.logger.warn(
{ err },
-
'failed to update computed view; ignoring as it should be caught by the firehose'
+
'failed to update computed view; ignoring as it should be caught by the firehose',
)
}
return res.redirect('/')
-
})
+
}),
)
return router