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 { LogLevel } from "@atbb/logger";
2
3export interface AppConfig {
4 port: number;
5 forumDid: string;
6 pdsUrl: string;
7 databaseUrl: string;
8 jetstreamUrl: string;
9 // Logging
10 logLevel: LogLevel;
11 // OAuth configuration
12 oauthPublicUrl: string;
13 sessionSecret: string;
14 sessionTtlDays: number;
15 redisUrl?: string;
16 // Forum credentials (optional - for server-side PDS writes)
17 forumHandle?: string;
18 forumPassword?: string;
19 // Backfill configuration
20 backfillRateLimit: number;
21 backfillConcurrency: number;
22 backfillCursorMaxAgeHours: number;
23}
24
25export function loadConfig(): AppConfig {
26 const config: AppConfig = {
27 port: parseInt(process.env.PORT ?? "3000", 10),
28 forumDid: process.env.FORUM_DID ?? "",
29 pdsUrl: process.env.PDS_URL ?? "https://bsky.social",
30 databaseUrl: process.env.DATABASE_URL ?? "",
31 jetstreamUrl:
32 process.env.JETSTREAM_URL ??
33 "wss://jetstream2.us-east.bsky.network/subscribe",
34 // Logging
35 logLevel: parseLogLevel(process.env.LOG_LEVEL),
36 // OAuth configuration
37 oauthPublicUrl: process.env.OAUTH_PUBLIC_URL ?? `http://localhost:${process.env.PORT ?? "3000"}`,
38 sessionSecret: process.env.SESSION_SECRET ?? "",
39 sessionTtlDays: parseInt(process.env.SESSION_TTL_DAYS ?? "7", 10),
40 redisUrl: process.env.REDIS_URL,
41 // Forum credentials (optional - for server-side PDS writes)
42 forumHandle: process.env.FORUM_HANDLE,
43 forumPassword: process.env.FORUM_PASSWORD,
44 // Backfill configuration
45 backfillRateLimit: parseInt(process.env.BACKFILL_RATE_LIMIT ?? "10", 10),
46 backfillConcurrency: parseInt(process.env.BACKFILL_CONCURRENCY ?? "10", 10),
47 backfillCursorMaxAgeHours: parseInt(process.env.BACKFILL_CURSOR_MAX_AGE_HOURS ?? "48", 10),
48 };
49
50 validateOAuthConfig(config);
51
52 return config;
53}
54
55const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error", "fatal"];
56
57function parseLogLevel(value: string | undefined): LogLevel {
58 if (value !== undefined && !(LOG_LEVELS as readonly string[]).includes(value)) {
59 console.warn(
60 `Invalid LOG_LEVEL "${value}". Must be one of: ${LOG_LEVELS.join(", ")}. Defaulting to "info".`
61 );
62 }
63 return (LOG_LEVELS as readonly string[]).includes(value ?? "")
64 ? (value as LogLevel)
65 : "info";
66}
67
68/**
69 * Validate OAuth-related configuration at startup.
70 * Fails fast if required config is missing or invalid.
71 */
72function validateOAuthConfig(config: AppConfig): void {
73 // Check environment-specific requirements first
74 if (!process.env.OAUTH_PUBLIC_URL && process.env.NODE_ENV === 'production') {
75 throw new Error('OAUTH_PUBLIC_URL is required in production');
76 }
77
78 // Validate OAuth public URL format
79 const url = new URL(config.oauthPublicUrl);
80
81 // AT Proto OAuth requires HTTPS in production (or proper domain in dev)
82 if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:') {
83 throw new Error(
84 'OAUTH_PUBLIC_URL must use HTTPS in production. Your OAuth client_id must be publicly accessible over HTTPS.'
85 );
86 }
87
88 // Warn about localhost usage (OAuth client will reject this)
89 if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname.endsWith('.local')) {
90 console.warn(
91 '\n⚠️ WARNING: AT Protocol OAuth requires a publicly accessible HTTPS URL.\n' +
92 'Local development URLs (localhost, 127.0.0.1) will not work for OAuth.\n\n' +
93 'Options for local development:\n' +
94 ' 1. Use ngrok or similar tunneling service: OAUTH_PUBLIC_URL=https://abc123.ngrok.io\n' +
95 ' 2. Use a local domain with mkcert for HTTPS: OAUTH_PUBLIC_URL=https://atbb.local\n' +
96 ' 3. Deploy to a staging environment with proper HTTPS\n\n' +
97 'See https://atproto.com/specs/oauth for details.\n'
98 );
99 }
100
101 // Then check global requirements
102 if (!config.sessionSecret || config.sessionSecret.length < 32) {
103 throw new Error(
104 'SESSION_SECRET must be at least 32 characters. Generate one with: openssl rand -hex 32'
105 );
106 }
107
108 // Warn about in-memory sessions in production
109 if (!config.redisUrl && process.env.NODE_ENV === 'production') {
110 console.warn(
111 '⚠️ Using in-memory session storage in production. Sessions will be lost on restart.'
112 );
113 }
114}