WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at main 148 lines 4.9 kB view raw
1import type { Database } from "@atbb/db"; 2import { createDb } from "@atbb/db"; 3import type { Logger } from "@atbb/logger"; 4import { createLogger } from "@atbb/logger"; 5import { FirehoseService } from "./firehose.js"; 6import { NodeOAuthClient } from "@atproto/oauth-client-node"; 7import { OAuthStateStore, OAuthSessionStore } from "./oauth-stores.js"; 8import { CookieSessionStore } from "./cookie-session-store.js"; 9import { ForumAgent } from "@atbb/atproto"; 10import type { AppConfig } from "./config.js"; 11import { BackfillManager } from "./backfill-manager.js"; 12 13/** 14 * Application context holding all shared dependencies. 15 * This interface defines the contract for dependency injection. 16 */ 17export interface AppContext { 18 config: AppConfig; 19 logger: Logger; 20 db: Database; 21 firehose: FirehoseService; 22 oauthClient: NodeOAuthClient; 23 oauthStateStore: OAuthStateStore; 24 oauthSessionStore: OAuthSessionStore; 25 cookieSessionStore: CookieSessionStore; 26 forumAgent: ForumAgent | null; 27 backfillManager: BackfillManager | null; 28} 29 30/** 31 * Create and initialize the application context with all dependencies. 32 * This is the composition root where we wire up all dependencies. 33 */ 34export async function createAppContext(config: AppConfig): Promise<AppContext> { 35 const logger = createLogger({ 36 service: "atbb-appview", 37 version: "0.1.0", 38 environment: process.env.NODE_ENV ?? "development", 39 level: config.logLevel, 40 }); 41 42 const db = createDb(config.databaseUrl); 43 const firehose = new FirehoseService(db, config.jetstreamUrl, logger); 44 45 // Initialize OAuth stores 46 const oauthStateStore = new OAuthStateStore(); 47 const oauthSessionStore = new OAuthSessionStore(); 48 const cookieSessionStore = new CookieSessionStore(); 49 50 // Simple in-memory lock for single-instance deployments 51 // For multi-instance production, use Redis-based locking (e.g., with redlock) 52 const locks = new Map<string, Promise<unknown>>(); 53 const requestLock = async <T>(key: string, fn: () => T | PromiseLike<T>): Promise<T> => { 54 // Wait for any existing lock on this key 55 while (locks.has(key)) { 56 await locks.get(key); 57 } 58 59 // Acquire lock 60 const promise = Promise.resolve(fn()); 61 locks.set(key, promise); 62 63 try { 64 return await promise; 65 } finally { 66 // Release lock 67 locks.delete(key); 68 } 69 }; 70 71 // Replace localhost with 127.0.0.1 for RFC 8252 compliance 72 const oauthUrl = config.oauthPublicUrl.replace('localhost', '127.0.0.1'); 73 74 // Initialize OAuth client with configuration 75 const oauthClient = new NodeOAuthClient({ 76 clientMetadata: { 77 client_id: `${oauthUrl}/.well-known/oauth-client-metadata`, 78 client_name: "atBB Forum", 79 client_uri: oauthUrl, 80 redirect_uris: [`${oauthUrl}/api/auth/callback`], 81 // Minimal-privilege scopes: 82 // include:space.atbb.authFull — permission-set published on atbb.space's PDS; 83 // grants repo write access to space.atbb.post, space.atbb.reaction, space.atbb.membership 84 // rpc:app.bsky.actor.getProfile?aud=... — grants getProfile against the Bluesky AppView; 85 // %23 is the literal encoding required by the PDS for the DID fragment separator 86 scope: "atproto include:space.atbb.authFull rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 87 grant_types: ["authorization_code", "refresh_token"], 88 response_types: ["code"], 89 application_type: "web", 90 token_endpoint_auth_method: "none", 91 dpop_bound_access_tokens: true, 92 }, 93 stateStore: oauthStateStore, 94 sessionStore: oauthSessionStore, 95 requestLock, 96 // Allow HTTP for development (never use in production!) 97 allowHttp: process.env.NODE_ENV !== "production", 98 }); 99 100 // Initialize ForumAgent (soft failure - never throws) 101 let forumAgent: ForumAgent | null = null; 102 if (config.forumHandle && config.forumPassword) { 103 forumAgent = new ForumAgent( 104 config.pdsUrl, 105 config.forumHandle, 106 config.forumPassword, 107 logger 108 ); 109 await forumAgent.initialize(); 110 } else { 111 logger.warn("ForumAgent credentials missing", { 112 operation: "createAppContext", 113 reason: "Missing FORUM_HANDLE or FORUM_PASSWORD environment variables", 114 }); 115 } 116 117 return { 118 config, 119 logger, 120 db, 121 firehose, 122 oauthClient, 123 oauthStateStore, 124 oauthSessionStore, 125 cookieSessionStore, 126 forumAgent, 127 backfillManager: new BackfillManager(db, config, logger), 128 }; 129} 130 131/** 132 * Cleanup and release resources held by the application context. 133 */ 134export async function destroyAppContext(ctx: AppContext): Promise<void> { 135 await ctx.firehose.stop(); 136 137 if (ctx.forumAgent) { 138 await ctx.forumAgent.shutdown(); 139 } 140 141 // Clean up OAuth store timers 142 ctx.oauthStateStore.destroy(); 143 ctx.oauthSessionStore.destroy(); 144 ctx.cookieSessionStore.destroy(); 145 146 // Flush pending log records and release OTel resources 147 await ctx.logger.shutdown(); 148}