Scratch space for learning atproto app development

Merge pull request #1 from bluesky-social/divy/oauth-setup

OAuth, sessions, and view setup

+5
.env.template
···
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.
# CORS Settings
CORS_ORIGIN="http://localhost:*" # Allowed CORS origin, adjust as necessary
···
# Rate Limiting
COMMON_RATE_LIMIT_WINDOW_MS="1000" # Window size for rate limiting (ms)
COMMON_RATE_LIMIT_MAX_REQUESTS="20" # Max number of requests per window per IP
+
+
# Secrets
+
# Must set this in production. May be generated with `openssl rand -base64 33`
+
# COOKIE_SECRET=""
+15
README.md
···
+
# AT Protocol Express App
+
+
A demo application covering:
+
- public firehose ingestion
+
- identity and login with OAuth
+
- writing to the network
+
+
## Getting Started
+
### Development
+
```sh
+
pnpm i
+
cp .env.template .env
+
pnpm run dev
+
# Navigate to http://localhost:8080
+
```
+11 -12
package.json
···
"test": "vitest run"
},
"dependencies": {
-
"@atproto/lexicon": "^0.4.0",
-
"@atproto/repo": "^0.4.1",
+
"@atproto/jwk-jose": "0.1.2-rc.0",
+
"@atproto/lexicon": "0.4.1-rc.0",
+
"@atproto/oauth-client-node": "0.0.2-rc.2",
+
"@atproto/repo": "0.4.2-rc.0",
"@atproto/syntax": "^0.3.0",
-
"@atproto/xrpc-server": "^0.5.3",
+
"@atproto/xrpc-server": "0.5.4-rc.0",
"better-sqlite3": "^11.1.2",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
···
"express-rate-limit": "^7.2.0",
"helmet": "^7.1.0",
"http-status-codes": "^2.3.0",
+
"iron-session": "^8.0.2",
"kysely": "^0.27.4",
"multiformats": "^9.9.0",
"pino": "^9.3.2",
-
"pino-http": "^10.0.0"
+
"pino-http": "^10.0.0",
+
"uhtml": "^4.5.9"
},
"devDependencies": {
"@atproto/lex-cli": "^0.4.1",
···
"pino-pretty": "^11.0.0",
"rimraf": "^5.0.0",
"supertest": "^7.0.0",
+
"ts-node": "^10.9.2",
"tsup": "^8.0.2",
"tsx": "^4.7.2",
"typescript": "^5.4.4",
···
"vitest": "^2.0.0"
},
"lint-staged": {
-
"*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": [
-
"biome check --apply --no-errors-on-unmatched"
-
]
+
"*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": ["biome check --apply --no-errors-on-unmatched"]
},
"tsup": {
-
"entry": [
-
"src",
-
"!src/**/__tests__/**",
-
"!src/**/*.test.*"
-
],
+
"entry": ["src", "!src/**/__tests__/**", "!src/**/*.test.*"],
"splitting": false,
"sourcemap": true,
"clean": true
+435 -24
pnpm-lock.yaml
···
excludeLinksFromLockfile: false
dependencies:
+
'@atproto/jwk-jose':
+
specifier: 0.1.2-rc.0
+
version: 0.1.2-rc.0
'@atproto/lexicon':
-
specifier: ^0.4.0
-
version: 0.4.0
+
specifier: 0.4.1-rc.0
+
version: 0.4.1-rc.0
+
'@atproto/oauth-client-node':
+
specifier: 0.0.2-rc.2
+
version: 0.0.2-rc.2
'@atproto/repo':
-
specifier: ^0.4.1
-
version: 0.4.1
+
specifier: 0.4.2-rc.0
+
version: 0.4.2-rc.0
'@atproto/syntax':
specifier: ^0.3.0
version: 0.3.0
'@atproto/xrpc-server':
-
specifier: ^0.5.3
-
version: 0.5.3
+
specifier: 0.5.4-rc.0
+
version: 0.5.4-rc.0
better-sqlite3:
specifier: ^11.1.2
version: 11.1.2
···
http-status-codes:
specifier: ^2.3.0
version: 2.3.0
+
iron-session:
+
specifier: ^8.0.2
+
version: 8.0.2
kysely:
specifier: ^0.27.4
version: 0.27.4
···
pino-http:
specifier: ^10.0.0
version: 10.2.0
+
uhtml:
+
specifier: ^4.5.9
+
version: 4.5.9
devDependencies:
'@atproto/lex-cli':
···
supertest:
specifier: ^7.0.0
version: 7.0.0
+
ts-node:
+
specifier: ^10.9.2
+
version: 10.9.2(@types/node@22.1.0)(typescript@5.5.4)
tsup:
specifier: ^8.0.2
version: 8.2.4(tsx@4.16.5)(typescript@5.5.4)
···
version: 4.3.2(typescript@5.5.4)
vitest:
specifier: ^2.0.0
-
version: 2.0.5
+
version: 2.0.5(@types/node@22.1.0)
packages:
···
'@jridgewell/trace-mapping': 0.3.25
dev: true
+
/@atproto-labs/did-resolver@0.1.2-rc.0:
+
resolution: {integrity: sha512-5lVxhLG9P1G1XjGXQr7fhk6mBM5vpbCalrfuVXqU5xQADvObLjEtpxpJuLheAacaV2pUMFDml+53ZLYWXCgFIg==}
+
dependencies:
+
'@atproto-labs/fetch': 0.1.0
+
'@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.1-rc.0
+
zod: 3.23.8
+
dev: false
+
+
/@atproto-labs/fetch-node@0.1.0:
+
resolution: {integrity: sha512-DUHgaGw8LBqiGg51pUDuWK/alMcmNbpcK7ALzlF2Gw//TNLTsgrj0qY9aEtK+np9rEC+x/o3bN4SGnuQEpgqIg==}
+
dependencies:
+
'@atproto-labs/fetch': 0.1.0
+
'@atproto-labs/pipe': 0.1.0
+
ipaddr.js: 2.2.0
+
psl: 1.9.0
+
undici: 6.19.5
+
dev: false
+
+
/@atproto-labs/fetch@0.1.0:
+
resolution: {integrity: sha512-uirja+uA/C4HNk7vayM+AJqsccxQn2wVziUHxbsjJGt/K6Q8ZOKDaEX2+GrcXvpUVcqUKh+94JFjuzH+CAEUlg==}
+
dependencies:
+
'@atproto-labs/pipe': 0.1.0
+
optionalDependencies:
+
zod: 3.23.8
+
dev: false
+
+
/@atproto-labs/handle-resolver-node@0.1.2-rc.0:
+
resolution: {integrity: sha512-wP1c0fqxdhnIQVxFgD3Z6fiToq1ri9ECTCSPoy/1zbNJ+KWrr0V6BSONF/I5MytEbQaICBh8bvZuurvX0OjbNw==}
+
dependencies:
+
'@atproto-labs/fetch-node': 0.1.0
+
'@atproto-labs/handle-resolver': 0.1.2-rc.0
+
'@atproto/did': 0.1.1-rc.0
+
dev: false
+
+
/@atproto-labs/handle-resolver@0.1.2-rc.0:
+
resolution: {integrity: sha512-sxk/Zr1hWyBBcg1HhZ8N/Tw1Iue/6+V6bzu2c8zYhO9VfKgCBp3FFU1/i3MpgR2AlsEqZpcjv6zj4KAnMHiLUg==}
+
dependencies:
+
'@atproto-labs/simple-store': 0.1.1
+
'@atproto-labs/simple-store-memory': 0.1.1
+
'@atproto/did': 0.1.1-rc.0
+
zod: 3.23.8
+
dev: false
+
+
/@atproto-labs/identity-resolver@0.1.2-rc.0:
+
resolution: {integrity: sha512-4TLjNRbufeGduac3c/No4teJ411qNgyBQck7eY5e2K8XrzS2a/xX/bq3JP91DrvERHiP3yE22PB6ATQkuALgXA==}
+
dependencies:
+
'@atproto-labs/did-resolver': 0.1.2-rc.0
+
'@atproto-labs/handle-resolver': 0.1.2-rc.0
+
'@atproto/syntax': 0.3.0
+
dev: false
+
+
/@atproto-labs/pipe@0.1.0:
+
resolution: {integrity: sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==}
+
dev: false
+
+
/@atproto-labs/simple-store-memory@0.1.1:
+
resolution: {integrity: sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==}
+
dependencies:
+
'@atproto-labs/simple-store': 0.1.1
+
lru-cache: 10.4.3
+
dev: false
+
+
/@atproto-labs/simple-store@0.1.1:
+
resolution: {integrity: sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==}
+
dev: false
+
+
/@atproto/api@0.13.0-rc.1:
+
resolution: {integrity: sha512-h2+M6OoMLnNzqf2KDxsbRkg3/1k2IMWH33PQI31GkiQHIdt3B+MIXvJwXePu0KnMUL/Lvv2Zk01BKiDnjd4LEw==}
+
dependencies:
+
'@atproto/common-web': 0.3.0
+
'@atproto/lexicon': 0.4.1-rc.0
+
'@atproto/syntax': 0.3.0
+
'@atproto/xrpc': 0.6.0-rc.0
+
await-lock: 2.2.2
+
multiformats: 9.9.0
+
tlds: 1.254.0
+
dev: false
+
/@atproto/common-web@0.3.0:
resolution: {integrity: sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==}
dependencies:
···
uint8arrays: 3.0.0
dev: false
+
/@atproto/did@0.1.1-rc.0:
+
resolution: {integrity: sha512-rbO6kQv/bKsMGqAqr1M4o7cmJf893gYzabr1CmJ0rr/FNdXHfr0b9s2lRphA6zCS0wPdT4/mw6/LWiCrnBmi9w==}
+
dependencies:
+
zod: 3.23.8
+
dev: false
+
+
/@atproto/jwk-jose@0.1.2-rc.0:
+
resolution: {integrity: sha512-guqGhgQjOx6OxxDWBENRa30G3CJ91Rqw+5NEwiv4GfhmmM/szS983kZIydmXpySpyyZhGAPZfkOfHai+HrLsXg==}
+
dependencies:
+
'@atproto/jwk': 0.1.1
+
jose: 5.6.3
+
dev: false
+
+
/@atproto/jwk-webcrypto@0.1.2-rc.0:
+
resolution: {integrity: sha512-TlLaJulKDWDhXQ8Wujte4l2RPe/Ym+jAnFR/+lwZbcGQHAUsatBMCKzvYVv3TtqXL3B5gIC9ry12+C7oQ5yE/Q==}
+
dependencies:
+
'@atproto/jwk': 0.1.1
+
'@atproto/jwk-jose': 0.1.2-rc.0
+
dev: false
+
+
/@atproto/jwk@0.1.1:
+
resolution: {integrity: sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==}
+
dependencies:
+
multiformats: 9.9.0
+
zod: 3.23.8
+
dev: false
+
/@atproto/lex-cli@0.4.1:
resolution: {integrity: sha512-QP9mE8MYzXR2ydhCBb/mtGqKZjqpffqcpZCr7JM4mFOZPvXV8k7OqVP1h+T94JB/tGcGPhB750S6tqUH9VRLVg==}
hasBin: true
···
iso-datestring-validator: 2.2.2
multiformats: 9.9.0
zod: 3.23.8
+
dev: true
-
/@atproto/repo@0.4.1:
-
resolution: {integrity: sha512-DXv/cBwRcAM0KFb4SwafcQBONd0g31QUNLfjTri1bg5adCbX3bxxE4fCPpQM9Qc3+5lcCkTL/EniHW1j3UQjVA==}
+
/@atproto/lexicon@0.4.1-rc.0:
+
resolution: {integrity: sha512-CSYO8MWbxTXTLQMEJ1mTXD2pDxIXO2oCK/FVw9T/BeXLMcvwmeVgKAaytd1AGFkapX8IMAAtjBB3cnaltuHwbg==}
+
dependencies:
+
'@atproto/common-web': 0.3.0
+
'@atproto/syntax': 0.3.0
+
iso-datestring-validator: 2.2.2
+
multiformats: 9.9.0
+
zod: 3.23.8
+
dev: false
+
+
/@atproto/oauth-client-node@0.0.2-rc.2:
+
resolution: {integrity: sha512-MxR2C84h6XjTB28RpXfctKLvB6Ot68tiOlsOSigeSTKnNJ5SRD2wISz2647P8dxOec81ugMu8wa5BKcZ5Ry7nw==}
+
dependencies:
+
'@atproto-labs/did-resolver': 0.1.2-rc.0
+
'@atproto-labs/handle-resolver-node': 0.1.2-rc.0
+
'@atproto-labs/simple-store': 0.1.1
+
'@atproto/did': 0.1.1-rc.0
+
'@atproto/jwk': 0.1.1
+
'@atproto/jwk-jose': 0.1.2-rc.0
+
'@atproto/jwk-webcrypto': 0.1.2-rc.0
+
'@atproto/oauth-client': 0.1.2-rc.2
+
'@atproto/oauth-types': 0.1.2-rc.0
+
dev: false
+
+
/@atproto/oauth-client@0.1.2-rc.2:
+
resolution: {integrity: sha512-FBYyEKEU1BFoW1ASFzsmw1oOpVPj/nkoR753OZItgNwl9i+Tr4kAA9TqeXGa6Ol3dh7K67oaxHw7DChdEqbtSg==}
+
dependencies:
+
'@atproto-labs/did-resolver': 0.1.2-rc.0
+
'@atproto-labs/fetch': 0.1.0
+
'@atproto-labs/handle-resolver': 0.1.2-rc.0
+
'@atproto-labs/identity-resolver': 0.1.2-rc.0
+
'@atproto-labs/simple-store': 0.1.1
+
'@atproto-labs/simple-store-memory': 0.1.1
+
'@atproto/api': 0.13.0-rc.1
+
'@atproto/did': 0.1.1-rc.0
+
'@atproto/jwk': 0.1.1
+
'@atproto/oauth-types': 0.1.2-rc.0
+
'@atproto/xrpc': 0.6.0-rc.0
+
multiformats: 9.9.0
+
zod: 3.23.8
+
dev: false
+
+
/@atproto/oauth-types@0.1.2-rc.0:
+
resolution: {integrity: sha512-q/AxPSdLf2xTgC4K1cU35HVl6T4T0LJ/QJmvqXwjpbiNWEqooIQIP9sTp2CqqSLsWpe26z3fIoA3R+oTR1EJsA==}
+
dependencies:
+
'@atproto/jwk': 0.1.1
+
zod: 3.23.8
+
dev: false
+
+
/@atproto/repo@0.4.2-rc.0:
+
resolution: {integrity: sha512-y8zXAR23r6qlsTmbzXaBEHYjvlgeNlAKj9eJ6V17JtT+4FVdW246alhsgSsglJ2Uv/e24RC1r90yNJNRxqDzXw==}
dependencies:
'@atproto/common': 0.4.1
'@atproto/common-web': 0.3.0
'@atproto/crypto': 0.4.0
-
'@atproto/lexicon': 0.4.0
+
'@atproto/lexicon': 0.4.1-rc.0
'@ipld/car': 3.2.4
'@ipld/dag-cbor': 7.0.3
multiformats: 9.9.0
···
/@atproto/syntax@0.3.0:
resolution: {integrity: sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==}
-
/@atproto/xrpc-server@0.5.3:
-
resolution: {integrity: sha512-Gxe5dPDp7mj7E1JaK0yEwGuWot78/HjszHYakqleKp+IXlM+iZxH0N20O+x7b3g7itImuQ2LzH3Zk1jLB0yZjQ==}
+
/@atproto/xrpc-server@0.5.4-rc.0:
+
resolution: {integrity: sha512-Vrx1gEoZfJtYoZhSxkbWQsU2r0DuJO/BuvMQGw9Nd66owmF5nPDVvYVd0pJhIDoaSxImTTIEeDWlNNl3WCSBPA==}
dependencies:
'@atproto/common': 0.4.1
'@atproto/crypto': 0.4.0
-
'@atproto/lexicon': 0.4.0
-
'@atproto/xrpc': 0.5.0
+
'@atproto/lexicon': 0.4.1-rc.0
+
'@atproto/xrpc': 0.6.0-rc.0
cbor-x: 1.6.0
express: 4.19.2
http-errors: 2.0.0
···
- utf-8-validate
dev: false
-
/@atproto/xrpc@0.5.0:
-
resolution: {integrity: sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==}
+
/@atproto/xrpc@0.6.0-rc.0:
+
resolution: {integrity: sha512-TOmynXvbA57Y6KR050UeiDfdzQoAnmgB0zu0qrvhYiu7oeg64fYzvOa7stWxSIP1nhrGqgexxICR1CnOnCEHjg==}
dependencies:
-
'@atproto/lexicon': 0.4.0
+
'@atproto/lexicon': 0.4.1-rc.0
zod: 3.23.8
dev: false
···
requiresBuild: true
dev: false
optional: true
+
+
/@cspotcode/source-map-support@0.8.1:
+
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
+
engines: {node: '>=12'}
+
dependencies:
+
'@jridgewell/trace-mapping': 0.3.9
+
dev: true
/@esbuild/aix-ppc64@0.21.5:
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
···
'@jridgewell/sourcemap-codec': 1.5.0
dev: true
+
/@jridgewell/trace-mapping@0.3.9:
+
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+
dependencies:
+
'@jridgewell/resolve-uri': 3.1.2
+
'@jridgewell/sourcemap-codec': 1.5.0
+
dev: true
+
/@noble/curves@1.4.2:
resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==}
dependencies:
···
engines: {node: '>=14'}
requiresBuild: true
dev: true
+
optional: true
+
+
/@preact/signals-core@1.8.0:
+
resolution: {integrity: sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==}
+
requiresBuild: true
+
dev: false
optional: true
/@rollup/rollup-android-arm-eabi@4.20.0:
···
path-browserify: 1.0.1
dev: true
+
/@tsconfig/node10@1.0.11:
+
resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==}
+
dev: true
+
+
/@tsconfig/node12@1.0.11:
+
resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
+
dev: true
+
+
/@tsconfig/node14@1.0.3:
+
resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
+
dev: true
+
+
/@tsconfig/node16@1.0.4:
+
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
+
dev: true
+
/@types/better-sqlite3@7.6.11:
resolution: {integrity: sha512-i8KcD3PgGtGBLl3+mMYA8PdKkButvPyARxA7IQAd6qeslht13qxb1zzO8dRCtE7U3IoJS782zDBAeoKiM695kg==}
dependencies:
···
tinyrainbow: 1.2.0
dev: true
+
/@webreflection/signal@2.1.2:
+
resolution: {integrity: sha512-0dW0fstQQkIt588JwhDiPS4xgeeQcQnBHn6MVInrBzmFlnLtzoSJL9G7JqdAlZVVi19tfb8R1QisZIT31cgiug==}
+
requiresBuild: true
+
dev: false
+
optional: true
+
+
/@webreflection/uparser@0.3.3:
+
resolution: {integrity: sha512-XxGfo8jr2eVuvP5lrmwjgMAM7QjtZ0ngFD+dd9Fd3GStcEb4QhLlTiqZYF5O3l5k4sU/V6ZiPrVCzCWXWFEmCw==}
+
dependencies:
+
domconstants: 1.1.6
+
dev: false
+
/abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
···
negotiator: 0.6.3
dev: false
+
/acorn-walk@8.3.3:
+
resolution: {integrity: sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==}
+
engines: {node: '>=0.4.0'}
+
dependencies:
+
acorn: 8.12.1
+
dev: true
+
+
/acorn@8.12.1:
+
resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==}
+
engines: {node: '>=0.4.0'}
+
hasBin: true
+
dev: true
+
/ansi-escapes@7.0.0:
resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==}
engines: {node: '>=18'}
···
picomatch: 2.3.1
dev: true
+
/arg@4.1.3:
+
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
+
dev: true
+
/array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
dev: false
···
/atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
+
+
/await-lock@2.2.2:
+
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
+
dev: false
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
···
vary: 1.1.2
dev: false
+
/create-require@1.1.1:
+
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
+
dev: true
+
/cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
···
shebang-command: 2.0.0
which: 2.0.2
dev: true
+
+
/custom-function@1.0.6:
+
resolution: {integrity: sha512-styyvwOki/EYr+VBe7/m9xAjq6uKx87SpDKIpFRdTQnofBDSZpBEFc9qJLmaJihjjTeEpAIJ+nz+9fUXj+BPNQ==}
+
dev: false
/dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
···
wrappy: 1.0.2
dev: true
+
/diff@4.0.2:
+
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
+
engines: {node: '>=0.3.1'}
+
dev: true
+
/dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
···
path-type: 4.0.0
dev: true
+
/dom-serializer@2.0.0:
+
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
dependencies:
+
domelementtype: 2.3.0
+
domhandler: 5.0.3
+
entities: 4.5.0
+
dev: false
+
+
/domconstants@1.1.6:
+
resolution: {integrity: sha512-CuaDrThJ4VM+LyZ4ax8n52k0KbLJZtffyGkuj1WhpTRRcSfcy/9DfOBa68jenhX96oNUTunblSJEUNC4baFdmQ==}
+
dev: false
+
+
/domelementtype@2.3.0:
+
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
dev: false
+
+
/domhandler@5.0.3:
+
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+
engines: {node: '>= 4'}
+
dependencies:
+
domelementtype: 2.3.0
+
dev: false
+
+
/domutils@3.1.0:
+
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
+
dependencies:
+
dom-serializer: 2.0.0
+
domelementtype: 2.3.0
+
domhandler: 5.0.3
+
dev: false
+
/dotenv@16.4.5:
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
engines: {node: '>=12'}
···
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
dependencies:
once: 1.4.0
+
+
/entities@4.5.0:
+
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+
engines: {node: '>=0.12'}
+
dev: false
/envalid@8.0.0:
resolution: {integrity: sha512-PGeYJnJB5naN0ME6SH8nFcDj9HVbLpYIfg1p5lAyM9T4cH2lwtu2fLbozC/bq+HUUOIFxhX/LP0/GmlqPHT4tQ==}
···
/function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
/gc-hook@0.3.1:
+
resolution: {integrity: sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==}
+
dev: false
+
/get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
···
engines: {node: '>=8'}
dev: true
+
/html-escaper@3.0.3:
+
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
+
dev: false
+
+
/htmlparser2@9.1.0:
+
resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==}
+
dependencies:
+
domelementtype: 2.3.0
+
domhandler: 5.0.3
+
domutils: 3.1.0
+
entities: 4.5.0
+
dev: false
+
/http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
···
engines: {node: '>= 0.10'}
dev: false
+
/ipaddr.js@2.2.0:
+
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
+
engines: {node: '>= 10'}
+
dev: false
+
+
/iron-session@8.0.2:
+
resolution: {integrity: sha512-p4Yf1moQr6gnCcXu5vCaxVKRKDmR9PZcQDfp7ZOgbsSHUsgaNti6OgDB2BdgxC2aS6V/6Hu4O0wYlj92sbdIJg==}
+
dependencies:
+
cookie: 0.6.0
+
iron-webcrypto: 1.2.1
+
uncrypto: 0.1.3
+
dev: false
+
+
/iron-webcrypto@1.2.1:
+
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
+
dev: false
+
/is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
···
'@pkgjs/parseargs': 0.11.0
dev: true
+
/jose@5.6.3:
+
resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==}
+
dev: false
+
/joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
···
/lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
-
dev: true
/magic-string@0.30.11:
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
···
'@jridgewell/sourcemap-codec': 1.5.0
dev: true
+
/make-error@1.3.6:
+
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
+
dev: true
+
/media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
···
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+
dev: false
+
+
/psl@1.9.0:
+
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
dev: false
/pump@3.0.0:
···
engines: {node: '>=14.0.0'}
dev: true
+
/tlds@1.254.0:
+
resolution: {integrity: sha512-YY4ei7K7gPGifqNSrfMaPdqTqiHcwYKUJ7zhLqQOK2ildlGgti5TSwJiXXN1YqG17I2GYZh5cZqv2r5fwBUM+w==}
+
hasBin: true
+
dev: false
+
/to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
···
code-block-writer: 11.0.3
dev: true
+
/ts-node@10.9.2(@types/node@22.1.0)(typescript@5.5.4):
+
resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
+
hasBin: true
+
peerDependencies:
+
'@swc/core': '>=1.2.50'
+
'@swc/wasm': '>=1.2.50'
+
'@types/node': '*'
+
typescript: '>=2.7'
+
peerDependenciesMeta:
+
'@swc/core':
+
optional: true
+
'@swc/wasm':
+
optional: true
+
dependencies:
+
'@cspotcode/source-map-support': 0.8.1
+
'@tsconfig/node10': 1.0.11
+
'@tsconfig/node12': 1.0.11
+
'@tsconfig/node14': 1.0.3
+
'@tsconfig/node16': 1.0.4
+
'@types/node': 22.1.0
+
acorn: 8.12.1
+
acorn-walk: 8.3.3
+
arg: 4.1.3
+
create-require: 1.1.1
+
diff: 4.0.2
+
make-error: 1.3.6
+
typescript: 5.5.4
+
v8-compile-cache-lib: 3.0.1
+
yn: 3.1.1
+
dev: true
+
/tsconfck@3.1.1(typescript@5.5.4):
resolution: {integrity: sha512-00eoI6WY57SvZEVjm13stEVE90VkEdJAFGgpFLTsZbJyW/LwFQ7uQxJHWpZ2hzSWgCPKc9AnBnNP+0X7o3hAmQ==}
engines: {node: ^18 || >=20}
···
hasBin: true
dev: true
+
/udomdiff@1.1.0:
+
resolution: {integrity: sha512-aqjTs5x/wsShZBkVagdafJkP8S3UMGhkHKszsu1cszjjZ7iOp86+Qb3QOFYh01oWjPMy5ZTuxD6hw5uTKxd+VA==}
+
dev: false
+
+
/uhtml@4.5.9:
+
resolution: {integrity: sha512-WAfIK/E3ZJpaFl0MSzGSB54r7I8Vc8ZyUlOsN8GnLnEaxuioOUyKAS6q/N/xQ5GD9vFFBnx6q+3N3Eq9KNCvTQ==}
+
dependencies:
+
'@webreflection/uparser': 0.3.3
+
custom-function: 1.0.6
+
domconstants: 1.1.6
+
gc-hook: 0.3.1
+
html-escaper: 3.0.3
+
htmlparser2: 9.1.0
+
udomdiff: 1.1.0
+
optionalDependencies:
+
'@preact/signals-core': 1.8.0
+
'@webreflection/signal': 2.1.2
+
dev: false
+
/uint8arrays@3.0.0:
resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==}
dependencies:
multiformats: 9.9.0
+
+
/uncrypto@0.1.3:
+
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+
dev: false
/undici-types@6.13.0:
resolution: {integrity: sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==}
dev: true
+
/undici@6.19.5:
+
resolution: {integrity: sha512-LryC15SWzqQsREHIOUybavaIHF5IoL0dJ9aWWxL/PgT1KfqAW5225FZpDUFlt9xiDMS2/S7DOKhFWA7RLksWdg==}
+
engines: {node: '>=18.17'}
+
dev: false
+
/unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
···
engines: {node: '>= 0.4.0'}
dev: false
+
/v8-compile-cache-lib@3.0.1:
+
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
+
dev: true
+
/varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
dev: false
···
engines: {node: '>= 0.8'}
dev: false
-
/vite-node@2.0.5:
+
/vite-node@2.0.5(@types/node@22.1.0):
resolution: {integrity: sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
···
debug: 4.3.6
pathe: 1.1.2
tinyrainbow: 1.2.0
-
vite: 5.3.5
+
vite: 5.3.5(@types/node@22.1.0)
transitivePeerDependencies:
- '@types/node'
- less
···
- typescript
dev: true
-
/vite@5.3.5:
+
/vite@5.3.5(@types/node@22.1.0):
resolution: {integrity: sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
···
terser:
optional: true
dependencies:
+
'@types/node': 22.1.0
esbuild: 0.21.5
postcss: 8.4.41
rollup: 4.20.0
···
fsevents: 2.3.3
dev: true
-
/vitest@2.0.5:
+
/vitest@2.0.5(@types/node@22.1.0):
resolution: {integrity: sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
···
optional: true
dependencies:
'@ampproject/remapping': 2.3.0
+
'@types/node': 22.1.0
'@vitest/expect': 2.0.5
'@vitest/pretty-format': 2.0.5
'@vitest/runner': 2.0.5
···
tinybench: 2.9.0
tinypool: 1.0.0
tinyrainbow: 1.2.0
-
vite: 5.3.5
-
vite-node: 2.0.5
+
vite: 5.3.5(@types/node@22.1.0)
+
vite-node: 2.0.5(@types/node@22.1.0)
why-is-node-running: 2.3.0
transitivePeerDependencies:
- less
···
/yesno@0.4.0:
resolution: {integrity: sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==}
+
dev: true
+
+
/yn@3.1.1:
+
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
+
engines: {node: '>=6'}
dev: true
/zod@3.23.8:
+28
src/auth/client.ts
···
+
import { JoseKey } from '@atproto/jwk-jose'
+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
import type { Database } from '#/db'
+
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}`
+
return new NodeOAuthClient({
+
clientMetadata: {
+
client_name: 'AT Protocol Express App',
+
client_id: publicUrl
+
? `${url}/client-metadata.json`
+
: `http://localhost?redirect_uri=${encodeURIComponent(`${url}/oauth/callback`)}`,
+
client_uri: url,
+
redirect_uris: [`${url}/oauth/callback`],
+
scope: 'profile offline_access',
+
grant_types: ['authorization_code', 'refresh_token'],
+
response_types: ['code'],
+
application_type: 'web',
+
token_endpoint_auth_method: 'none',
+
dpop_bound_access_tokens: true,
+
},
+
stateStore: new StateStore(db),
+
sessionStore: new SessionStore(db),
+
})
+
}
+35
src/auth/session.ts
···
+
'use server'
+
+
import assert from 'node:assert'
+
import type { IncomingMessage, ServerResponse } from 'node:http'
+
import { getIronSession } from 'iron-session'
+
import { env } from '#/env'
+
+
export type Session = { did: string }
+
+
export async function createSession(req: IncomingMessage, res: ServerResponse<IncomingMessage>, did: string) {
+
const session = await getSessionRaw(req, res)
+
assert(!session.did, 'session already exists')
+
session.did = did
+
await session.save()
+
return { did: session.did }
+
}
+
+
export async function destroySession(req: IncomingMessage, res: ServerResponse<IncomingMessage>) {
+
const session = await getSessionRaw(req, res)
+
await session.destroy()
+
return null
+
}
+
+
export async function getSession(req: IncomingMessage, res: ServerResponse<IncomingMessage>) {
+
const session = await getSessionRaw(req, res)
+
if (!session.did) return null
+
return { did: session.did }
+
}
+
+
async function getSessionRaw(req: IncomingMessage, res: ServerResponse<IncomingMessage>) {
+
return await getIronSession<Session>(req, res, {
+
cookieName: 'sid',
+
password: env.COOKIE_SECRET,
+
})
+
}
+47
src/auth/storage.ts
···
+
import type {
+
NodeSavedSession,
+
NodeSavedSessionStore,
+
NodeSavedState,
+
NodeSavedStateStore,
+
} from '@atproto/oauth-client-node'
+
import type { Database } from '#/db'
+
+
export class StateStore implements NodeSavedStateStore {
+
constructor(private db: Database) {}
+
async get(key: string): Promise<NodeSavedState | undefined> {
+
const result = await this.db.selectFrom('auth_state').selectAll().where('key', '=', key).executeTakeFirst()
+
if (!result) return
+
return JSON.parse(result.state) as NodeSavedState
+
}
+
async set(key: string, val: NodeSavedState) {
+
const state = JSON.stringify(val)
+
await this.db
+
.insertInto('auth_state')
+
.values({ key, state })
+
.onConflict((oc) => oc.doUpdateSet({ state }))
+
.execute()
+
}
+
async del(key: string) {
+
await this.db.deleteFrom('auth_state').where('key', '=', key).execute()
+
}
+
}
+
+
export class SessionStore implements NodeSavedSessionStore {
+
constructor(private db: Database) {}
+
async get(key: string): Promise<NodeSavedSession | undefined> {
+
const result = await this.db.selectFrom('auth_session').selectAll().where('key', '=', key).executeTakeFirst()
+
if (!result) return
+
return JSON.parse(result.session) as NodeSavedSession
+
}
+
async set(key: string, val: NodeSavedSession) {
+
const session = JSON.stringify(val)
+
await this.db
+
.insertInto('auth_session')
+
.values({ key, session })
+
.onConflict((oc) => oc.doUpdateSet({ session }))
+
.execute()
+
}
+
async del(key: string) {
+
await this.db.deleteFrom('auth_session').where('key', '=', key).execute()
+
}
+
}
+2
src/config.ts
···
+
import type { OAuthClient } from '@atproto/oauth-client-node'
import type pino from 'pino'
import type { Database } from '#/db'
import type { Ingester } from '#/firehose/ingester'
···
db: Database
ingester: Ingester
logger: pino.Logger
+
oauthClient: OAuthClient
}
+12
src/db/migrations.ts
···
.addColumn('text', 'varchar', (col) => col.notNull())
.addColumn('indexedAt', 'varchar', (col) => col.notNull())
.execute()
+
await db.schema
+
.createTable('auth_session')
+
.addColumn('key', 'varchar', (col) => col.primaryKey())
+
.addColumn('session', 'varchar', (col) => col.notNull())
+
.execute()
+
await db.schema
+
.createTable('auth_state')
+
.addColumn('key', 'varchar', (col) => col.primaryKey())
+
.addColumn('state', 'varchar', (col) => col.notNull())
+
.execute()
},
async down(db: Kysely<unknown>) {
+
await db.schema.dropTable('auth_state').execute()
+
await db.schema.dropTable('auth_session').execute()
await db.schema.dropTable('post').execute()
},
}
+16
src/db/schema.ts
···
export type DatabaseSchema = {
post: Post
+
auth_session: AuthSession
+
auth_state: AuthState
}
export type Post = {
···
text: string
indexedAt: string
}
+
+
export type AuthSession = {
+
key: string
+
session: AuthSessionJson
+
}
+
+
export type AuthState = {
+
key: string
+
state: AuthStateJson
+
}
+
+
type AuthStateJson = string
+
+
type AuthSessionJson = string
+2
src/env.ts
···
NODE_ENV: str({ devDefault: testOnly('test'), choices: ['development', 'production', 'test'] }),
HOST: host({ devDefault: testOnly('localhost') }),
PORT: port({ devDefault: testOnly(3000) }),
+
PUBLIC_URL: str({}),
+
COOKIE_SECRET: str({ devDefault: '00000000000000000000000000000000' }),
CORS_ORIGIN: str({ devDefault: testOnly('http://localhost:3000') }),
COMMON_RATE_LIMIT_MAX_REQUESTS: num({ devDefault: testOnly(1000) }),
COMMON_RATE_LIMIT_WINDOW_MS: num({ devDefault: testOnly(1000) }),
+1
src/firehose/ingester.ts
···
text: post.text as string,
indexedAt: new Date().toISOString(),
})
+
.onConflict((oc) => oc.doNothing())
.execute()
}
}
+46
src/pages/home.ts
···
+
import { AtUri } from '@atproto/syntax'
+
import type { Post } from '#/db/schema'
+
import { html } from '../view'
+
import { shell } from './shell'
+
+
type Props = { posts: Post[]; profile?: { displayName?: string; handle: string } }
+
+
export function home(props: Props) {
+
return shell({
+
title: 'Home',
+
content: content(props),
+
})
+
}
+
+
function content({ posts, profile }: Props) {
+
return html`<div id="root">
+
<h1>Welcome to the Atmosphere</h1>
+
${
+
profile
+
? html`<form action="/logout" method="post">
+
<p>
+
Hi, <b>${profile.displayName || profile.handle}</b>. It's pretty special here.
+
<button type="submit">Log out.</button>
+
</p>
+
</form>`
+
: html`<p>
+
It's pretty special here.
+
<a href="/login">Log in.</a>
+
</p>`
+
}
+
<ul>
+
${posts.map((post) => {
+
return html`<li>
+
<a href="${toBskyLink(post.uri)}" target="_blank">🔗</a>
+
${post.text}
+
</li>`
+
})}
+
</ul>
+
<a href="/">Give me more</a>
+
</div>`
+
}
+
+
function toBskyLink(uriStr: string) {
+
const uri = new AtUri(uriStr)
+
return `https://bsky.app/profile/${uri.host}/post/${uri.rkey}`
+
}
+21
src/pages/login.ts
···
+
import { html } from '../view'
+
import { shell } from './shell'
+
+
type Props = { error?: string }
+
+
export function login(props: Props) {
+
return shell({
+
title: 'Log in',
+
content: content(props),
+
})
+
}
+
+
function content({ error }: Props) {
+
return html`<div id="root">
+
<form action="/login" method="post">
+
<input type="text" name="handle" placeholder="handle" required />
+
<button type="submit">Log in.</button>
+
${error ? html`<p>Error: <i>${error}</i></p>` : undefined}
+
</form>
+
</div>`
+
}
+13
src/pages/shell.ts
···
+
import { type Hole, html } from '../view'
+
+
export function shell({ title, content }: { title: string; content: Hole }) {
+
return html`<html>
+
<head>
+
<title>${title}</title>
+
<link rel="stylesheet" href="/public/styles.css">
+
</head>
+
<body>
+
${content}
+
</body>
+
</html>`
+
}
+35
src/public/styles.css
···
+
body {
+
font-family: Arial, Helvetica, sans-serif;
+
}
+
+
#root {
+
padding: 20px;
+
}
+
+
/*
+
Josh's Custom CSS Reset
+
https://www.joshwcomeau.com/css/custom-css-reset/
+
*/
+
*, *::before, *::after {
+
box-sizing: border-box;
+
}
+
* {
+
margin: 0;
+
}
+
body {
+
line-height: 1.5;
+
-webkit-font-smoothing: antialiased;
+
}
+
img, picture, video, canvas, svg {
+
display: block;
+
max-width: 100%;
+
}
+
input, button, textarea, select {
+
font: inherit;
+
}
+
p, h1, h2, h3, h4, h5, h6 {
+
overflow-wrap: break-word;
+
}
+
#root, #__next {
+
isolation: isolate;
+
}
+82 -2
src/routes/index.ts
···
+
import path from 'node:path'
+
import { OAuthResolverError } from '@atproto/oauth-client-node'
+
import { isValidHandle } from '@atproto/syntax'
import express from 'express'
+
import { createSession, destroySession, getSession } from '#/auth/session'
import type { AppContext } from '#/config'
+
import { home } from '#/pages/home'
+
import { login } from '#/pages/login'
+
import { page } from '#/view'
import { handler } from './util'
export const createRouter = (ctx: AppContext) => {
const router = express.Router()
+
+
router.use('/public', express.static(path.join(__dirname, '..', 'public')))
+
+
router.get(
+
'/client-metadata.json',
+
handler((_req, res) => {
+
return res.json(ctx.oauthClient.clientMetadata)
+
}),
+
)
+
+
router.get(
+
'/oauth/callback',
+
handler(async (req, res) => {
+
const params = new URLSearchParams(req.originalUrl.split('?')[1])
+
try {
+
const { agent } = await ctx.oauthClient.callback(params)
+
await createSession(req, res, agent.accountDid)
+
} catch (err) {
+
ctx.logger.error({ err }, 'oauth callback failed')
+
return res.redirect('/?error')
+
}
+
return res.redirect('/')
+
}),
+
)
+
+
router.get(
+
'/login',
+
handler(async (_req, res) => {
+
return res.type('html').send(page(login({})))
+
}),
+
)
+
+
router.post(
+
'/login',
+
handler(async (req, res) => {
+
const handle = req.body?.handle
+
if (typeof handle !== 'string' || !isValidHandle(handle)) {
+
return res.type('html').send(page(login({ error: 'invalid handle' })))
+
}
+
try {
+
const url = await ctx.oauthClient.authorize(handle)
+
return res.redirect(url.toString())
+
} catch (err) {
+
ctx.logger.error({ err }, 'oauth authorize failed')
+
return res.type('html').send(
+
page(
+
login({
+
error: err instanceof OAuthResolverError ? err.message : "couldn't initiate login",
+
}),
+
),
+
)
+
}
+
}),
+
)
+
+
router.post(
+
'/logout',
+
handler(async (req, res) => {
+
await destroySession(req, res)
+
return res.redirect('/')
+
}),
+
)
router.get(
'/',
handler(async (req, res) => {
+
const session = await getSession(req, res)
+
const agent =
+
session &&
+
(await ctx.oauthClient.restore(session.did).catch(async (err) => {
+
ctx.logger.warn({ err }, 'oauth restore failed')
+
await destroySession(req, res)
+
return null
+
}))
const posts = await ctx.db.selectFrom('post').selectAll().orderBy('indexedAt', 'desc').limit(10).execute()
-
const postTexts = posts.map((row) => row.text)
-
res.json(postTexts)
+
if (!agent) {
+
return res.type('html').send(page(home({ posts })))
+
}
+
const { data: profile } = await agent.getProfile({ actor: session.did })
+
return res.type('html').send(page(home({ posts, profile })))
}),
)
+13 -1
src/server.ts
···
import errorHandler from '#/middleware/errorHandler'
import requestLogger from '#/middleware/requestLogger'
import { createRouter } from '#/routes'
+
import { createClient } from './auth/client'
import type { AppContext } from './config'
export class Server {
···
const db = createDb(':memory:')
await migrateToLatest(db)
const ingester = new Ingester(db)
+
const oauthClient = await createClient(db)
ingester.start()
const ctx = {
db,
ingester,
logger,
+
oauthClient,
}
const app: Express = express()
···
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }))
-
app.use(helmet())
+
app.use(
+
helmet({
+
contentSecurityPolicy: {
+
directives: {
+
// allow oauth redirect when submitting login form
+
formAction: null,
+
},
+
},
+
}),
+
)
// Request logging
app.use(requestLogger)
+12
src/view.ts
···
+
// @ts-ignore
+
import ssr from 'uhtml/ssr'
+
import type initSSR from 'uhtml/types/init-ssr'
+
import type { Hole } from 'uhtml/types/keyed'
+
+
export type { Hole }
+
+
export const { html }: ReturnType<typeof initSSR> = ssr()
+
+
export function page(hole: Hole) {
+
return `<!DOCTYPE html>\n${hole.toDOM().toString()}`
+
}
+1 -1
tsconfig.json
···
"paths": {
"#/*": ["src/*"]
},
-
"moduleResolution": "Node",
+
"moduleResolution": "Node10",
"outDir": "dist",
"importsNotUsedAsValues": "remove",
"strict": true,