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 root/atb-56-theme-caching-layer 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}