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 { AtpAgent } from "@atproto/api";
2import type { Logger } from "@atbb/logger";
3import { isAuthError, isNetworkError } from "./errors.js";
4
5export type ForumAgentStatus =
6 | "initializing"
7 | "authenticated"
8 | "retrying"
9 | "failed"
10 | "unavailable";
11
12export interface ForumAgentState {
13 status: ForumAgentStatus;
14 authenticated: boolean;
15 lastAuthAttempt?: Date;
16 nextRetryAt?: Date;
17 retryCount?: number;
18 error?: string;
19}
20
21/**
22 * ForumAgent manages authentication as the Forum DID for server-side PDS writes.
23 * Handles session lifecycle with smart retry logic and graceful degradation.
24 */
25export class ForumAgent {
26 private agent: AtpAgent;
27 private status: ForumAgentStatus = "initializing";
28 private authenticated = false;
29 private retryCount = 0;
30 private readonly maxRetries = 5;
31 private refreshTimer: NodeJS.Timeout | null = null;
32 private retryTimer: NodeJS.Timeout | null = null;
33 private isRefreshing = false;
34 private lastError: string | null = null;
35 private lastAuthAttempt: Date | null = null;
36 private nextRetryAt: Date | null = null;
37
38 constructor(
39 private readonly pdsUrl: string,
40 private readonly handle: string,
41 private readonly password: string,
42 private readonly logger: Logger
43 ) {
44 this.agent = new AtpAgent({ service: pdsUrl });
45 }
46
47 /**
48 * Initialize the agent by attempting authentication.
49 * Never throws - returns gracefully even on failure.
50 */
51 async initialize(): Promise<void> {
52 await this.attemptAuth();
53 }
54
55 /**
56 * Check if the agent is authenticated and ready for use.
57 */
58 isAuthenticated(): boolean {
59 return this.authenticated;
60 }
61
62 /**
63 * Get the underlying AtpAgent for PDS operations.
64 * Returns null if not authenticated.
65 */
66 getAgent(): AtpAgent | null {
67 return this.authenticated ? this.agent : null;
68 }
69
70 /**
71 * Get current agent status for health reporting.
72 */
73 getStatus(): ForumAgentState {
74 const state: ForumAgentState = {
75 status: this.status,
76 authenticated: this.authenticated,
77 };
78
79 if (this.lastAuthAttempt) {
80 state.lastAuthAttempt = this.lastAuthAttempt;
81 }
82
83 if (this.status === "retrying") {
84 state.retryCount = this.retryCount;
85 if (this.nextRetryAt) {
86 state.nextRetryAt = this.nextRetryAt;
87 }
88 }
89
90 if (this.lastError) {
91 state.error = this.lastError;
92 }
93
94 return state;
95 }
96
97 /**
98 * Attempt to authenticate with the PDS.
99 * Implements smart retry logic and error classification.
100 */
101 private async attemptAuth(): Promise<void> {
102 try {
103 this.lastAuthAttempt = new Date();
104 await this.agent.login({
105 identifier: this.handle,
106 password: this.password,
107 });
108
109 // Success!
110 this.status = "authenticated";
111 this.authenticated = true;
112 this.retryCount = 0;
113 this.lastError = null;
114 this.nextRetryAt = null;
115
116 this.logger.info("ForumAgent authenticated", {
117 handle: this.handle,
118 did: this.agent.session?.did,
119 });
120
121 // Schedule proactive session refresh
122 this.scheduleRefresh();
123 } catch (error) {
124 this.authenticated = false;
125
126 // Check error type for smart retry
127 if (isAuthError(error)) {
128 // Permanent failure - don't retry to avoid account lockouts
129 this.status = "failed";
130 this.lastError = "Authentication failed: invalid credentials";
131 this.retryCount = 0;
132 this.nextRetryAt = null;
133
134 this.logger.error("ForumAgent auth permanent failure", {
135 handle: this.handle,
136 error: error instanceof Error ? error.message : String(error),
137 });
138 return;
139 }
140
141 if (isNetworkError(error) && this.retryCount < this.maxRetries) {
142 // Transient failure - retry with exponential backoff
143 this.status = "retrying";
144 // Retry delays: 10s, 30s, 1m, 5m, 10m (max)
145 const retryDelays = [10000, 30000, 60000, 300000, 600000];
146 const delay = retryDelays[Math.min(this.retryCount, retryDelays.length - 1)];
147 this.retryCount++;
148 this.nextRetryAt = new Date(Date.now() + delay);
149 this.lastError = "Connection to PDS temporarily unavailable";
150
151 this.logger.warn("ForumAgent auth retrying", {
152 handle: this.handle,
153 attempt: this.retryCount,
154 maxAttempts: this.maxRetries,
155 retryInMs: delay,
156 error: error instanceof Error ? error.message : String(error),
157 });
158
159 this.retryTimer = setTimeout(() => {
160 this.attemptAuth();
161 }, delay);
162 return;
163 }
164
165 // Unknown error or max retries exceeded
166 this.status = "failed";
167 this.lastError =
168 this.retryCount >= this.maxRetries
169 ? "Auth failed after max retries"
170 : "Authentication failed";
171 this.nextRetryAt = null;
172
173 this.logger.error("ForumAgent auth failed", {
174 handle: this.handle,
175 attempts: this.retryCount + 1,
176 reason:
177 this.retryCount >= this.maxRetries
178 ? "max retries exceeded"
179 : "unknown error",
180 error: error instanceof Error ? error.message : String(error),
181 });
182 }
183 }
184
185 /**
186 * Schedule proactive session refresh to prevent expiry.
187 * Runs every 30 minutes to keep session alive.
188 */
189 private scheduleRefresh(): void {
190 // Clear any existing timer
191 if (this.refreshTimer) {
192 clearTimeout(this.refreshTimer);
193 }
194
195 // Schedule refresh check every 30 minutes
196 this.refreshTimer = setTimeout(async () => {
197 await this.refreshSession();
198 }, 30 * 60 * 1000); // 30 minutes
199 }
200
201 /**
202 * Attempt to refresh the current session.
203 * Falls back to full re-auth if refresh fails.
204 */
205 private async refreshSession(): Promise<void> {
206 if (!this.agent || !this.authenticated || !this.agent.session) {
207 return;
208 }
209
210 // Prevent concurrent refresh
211 if (this.isRefreshing) {
212 return;
213 }
214
215 this.isRefreshing = true;
216 try {
217 await this.agent.resumeSession(this.agent.session);
218
219 this.logger.debug("ForumAgent session refreshed", {
220 did: this.agent.session?.did,
221 });
222
223 // Schedule next refresh
224 this.scheduleRefresh();
225 } catch (error) {
226 this.logger.warn("ForumAgent session refresh failed", {
227 error: error instanceof Error ? error.message : String(error),
228 });
229
230 // Refresh failed - transition to retrying and attempt full re-auth
231 this.authenticated = false;
232 this.status = "retrying";
233 this.retryCount = 0;
234 await this.attemptAuth();
235 } finally {
236 this.isRefreshing = false;
237 }
238 }
239
240 /**
241 * Clean up resources (timers, etc).
242 */
243 async shutdown(): Promise<void> {
244 if (this.refreshTimer) {
245 clearTimeout(this.refreshTimer);
246 this.refreshTimer = null;
247 }
248 if (this.retryTimer) {
249 clearTimeout(this.retryTimer);
250 this.retryTimer = null;
251 }
252 }
253}