import type { Logger } from '../utils/logger.js'; export type AccountAuthStatus = | 'login-required' | 'checking' | 'authenticated'; export interface AuthenticatedAccount { accountId: string; redditUsername: string; matrixUserId: string; } export interface AccountAuthSnapshot { status: AccountAuthStatus; accountId: string | null; redditUsername: string | null; matrixUserId: string | null; reason: string | null; } export interface AccountAuthGate { isFullyAuthenticated(): boolean; invalidate(reason: string): void; } export type AccountAuthenticator = () => Promise; export type AccountAuthenticatedListener = (account: AuthenticatedAccount) => void | Promise; export interface AccountAuthContextOptions { leaseMs?: number; retryDelayMs?: number; now?: () => number; } export interface CurrentAccountAuthentication { getRedditUsername(): Promise; refreshMatrixCredentials(): Promise<{ userId: string } | null>; } export async function authenticateCurrentAccount( authentication: CurrentAccountAuthentication, ): Promise { const redditUsername = await authentication.getRedditUsername(); if (!redditUsername) return null; const credentials = await authentication.refreshMatrixCredentials(); if (!credentials?.userId) return null; return { accountId: credentials.userId, redditUsername, matrixUserId: credentials.userId, }; } export const denyAccountAuth: AccountAuthGate = { isFullyAuthenticated: () => false, invalidate: () => undefined, }; export class AccountAuthContext implements AccountAuthGate { private snapshot: AccountAuthSnapshot = { status: 'login-required', accountId: null, redditUsername: null, matrixUserId: null, reason: 'Full Reddit and Matrix authentication has not been verified', }; private authenticator: AccountAuthenticator | null = null; private authenticationInFlight: Promise | null = null; private readonly listeners = new Set(); private authenticatedUntil = 0; private nextAuthenticationAttemptAt = 0; private readonly leaseMs: number; private readonly retryDelayMs: number; private readonly now: () => number; constructor( private readonly logger: Logger, options: AccountAuthContextOptions = {}, ) { this.leaseMs = options.leaseMs ?? 30 * 60 * 1000; this.retryDelayMs = options.retryDelayMs ?? 60 * 1000; this.now = options.now ?? Date.now; } setAuthenticator(authenticator: AccountAuthenticator): void { this.authenticator = authenticator; } isFullyAuthenticated(): boolean { if (this.snapshot.status !== 'authenticated') return false; if (this.now() < this.authenticatedUntil) return true; this.invalidate('Full account authentication lease expired'); return false; } getSnapshot(): AccountAuthSnapshot { this.isFullyAuthenticated(); return { ...this.snapshot }; } markAuthenticated(account: AuthenticatedAccount): void { if (!account.accountId || !account.redditUsername || !account.matrixUserId) { throw new Error('A fully authenticated account requires Reddit and Matrix identities'); } if (account.accountId !== account.matrixUserId) { throw new Error('Authenticated account ID must match the Matrix user ID'); } const changedAccount = this.snapshot.accountId !== account.accountId; const wasAuthenticated = this.isFullyAuthenticated(); this.snapshot = { status: 'authenticated', accountId: account.accountId, redditUsername: account.redditUsername, matrixUserId: account.matrixUserId, reason: null, }; this.authenticatedUntil = this.now() + this.leaseMs; this.nextAuthenticationAttemptAt = 0; if (!wasAuthenticated || changedAccount) { for (const listener of this.listeners) { void Promise.resolve(listener(account)).catch((error) => { this.logger.error('Authenticated account listener failed:', error); }); } } } invalidate(reason: string): void { this.authenticatedUntil = 0; this.snapshot = { status: 'login-required', accountId: null, redditUsername: null, matrixUserId: null, reason, }; } onAuthenticated(listener: AccountAuthenticatedListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } ensureFullyAuthenticated(): Promise { if (this.isFullyAuthenticated()) return Promise.resolve(true); if (this.authenticationInFlight) return this.authenticationInFlight; if (!this.authenticator) return Promise.resolve(false); if (this.now() < this.nextAuthenticationAttemptAt) return Promise.resolve(false); this.snapshot = { ...this.snapshot, status: 'checking', reason: null }; this.authenticationInFlight = this.authenticate().finally(() => { this.authenticationInFlight = null; }); return this.authenticationInFlight; } private async authenticate(): Promise { try { const account = await this.authenticator!(); if (!account) { this.invalidate('Full Reddit and Matrix authentication is required'); this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs; return false; } this.markAuthenticated(account); return true; } catch (error) { this.invalidate(error instanceof Error ? error.message : String(error)); this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs; this.logger.warn('Full account authentication failed:', error); return false; } } }