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