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 253 lines 7.0 kB view raw
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}