├── .nvmrc ├── .prettierrc ├── src ├── lib │ ├── util.ts │ ├── view.ts │ ├── jwk.ts │ ├── process.ts │ └── http.ts ├── lexicon │ ├── util.ts │ ├── types │ │ ├── xyz │ │ │ └── statusphere │ │ │ │ └── status.ts │ │ ├── com │ │ │ └── atproto │ │ │ │ ├── repo │ │ │ │ └── strongRef.ts │ │ │ │ └── label │ │ │ │ └── defs.ts │ │ └── app │ │ │ └── bsky │ │ │ └── actor │ │ │ └── profile.ts │ ├── index.ts │ └── lexicons.ts ├── pages │ ├── shell.ts │ ├── login.ts │ ├── home.ts │ └── public │ │ └── styles.css ├── env.ts ├── index.ts ├── id-resolver.ts ├── context.ts ├── auth │ ├── storage.ts │ └── client.ts ├── db.ts ├── ingester.ts └── routes.ts ├── bin └── gen-jwk ├── .gitignore ├── lexicons ├── strongRef.json ├── status.json ├── profile.json └── defs.json ├── .vscode ├── settings.json └── launch.json ├── tsconfig.json ├── .env.template ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.5.1 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "useTabs": false 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | export function ifString(value: T): (T & string) | undefined { 2 | if (typeof value === 'string') return value 3 | return undefined 4 | } 5 | -------------------------------------------------------------------------------- /bin/gen-jwk: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | const { JoseKey } = require('@atproto/oauth-client-node') 6 | 7 | async function main() { 8 | const kid = Date.now().toString() 9 | const key = await JoseKey.generate(['ES256'], kid) 10 | const jwk = key.privateJwk 11 | 12 | console.log(JSON.stringify(jwk)) 13 | } 14 | 15 | main() 16 | -------------------------------------------------------------------------------- /src/lexicon/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | export function isObj(v: unknown): v is Record { 5 | return typeof v === 'object' && v !== null 6 | } 7 | 8 | export function hasProp( 9 | data: object, 10 | prop: K, 11 | ): data is Record { 12 | return prop in data 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/view.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import ssr from 'uhtml/ssr' 3 | import type initSSR from 'uhtml/types/init-ssr' 4 | import type { Hole } from 'uhtml/types/keyed' 5 | 6 | export type { Hole } 7 | 8 | export const { html }: ReturnType = ssr() 9 | 10 | export function page(hole: Hole) { 11 | return `\n${hole.toDOM().toString()}` 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/shell.ts: -------------------------------------------------------------------------------- 1 | import { type Hole, html } from '../lib/view' 2 | 3 | export function shell({ title, content }: { title: string; content: Hole }) { 4 | return html` 5 | 6 | ${title} 7 | 8 | 9 | 10 | ${content} 11 | 12 | ` 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | coverage 11 | node_modules 12 | dist 13 | build 14 | dist-ssr 15 | *.local 16 | .env 17 | *.sqlite 18 | 19 | # Editor directories and files 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? -------------------------------------------------------------------------------- /lexicons/strongRef.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.strongRef", 4 | "description": "A URI with a content-hash fingerprint.", 5 | "defs": { 6 | "main": { 7 | "type": "object", 8 | "required": ["uri", "cid"], 9 | "properties": { 10 | "uri": { "type": "string", "format": "at-uri" }, 11 | "cid": { "type": "string", "format": "cid" } 12 | } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.codeActionsOnSave": { 5 | "quickfix.biome": "explicit", 6 | "source.organizeImports.biome": "explicit", 7 | "source.fixAll": "explicit" 8 | }, 9 | "json.schemas": [ 10 | { 11 | "url": "https://cdn.jsdelivr.net/npm/tsup/schema.json", 12 | "fileMatch": ["package.json", "tsup.config.json"] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "CommonJS", 5 | "baseUrl": ".", 6 | "paths": { 7 | "#/*": ["src/*"] 8 | }, 9 | "moduleResolution": "Node10", 10 | "outDir": "dist", 11 | "importsNotUsedAsValues": "remove", 12 | "strict": true, 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsx", 3 | "type": "node", 4 | "request": "launch", 5 | "program": "${workspaceFolder}/src/index.ts", 6 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/tsx", 7 | "console": "integratedTerminal", 8 | "internalConsoleOptions": "neverOpen", 9 | "skipFiles": ["/**", "${workspaceFolder}/node_modules/**"], 10 | "configurations": [ 11 | { 12 | "command": "npm start", 13 | "name": "Run npm start", 14 | "request": "launch", 15 | "type": "node-terminal" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/jwk.ts: -------------------------------------------------------------------------------- 1 | import { Jwk, jwkValidator } from '@atproto/oauth-client-node' 2 | import { makeValidator } from 'envalid' 3 | import { z } from 'zod' 4 | 5 | export type JsonWebKey = Jwk & { kid: string } 6 | 7 | const jsonWebKeySchema = z.intersection( 8 | jwkValidator, 9 | z.object({ kid: z.string().nonempty() }), 10 | ) satisfies z.ZodType 11 | 12 | const jsonWebKeysSchema = z.array(jsonWebKeySchema).nonempty() 13 | 14 | export const envalidJsonWebKeys = makeValidator((input) => { 15 | const value = JSON.parse(input) 16 | return jsonWebKeysSchema.parse(value) 17 | }) 18 | -------------------------------------------------------------------------------- /lexicons/status.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "xyz.statusphere.status", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "key": "tid", 8 | "record": { 9 | "type": "object", 10 | "required": ["status", "createdAt"], 11 | "properties": { 12 | "status": { 13 | "type": "string", 14 | "minLength": 1, 15 | "maxGraphemes": 1, 16 | "maxLength": 32 17 | }, 18 | "createdAt": { "type": "string", "format": "datetime" } 19 | } 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/process.ts: -------------------------------------------------------------------------------- 1 | const SIGNALS = ['SIGINT', 'SIGTERM'] as const 2 | 3 | /** 4 | * Runs a function with an abort signal that will be triggered when the process 5 | * receives a termination signal. 6 | */ 7 | export async function run Promise>( 8 | fn: F, 9 | ): Promise { 10 | const killController = new AbortController() 11 | 12 | const abort = (signal?: string) => { 13 | for (const sig of SIGNALS) process.off(sig, abort) 14 | killController.abort(signal) 15 | } 16 | 17 | for (const sig of SIGNALS) process.on(sig, abort) 18 | 19 | try { 20 | await fn(killController.signal) 21 | } finally { 22 | abort() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/lexicon/types/xyz/statusphere/status.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 | import { lexicons } from '../../../lexicons' 6 | import { isObj, hasProp } from '../../../util' 7 | import { CID } from 'multiformats/cid' 8 | 9 | export interface Record { 10 | status: string 11 | createdAt: string 12 | [k: string]: unknown 13 | } 14 | 15 | export function isRecord(v: unknown): v is Record { 16 | return ( 17 | isObj(v) && 18 | hasProp(v, '$type') && 19 | (v.$type === 'xyz.statusphere.status#main' || 20 | v.$type === 'xyz.statusphere.status') 21 | ) 22 | } 23 | 24 | export function validateRecord(v: unknown): ValidationResult { 25 | return lexicons.validate('xyz.statusphere.status#main', v) 26 | } 27 | -------------------------------------------------------------------------------- /src/lexicon/types/com/atproto/repo/strongRef.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 | import { lexicons } from '../../../../lexicons' 6 | import { isObj, hasProp } from '../../../../util' 7 | import { CID } from 'multiformats/cid' 8 | 9 | export interface Main { 10 | uri: string 11 | cid: string 12 | [k: string]: unknown 13 | } 14 | 15 | export function isMain(v: unknown): v is Main { 16 | return ( 17 | isObj(v) && 18 | hasProp(v, '$type') && 19 | (v.$type === 'com.atproto.repo.strongRef#main' || 20 | v.$type === 'com.atproto.repo.strongRef') 21 | ) 22 | } 23 | 24 | export function validateMain(v: unknown): ValidationResult { 25 | return lexicons.validate('com.atproto.repo.strongRef#main', v) 26 | } 27 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # Environment Configuration 2 | NODE_ENV="development" # Options: 'development', 'production' 3 | PORT="8080" # The port your server will listen on 4 | DB_PATH=":memory:" # The SQLite database path. Set as ":memory:" to use a temporary in-memory database. 5 | # PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id. 6 | # LOG_LEVEL="info" # Options: 'fatal', 'error', 'warn', 'info', 'debug' 7 | # PDS_URL="https://my.pds" # The default PDS for login and sign-ups 8 | 9 | # Secrets below *MUST* be set in production 10 | 11 | # May be generated with `openssl rand -base64 33` 12 | # COOKIE_SECRET="" 13 | 14 | # May be generated with `./bin/gen-jwk` (requires `npm install` once first) 15 | # PRIVATE_KEYS='[{"kty":"EC","kid":"123",...}]' 16 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import { cleanEnv, port, str, testOnly, url } from 'envalid' 3 | import { envalidJsonWebKeys as keys } from '#/lib/jwk' 4 | 5 | dotenv.config() 6 | 7 | export const env = cleanEnv(process.env, { 8 | NODE_ENV: str({ 9 | devDefault: testOnly('test'), 10 | choices: ['development', 'production', 'test'], 11 | }), 12 | PORT: port({ devDefault: testOnly(3000) }), 13 | PUBLIC_URL: url({ default: undefined }), 14 | DB_PATH: str({ devDefault: ':memory:' }), 15 | COOKIE_SECRET: str({ devDefault: '00000000000000000000000000000000' }), 16 | PRIVATE_KEYS: keys({ default: undefined }), 17 | LOG_LEVEL: str({ 18 | devDefault: 'debug', 19 | default: 'info', 20 | choices: ['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'], 21 | }), 22 | PDS_URL: url({ default: undefined }), 23 | PLC_URL: url({ default: undefined }), 24 | FIREHOSE_URL: url({ default: undefined }), 25 | }) 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { once } from 'node:events' 2 | 3 | import { createAppContext } from '#/context' 4 | import { env } from '#/env' 5 | import { startServer } from '#/lib/http' 6 | import { run } from '#/lib/process' 7 | import { createRouter } from '#/routes' 8 | 9 | run(async (killSignal) => { 10 | // Create the application context 11 | const ctx = await createAppContext() 12 | 13 | // Create the HTTP router 14 | const router = createRouter(ctx) 15 | 16 | // Start the HTTP server 17 | const { terminate } = await startServer(router, { port: env.PORT }) 18 | 19 | const url = env.PUBLIC_URL || `http://localhost:${env.PORT}` 20 | ctx.logger.info(`Server (${env.NODE_ENV}) running at ${url}`) 21 | 22 | // Subscribe to events on the firehose 23 | ctx.ingester.start() 24 | 25 | // Wait for a termination signal 26 | if (!killSignal.aborted) await once(killSignal, 'abort') 27 | ctx.logger.info(`Signal received, shutting down...`) 28 | 29 | // Gracefully shutdown the http server 30 | await terminate() 31 | 32 | // Gracefully shutdown the application context 33 | await ctx.destroy() 34 | }) 35 | -------------------------------------------------------------------------------- /src/id-resolver.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient } from '@atproto/oauth-client-node' 2 | 3 | export interface BidirectionalResolver { 4 | resolveDidToHandle(did: string): Promise 5 | resolveDidsToHandles( 6 | dids: string[], 7 | ): Promise> 8 | } 9 | 10 | export function createBidirectionalResolver({ 11 | identityResolver, 12 | }: OAuthClient): BidirectionalResolver { 13 | return { 14 | async resolveDidToHandle(did: string): Promise { 15 | try { 16 | const { handle } = await identityResolver.resolve(did) 17 | if (handle) return handle 18 | } catch { 19 | // Ignore 20 | } 21 | }, 22 | 23 | async resolveDidsToHandles( 24 | dids: string[], 25 | ): Promise> { 26 | const uniqueDids = [...new Set(dids)] 27 | 28 | return Object.fromEntries( 29 | await Promise.all( 30 | uniqueDids.map((did) => 31 | this.resolveDidToHandle(did).then((handle) => [did, handle]), 32 | ), 33 | ), 34 | ) 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Bluesky PBC, and Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/pages/login.ts: -------------------------------------------------------------------------------- 1 | import { env } from '#/env' 2 | import { html } from '../lib/view' 3 | import { shell } from './shell' 4 | 5 | type Props = { error?: string } 6 | 7 | export function login(props: Props) { 8 | return shell({ 9 | title: 'Log in', 10 | content: content(props), 11 | }) 12 | } 13 | 14 | function content({ error }: Props) { 15 | const signupService = 16 | !env.PDS_URL || env.PDS_URL === 'https://bsky.social' 17 | ? 'Bluesky' 18 | : new URL(env.PDS_URL).hostname 19 | 20 | return html`
21 | 25 |
26 | 36 | 37 | 40 | 41 | ${error ? html`

Error: ${error}

` : undefined} 42 |
43 |
` 44 | } 45 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { NodeOAuthClient } from '@atproto/oauth-client-node' 2 | import { Firehose } from '@atproto/sync' 3 | import { pino } from 'pino' 4 | 5 | import { createOAuthClient } from '#/auth/client' 6 | import { createDb, Database, migrateToLatest } from '#/db' 7 | import { createIngester } from '#/ingester' 8 | import { env } from '#/env' 9 | import { 10 | BidirectionalResolver, 11 | createBidirectionalResolver, 12 | } from '#/id-resolver' 13 | 14 | /** 15 | * Application state passed to the router and elsewhere 16 | */ 17 | export type AppContext = { 18 | db: Database 19 | ingester: Firehose 20 | logger: pino.Logger 21 | oauthClient: NodeOAuthClient 22 | resolver: BidirectionalResolver 23 | destroy: () => Promise 24 | } 25 | 26 | export async function createAppContext(): Promise { 27 | const db = createDb(env.DB_PATH) 28 | await migrateToLatest(db) 29 | const oauthClient = await createOAuthClient(db) 30 | const ingester = createIngester(db) 31 | const logger = pino({ name: 'server', level: env.LOG_LEVEL }) 32 | const resolver = createBidirectionalResolver(oauthClient) 33 | 34 | return { 35 | db, 36 | ingester, 37 | logger, 38 | oauthClient, 39 | resolver, 40 | 41 | async destroy() { 42 | await ingester.destroy() 43 | await db.destroy() 44 | }, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lexicon/types/app/bsky/actor/profile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 | import { lexicons } from '../../../../lexicons' 6 | import { isObj, hasProp } from '../../../../util' 7 | import { CID } from 'multiformats/cid' 8 | import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs' 9 | import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' 10 | 11 | export interface Record { 12 | displayName?: string 13 | /** Free-form profile description text. */ 14 | description?: string 15 | /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 16 | avatar?: BlobRef 17 | /** Larger horizontal image to display behind profile view. */ 18 | banner?: BlobRef 19 | labels?: 20 | | ComAtprotoLabelDefs.SelfLabels 21 | | { $type: string; [k: string]: unknown } 22 | joinedViaStarterPack?: ComAtprotoRepoStrongRef.Main 23 | createdAt?: string 24 | [k: string]: unknown 25 | } 26 | 27 | export function isRecord(v: unknown): v is Record { 28 | return ( 29 | isObj(v) && 30 | hasProp(v, '$type') && 31 | (v.$type === 'app.bsky.actor.profile#main' || 32 | v.$type === 'app.bsky.actor.profile') 33 | ) 34 | } 35 | 36 | export function validateRecord(v: unknown): ValidationResult { 37 | return lexicons.validate('app.bsky.actor.profile#main', v) 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atproto-example-app", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "main": "index.ts", 8 | "private": true, 9 | "scripts": { 10 | "dev": "tsx watch --clear-screen=false src/index.ts | pino-pretty", 11 | "build": "tsup", 12 | "start": "node dist/index.js", 13 | "lexgen": "lex gen-server ./src/lexicon ./lexicons/*", 14 | "clean": "rimraf dist coverage" 15 | }, 16 | "dependencies": { 17 | "@atproto/api": "^0.15.6", 18 | "@atproto/common": "^0.4.11", 19 | "@atproto/identity": "^0.4.8", 20 | "@atproto/lexicon": "^0.4.11", 21 | "@atproto/oauth-client-node": "^0.3.1", 22 | "@atproto/sync": "^0.1.26", 23 | "@atproto/xrpc-server": "^0.8.0", 24 | "better-sqlite3": "^11.1.2", 25 | "dotenv": "^16.4.5", 26 | "envalid": "^8.0.0", 27 | "express": "^4.19.2", 28 | "http-terminator": "^3.2.0", 29 | "iron-session": "^8.0.2", 30 | "kysely": "^0.27.4", 31 | "multiformats": "^9.9.0", 32 | "pino": "^9.3.2", 33 | "uhtml": "^4.5.9", 34 | "zod": "^3.25.67" 35 | }, 36 | "devDependencies": { 37 | "@atproto/lex-cli": "^0.4.1", 38 | "@types/better-sqlite3": "^7.6.11", 39 | "@types/express": "^4.17.21", 40 | "pino-pretty": "^11.0.0", 41 | "rimraf": "^5.0.0", 42 | "ts-node": "^10.9.2", 43 | "tsup": "^8.0.2", 44 | "tsx": "^4.7.2", 45 | "typescript": "^5.4.4" 46 | }, 47 | "tsup": { 48 | "entry": [ 49 | "src", 50 | "!src/**/__tests__/**", 51 | "!src/**/*.test.*" 52 | ], 53 | "splitting": false, 54 | "sourcemap": true, 55 | "clean": true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lexicons/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "app.bsky.actor.profile", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "description": "A declaration of a Bluesky account profile.", 8 | "key": "literal:self", 9 | "record": { 10 | "type": "object", 11 | "properties": { 12 | "displayName": { 13 | "type": "string", 14 | "maxGraphemes": 64, 15 | "maxLength": 640 16 | }, 17 | "description": { 18 | "type": "string", 19 | "description": "Free-form profile description text.", 20 | "maxGraphemes": 256, 21 | "maxLength": 2560 22 | }, 23 | "avatar": { 24 | "type": "blob", 25 | "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'", 26 | "accept": ["image/png", "image/jpeg"], 27 | "maxSize": 1000000 28 | }, 29 | "banner": { 30 | "type": "blob", 31 | "description": "Larger horizontal image to display behind profile view.", 32 | "accept": ["image/png", "image/jpeg"], 33 | "maxSize": 1000000 34 | }, 35 | "labels": { 36 | "type": "union", 37 | "description": "Self-label values, specific to the Bluesky application, on the overall account.", 38 | "refs": ["com.atproto.label.defs#selfLabels"] 39 | }, 40 | "joinedViaStarterPack": { 41 | "type": "ref", 42 | "ref": "com.atproto.repo.strongRef" 43 | }, 44 | "createdAt": { "type": "string", "format": "datetime" } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/auth/storage.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NodeSavedSession, 3 | NodeSavedSessionStore, 4 | NodeSavedState, 5 | NodeSavedStateStore, 6 | } from '@atproto/oauth-client-node' 7 | import type { Database } from '#/db' 8 | 9 | export class StateStore implements NodeSavedStateStore { 10 | constructor(private db: Database) {} 11 | async get(key: string): Promise { 12 | const result = await this.db.selectFrom('auth_state').selectAll().where('key', '=', key).executeTakeFirst() 13 | if (!result) return 14 | return JSON.parse(result.state) as NodeSavedState 15 | } 16 | async set(key: string, val: NodeSavedState) { 17 | const state = JSON.stringify(val) 18 | await this.db 19 | .insertInto('auth_state') 20 | .values({ key, state }) 21 | .onConflict((oc) => oc.doUpdateSet({ state })) 22 | .execute() 23 | } 24 | async del(key: string) { 25 | await this.db.deleteFrom('auth_state').where('key', '=', key).execute() 26 | } 27 | } 28 | 29 | export class SessionStore implements NodeSavedSessionStore { 30 | constructor(private db: Database) {} 31 | async get(key: string): Promise { 32 | const result = await this.db.selectFrom('auth_session').selectAll().where('key', '=', key).executeTakeFirst() 33 | if (!result) return 34 | return JSON.parse(result.session) as NodeSavedSession 35 | } 36 | async set(key: string, val: NodeSavedSession) { 37 | const session = JSON.stringify(val) 38 | await this.db 39 | .insertInto('auth_session') 40 | .values({ key, session }) 41 | .onConflict((oc) => oc.doUpdateSet({ session })) 42 | .execute() 43 | } 44 | async del(key: string) { 45 | await this.db.deleteFrom('auth_session').where('key', '=', key).execute() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AT Protocol "Statusphere" Example App 2 | 3 | An example application covering: 4 | 5 | - Signin via OAuth 6 | - Fetch information about users (profiles) 7 | - Listen to the network firehose for new data 8 | - Publish data on the user's account using a custom schema 9 | 10 | See https://atproto.com/guides/applications for a guide through the codebase. 11 | 12 | ## Getting Started 13 | 14 | ```sh 15 | git clone https://github.com/bluesky-social/statusphere-example-app.git 16 | cd statusphere-example-app 17 | cp .env.template .env 18 | npm install 19 | npm run dev 20 | # Navigate to http://localhost:8080 21 | ``` 22 | 23 | ## Deploying 24 | 25 | In production, you will need a private key to sign OAuth tokens request. Use the 26 | following command to generate a new private key: 27 | 28 | ```sh 29 | ./bin/gen-jwk 30 | ``` 31 | 32 | The generated key must be added to the environment variables (`.env` file) in `PRIVATE_KEYS`. 33 | 34 | ```env 35 | PRIVATE_KEYS='[{"kty":"EC","kid":"12",...}]' 36 | ``` 37 | 38 | > [!NOTE] 39 | > 40 | > The `PRIVATE_KEYS` can contain multiple keys. The first key in the array is 41 | > the most recent one, and it will be used to sign new tokens. When a key is 42 | > removed, all associated sessions will be invalidated. 43 | 44 | Make sure to also set the `COOKIE_SECRET`, which is used to sign session 45 | cookies, in your environment variables (`.env` file). You should use a random 46 | string for this: 47 | 48 | ```sh 49 | openssl rand -base64 33 50 | ``` 51 | 52 | Finally, set the `PUBLIC_URL` to the URL where your app will be accessible. This 53 | will allow the authorization servers to download the app's public keys. 54 | 55 | ```env 56 | PUBLIC_URL="https://your-app-url.com" 57 | ``` 58 | 59 | > [!NOTE] 60 | > 61 | > You can use services like [ngrok](https://ngrok.com/) to expose your local 62 | > server to the internet for testing purposes. Just set the `PUBLIC_URL` to the 63 | > ngrok URL. 64 | -------------------------------------------------------------------------------- /src/lib/http.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { createHttpTerminator } from 'http-terminator' 3 | import { once } from 'node:events' 4 | import type { 5 | IncomingMessage, 6 | RequestListener, 7 | ServerResponse, 8 | } from 'node:http' 9 | import { createServer } from 'node:http' 10 | 11 | export type NextFunction = (err?: unknown) => void 12 | 13 | export type Middleware< 14 | Req extends IncomingMessage = IncomingMessage, 15 | Res extends ServerResponse = ServerResponse, 16 | > = (req: Req, res: Res, next: NextFunction) => void 17 | 18 | export type Handler< 19 | Req extends IncomingMessage = IncomingMessage, 20 | Res extends ServerResponse = ServerResponse, 21 | > = (req: Req, res: Res) => unknown | Promise 22 | /** 23 | * Wraps a request handler middleware to ensure that `next` is called if it 24 | * throws or returns a promise that rejects. 25 | */ 26 | export function handler< 27 | Req extends IncomingMessage = Request, 28 | Res extends ServerResponse = Response, 29 | >(fn: Handler): Middleware { 30 | return async (req, res, next) => { 31 | try { 32 | await fn(req, res) 33 | } catch (err) { 34 | next(err) 35 | } 36 | } 37 | } 38 | 39 | /** 40 | * Create an HTTP server with the provided request listener, ensuring that it 41 | * can bind the listening port, and returns a termination function that allows 42 | * graceful termination of HTTP connections. 43 | */ 44 | export async function startServer( 45 | requestListener: RequestListener, 46 | { 47 | port, 48 | gracefulTerminationTimeout, 49 | }: { port?: number; gracefulTerminationTimeout?: number } = {}, 50 | ) { 51 | const server = createServer(requestListener) 52 | const { terminate } = createHttpTerminator({ 53 | gracefulTerminationTimeout, 54 | server, 55 | }) 56 | server.listen(port) 57 | await once(server, 'listening') 58 | return { server, terminate } 59 | } 60 | -------------------------------------------------------------------------------- /src/auth/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Keyset, 3 | JoseKey, 4 | atprotoLoopbackClientMetadata, 5 | NodeOAuthClient, 6 | OAuthClientMetadataInput, 7 | } from '@atproto/oauth-client-node' 8 | import assert from 'node:assert' 9 | 10 | import type { Database } from '#/db' 11 | import { env } from '#/env' 12 | import { SessionStore, StateStore } from './storage' 13 | 14 | export async function createOAuthClient(db: Database) { 15 | // Confidential client require a keyset accessible on the internet. Non 16 | // internet clients (e.g. development) cannot expose a keyset on the internet 17 | // so they can't be private.. 18 | const keyset = 19 | env.PUBLIC_URL && env.PRIVATE_KEYS 20 | ? new Keyset( 21 | await Promise.all( 22 | env.PRIVATE_KEYS.map((jwk) => JoseKey.fromJWK(jwk)), 23 | ), 24 | ) 25 | : undefined 26 | 27 | assert( 28 | !env.PUBLIC_URL || keyset?.size, 29 | 'ATProto requires backend clients to be confidential. Make sure to set the PRIVATE_KEYS environment variable.', 30 | ) 31 | 32 | // If a keyset is defined (meaning the client is confidential). Let's make 33 | // sure it has a private key for signing. Note: findPrivateKey will throw if 34 | // the keyset does not contain a suitable private key. 35 | const pk = keyset?.findPrivateKey({ use: 'sig' }) 36 | 37 | const clientMetadata: OAuthClientMetadataInput = env.PUBLIC_URL 38 | ? { 39 | client_name: 'Statusphere Example App', 40 | client_id: `${env.PUBLIC_URL}/oauth-client-metadata.json`, 41 | jwks_uri: `${env.PUBLIC_URL}/.well-known/jwks.json`, 42 | redirect_uris: [`${env.PUBLIC_URL}/oauth/callback`], 43 | scope: 'atproto transition:generic', 44 | grant_types: ['authorization_code', 'refresh_token'], 45 | response_types: ['code'], 46 | application_type: 'web', 47 | token_endpoint_auth_method: pk ? 'private_key_jwt' : 'none', 48 | token_endpoint_auth_signing_alg: pk ? pk.alg : undefined, 49 | dpop_bound_access_tokens: true, 50 | } 51 | : atprotoLoopbackClientMetadata( 52 | `http://localhost?${new URLSearchParams([ 53 | ['redirect_uri', `http://127.0.0.1:${env.PORT}/oauth/callback`], 54 | ['scope', `atproto transition:generic`], 55 | ])}`, 56 | ) 57 | 58 | return new NodeOAuthClient({ 59 | keyset, 60 | clientMetadata, 61 | stateStore: new StateStore(db), 62 | sessionStore: new SessionStore(db), 63 | plcDirectoryUrl: env.PLC_URL, 64 | handleResolver: env.PDS_URL, 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import SqliteDb from 'better-sqlite3' 2 | import { 3 | Kysely, 4 | Migrator, 5 | SqliteDialect, 6 | Migration, 7 | MigrationProvider, 8 | } from 'kysely' 9 | 10 | // Types 11 | 12 | export type DatabaseSchema = { 13 | status: Status 14 | auth_session: AuthSession 15 | auth_state: AuthState 16 | } 17 | 18 | export type Status = { 19 | uri: string 20 | authorDid: string 21 | status: string 22 | createdAt: string 23 | indexedAt: string 24 | } 25 | 26 | export type AuthSession = { 27 | key: string 28 | session: AuthSessionJson 29 | } 30 | 31 | export type AuthState = { 32 | key: string 33 | state: AuthStateJson 34 | } 35 | 36 | type AuthStateJson = string 37 | 38 | type AuthSessionJson = string 39 | 40 | // Migrations 41 | 42 | const migrations: Record = {} 43 | 44 | const migrationProvider: MigrationProvider = { 45 | async getMigrations() { 46 | return migrations 47 | }, 48 | } 49 | 50 | migrations['001'] = { 51 | async up(db: Kysely) { 52 | await db.schema 53 | .createTable('status') 54 | .addColumn('uri', 'varchar', (col) => col.primaryKey()) 55 | .addColumn('authorDid', 'varchar', (col) => col.notNull()) 56 | .addColumn('status', 'varchar', (col) => col.notNull()) 57 | .addColumn('createdAt', 'varchar', (col) => col.notNull()) 58 | .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 59 | .execute() 60 | await db.schema 61 | .createTable('auth_session') 62 | .addColumn('key', 'varchar', (col) => col.primaryKey()) 63 | .addColumn('session', 'varchar', (col) => col.notNull()) 64 | .execute() 65 | await db.schema 66 | .createTable('auth_state') 67 | .addColumn('key', 'varchar', (col) => col.primaryKey()) 68 | .addColumn('state', 'varchar', (col) => col.notNull()) 69 | .execute() 70 | }, 71 | async down(db: Kysely) { 72 | await db.schema.dropTable('auth_state').execute() 73 | await db.schema.dropTable('auth_session').execute() 74 | await db.schema.dropTable('status').execute() 75 | }, 76 | } 77 | 78 | // APIs 79 | 80 | export const createDb = (location: string): Database => { 81 | return new Kysely({ 82 | dialect: new SqliteDialect({ 83 | database: new SqliteDb(location), 84 | }), 85 | }) 86 | } 87 | 88 | export const migrateToLatest = async (db: Database) => { 89 | const migrator = new Migrator({ db, provider: migrationProvider }) 90 | const { error } = await migrator.migrateToLatest() 91 | if (error) throw error 92 | } 93 | 94 | export type Database = Kysely 95 | -------------------------------------------------------------------------------- /src/ingester.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from '#/db' 2 | import * as Status from '#/lexicon/types/xyz/statusphere/status' 3 | import { IdResolver, MemoryCache } from '@atproto/identity' 4 | import { Event, Firehose } from '@atproto/sync' 5 | import pino from 'pino' 6 | import { env } from './env' 7 | 8 | const HOUR = 60e3 * 60 9 | const DAY = HOUR * 24 10 | 11 | export function createIngester(db: Database) { 12 | const logger = pino({ name: 'firehose', level: env.LOG_LEVEL }) 13 | return new Firehose({ 14 | filterCollections: ['xyz.statusphere.status'], 15 | handleEvent: async (evt: Event) => { 16 | // Watch for write events 17 | if (evt.event === 'create' || evt.event === 'update') { 18 | const now = new Date() 19 | const record = evt.record 20 | 21 | // If the write is a valid status update 22 | if ( 23 | evt.collection === 'xyz.statusphere.status' && 24 | Status.isRecord(record) && 25 | Status.validateRecord(record).success 26 | ) { 27 | logger.debug( 28 | { uri: evt.uri.toString(), status: record.status }, 29 | 'ingesting status', 30 | ) 31 | 32 | // Store the status in our SQLite 33 | await db 34 | .insertInto('status') 35 | .values({ 36 | uri: evt.uri.toString(), 37 | authorDid: evt.did, 38 | status: record.status, 39 | createdAt: record.createdAt, 40 | indexedAt: now.toISOString(), 41 | }) 42 | .onConflict((oc) => 43 | oc.column('uri').doUpdateSet({ 44 | status: record.status, 45 | indexedAt: now.toISOString(), 46 | }), 47 | ) 48 | .execute() 49 | } 50 | } else if ( 51 | evt.event === 'delete' && 52 | evt.collection === 'xyz.statusphere.status' 53 | ) { 54 | logger.debug( 55 | { uri: evt.uri.toString(), did: evt.did }, 56 | 'deleting status', 57 | ) 58 | 59 | // Remove the status from our SQLite 60 | await db 61 | .deleteFrom('status') 62 | .where('uri', '=', evt.uri.toString()) 63 | .execute() 64 | } 65 | }, 66 | onError: (err: unknown) => { 67 | logger.error({ err }, 'error on firehose ingestion') 68 | }, 69 | excludeIdentity: true, 70 | excludeAccount: true, 71 | service: env.FIREHOSE_URL, 72 | idResolver: new IdResolver({ 73 | plcUrl: env.PLC_URL, 74 | didCache: new MemoryCache(HOUR, DAY), 75 | }), 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /src/lexicon/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { 5 | createServer as createXrpcServer, 6 | Server as XrpcServer, 7 | Options as XrpcOptions, 8 | AuthVerifier, 9 | StreamAuthVerifier, 10 | } from '@atproto/xrpc-server' 11 | import { schemas } from './lexicons' 12 | 13 | export function createServer(options?: XrpcOptions): Server { 14 | return new Server(options) 15 | } 16 | 17 | export class Server { 18 | xrpc: XrpcServer 19 | app: AppNS 20 | xyz: XyzNS 21 | com: ComNS 22 | 23 | constructor(options?: XrpcOptions) { 24 | this.xrpc = createXrpcServer(schemas, options) 25 | this.app = new AppNS(this) 26 | this.xyz = new XyzNS(this) 27 | this.com = new ComNS(this) 28 | } 29 | } 30 | 31 | export class AppNS { 32 | _server: Server 33 | bsky: AppBskyNS 34 | 35 | constructor(server: Server) { 36 | this._server = server 37 | this.bsky = new AppBskyNS(server) 38 | } 39 | } 40 | 41 | export class AppBskyNS { 42 | _server: Server 43 | actor: AppBskyActorNS 44 | 45 | constructor(server: Server) { 46 | this._server = server 47 | this.actor = new AppBskyActorNS(server) 48 | } 49 | } 50 | 51 | export class AppBskyActorNS { 52 | _server: Server 53 | 54 | constructor(server: Server) { 55 | this._server = server 56 | } 57 | } 58 | 59 | export class XyzNS { 60 | _server: Server 61 | statusphere: XyzStatusphereNS 62 | 63 | constructor(server: Server) { 64 | this._server = server 65 | this.statusphere = new XyzStatusphereNS(server) 66 | } 67 | } 68 | 69 | export class XyzStatusphereNS { 70 | _server: Server 71 | 72 | constructor(server: Server) { 73 | this._server = server 74 | } 75 | } 76 | 77 | export class ComNS { 78 | _server: Server 79 | atproto: ComAtprotoNS 80 | 81 | constructor(server: Server) { 82 | this._server = server 83 | this.atproto = new ComAtprotoNS(server) 84 | } 85 | } 86 | 87 | export class ComAtprotoNS { 88 | _server: Server 89 | repo: ComAtprotoRepoNS 90 | 91 | constructor(server: Server) { 92 | this._server = server 93 | this.repo = new ComAtprotoRepoNS(server) 94 | } 95 | } 96 | 97 | export class ComAtprotoRepoNS { 98 | _server: Server 99 | 100 | constructor(server: Server) { 101 | this._server = server 102 | } 103 | } 104 | 105 | type SharedRateLimitOpts = { 106 | name: string 107 | calcKey?: (ctx: T) => string 108 | calcPoints?: (ctx: T) => number 109 | } 110 | type RouteRateLimitOpts = { 111 | durationMs: number 112 | points: number 113 | calcKey?: (ctx: T) => string 114 | calcPoints?: (ctx: T) => number 115 | } 116 | type HandlerOpts = { blobLimit?: number } 117 | type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts 118 | type ConfigOf = 119 | | Handler 120 | | { 121 | auth?: Auth 122 | opts?: HandlerOpts 123 | rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] 124 | handler: Handler 125 | } 126 | type ExtractAuth = Extract< 127 | Awaited>, 128 | { credentials: unknown } 129 | > 130 | -------------------------------------------------------------------------------- /src/pages/home.ts: -------------------------------------------------------------------------------- 1 | import type { Status } from '#/db' 2 | import { html } from '../lib/view' 3 | import { shell } from './shell' 4 | 5 | const TODAY = new Date().toDateString() 6 | 7 | export const STATUS_OPTIONS = [ 8 | '👍', 9 | '👎', 10 | '💙', 11 | '🥹', 12 | '😧', 13 | '🙃', 14 | '😉', 15 | '😎', 16 | '🤓', 17 | '🤨', 18 | '🥳', 19 | '😭', 20 | '😤', 21 | '🤯', 22 | '🫡', 23 | '💀', 24 | '✊', 25 | '🤘', 26 | '👀', 27 | '🧠', 28 | '👩‍💻', 29 | '🧑‍💻', 30 | '🥷', 31 | '🧌', 32 | '🦋', 33 | '🚀', 34 | ] 35 | 36 | type Props = { 37 | statuses: Status[] 38 | didHandleMap: Record 39 | profile?: { displayName?: string } 40 | myStatus?: Status 41 | } 42 | 43 | export function home(props: Props) { 44 | return shell({ 45 | title: 'Home', 46 | content: content(props), 47 | }) 48 | } 49 | 50 | function content({ statuses, didHandleMap, profile, myStatus }: Props) { 51 | return html`
52 |
53 | 57 |
58 |
59 | ${profile 60 | ? html`
61 |
62 | Hi, ${profile.displayName || 'friend'}. What's 63 | your status today? 64 |
65 |
66 | 67 |
68 |
` 69 | : html`
70 |
Log in to set your status!
71 |
72 | Log in 73 |
74 |
`} 75 |
76 |
77 | ${STATUS_OPTIONS.map( 78 | (status) => 79 | html``, 88 | )} 89 |
90 | ${statuses.map((status, i) => { 91 | const handle = didHandleMap[status.authorDid] || status.authorDid 92 | const date = ts(status) 93 | return html` 94 |
95 |
96 |
${status.status}
97 |
98 |
99 | @${handle} 100 | ${date === TODAY 101 | ? `is feeling ${status.status} today` 102 | : `was feeling ${status.status} on ${date}`} 103 |
104 |
105 | ` 106 | })} 107 |
108 |
` 109 | } 110 | 111 | function toBskyLink(did: string) { 112 | return `https://bsky.app/profile/${did}` 113 | } 114 | 115 | function ts(status: Status) { 116 | const createdAt = new Date(status.createdAt) 117 | const indexedAt = new Date(status.indexedAt) 118 | if (createdAt < indexedAt) return createdAt.toDateString() 119 | return indexedAt.toDateString() 120 | } 121 | -------------------------------------------------------------------------------- /src/pages/public/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, Helvetica, sans-serif; 3 | 4 | --border-color: #ddd; 5 | --gray-100: #fafafa; 6 | --gray-500: #666; 7 | --gray-700: #333; 8 | --primary-100: #d2e7ff; 9 | --primary-200: #b1d3fa; 10 | --primary-400: #2e8fff; 11 | --primary-500: #0078ff; 12 | --primary-600: #0066db; 13 | --error-500: #f00; 14 | --error-100: #fee; 15 | } 16 | 17 | /* 18 | Josh's Custom CSS Reset 19 | https://www.joshwcomeau.com/css/custom-css-reset/ 20 | */ 21 | *, 22 | *::before, 23 | *::after { 24 | box-sizing: border-box; 25 | } 26 | * { 27 | margin: 0; 28 | } 29 | body { 30 | line-height: 1.5; 31 | -webkit-font-smoothing: antialiased; 32 | } 33 | img, 34 | picture, 35 | video, 36 | canvas, 37 | svg { 38 | display: block; 39 | max-width: 100%; 40 | } 41 | input, 42 | button, 43 | textarea, 44 | select { 45 | font: inherit; 46 | } 47 | p, 48 | h1, 49 | h2, 50 | h3, 51 | h4, 52 | h5, 53 | h6 { 54 | overflow-wrap: break-word; 55 | } 56 | #root, 57 | #__next { 58 | isolation: isolate; 59 | } 60 | 61 | /* 62 | Common components 63 | */ 64 | button, 65 | .button { 66 | display: inline-block; 67 | border: 0; 68 | background-color: var(--primary-500); 69 | border-radius: 50px; 70 | color: #fff; 71 | padding: 2px 10px; 72 | cursor: pointer; 73 | text-decoration: none; 74 | } 75 | button:hover, 76 | .button:hover { 77 | background: var(--primary-400); 78 | } 79 | 80 | /* 81 | Custom components 82 | */ 83 | .error { 84 | background-color: var(--error-100); 85 | color: var(--error-500); 86 | text-align: center; 87 | padding: 1rem; 88 | display: none; 89 | } 90 | .error.visible { 91 | display: block; 92 | } 93 | 94 | #header { 95 | background-color: #fff; 96 | text-align: center; 97 | padding: 0.5rem 0 1.5rem; 98 | } 99 | 100 | #header h1 { 101 | font-size: 5rem; 102 | } 103 | 104 | .container { 105 | display: flex; 106 | flex-direction: column; 107 | gap: 4px; 108 | margin: 0 auto; 109 | max-width: 600px; 110 | padding: 20px; 111 | } 112 | 113 | .card { 114 | /* border: 1px solid var(--border-color); */ 115 | border-radius: 6px; 116 | padding: 10px 16px; 117 | background-color: #fff; 118 | } 119 | .card > :first-child { 120 | margin-top: 0; 121 | } 122 | .card > :last-child { 123 | margin-bottom: 0; 124 | } 125 | 126 | .session-form { 127 | display: flex; 128 | flex-direction: row; 129 | align-items: center; 130 | justify-content: space-between; 131 | } 132 | 133 | .login-form { 134 | display: flex; 135 | flex-direction: row; 136 | gap: 6px; 137 | border: 1px solid var(--border-color); 138 | border-radius: 6px; 139 | padding: 10px 16px; 140 | background-color: #fff; 141 | } 142 | 143 | .login-form input { 144 | flex: 1; 145 | border: 0; 146 | } 147 | 148 | .status-options { 149 | display: flex; 150 | flex-direction: row; 151 | flex-wrap: wrap; 152 | gap: 8px; 153 | margin: 10px 0; 154 | } 155 | 156 | .status-option { 157 | font-size: 2rem; 158 | width: 3rem; 159 | height: 3rem; 160 | padding: 0; 161 | background-color: #fff; 162 | border: 1px solid var(--border-color); 163 | border-radius: 3rem; 164 | text-align: center; 165 | box-shadow: 0 1px 4px #0001; 166 | cursor: pointer; 167 | } 168 | 169 | .status-option:hover { 170 | background-color: var(--primary-100); 171 | box-shadow: 0 0 0 1px var(--primary-400); 172 | } 173 | 174 | .status-option.selected { 175 | box-shadow: 0 0 0 1px var(--primary-500); 176 | background-color: var(--primary-100); 177 | } 178 | 179 | .status-option.selected:hover { 180 | background-color: var(--primary-200); 181 | } 182 | 183 | .status-line { 184 | display: flex; 185 | flex-direction: row; 186 | align-items: center; 187 | gap: 10px; 188 | position: relative; 189 | margin-top: 15px; 190 | } 191 | 192 | .status-line:not(.no-line)::before { 193 | content: ''; 194 | position: absolute; 195 | width: 2px; 196 | background-color: var(--border-color); 197 | left: 1.45rem; 198 | bottom: calc(100% + 2px); 199 | height: 15px; 200 | } 201 | 202 | .status-line .status { 203 | font-size: 2rem; 204 | background-color: #fff; 205 | width: 3rem; 206 | height: 3rem; 207 | border-radius: 1.5rem; 208 | text-align: center; 209 | border: 1px solid var(--border-color); 210 | } 211 | 212 | .status-line .desc { 213 | color: var(--gray-500); 214 | } 215 | 216 | .status-line .author { 217 | color: var(--gray-700); 218 | font-weight: 600; 219 | text-decoration: none; 220 | } 221 | 222 | .status-line .author:hover { 223 | text-decoration: underline; 224 | } 225 | 226 | .signup-cta { 227 | text-align: center; 228 | width: 100%; 229 | display: block; 230 | margin-top: 1rem; 231 | } 232 | -------------------------------------------------------------------------------- /src/lexicon/types/com/atproto/label/defs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 | import { lexicons } from '../../../../lexicons' 6 | import { isObj, hasProp } from '../../../../util' 7 | import { CID } from 'multiformats/cid' 8 | 9 | /** Metadata tag on an atproto resource (eg, repo or record). */ 10 | export interface Label { 11 | /** The AT Protocol version of the label object. */ 12 | ver?: number 13 | /** DID of the actor who created this label. */ 14 | src: string 15 | /** AT URI of the record, repository (account), or other resource that this label applies to. */ 16 | uri: string 17 | /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ 18 | cid?: string 19 | /** The short string name of the value or type of this label. */ 20 | val: string 21 | /** If true, this is a negation label, overwriting a previous label. */ 22 | neg?: boolean 23 | /** Timestamp when this label was created. */ 24 | cts: string 25 | /** Timestamp at which this label expires (no longer applies). */ 26 | exp?: string 27 | /** Signature of dag-cbor encoded label. */ 28 | sig?: Uint8Array 29 | [k: string]: unknown 30 | } 31 | 32 | export function isLabel(v: unknown): v is Label { 33 | return ( 34 | isObj(v) && 35 | hasProp(v, '$type') && 36 | v.$type === 'com.atproto.label.defs#label' 37 | ) 38 | } 39 | 40 | export function validateLabel(v: unknown): ValidationResult { 41 | return lexicons.validate('com.atproto.label.defs#label', v) 42 | } 43 | 44 | /** Metadata tags on an atproto record, published by the author within the record. */ 45 | export interface SelfLabels { 46 | values: SelfLabel[] 47 | [k: string]: unknown 48 | } 49 | 50 | export function isSelfLabels(v: unknown): v is SelfLabels { 51 | return ( 52 | isObj(v) && 53 | hasProp(v, '$type') && 54 | v.$type === 'com.atproto.label.defs#selfLabels' 55 | ) 56 | } 57 | 58 | export function validateSelfLabels(v: unknown): ValidationResult { 59 | return lexicons.validate('com.atproto.label.defs#selfLabels', v) 60 | } 61 | 62 | /** Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel. */ 63 | export interface SelfLabel { 64 | /** The short string name of the value or type of this label. */ 65 | val: string 66 | [k: string]: unknown 67 | } 68 | 69 | export function isSelfLabel(v: unknown): v is SelfLabel { 70 | return ( 71 | isObj(v) && 72 | hasProp(v, '$type') && 73 | v.$type === 'com.atproto.label.defs#selfLabel' 74 | ) 75 | } 76 | 77 | export function validateSelfLabel(v: unknown): ValidationResult { 78 | return lexicons.validate('com.atproto.label.defs#selfLabel', v) 79 | } 80 | 81 | /** Declares a label value and its expected interpretations and behaviors. */ 82 | export interface LabelValueDefinition { 83 | /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */ 84 | identifier: string 85 | /** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */ 86 | severity: 'inform' | 'alert' | 'none' | (string & {}) 87 | /** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */ 88 | blurs: 'content' | 'media' | 'none' | (string & {}) 89 | /** The default setting for this label. */ 90 | defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {}) 91 | /** Does the user need to have adult content enabled in order to configure this label? */ 92 | adultOnly?: boolean 93 | locales: LabelValueDefinitionStrings[] 94 | [k: string]: unknown 95 | } 96 | 97 | export function isLabelValueDefinition(v: unknown): v is LabelValueDefinition { 98 | return ( 99 | isObj(v) && 100 | hasProp(v, '$type') && 101 | v.$type === 'com.atproto.label.defs#labelValueDefinition' 102 | ) 103 | } 104 | 105 | export function validateLabelValueDefinition(v: unknown): ValidationResult { 106 | return lexicons.validate('com.atproto.label.defs#labelValueDefinition', v) 107 | } 108 | 109 | /** Strings which describe the label in the UI, localized into a specific language. */ 110 | export interface LabelValueDefinitionStrings { 111 | /** The code of the language these strings are written in. */ 112 | lang: string 113 | /** A short human-readable name for the label. */ 114 | name: string 115 | /** A longer description of what the label means and why it might be applied. */ 116 | description: string 117 | [k: string]: unknown 118 | } 119 | 120 | export function isLabelValueDefinitionStrings( 121 | v: unknown, 122 | ): v is LabelValueDefinitionStrings { 123 | return ( 124 | isObj(v) && 125 | hasProp(v, '$type') && 126 | v.$type === 'com.atproto.label.defs#labelValueDefinitionStrings' 127 | ) 128 | } 129 | 130 | export function validateLabelValueDefinitionStrings( 131 | v: unknown, 132 | ): ValidationResult { 133 | return lexicons.validate( 134 | 'com.atproto.label.defs#labelValueDefinitionStrings', 135 | v, 136 | ) 137 | } 138 | 139 | export type LabelValue = 140 | | '!hide' 141 | | '!no-promote' 142 | | '!warn' 143 | | '!no-unauthenticated' 144 | | 'dmca-violation' 145 | | 'doxxing' 146 | | 'porn' 147 | | 'sexual' 148 | | 'nudity' 149 | | 'nsfl' 150 | | 'gore' 151 | | (string & {}) 152 | -------------------------------------------------------------------------------- /lexicons/defs.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.label.defs", 4 | "defs": { 5 | "label": { 6 | "type": "object", 7 | "description": "Metadata tag on an atproto resource (eg, repo or record).", 8 | "required": ["src", "uri", "val", "cts"], 9 | "properties": { 10 | "ver": { 11 | "type": "integer", 12 | "description": "The AT Protocol version of the label object." 13 | }, 14 | "src": { 15 | "type": "string", 16 | "format": "did", 17 | "description": "DID of the actor who created this label." 18 | }, 19 | "uri": { 20 | "type": "string", 21 | "format": "uri", 22 | "description": "AT URI of the record, repository (account), or other resource that this label applies to." 23 | }, 24 | "cid": { 25 | "type": "string", 26 | "format": "cid", 27 | "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 28 | }, 29 | "val": { 30 | "type": "string", 31 | "maxLength": 128, 32 | "description": "The short string name of the value or type of this label." 33 | }, 34 | "neg": { 35 | "type": "boolean", 36 | "description": "If true, this is a negation label, overwriting a previous label." 37 | }, 38 | "cts": { 39 | "type": "string", 40 | "format": "datetime", 41 | "description": "Timestamp when this label was created." 42 | }, 43 | "exp": { 44 | "type": "string", 45 | "format": "datetime", 46 | "description": "Timestamp at which this label expires (no longer applies)." 47 | }, 48 | "sig": { 49 | "type": "bytes", 50 | "description": "Signature of dag-cbor encoded label." 51 | } 52 | } 53 | }, 54 | "selfLabels": { 55 | "type": "object", 56 | "description": "Metadata tags on an atproto record, published by the author within the record.", 57 | "required": ["values"], 58 | "properties": { 59 | "values": { 60 | "type": "array", 61 | "items": { "type": "ref", "ref": "#selfLabel" }, 62 | "maxLength": 10 63 | } 64 | } 65 | }, 66 | "selfLabel": { 67 | "type": "object", 68 | "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", 69 | "required": ["val"], 70 | "properties": { 71 | "val": { 72 | "type": "string", 73 | "maxLength": 128, 74 | "description": "The short string name of the value or type of this label." 75 | } 76 | } 77 | }, 78 | "labelValueDefinition": { 79 | "type": "object", 80 | "description": "Declares a label value and its expected interpretations and behaviors.", 81 | "required": ["identifier", "severity", "blurs", "locales"], 82 | "properties": { 83 | "identifier": { 84 | "type": "string", 85 | "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 86 | "maxLength": 100, 87 | "maxGraphemes": 100 88 | }, 89 | "severity": { 90 | "type": "string", 91 | "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 92 | "knownValues": ["inform", "alert", "none"] 93 | }, 94 | "blurs": { 95 | "type": "string", 96 | "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 97 | "knownValues": ["content", "media", "none"] 98 | }, 99 | "defaultSetting": { 100 | "type": "string", 101 | "description": "The default setting for this label.", 102 | "knownValues": ["ignore", "warn", "hide"], 103 | "default": "warn" 104 | }, 105 | "adultOnly": { 106 | "type": "boolean", 107 | "description": "Does the user need to have adult content enabled in order to configure this label?" 108 | }, 109 | "locales": { 110 | "type": "array", 111 | "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } 112 | } 113 | } 114 | }, 115 | "labelValueDefinitionStrings": { 116 | "type": "object", 117 | "description": "Strings which describe the label in the UI, localized into a specific language.", 118 | "required": ["lang", "name", "description"], 119 | "properties": { 120 | "lang": { 121 | "type": "string", 122 | "description": "The code of the language these strings are written in.", 123 | "format": "language" 124 | }, 125 | "name": { 126 | "type": "string", 127 | "description": "A short human-readable name for the label.", 128 | "maxGraphemes": 64, 129 | "maxLength": 640 130 | }, 131 | "description": { 132 | "type": "string", 133 | "description": "A longer description of what the label means and why it might be applied.", 134 | "maxGraphemes": 10000, 135 | "maxLength": 100000 136 | } 137 | } 138 | }, 139 | "labelValue": { 140 | "type": "string", 141 | "knownValues": [ 142 | "!hide", 143 | "!no-promote", 144 | "!warn", 145 | "!no-unauthenticated", 146 | "dmca-violation", 147 | "doxxing", 148 | "porn", 149 | "sexual", 150 | "nudity", 151 | "nsfl", 152 | "gore" 153 | ] 154 | } 155 | } 156 | } -------------------------------------------------------------------------------- /src/lexicon/lexicons.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { LexiconDoc, Lexicons } from '@atproto/lexicon' 5 | 6 | export const schemaDict = { 7 | ComAtprotoLabelDefs: { 8 | lexicon: 1, 9 | id: 'com.atproto.label.defs', 10 | defs: { 11 | label: { 12 | type: 'object', 13 | description: 14 | 'Metadata tag on an atproto resource (eg, repo or record).', 15 | required: ['src', 'uri', 'val', 'cts'], 16 | properties: { 17 | ver: { 18 | type: 'integer', 19 | description: 'The AT Protocol version of the label object.', 20 | }, 21 | src: { 22 | type: 'string', 23 | format: 'did', 24 | description: 'DID of the actor who created this label.', 25 | }, 26 | uri: { 27 | type: 'string', 28 | format: 'uri', 29 | description: 30 | 'AT URI of the record, repository (account), or other resource that this label applies to.', 31 | }, 32 | cid: { 33 | type: 'string', 34 | format: 'cid', 35 | description: 36 | "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", 37 | }, 38 | val: { 39 | type: 'string', 40 | maxLength: 128, 41 | description: 42 | 'The short string name of the value or type of this label.', 43 | }, 44 | neg: { 45 | type: 'boolean', 46 | description: 47 | 'If true, this is a negation label, overwriting a previous label.', 48 | }, 49 | cts: { 50 | type: 'string', 51 | format: 'datetime', 52 | description: 'Timestamp when this label was created.', 53 | }, 54 | exp: { 55 | type: 'string', 56 | format: 'datetime', 57 | description: 58 | 'Timestamp at which this label expires (no longer applies).', 59 | }, 60 | sig: { 61 | type: 'bytes', 62 | description: 'Signature of dag-cbor encoded label.', 63 | }, 64 | }, 65 | }, 66 | selfLabels: { 67 | type: 'object', 68 | description: 69 | 'Metadata tags on an atproto record, published by the author within the record.', 70 | required: ['values'], 71 | properties: { 72 | values: { 73 | type: 'array', 74 | items: { 75 | type: 'ref', 76 | ref: 'lex:com.atproto.label.defs#selfLabel', 77 | }, 78 | maxLength: 10, 79 | }, 80 | }, 81 | }, 82 | selfLabel: { 83 | type: 'object', 84 | description: 85 | 'Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.', 86 | required: ['val'], 87 | properties: { 88 | val: { 89 | type: 'string', 90 | maxLength: 128, 91 | description: 92 | 'The short string name of the value or type of this label.', 93 | }, 94 | }, 95 | }, 96 | labelValueDefinition: { 97 | type: 'object', 98 | description: 99 | 'Declares a label value and its expected interpretations and behaviors.', 100 | required: ['identifier', 'severity', 'blurs', 'locales'], 101 | properties: { 102 | identifier: { 103 | type: 'string', 104 | description: 105 | "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 106 | maxLength: 100, 107 | maxGraphemes: 100, 108 | }, 109 | severity: { 110 | type: 'string', 111 | description: 112 | "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 113 | knownValues: ['inform', 'alert', 'none'], 114 | }, 115 | blurs: { 116 | type: 'string', 117 | description: 118 | "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 119 | knownValues: ['content', 'media', 'none'], 120 | }, 121 | defaultSetting: { 122 | type: 'string', 123 | description: 'The default setting for this label.', 124 | knownValues: ['ignore', 'warn', 'hide'], 125 | default: 'warn', 126 | }, 127 | adultOnly: { 128 | type: 'boolean', 129 | description: 130 | 'Does the user need to have adult content enabled in order to configure this label?', 131 | }, 132 | locales: { 133 | type: 'array', 134 | items: { 135 | type: 'ref', 136 | ref: 'lex:com.atproto.label.defs#labelValueDefinitionStrings', 137 | }, 138 | }, 139 | }, 140 | }, 141 | labelValueDefinitionStrings: { 142 | type: 'object', 143 | description: 144 | 'Strings which describe the label in the UI, localized into a specific language.', 145 | required: ['lang', 'name', 'description'], 146 | properties: { 147 | lang: { 148 | type: 'string', 149 | description: 150 | 'The code of the language these strings are written in.', 151 | format: 'language', 152 | }, 153 | name: { 154 | type: 'string', 155 | description: 'A short human-readable name for the label.', 156 | maxGraphemes: 64, 157 | maxLength: 640, 158 | }, 159 | description: { 160 | type: 'string', 161 | description: 162 | 'A longer description of what the label means and why it might be applied.', 163 | maxGraphemes: 10000, 164 | maxLength: 100000, 165 | }, 166 | }, 167 | }, 168 | labelValue: { 169 | type: 'string', 170 | knownValues: [ 171 | '!hide', 172 | '!no-promote', 173 | '!warn', 174 | '!no-unauthenticated', 175 | 'dmca-violation', 176 | 'doxxing', 177 | 'porn', 178 | 'sexual', 179 | 'nudity', 180 | 'nsfl', 181 | 'gore', 182 | ], 183 | }, 184 | }, 185 | }, 186 | AppBskyActorProfile: { 187 | lexicon: 1, 188 | id: 'app.bsky.actor.profile', 189 | defs: { 190 | main: { 191 | type: 'record', 192 | description: 'A declaration of a Bluesky account profile.', 193 | key: 'literal:self', 194 | record: { 195 | type: 'object', 196 | properties: { 197 | displayName: { 198 | type: 'string', 199 | maxGraphemes: 64, 200 | maxLength: 640, 201 | }, 202 | description: { 203 | type: 'string', 204 | description: 'Free-form profile description text.', 205 | maxGraphemes: 256, 206 | maxLength: 2560, 207 | }, 208 | avatar: { 209 | type: 'blob', 210 | description: 211 | "Small image to be displayed next to posts from account. AKA, 'profile picture'", 212 | accept: ['image/png', 'image/jpeg'], 213 | maxSize: 1000000, 214 | }, 215 | banner: { 216 | type: 'blob', 217 | description: 218 | 'Larger horizontal image to display behind profile view.', 219 | accept: ['image/png', 'image/jpeg'], 220 | maxSize: 1000000, 221 | }, 222 | labels: { 223 | type: 'union', 224 | description: 225 | 'Self-label values, specific to the Bluesky application, on the overall account.', 226 | refs: ['lex:com.atproto.label.defs#selfLabels'], 227 | }, 228 | joinedViaStarterPack: { 229 | type: 'ref', 230 | ref: 'lex:com.atproto.repo.strongRef', 231 | }, 232 | createdAt: { 233 | type: 'string', 234 | format: 'datetime', 235 | }, 236 | }, 237 | }, 238 | }, 239 | }, 240 | }, 241 | XyzStatusphereStatus: { 242 | lexicon: 1, 243 | id: 'xyz.statusphere.status', 244 | defs: { 245 | main: { 246 | type: 'record', 247 | key: 'tid', 248 | record: { 249 | type: 'object', 250 | required: ['status', 'createdAt'], 251 | properties: { 252 | status: { 253 | type: 'string', 254 | minLength: 1, 255 | maxGraphemes: 1, 256 | maxLength: 32, 257 | }, 258 | createdAt: { 259 | type: 'string', 260 | format: 'datetime', 261 | }, 262 | }, 263 | }, 264 | }, 265 | }, 266 | }, 267 | ComAtprotoRepoStrongRef: { 268 | lexicon: 1, 269 | id: 'com.atproto.repo.strongRef', 270 | description: 'A URI with a content-hash fingerprint.', 271 | defs: { 272 | main: { 273 | type: 'object', 274 | required: ['uri', 'cid'], 275 | properties: { 276 | uri: { 277 | type: 'string', 278 | format: 'at-uri', 279 | }, 280 | cid: { 281 | type: 'string', 282 | format: 'cid', 283 | }, 284 | }, 285 | }, 286 | }, 287 | }, 288 | } 289 | export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] 290 | export const lexicons: Lexicons = new Lexicons(schemas) 291 | export const ids = { 292 | ComAtprotoLabelDefs: 'com.atproto.label.defs', 293 | AppBskyActorProfile: 'app.bsky.actor.profile', 294 | XyzStatusphereStatus: 'xyz.statusphere.status', 295 | ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', 296 | } 297 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import { Agent } from '@atproto/api' 2 | import { TID } from '@atproto/common' 3 | import { OAuthResolverError } from '@atproto/oauth-client-node' 4 | import express, { Request, Response } from 'express' 5 | import { getIronSession } from 'iron-session' 6 | import type { 7 | IncomingMessage, 8 | RequestListener, 9 | ServerResponse, 10 | } from 'node:http' 11 | import path from 'node:path' 12 | 13 | import type { AppContext } from '#/context' 14 | import { env } from '#/env' 15 | import * as Profile from '#/lexicon/types/app/bsky/actor/profile' 16 | import * as Status from '#/lexicon/types/xyz/statusphere/status' 17 | import { handler } from '#/lib/http' 18 | import { ifString } from '#/lib/util' 19 | import { page } from '#/lib/view' 20 | import { home } from '#/pages/home' 21 | import { login } from '#/pages/login' 22 | 23 | // Max age, in seconds, for static routes and assets 24 | const MAX_AGE = env.NODE_ENV === 'production' ? 60 : 0 25 | 26 | type Session = { did?: string } 27 | 28 | // Helper function to get the Atproto Agent for the active session 29 | async function getSessionAgent( 30 | req: IncomingMessage, 31 | res: ServerResponse, 32 | ctx: AppContext, 33 | ) { 34 | res.setHeader('Vary', 'Cookie') 35 | 36 | const session = await getIronSession(req, res, { 37 | cookieName: 'sid', 38 | password: env.COOKIE_SECRET, 39 | }) 40 | if (!session.did) return null 41 | 42 | // This page is dynamic and should not be cached publicly 43 | res.setHeader('cache-control', `max-age=${MAX_AGE}, private`) 44 | 45 | try { 46 | const oauthSession = await ctx.oauthClient.restore(session.did) 47 | return oauthSession ? new Agent(oauthSession) : null 48 | } catch (err) { 49 | ctx.logger.warn({ err }, 'oauth restore failed') 50 | await session.destroy() 51 | return null 52 | } 53 | } 54 | 55 | export const createRouter = (ctx: AppContext): RequestListener => { 56 | const router = express() 57 | 58 | // Static assets 59 | router.use( 60 | '/public', 61 | express.static(path.join(__dirname, 'pages', 'public'), { 62 | maxAge: MAX_AGE * 1000, 63 | }), 64 | ) 65 | 66 | // OAuth metadata 67 | router.get( 68 | '/oauth-client-metadata.json', 69 | handler((req, res) => { 70 | res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 71 | res.json(ctx.oauthClient.clientMetadata) 72 | }), 73 | ) 74 | 75 | // Public keys 76 | router.get( 77 | '/.well-known/jwks.json', 78 | handler((req, res) => { 79 | res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 80 | res.json(ctx.oauthClient.jwks) 81 | }), 82 | ) 83 | 84 | // OAuth callback to complete session creation 85 | router.get( 86 | '/oauth/callback', 87 | handler(async (req, res) => { 88 | res.setHeader('cache-control', 'no-store') 89 | 90 | const params = new URLSearchParams(req.originalUrl.split('?')[1]) 91 | try { 92 | // Load the session cookie 93 | const session = await getIronSession(req, res, { 94 | cookieName: 'sid', 95 | password: env.COOKIE_SECRET, 96 | }) 97 | 98 | // If the user is already signed in, destroy the old credentials 99 | if (session.did) { 100 | try { 101 | const oauthSession = await ctx.oauthClient.restore(session.did) 102 | if (oauthSession) oauthSession.signOut() 103 | } catch (err) { 104 | ctx.logger.warn({ err }, 'oauth restore failed') 105 | } 106 | } 107 | 108 | // Complete the OAuth flow 109 | const oauth = await ctx.oauthClient.callback(params) 110 | 111 | // Update the session cookie 112 | session.did = oauth.session.did 113 | 114 | await session.save() 115 | } catch (err) { 116 | ctx.logger.error({ err }, 'oauth callback failed') 117 | } 118 | 119 | return res.redirect('/') 120 | }), 121 | ) 122 | 123 | // Login page 124 | router.get( 125 | '/login', 126 | handler(async (req, res) => { 127 | res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 128 | res.type('html').send(page(login({}))) 129 | }), 130 | ) 131 | 132 | // Login handler 133 | router.post( 134 | '/login', 135 | express.urlencoded(), 136 | handler(async (req, res) => { 137 | // Never store this route 138 | res.setHeader('cache-control', 'no-store') 139 | 140 | // Initiate the OAuth flow 141 | try { 142 | // Validate input: can be a handle, a DID or a service URL (PDS). 143 | const input = ifString(req.body.input) 144 | if (!input) { 145 | throw new Error('Invalid input') 146 | } 147 | 148 | // Initiate the OAuth flow 149 | const url = await ctx.oauthClient.authorize(input, { 150 | scope: 'atproto transition:generic', 151 | }) 152 | 153 | res.redirect(url.toString()) 154 | } catch (err) { 155 | ctx.logger.error({ err }, 'oauth authorize failed') 156 | 157 | const error = err instanceof Error ? err.message : 'unexpected error' 158 | 159 | return res.type('html').send(page(login({ error }))) 160 | } 161 | }), 162 | ) 163 | 164 | // Signup 165 | router.get( 166 | '/signup', 167 | handler(async (req, res) => { 168 | res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 169 | 170 | try { 171 | const service = env.PDS_URL ?? 'https://bsky.social' 172 | const url = await ctx.oauthClient.authorize(service, { 173 | scope: 'atproto transition:generic', 174 | }) 175 | res.redirect(url.toString()) 176 | } catch (err) { 177 | ctx.logger.error({ err }, 'oauth authorize failed') 178 | res.type('html').send( 179 | page( 180 | login({ 181 | error: 182 | err instanceof OAuthResolverError 183 | ? err.message 184 | : "couldn't initiate login", 185 | }), 186 | ), 187 | ) 188 | } 189 | }), 190 | ) 191 | 192 | // Logout handler 193 | router.post( 194 | '/logout', 195 | handler(async (req, res) => { 196 | // Never store this route 197 | res.setHeader('cache-control', 'no-store') 198 | 199 | const session = await getIronSession(req, res, { 200 | cookieName: 'sid', 201 | password: env.COOKIE_SECRET, 202 | }) 203 | 204 | // Revoke credentials on the server 205 | if (session.did) { 206 | try { 207 | const oauthSession = await ctx.oauthClient.restore(session.did) 208 | if (oauthSession) await oauthSession.signOut() 209 | } catch (err) { 210 | ctx.logger.warn({ err }, 'Failed to revoke credentials') 211 | } 212 | } 213 | 214 | session.destroy() 215 | 216 | return res.redirect('/') 217 | }), 218 | ) 219 | 220 | // Homepage 221 | router.get( 222 | '/', 223 | handler(async (req, res) => { 224 | // If the user is signed in, get an agent which communicates with their server 225 | const agent = await getSessionAgent(req, res, ctx) 226 | 227 | // Fetch data stored in our SQLite 228 | const statuses = await ctx.db 229 | .selectFrom('status') 230 | .selectAll() 231 | .orderBy('indexedAt', 'desc') 232 | .limit(10) 233 | .execute() 234 | const myStatus = agent 235 | ? await ctx.db 236 | .selectFrom('status') 237 | .selectAll() 238 | .where('authorDid', '=', agent.assertDid) 239 | .orderBy('indexedAt', 'desc') 240 | .executeTakeFirst() 241 | : undefined 242 | 243 | // Map user DIDs to their domain-name handles 244 | const didHandleMap = await ctx.resolver.resolveDidsToHandles( 245 | statuses.map((s) => s.authorDid), 246 | ) 247 | 248 | if (!agent) { 249 | // Serve the logged-out view 250 | return res.type('html').send(page(home({ statuses, didHandleMap }))) 251 | } 252 | 253 | // Fetch additional information about the logged-in user 254 | const profileResponse = await agent.com.atproto.repo 255 | .getRecord({ 256 | repo: agent.assertDid, 257 | collection: 'app.bsky.actor.profile', 258 | rkey: 'self', 259 | }) 260 | .catch(() => undefined) 261 | 262 | const profileRecord = profileResponse?.data 263 | 264 | const profile = 265 | profileRecord && 266 | Profile.isRecord(profileRecord.value) && 267 | Profile.validateRecord(profileRecord.value).success 268 | ? profileRecord.value 269 | : {} 270 | 271 | // Serve the logged-in view 272 | res 273 | .type('html') 274 | .send(page(home({ statuses, didHandleMap, profile, myStatus }))) 275 | }), 276 | ) 277 | 278 | // "Set status" handler 279 | router.post( 280 | '/status', 281 | express.urlencoded(), 282 | handler(async (req, res) => { 283 | // If the user is signed in, get an agent which communicates with their server 284 | const agent = await getSessionAgent(req, res, ctx) 285 | if (!agent) { 286 | return res 287 | .status(401) 288 | .type('html') 289 | .send('

Error: Session required

') 290 | } 291 | 292 | // Construct their status record 293 | const record = { 294 | $type: 'xyz.statusphere.status', 295 | status: req.body?.status, 296 | createdAt: new Date().toISOString(), 297 | } 298 | 299 | // Make sure the record generated from the input is valid 300 | if (!Status.validateRecord(record).success) { 301 | return res 302 | .status(400) 303 | .type('html') 304 | .send('

Error: Invalid status

') 305 | } 306 | 307 | let uri 308 | try { 309 | // Write the status record to the user's repository 310 | const res = await agent.com.atproto.repo.putRecord({ 311 | repo: agent.assertDid, 312 | collection: 'xyz.statusphere.status', 313 | rkey: TID.nextStr(), 314 | record, 315 | validate: false, 316 | }) 317 | uri = res.data.uri 318 | } catch (err) { 319 | ctx.logger.warn({ err }, 'failed to write record') 320 | return res 321 | .status(500) 322 | .type('html') 323 | .send('

Error: Failed to write record

') 324 | } 325 | 326 | try { 327 | // Optimistically update our SQLite 328 | // This isn't strictly necessary because the write event will be 329 | // handled in #/firehose/ingestor.ts, but it ensures that future reads 330 | // will be up-to-date after this method finishes. 331 | await ctx.db 332 | .insertInto('status') 333 | .values({ 334 | uri, 335 | authorDid: agent.assertDid, 336 | status: record.status, 337 | createdAt: record.createdAt, 338 | indexedAt: new Date().toISOString(), 339 | }) 340 | .execute() 341 | } catch (err) { 342 | ctx.logger.warn( 343 | { err }, 344 | 'failed to update computed view; ignoring as it should be caught by the firehose', 345 | ) 346 | } 347 | 348 | return res.redirect('/') 349 | }), 350 | ) 351 | 352 | return router 353 | } 354 | --------------------------------------------------------------------------------