import type { Database } from "@atbb/db"; import { createDb } from "@atbb/db"; import type { Logger } from "@atbb/logger"; import { createLogger } from "@atbb/logger"; import { FirehoseService } from "./firehose.js"; import { NodeOAuthClient } from "@atproto/oauth-client-node"; import { OAuthStateStore, OAuthSessionStore } from "./oauth-stores.js"; import { CookieSessionStore } from "./cookie-session-store.js"; import { ForumAgent } from "@atbb/atproto"; import type { AppConfig } from "./config.js"; import { BackfillManager } from "./backfill-manager.js"; /** * Application context holding all shared dependencies. * This interface defines the contract for dependency injection. */ export interface AppContext { config: AppConfig; logger: Logger; db: Database; firehose: FirehoseService; oauthClient: NodeOAuthClient; oauthStateStore: OAuthStateStore; oauthSessionStore: OAuthSessionStore; cookieSessionStore: CookieSessionStore; forumAgent: ForumAgent | null; backfillManager: BackfillManager | null; } /** * Create and initialize the application context with all dependencies. * This is the composition root where we wire up all dependencies. */ export async function createAppContext(config: AppConfig): Promise { const logger = createLogger({ service: "atbb-appview", version: "0.1.0", environment: process.env.NODE_ENV ?? "development", level: config.logLevel, }); const db = createDb(config.databaseUrl); const firehose = new FirehoseService(db, config.jetstreamUrl, logger); // Initialize OAuth stores const oauthStateStore = new OAuthStateStore(); const oauthSessionStore = new OAuthSessionStore(); const cookieSessionStore = new CookieSessionStore(); // Simple in-memory lock for single-instance deployments // For multi-instance production, use Redis-based locking (e.g., with redlock) const locks = new Map>(); const requestLock = async (key: string, fn: () => T | PromiseLike): Promise => { // Wait for any existing lock on this key while (locks.has(key)) { await locks.get(key); } // Acquire lock const promise = Promise.resolve(fn()); locks.set(key, promise); try { return await promise; } finally { // Release lock locks.delete(key); } }; // Replace localhost with 127.0.0.1 for RFC 8252 compliance const oauthUrl = config.oauthPublicUrl.replace('localhost', '127.0.0.1'); // Initialize OAuth client with configuration const oauthClient = new NodeOAuthClient({ clientMetadata: { client_id: `${oauthUrl}/.well-known/oauth-client-metadata`, client_name: "atBB Forum", client_uri: oauthUrl, redirect_uris: [`${oauthUrl}/api/auth/callback`], // Minimal-privilege scopes: // include:space.atbb.authFull — permission-set published on atbb.space's PDS; // grants repo write access to space.atbb.post, space.atbb.reaction, space.atbb.membership // rpc:app.bsky.actor.getProfile?aud=... — grants getProfile against the Bluesky AppView; // %23 is the literal encoding required by the PDS for the DID fragment separator scope: "atproto include:space.atbb.authFull rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], application_type: "web", token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, }, stateStore: oauthStateStore, sessionStore: oauthSessionStore, requestLock, // Allow HTTP for development (never use in production!) allowHttp: process.env.NODE_ENV !== "production", }); // Initialize ForumAgent (soft failure - never throws) let forumAgent: ForumAgent | null = null; if (config.forumHandle && config.forumPassword) { forumAgent = new ForumAgent( config.pdsUrl, config.forumHandle, config.forumPassword, logger ); await forumAgent.initialize(); } else { logger.warn("ForumAgent credentials missing", { operation: "createAppContext", reason: "Missing FORUM_HANDLE or FORUM_PASSWORD environment variables", }); } return { config, logger, db, firehose, oauthClient, oauthStateStore, oauthSessionStore, cookieSessionStore, forumAgent, backfillManager: new BackfillManager(db, config, logger), }; } /** * Cleanup and release resources held by the application context. */ export async function destroyAppContext(ctx: AppContext): Promise { await ctx.firehose.stop(); if (ctx.forumAgent) { await ctx.forumAgent.shutdown(); } // Clean up OAuth store timers ctx.oauthStateStore.destroy(); ctx.oauthSessionStore.destroy(); ctx.cookieSessionStore.destroy(); // Flush pending log records and release OTel resources await ctx.logger.shutdown(); }