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
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}