From 4db6de8bf543fbaa220f390ae4819cf2d059d8d5 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 15:40:55 +0100 Subject: [PATCH 1/4] Stop Matrix refresh loop when login is unavailable --- src/services/chat/service.ts | 22 ++++++++- tests/chat-service-username-persist.test.ts | 54 ++++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index 3a324f0..0506e67 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -25,6 +25,13 @@ export interface TokenRefresher { refresh(): Promise; } +class MatrixAuthenticationUnavailableError extends Error { + constructor(cause: unknown) { + super('Matrix authentication could not be restored', { cause }); + this.name = 'MatrixAuthenticationUnavailableError'; + } +} + /** * Optional Reddit-API hook for resolving t2 IDs to real usernames. Without * it, conversations show up as `t2_abc123` (stable but ugly). With it, the @@ -174,7 +181,11 @@ export class ChatService { } catch (err) { if (err instanceof MatrixAuthError && this.tokenRefresher) { this.logger.warn('[chat] Matrix token rejected — refreshing via browser session'); - await this.tokenRefresher.refresh(); + try { + await this.tokenRefresher.refresh(); + } catch (refreshError) { + throw new MatrixAuthenticationUnavailableError(refreshError); + } return await fn(); } throw err; @@ -331,6 +342,7 @@ export class ChatService { try { await this.loadDetails(chat); } catch (err) { + if (err instanceof MatrixAuthenticationUnavailableError) throw err; this.logger.error(`[chat] backfill: failed details for ${chat.roomName}`, err); } done++; @@ -1062,6 +1074,10 @@ export class ChatService { }) as { state_key?: string } | undefined; t2 = otherMember?.state_key?.match(/^@(t2_[^:]+):/)?.[1]; } catch (err) { + if (err instanceof MatrixAuthenticationUnavailableError) { + this.logger.warn('[chat] username enrichment paused until Reddit login is restored'); + return; + } this.logger.debug(`[chat] /state lookup failed for ${chat.roomId}:`, err); } if (!t2) { @@ -1074,6 +1090,10 @@ export class ChatService { )?.sender; t2 = otherSender?.match(/^@(t2_[^:]+):/)?.[1]; } catch (err) { + if (err instanceof MatrixAuthenticationUnavailableError) { + this.logger.warn('[chat] username enrichment paused until Reddit login is restored'); + return; + } this.logger.debug(`[chat] /messages lookup failed for ${chat.roomId}:`, err); } } diff --git a/tests/chat-service-username-persist.test.ts b/tests/chat-service-username-persist.test.ts index a504b5a..afefccb 100644 --- a/tests/chat-service-username-persist.test.ts +++ b/tests/chat-service-username-persist.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import os from 'os'; import path from 'path'; import Database from 'better-sqlite3'; @@ -6,7 +6,7 @@ import { runMigrations } from '../src/database/client.js'; import { RedditUsersRepository } from '../src/database/repositories.js'; import { ChatService } from '../src/services/chat/service.js'; import { ChatCache } from '../src/services/chat/cache.js'; -import { MatrixClient } from '../src/services/chat/matrix/client.js'; +import { MatrixAuthError, MatrixClient } from '../src/services/chat/matrix/client.js'; import { createMockLogger } from './mockLogger.js'; const creds = { @@ -103,4 +103,54 @@ describe('ChatService username persistence', () => { expect(chats[0].roomName).toBe('alice'); db.close(); }); + + it('aborts username enrichment after one failed token refresh', async () => { + const logger = createMockLogger(); + const cache = tmpCache(logger); + cache.set([ + { + username: 'unknown', + roomName: 'unknown', + lastMessageTime: Date.now(), + lastMessageSnippet: '', + isFromUs: false, + unread: false, + roomId: '!first:reddit.com', + messages: [], + }, + { + username: 'unknown', + roomName: 'unknown', + lastMessageTime: Date.now(), + lastMessageSnippet: '', + isFromUs: false, + unread: false, + roomId: '!second:reddit.com', + messages: [], + }, + ]); + + const matrix = buildMatrix(); + matrix.getRoomFullState = vi.fn().mockRejectedValue(new MatrixAuthError('expired')); + matrix.getRoomMessages = vi.fn().mockRejectedValue(new MatrixAuthError('expired')); + const tokenRefresher = { + refresh: vi.fn().mockRejectedValue(new Error('interactive login required')), + }; + const usernameResolver = { resolveT2Ids: vi.fn().mockResolvedValue({}) }; + const svc = new ChatService( + matrix, + logger, + undefined, + undefined, + tokenRefresher, + usernameResolver, + cache, + ); + + await svc.enrichUsernames(); + + expect(tokenRefresher.refresh).toHaveBeenCalledOnce(); + expect(matrix.getRoomFullState).toHaveBeenCalledOnce(); + expect(matrix.getRoomMessages).not.toHaveBeenCalled(); + }); }); From 2fd92bf82c387024f2f25da7d440352ce500f13f Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 16:01:06 +0100 Subject: [PATCH 2/4] Gate bot logic on full account authentication --- src/bootstrap.ts | 123 +++++++++------ src/browser/client.ts | 23 ++- src/main-loop.ts | 17 ++- src/services/account-auth-context.ts | 157 ++++++++++++++++++++ src/services/chat/service.ts | 24 ++- src/services/media-sync.ts | 12 +- src/services/response-checker.ts | 8 +- src/web/context.ts | 2 + src/web/routes/control.ts | 5 + src/web/routes/login.ts | 17 ++- src/web/server.ts | 5 + tests/account-auth-context.test.ts | 104 +++++++++++++ tests/chat-service-auth-gate.test.ts | 41 +++++ tests/chat-service-cooldown.test.ts | 11 +- tests/chat-service-delta-media.test.ts | 7 +- tests/chat-service-username-persist.test.ts | 9 ++ tests/control-auth.test.ts | 35 +++++ tests/login-status.test.ts | 27 ++++ tests/media-sync-auth.test.ts | 23 +++ tests/response-checker-auth.test.ts | 23 +++ tests/response-checker.test.ts | 22 ++- tests/testUtils.ts | 5 + 22 files changed, 635 insertions(+), 65 deletions(-) create mode 100644 src/services/account-auth-context.ts create mode 100644 tests/account-auth-context.test.ts create mode 100644 tests/chat-service-auth-gate.test.ts create mode 100644 tests/control-auth.test.ts create mode 100644 tests/media-sync-auth.test.ts create mode 100644 tests/response-checker-auth.test.ts diff --git a/src/bootstrap.ts b/src/bootstrap.ts index f17135a..42db106 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -23,8 +23,6 @@ import { MatrixClient, MatrixMediaDownloader, MatrixTokenRefresher, - extractMatrixCredentials, - loadCachedCredentials, type UsernameResolver, type MatrixCredentials, } from './services/chat/index.js'; @@ -33,6 +31,7 @@ import { MediaSyncService } from './services/media-sync.js'; import { RateLimiter } from './utils/rate-limiter.js'; import { fetchWithTimeout } from './utils/fetch-with-timeout.js'; import { WebServer } from './web/server.js'; +import { AccountAuthContext, type AuthenticatedAccount } from './services/account-auth-context.js'; export interface AppContainer { config: Config; @@ -41,6 +40,7 @@ export interface AppContainer { browserClient: BrowserClient; messagePage: RedditMessagePage; + accountAuth: AccountAuthContext; postsRepo: PostsRepository; messagesRepo: MessagesRepository; @@ -97,6 +97,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise chatService.setMatrixClient(new MatrixClient(fresh, logger)), + (fresh) => { + credentialState.latest = fresh; + matrixClient.updateCredentials(fresh); + }, ); + accountAuth.setAuthenticator(async (): Promise => { + credentialState.latest = null; + await tokenRefresher.refresh(); + const redditUsername = await browserClient.getAuthenticatedUsername(); + const freshCredentials = credentialState.latest as MatrixCredentials | null; + if (!redditUsername || !freshCredentials?.userId) return null; + return { + accountId: freshCredentials.userId, + redditUsername, + matrixUserId: freshCredentials.userId, + }; + }); + + if (loggedIn) { + logger.info('Verifying full Reddit and Matrix account authentication...'); + await accountAuth.ensureFullyAuthenticated(); + } else { + accountAuth.invalidate('Interactive Reddit login is required'); + } + // 4. Username resolver — turns t2 IDs into real Reddit usernames so the // dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint. const usernameResolver: UsernameResolver = { @@ -228,7 +235,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise { - logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`); - // Resolve t2 IDs now that the full chat list is available. + let startupSyncAccountId: string | null = null; + let startupSyncInFlightAccountId: string | null = null; + const startAuthenticatedAccount = async (account: AuthenticatedAccount): Promise => { + if ( + startupSyncAccountId === account.accountId || + startupSyncInFlightAccountId === account.accountId + ) return; + startupSyncInFlightAccountId = account.accountId; + try { + const { kind, chats } = await chatService.runStartupSync(); await chatService.enrichUsernames(); - }) - .catch((err) => logger.error('[chat] startup sync failed:', err)); + if (accountAuth.isFullyAuthenticated()) { + startupSyncAccountId = account.accountId; + logger.info(`[chat] startup sync (${kind}) complete for ${account.redditUsername}: ${chats} chats`); + } + } finally { + startupSyncInFlightAccountId = null; + } + }; + accountAuth.onAuthenticated((account) => startAuthenticatedAccount(account)); + const authenticatedAccount = accountAuth.getSnapshot(); + if ( + authenticatedAccount.status === 'authenticated' && + authenticatedAccount.accountId && + authenticatedAccount.redditUsername && + authenticatedAccount.matrixUserId + ) { + void startAuthenticatedAccount({ + accountId: authenticatedAccount.accountId, + redditUsername: authenticatedAccount.redditUsername, + matrixUserId: authenticatedAccount.matrixUserId, + }).catch((err) => logger.error('[chat] startup sync failed:', err)); + } let webServer: WebServer | null = null; if (config.web.enabled) { @@ -291,6 +326,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise logger.info('Runtime config updated via dashboard')); } @@ -312,6 +348,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise { + try { + const page = await this.getPage(); + return await this.readAuthenticatedUsername(page); + } catch { + return null; + } + } + async close(): Promise { if (this.connectedBrowser) { this.logger.info('Closing Google Chrome connection'); @@ -185,19 +194,23 @@ export class BrowserClient { } private async isLoggedInViaApi(page: Page): Promise { + return (await this.readAuthenticatedUsername(page)) !== null; + } + + private async readAuthenticatedUsername(page: Page): Promise { try { return await page.evaluate(async () => { try { const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' }); - if (!res.ok) return false; - const data = await res.json(); - return Boolean(data && data.name); + if (!res.ok) return null; + const data = await res.json() as { name?: unknown }; + return typeof data.name === 'string' && data.name ? data.name : null; } catch { - return false; + return null; } }); } catch { - return false; + return null; } } diff --git a/src/main-loop.ts b/src/main-loop.ts index d36d57f..41b6e6d 100644 --- a/src/main-loop.ts +++ b/src/main-loop.ts @@ -19,6 +19,7 @@ export async function runMainLoop(app: AppContainer): Promise { config, logger, browserClient, + accountAuth, postsRepo, messagesRepo, redditApi, @@ -65,7 +66,7 @@ export async function runMainLoop(app: AppContainer): Promise { const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60_000); const checkResponses = async (): Promise => { - if (isShuttingDown) return; + if (isShuttingDown || !accountAuth.isFullyAuthenticated()) return; try { logger.debug('Checking for responses to sent messages (Background Thread)...'); const responses = await responseChecker.checkForResponses(); @@ -92,11 +93,25 @@ export async function runMainLoop(app: AppContainer): Promise { responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS); let lastPausedLog = 0; + let lastAuthPausedLog = 0; let lastCooldownLog = 0; const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000; while (!isShuttingDown) { try { + if (!accountAuth.isFullyAuthenticated()) { + await accountAuth.ensureFullyAuthenticated(); + } + if (!accountAuth.isFullyAuthenticated()) { + if (Date.now() - lastAuthPausedLog > PAUSED_LOG_INTERVAL_MS) { + logger.info('Bot logic is locked until full Reddit and Matrix authentication is verified.'); + lastAuthPausedLog = Date.now(); + } + await sleep(PAUSE_POLL_MS); + continue; + } + lastAuthPausedLog = 0; + if (webServer && !webServer.isRunning()) { if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) { logger.info('Bot is paused. Use the web dashboard to start.'); diff --git a/src/services/account-auth-context.ts b/src/services/account-auth-context.ts new file mode 100644 index 0000000..8384f0a --- /dev/null +++ b/src/services/account-auth-context.ts @@ -0,0 +1,157 @@ +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 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; + } + } +} diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index 0506e67..292ddd5 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -12,6 +12,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown } export type { InvitationCooldown } from './types.js'; import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js'; +import type { AccountAuthGate } from '../account-auth-context.js'; const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000; const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/'; @@ -129,6 +130,7 @@ export class ChatService { cache?: ChatCache, private messagesRepo?: MessagesRepository, private redditUsersRepo?: RedditUsersRepository, + private accountAuth?: AccountAuthGate, ) { this.cache = cache ?? new ChatCache(logger); if (this.redditUsersRepo) { @@ -176,6 +178,11 @@ export class ChatService { * refresh itself fails, we propagate. */ private async withAuth(fn: () => Promise): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) { + throw new MatrixAuthenticationUnavailableError( + new Error('Full account authentication is required'), + ); + } try { return await fn(); } catch (err) { @@ -184,9 +191,15 @@ export class ChatService { try { await this.tokenRefresher.refresh(); } catch (refreshError) { + this.accountAuth?.invalidate('Matrix authentication could not be restored'); throw new MatrixAuthenticationUnavailableError(refreshError); } - return await fn(); + try { + return await fn(); + } catch (retryError) { + this.accountAuth?.invalidate('Matrix authentication was rejected after refresh'); + throw retryError; + } } throw err; } @@ -459,6 +472,7 @@ export class ChatService { * `chat.messages`. */ async loadConversationDetails(chat: ActiveChat): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) return; return this.loadDetails(chat); } @@ -468,6 +482,7 @@ export class ChatService { * a raw Matrix room ID like `!abc:reddit.com`. */ async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) return false; const active = this.getInvitationCooldown(); if (active) { const secondsLeft = Math.ceil((active.until - Date.now()) / 1000); @@ -508,6 +523,7 @@ export class ChatService { * line. Returns the new chat (or null on failure). */ async startConversation(username: string, message: string): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) return null; const active = this.getInvitationCooldown(); if (active) { const secondsLeft = Math.ceil((active.until - Date.now()) / 1000); @@ -571,7 +587,7 @@ export class ChatService { * old Playwright sender; always returns true. */ async verifyMessageSent(_recipient: string, _messageText: string): Promise { - return true; + return this.accountAuth?.isFullyAuthenticated() ?? false; } /** @@ -1026,6 +1042,10 @@ export class ChatService { * cache. Best-effort; safe to await or fire-and-forget. */ async enrichUsernames(): Promise { + if (!this.accountAuth?.isFullyAuthenticated()) { + this.logger.debug('[chat] username enrichment skipped until full account authentication'); + return; + } if (!this.usernameResolver) return; let dirty = false; diff --git a/src/services/media-sync.ts b/src/services/media-sync.ts index 566e663..62e736a 100644 --- a/src/services/media-sync.ts +++ b/src/services/media-sync.ts @@ -1,5 +1,6 @@ import type { Logger } from '../utils/logger.js'; import type { ChatService } from './chat.js'; +import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js'; export interface MediaSyncStatus { isSyncing: boolean; @@ -22,7 +23,8 @@ export class MediaSyncService { constructor( private chatService: ChatService, private logger: Logger, - intervalMs: number = 30 * 60 * 1000 // default 30 minutes + intervalMs: number = 30 * 60 * 1000, // default 30 minutes + private accountAuth: AccountAuthGate = denyAccountAuth, ) { this.intervalMs = intervalMs; } @@ -64,6 +66,10 @@ export class MediaSyncService { * Returns immediately if a sync is already in progress. */ async triggerSync(full = false): Promise { + if (!this.accountAuth.isFullyAuthenticated()) { + this.logger.warn('Media sync: blocked until the account is fully authenticated'); + return false; + } if (this.syncing) { this.logger.info('Media sync: sync already in progress, skipping trigger'); return false; @@ -86,6 +92,10 @@ export class MediaSyncService { } private async runSync(full = false): Promise { + if (!this.accountAuth.isFullyAuthenticated()) { + this.logger.debug('Media sync: skipped because the account is not fully authenticated'); + return; + } if (this.syncing) return; this.syncing = true; this.lastError = null; diff --git a/src/services/response-checker.ts b/src/services/response-checker.ts index c26f969..2a11385 100644 --- a/src/services/response-checker.ts +++ b/src/services/response-checker.ts @@ -2,6 +2,7 @@ import type { Page } from 'playwright'; import type { Logger } from '../utils/logger.js'; import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js'; import type { PostsRepository } from '../database/repositories.js'; +import type { AccountAuthGate } from './account-auth-context.js'; export interface ResponseCheckResult { messageId: number; @@ -16,11 +17,16 @@ export class ResponseCheckerService { private messagesRepo: MessagesRepository, private successfulMessagesRepo: SuccessfulMessagesRepository, private postsRepo: PostsRepository, - private logger: Logger + private logger: Logger, + private accountAuth?: AccountAuthGate, ) { } async checkForResponses(): Promise { const results: ResponseCheckResult[] = []; + if (!this.accountAuth?.isFullyAuthenticated()) { + this.logger.debug('Response check skipped until full account authentication'); + return results; + } // Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours) const minAgeMs = 60 * 60 * 1000; // 1 hour diff --git a/src/web/context.ts b/src/web/context.ts index 0eaaf4c..5efca2b 100644 --- a/src/web/context.ts +++ b/src/web/context.ts @@ -13,6 +13,7 @@ import type { RateLimiter } from '../utils/rate-limiter.js'; import type { OpenRouterClient } from '../ai/openrouter.js'; import type { StrategySelector } from '../services/strategy-selector.js'; import type { BrowserClient } from '../browser/client.js'; +import type { AccountAuthContext } from '../services/account-auth-context.js'; import type { ActivityLog } from './activity-log.js'; import type { LogBroadcaster } from './log-broadcaster.js'; import type { RuntimeConfig } from './types.js'; @@ -40,6 +41,7 @@ export interface WebContext { ai?: OpenRouterClient; strategySelector?: StrategySelector; browser?: BrowserClient; + accountAuth?: AccountAuthContext; }; /** Bot pause/resume toggle read by the main loop. */ diff --git a/src/web/routes/control.ts b/src/web/routes/control.ts index 48511b0..d69eed0 100644 --- a/src/web/routes/control.ts +++ b/src/web/routes/control.ts @@ -5,6 +5,11 @@ export function createControlRouter(ctx: WebContext): Router { const router = Router(); router.post('/start', (_req, res) => { + if (!ctx.services.accountAuth?.isFullyAuthenticated()) { + ctx.logger.warn('Bot start blocked until full account authentication'); + res.status(409).json({ success: false, running: false, authenticated: false }); + return; + } ctx.setBotRunning(true); ctx.activity.push('sent', 'Bot started by user'); ctx.logger.info('Bot started via web UI'); diff --git a/src/web/routes/login.ts b/src/web/routes/login.ts index 46fc7b0..120b93c 100644 --- a/src/web/routes/login.ts +++ b/src/web/routes/login.ts @@ -18,13 +18,23 @@ export function createLoginRouter(ctx: WebContext): Router { router.get('/login/status', async (_req, res) => { const browser = ctx.services.browser; - if (!browser) { + const accountAuth = ctx.services.accountAuth; + if (!browser || !accountAuth) { res.status(503).json({ ready: false, error: 'browser not initialised' }); return; } try { - const loggedIn = await browser.isSessionValid(); - res.json({ ready: true, loggedIn, status: browser.getSessionState() }); + const loggedIn = await accountAuth.ensureFullyAuthenticated(); + const browserState = browser.getSessionState(); + res.json({ + ready: true, + loggedIn, + status: loggedIn + ? 'authenticated' + : browserState === 'unreachable' + ? 'unreachable' + : 'login-required', + }); } catch (error) { ctx.logger.error('Failed to check login status:', error); res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' }); @@ -40,6 +50,7 @@ export function createLoginRouter(ctx: WebContext): Router { return; } try { + ctx.services.accountAuth?.invalidate('Interactive Reddit login requested'); const page = await browser.getPage(); await page.bringToFront().catch(() => undefined); await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' }); diff --git a/src/web/server.ts b/src/web/server.ts index 046bd8b..cbbd9dd 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -17,6 +17,7 @@ import type { RateLimiter } from '../utils/rate-limiter.js'; import type { OpenRouterClient } from '../ai/openrouter.js'; import type { StrategySelector } from '../services/strategy-selector.js'; import type { BrowserClient } from '../browser/client.js'; +import type { AccountAuthContext } from '../services/account-auth-context.js'; import { ActivityLog } from './activity-log.js'; import { LogBroadcaster } from './log-broadcaster.js'; import type { WebContext } from './context.js'; @@ -175,6 +176,10 @@ export class WebServer { this.services.browser = browserClient; } + setAccountAuthContext(accountAuth: AccountAuthContext): void { + this.services.accountAuth = accountAuth; + } + logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void { this.activity.push(type, message, recipient, imageUrl); } diff --git a/tests/account-auth-context.test.ts b/tests/account-auth-context.test.ts new file mode 100644 index 0000000..4ef3399 --- /dev/null +++ b/tests/account-auth-context.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AccountAuthContext } from '../src/services/account-auth-context.js'; +import { createMockLogger } from './mockLogger.js'; + +describe('AccountAuthContext', () => { + it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => { + const auth = new AccountAuthContext(createMockLogger()); + const authenticate = vi.fn().mockResolvedValue({ + accountId: '@t2_account:reddit.com', + redditUsername: 'account', + matrixUserId: '@t2_account:reddit.com', + }); + auth.setAuthenticator(authenticate); + + expect(auth.isFullyAuthenticated()).toBe(false); + expect(auth.getSnapshot().status).toBe('login-required'); + + await expect(auth.ensureFullyAuthenticated()).resolves.toBe(true); + + expect(auth.isFullyAuthenticated()).toBe(true); + expect(auth.getSnapshot()).toMatchObject({ + status: 'authenticated', + accountId: '@t2_account:reddit.com', + }); + }); + + it('coalesces concurrent authentication checks globally', async () => { + const auth = new AccountAuthContext(createMockLogger()); + let resolveAuth: ((account: { + accountId: string; + redditUsername: string; + matrixUserId: string; + }) => void) | undefined; + const authenticate = vi.fn(() => new Promise((resolve) => { resolveAuth = resolve; })); + auth.setAuthenticator(authenticate); + + const first = auth.ensureFullyAuthenticated(); + const second = auth.ensureFullyAuthenticated(); + await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce()); + resolveAuth?.({ + accountId: '@t2_account:reddit.com', + redditUsername: 'account', + matrixUserId: '@t2_account:reddit.com', + }); + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + }); + + it('invalidates the whole account context when either login layer expires', async () => { + const auth = new AccountAuthContext(createMockLogger()); + auth.markAuthenticated({ + accountId: '@t2_account:reddit.com', + redditUsername: 'account', + matrixUserId: '@t2_account:reddit.com', + }); + + auth.invalidate('matrix token rejected'); + + expect(auth.isFullyAuthenticated()).toBe(false); + expect(auth.getSnapshot()).toMatchObject({ + status: 'login-required', + accountId: null, + reason: 'matrix token rejected', + }); + }); + + it('fails closed when the authentication lease expires', () => { + let now = 1_000; + const auth = new AccountAuthContext(createMockLogger(), { + leaseMs: 100, + retryDelayMs: 1_000, + now: () => now, + }); + auth.markAuthenticated({ + accountId: '@t2_account:reddit.com', + redditUsername: 'account', + matrixUserId: '@t2_account:reddit.com', + }); + + now = 1_101; + + expect(auth.isFullyAuthenticated()).toBe(false); + expect(auth.getSnapshot().status).toBe('login-required'); + }); + + it('throttles failed global authentication checks', async () => { + let now = 1_000; + const auth = new AccountAuthContext(createMockLogger(), { + leaseMs: 100, + retryDelayMs: 1_000, + now: () => now, + }); + const authenticate = vi.fn().mockResolvedValue(null); + auth.setAuthenticator(authenticate); + + await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false); + await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false); + expect(authenticate).toHaveBeenCalledOnce(); + + now = 2_001; + await auth.ensureFullyAuthenticated(); + expect(authenticate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/chat-service-auth-gate.test.ts b/tests/chat-service-auth-gate.test.ts new file mode 100644 index 0000000..fc14b15 --- /dev/null +++ b/tests/chat-service-auth-gate.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import { ChatService } from '../src/services/chat/service.js'; +import { ChatCache } from '../src/services/chat/cache.js'; +import { MatrixClient } from '../src/services/chat/matrix/client.js'; +import { createMockLogger } from './mockLogger.js'; + +describe('ChatService account authentication gate', () => { + it('does not make Matrix requests before full account authentication', async () => { + const logger = createMockLogger(); + const matrix = new MatrixClient({ + homeserver: 'https://matrix.example.test', + accessToken: 'cached-but-unverified', + userId: '@t2_account:reddit.com', + capturedAt: 1, + }, logger, { minRequestIntervalMs: 0 }); + matrix.sync = vi.fn(); + const cache = new ChatCache( + logger, + path.join(os.tmpdir(), `chat-auth-${Date.now()}-${Math.random()}.json`), + ); + const auth = { isFullyAuthenticated: vi.fn(() => false) }; + const service = new ChatService( + matrix, + logger, + undefined, + undefined, + undefined, + undefined, + cache, + undefined, + undefined, + auth, + ); + + await service.refreshTopChats(); + + expect(matrix.sync).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/chat-service-cooldown.test.ts b/tests/chat-service-cooldown.test.ts index 26d6c2b..b349446 100644 --- a/tests/chat-service-cooldown.test.ts +++ b/tests/chat-service-cooldown.test.ts @@ -6,6 +6,7 @@ import { ChatService } from '../src/services/chat/service.js'; import { ChatCache } from '../src/services/chat/cache.js'; import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js'; import { createMockLogger } from './mockLogger.js'; +import { authenticatedAccountAuth } from './testUtils.js'; const creds = { homeserver: 'https://matrix.example.test', @@ -29,7 +30,7 @@ function buildService() { logger, path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`), ); - const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); + const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth); return { svc, logger }; } @@ -97,7 +98,7 @@ describe('ChatService invitation cooldown', () => { try { const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 }); const cache1 = new ChatCache(logger, cacheFile); - const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1); + const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1, undefined, undefined, authenticatedAccountAuth); svc1.setInvitationCooldown( new MatrixRateLimitError('429', 120_000, 'M_LIMIT_EXCEEDED', null), ); @@ -105,7 +106,7 @@ describe('ChatService invitation cooldown', () => { await cache1.save(); const cache2 = new ChatCache(logger, cacheFile); - const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2); + const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2, undefined, undefined, authenticatedAccountAuth); await svc2.loadCache(); const restored = svc2.getInvitationCooldown(); expect(restored).not.toBeNull(); @@ -144,7 +145,7 @@ describe('ChatService invitation cooldown', () => { const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 }); const cache = new ChatCache(logger, cacheFile); - const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); + const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth); // Cooldown fires before loadCache() — the in-memory chats are still []. svc.setInvitationCooldown( @@ -178,7 +179,7 @@ describe('ChatService invitation cooldown', () => { logger, path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`), ); - const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); + const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth); const result = await svc.sendChatMessage('!room:example.test', 'hi'); expect(result).toBe(false); diff --git a/tests/chat-service-delta-media.test.ts b/tests/chat-service-delta-media.test.ts index 00d2506..6e347d3 100644 --- a/tests/chat-service-delta-media.test.ts +++ b/tests/chat-service-delta-media.test.ts @@ -8,6 +8,7 @@ import { MatrixClient } from '../src/services/chat/matrix/client.js'; import { MediaService } from '../src/services/media.js'; import type { MediaDownloader } from '../src/services/media.js'; import { createMockLogger } from './mockLogger.js'; +import { authenticatedAccountAuth } from './testUtils.js'; const creds = { homeserver: 'https://matrix.example.test', @@ -60,7 +61,7 @@ describe('ChatService delta sync — media attachment', () => { const downloader = buildDownloader(); const media = new MediaService(downloader, logger, tmpDir); const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')); - const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache); + const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth); await svc.getActiveChats(); @@ -128,7 +129,7 @@ describe('ChatService delta sync — media attachment', () => { const media = new MediaService(buildDownloader(), logger, tmpDir); const cache = new ChatCache(logger, cacheFile); - const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache); + const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth); await svc.loadCache(); // refreshTopChats always runs a /sync (unlike getActiveChats, which can @@ -176,7 +177,7 @@ describe('ChatService delta sync — media attachment', () => { logger, path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`), ); - const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); + const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth); await expect(svc.getActiveChats()).resolves.toBeDefined(); }); }); diff --git a/tests/chat-service-username-persist.test.ts b/tests/chat-service-username-persist.test.ts index afefccb..5655204 100644 --- a/tests/chat-service-username-persist.test.ts +++ b/tests/chat-service-username-persist.test.ts @@ -8,6 +8,7 @@ import { ChatService } from '../src/services/chat/service.js'; import { ChatCache } from '../src/services/chat/cache.js'; import { MatrixAuthError, MatrixClient } from '../src/services/chat/matrix/client.js'; import { createMockLogger } from './mockLogger.js'; +import { authenticatedAccountAuth } from './testUtils.js'; const creds = { homeserver: 'https://matrix.example.test', @@ -47,6 +48,7 @@ describe('ChatService username persistence', () => { tmpCache(logger), undefined, repo, + authenticatedAccountAuth, ); expect(svc.resolveSenderDisplay('@t2_abc:reddit.com')).toBe('alice'); @@ -61,6 +63,9 @@ describe('ChatService username persistence', () => { logger, undefined, undefined, undefined, undefined, tmpCache(logger), + undefined, + undefined, + authenticatedAccountAuth, ); expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown'); }); @@ -95,6 +100,7 @@ describe('ChatService username persistence', () => { cache, undefined, repo, + authenticatedAccountAuth, ); await svc.loadCache(); @@ -145,6 +151,9 @@ describe('ChatService username persistence', () => { tokenRefresher, usernameResolver, cache, + undefined, + undefined, + authenticatedAccountAuth, ); await svc.enrichUsernames(); diff --git a/tests/control-auth.test.ts b/tests/control-auth.test.ts new file mode 100644 index 0000000..996a4bd --- /dev/null +++ b/tests/control-auth.test.ts @@ -0,0 +1,35 @@ +import express from 'express'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createControlRouter } from '../src/web/routes/control.js'; + +const servers: Array> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map( + (server) => new Promise((resolve) => server.close(() => resolve())), + )); +}); + +describe('bot control account authentication gate', () => { + it('refuses to start the bot until full account authentication', async () => { + const setBotRunning = vi.fn(); + const app = express(); + app.use(createControlRouter({ + services: { accountAuth: { isFullyAuthenticated: () => false } }, + setBotRunning, + activity: { push: vi.fn() }, + logger: { warn: vi.fn(), info: vi.fn() }, + } as never)); + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + + const response = await fetch(`http://127.0.0.1:${port}/start`, { method: 'POST' }); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ success: false, authenticated: false }); + expect(setBotRunning).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/login-status.test.ts b/tests/login-status.test.ts index cd441bd..762146e 100644 --- a/tests/login-status.test.ts +++ b/tests/login-status.test.ts @@ -28,6 +28,9 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable app.use( createLoginRouter({ services: { + accountAuth: { + ensureFullyAuthenticated: async () => state === 'authenticated', + }, browser: { isSessionValid: async () => state === 'authenticated', getSessionState: () => state, @@ -39,6 +42,20 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable return app; } +function appWithPartialAuthentication() { + const app = express(); + app.use(createLoginRouter({ + services: { + accountAuth: { ensureFullyAuthenticated: async () => false }, + browser: { + getSessionState: () => 'authenticated', + }, + }, + logger: { error: () => undefined }, + } as never)); + return app; +} + describe('Reddit session status', () => { it('reports when Reddit is unreachable instead of presenting it as logged out', async () => { const response = await request(appWithBrowser('unreachable'), '/login/status'); @@ -60,6 +77,16 @@ describe('Reddit session status', () => { }); }); + it('does not report authenticated when only the Reddit browser session is valid', async () => { + const response = await request(appWithPartialAuthentication(), '/login/status'); + + expect(await response.json()).toEqual({ + ready: true, + loggedIn: false, + status: 'login-required', + }); + }); + it('returns the configured public noVNC URL', async () => { process.env.NOVNC_PUBLIC_URL = 'https://red-vnc.apps.jbrunton.co.uk'; diff --git a/tests/media-sync-auth.test.ts b/tests/media-sync-auth.test.ts new file mode 100644 index 0000000..daf8108 --- /dev/null +++ b/tests/media-sync-auth.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MediaSyncService } from '../src/services/media-sync.js'; +import { createMockLogger } from './mockLogger.js'; + +describe('MediaSyncService account authentication gate', () => { + it('does not touch chat while the global account context is unauthenticated', async () => { + const chat = { + refreshTopChats: vi.fn(), + fetchAllMessages: vi.fn(), + }; + const auth = { isFullyAuthenticated: vi.fn(() => false) }; + const service = new MediaSyncService( + chat as never, + createMockLogger(), + 30 * 60 * 1000, + auth, + ); + + await expect(service.triggerSync()).resolves.toBe(false); + expect(chat.refreshTopChats).not.toHaveBeenCalled(); + expect(chat.fetchAllMessages).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/response-checker-auth.test.ts b/tests/response-checker-auth.test.ts new file mode 100644 index 0000000..79f8e62 --- /dev/null +++ b/tests/response-checker-auth.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ResponseCheckerService } from '../src/services/response-checker.js'; +import { createMockLogger } from './mockLogger.js'; + +describe('ResponseCheckerService account authentication gate', () => { + it('does not query repositories or navigate while unauthenticated', async () => { + const page = { goto: vi.fn() }; + const messages = { getUncheckedMessages: vi.fn() }; + const auth = { isFullyAuthenticated: vi.fn(() => false) }; + const checker = new ResponseCheckerService( + page as never, + messages as never, + {} as never, + {} as never, + createMockLogger(), + auth, + ); + + await expect(checker.checkForResponses()).resolves.toEqual([]); + expect(messages.getUncheckedMessages).not.toHaveBeenCalled(); + expect(page.goto).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/response-checker.test.ts b/tests/response-checker.test.ts index a83d6ca..043f82a 100644 --- a/tests/response-checker.test.ts +++ b/tests/response-checker.test.ts @@ -1,9 +1,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Page } from 'playwright'; -import { ResponseCheckerService } from '../src/services/response-checker.js'; +import { ResponseCheckerService as BaseResponseCheckerService } from '../src/services/response-checker.js'; import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ProcessedPostRecord } from '../src/database/repositories.js'; import type { PostsRepository } from '../src/database/repositories.js'; import { createMockLogger } from '../tests/mockLogger.js'; +import { authenticatedAccountAuth } from './testUtils.js'; + +class ResponseCheckerService extends BaseResponseCheckerService { + constructor( + page: Page, + messagesRepo: MessagesRepository, + successfulMessagesRepo: SuccessfulMessagesRepository, + postsRepo: PostsRepository, + logger: ReturnType, + ) { + super( + page, + messagesRepo, + successfulMessagesRepo, + postsRepo, + logger, + authenticatedAccountAuth, + ); + } +} function createMockPage(): Page { return { diff --git a/tests/testUtils.ts b/tests/testUtils.ts index 72de47f..1c9132c 100644 --- a/tests/testUtils.ts +++ b/tests/testUtils.ts @@ -3,6 +3,11 @@ import type { Config } from '../src/config/index.js'; import { runMigrations } from '../src/database/client.js'; import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../src/database/repositories.js'; +export const authenticatedAccountAuth = { + isFullyAuthenticated: () => true, + invalidate: () => undefined, +}; + export function createTestDb(): Database.Database { const db = new Database(':memory:'); db.pragma('journal_mode = WAL'); From 2c533fa52a6df146d2e9acb7a265552e129ee0ef Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 16:03:54 +0100 Subject: [PATCH 3/4] Verify Reddit identity before Matrix capture --- src/bootstrap.ts | 26 +++++++++++++------------- src/services/account-auth-context.ts | 20 ++++++++++++++++++++ tests/account-auth-context.test.ts | 16 +++++++++++++++- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 42db106..94b676f 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -31,7 +31,11 @@ import { MediaSyncService } from './services/media-sync.js'; import { RateLimiter } from './utils/rate-limiter.js'; import { fetchWithTimeout } from './utils/fetch-with-timeout.js'; import { WebServer } from './web/server.js'; -import { AccountAuthContext, type AuthenticatedAccount } from './services/account-auth-context.js'; +import { + AccountAuthContext, + authenticateCurrentAccount, + type AuthenticatedAccount, +} from './services/account-auth-context.js'; export interface AppContainer { config: Config; @@ -184,18 +188,14 @@ export async function bootstrap(config: Config, logger: Logger): Promise => { - credentialState.latest = null; - await tokenRefresher.refresh(); - const redditUsername = await browserClient.getAuthenticatedUsername(); - const freshCredentials = credentialState.latest as MatrixCredentials | null; - if (!redditUsername || !freshCredentials?.userId) return null; - return { - accountId: freshCredentials.userId, - redditUsername, - matrixUserId: freshCredentials.userId, - }; - }); + accountAuth.setAuthenticator(() => authenticateCurrentAccount({ + getRedditUsername: () => browserClient.getAuthenticatedUsername(), + refreshMatrixCredentials: async () => { + credentialState.latest = null; + await tokenRefresher.refresh(); + return credentialState.latest as MatrixCredentials | null; + }, + })); if (loggedIn) { logger.info('Verifying full Reddit and Matrix account authentication...'); diff --git a/src/services/account-auth-context.ts b/src/services/account-auth-context.ts index 8384f0a..fe9fbfa 100644 --- a/src/services/account-auth-context.ts +++ b/src/services/account-auth-context.ts @@ -33,6 +33,26 @@ export interface AccountAuthContextOptions { 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, diff --git a/tests/account-auth-context.test.ts b/tests/account-auth-context.test.ts index 4ef3399..f158eed 100644 --- a/tests/account-auth-context.test.ts +++ b/tests/account-auth-context.test.ts @@ -1,8 +1,22 @@ import { describe, expect, it, vi } from 'vitest'; -import { AccountAuthContext } from '../src/services/account-auth-context.js'; +import { + AccountAuthContext, + authenticateCurrentAccount, +} from '../src/services/account-auth-context.js'; import { createMockLogger } from './mockLogger.js'; describe('AccountAuthContext', () => { + it('does not attempt Matrix capture until Reddit identity is authoritative', async () => { + const refreshMatrixCredentials = vi.fn(); + + await expect(authenticateCurrentAccount({ + getRedditUsername: vi.fn().mockResolvedValue(null), + refreshMatrixCredentials, + })).resolves.toBeNull(); + + expect(refreshMatrixCredentials).not.toHaveBeenCalled(); + }); + it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => { const auth = new AccountAuthContext(createMockLogger()); const authenticate = vi.fn().mockResolvedValue({ From dbb1842ef4e891ba4f23a49ffb4173326fee964c Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 16:16:11 +0100 Subject: [PATCH 4/4] Use native Chrome for Reddit login handoff --- docker/entrypoint.sh | 112 ++++++++++++++++++++------ public/index.html | 5 ++ src/bootstrap.ts | 4 +- src/browser/client.ts | 12 +-- src/native-login-gate.ts | 102 +++++++++++++++++++++++ src/services/native-login-handoff.ts | 28 +++++++ src/web/context.ts | 2 + src/web/routes/login.ts | 24 +++--- src/web/server.ts | 5 ++ tests/browser-external-chrome.test.ts | 9 +-- tests/login-status.test.ts | 33 +++++++- tests/native-login-gate.test.ts | 36 +++++++++ tests/vnc-entrypoint.test.ts | 12 ++- 13 files changed, 324 insertions(+), 60 deletions(-) create mode 100644 src/native-login-gate.ts create mode 100644 src/services/native-login-handoff.ts create mode 100644 tests/native-login-gate.test.ts diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ac1954b..a4257ee 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -11,6 +11,7 @@ GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}" BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}" CHROME_CDP_PORT="${CHROME_CDP_PORT:-9222}" GOOGLE_CHROME_BIN="${GOOGLE_CHROME_BIN:-google-chrome}" +NATIVE_LOGIN_EXIT_CODE=75 export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}" export DISPLAY=":${DISPLAY_NUM}" @@ -107,13 +108,46 @@ websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \ >/var/log/websockify.log 2>&1 & NOVNC_PID=$! -# Start the installed consumer Google Chrome directly. Playwright attaches only -# after Chrome is running, so the human login is not performed in a browser -# process launched with Playwright's automation arguments. CHROME_PID="" -CHROME_READY=0 -if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then - echo "[entrypoint] starting Google Chrome for interactive login" +BOT_PID="" +GATE_PID="" + +clear_chrome_locks() { + rm -f \ + "${BROWSER_SESSION_DIR}/SingletonLock" \ + "${BROWSER_SESSION_DIR}/SingletonSocket" \ + "${BROWSER_SESSION_DIR}/SingletonCookie" \ + 2>/dev/null || true +} + +stop_chrome() { + if [ -n "${CHROME_PID}" ]; then + kill "${CHROME_PID}" 2>/dev/null || true + wait "${CHROME_PID}" 2>/dev/null || true + CHROME_PID="" + fi + clear_chrome_locks +} + +start_native_login_chrome() { + unset CHROME_CDP_URL + echo "[entrypoint] starting native Google Chrome login with DevTools disabled" + "${GOOGLE_CHROME_BIN}" \ + --user-data-dir="${BROWSER_SESSION_DIR}" \ + --no-first-run \ + --no-default-browser-check \ + --disable-dev-shm-usage \ + --no-sandbox \ + --start-maximized \ + https://www.reddit.com/login/ \ + >/var/log/google-chrome-native.log 2>&1 & + CHROME_PID=$! +} + +start_controlled_chrome() { + export CHROME_CDP_URL="http://127.0.0.1:${CHROME_CDP_PORT}" + local chrome_ready=0 + echo "[entrypoint] starting Google Chrome for authenticated bot control" "${GOOGLE_CHROME_BIN}" \ --user-data-dir="${BROWSER_SESSION_DIR}" \ --remote-debugging-address=127.0.0.1 \ @@ -133,37 +167,63 @@ if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then fi if curl -fsS "${CHROME_CDP_URL}/json/version" >/dev/null 2>&1; then echo "[entrypoint] Google Chrome is ready after ${i} checks" - CHROME_READY=1 + chrome_ready=1 break fi sleep 0.2 done -fi -if [ "${CHROME_READY}" != "1" ]; then - echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2 - if [ -n "${CHROME_PID}" ]; then - kill "${CHROME_PID}" 2>/dev/null || true - wait "${CHROME_PID}" 2>/dev/null || true + if [ "${chrome_ready}" != "1" ]; then + echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2 + stop_chrome + unset CHROME_CDP_URL fi - rm -f \ - "${BROWSER_SESSION_DIR}/SingletonLock" \ - "${BROWSER_SESSION_DIR}/SingletonSocket" \ - "${BROWSER_SESSION_DIR}/SingletonCookie" \ - 2>/dev/null || true - CHROME_PID="" - unset CHROME_CDP_URL -fi +} cleanup() { echo "[entrypoint] shutting down" - if [ -n "${CHROME_PID}" ]; then - kill "${CHROME_PID}" 2>/dev/null || true - fi + [ -n "${BOT_PID}" ] && kill "${BOT_PID}" 2>/dev/null || true + [ -n "${GATE_PID}" ] && kill "${GATE_PID}" 2>/dev/null || true + stop_chrome kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true wait || true } trap cleanup TERM INT EXIT -echo "[entrypoint] launching bot" -exec node dist/index.js +if ! command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then + echo "[entrypoint] WARNING: Google Chrome is unavailable; using Playwright fallback" >&2 + unset CHROME_CDP_URL +else + start_controlled_chrome +fi + +while true; do + echo "[entrypoint] launching bot" + set +e + node dist/index.js & + BOT_PID=$! + wait "${BOT_PID}" + BOT_EXIT=$? + BOT_PID="" + set -e + + if [ "${BOT_EXIT}" -ne "${NATIVE_LOGIN_EXIT_CODE}" ]; then + exit "${BOT_EXIT}" + fi + + echo "[entrypoint] bot requested native Reddit login mode" + stop_chrome + start_native_login_chrome + node dist/native-login-gate.js & + GATE_PID=$! + wait "${GATE_PID}" + GATE_EXIT=$? + GATE_PID="" + if [ "${GATE_EXIT}" -ne 0 ]; then + exit "${GATE_EXIT}" + fi + + echo "[entrypoint] human completed login handoff; enabling authenticated control" + stop_chrome + start_controlled_chrome +done diff --git a/public/index.html b/public/index.html index c3a5a6c..b56acd1 100644 --- a/public/index.html +++ b/public/index.html @@ -4079,6 +4079,11 @@ const res = await fetch('/api/login/start', { method: 'POST' }); if (!res.ok) { alert('Failed to open Reddit login page. Check the server logs.'); + return; + } + const data = await res.json(); + if (data.restarting) { + window.setTimeout(() => window.location.reload(), 1500); } } catch (err) { alert('Failed to open Reddit login page: ' + err); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 94b676f..a415e8a 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -36,6 +36,7 @@ import { authenticateCurrentAccount, type AuthenticatedAccount, } from './services/account-auth-context.js'; +import { ProcessNativeLoginHandoff } from './services/native-login-handoff.js'; export interface AppContainer { config: Config; @@ -111,7 +112,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise logger.info('Runtime config updated via dashboard')); } diff --git a/src/browser/client.ts b/src/browser/client.ts index 0ef09fc..c48c431 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -216,16 +216,8 @@ export class BrowserClient { async login(): Promise { if (this.usingExternalChrome) { - try { - const page = await this.getPage(); - await page.bringToFront(); - await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' }); - this.sessionState = 'login-required'; - this.logger.warn('Interactive Reddit login required. Open the VNC page and sign in using Google Chrome.'); - } catch (error) { - this.sessionState = classifyRedditSessionError(error); - this.logger.error('Failed to open Google Chrome for interactive login:', error); - } + this.sessionState = 'login-required'; + this.logger.warn('Native Reddit login required. Start it from the dashboard before signing in through VNC.'); return false; } diff --git a/src/native-login-gate.ts b/src/native-login-gate.ts new file mode 100644 index 0000000..e94d7c3 --- /dev/null +++ b/src/native-login-gate.ts @@ -0,0 +1,102 @@ +import http, { type Server } from 'node:http'; +import { pathToFileURL } from 'node:url'; + +export interface NativeLoginGateOptions { + publicVncUrl: string; + onComplete: () => void; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character] ?? character); +} + +function loginPage(publicVncUrl: string): string { + const safeVncUrl = escapeHtml(publicVncUrl); + return ` + + + + + Reddit login required + + +
+
+ Chrome is running without automation. +

Log in to Reddit in the browser below. Only after Reddit shows you as signed in, click I'm logged in - connect the bot. Chrome will restart with the same profile and the bot will verify both Reddit and Matrix before any bot logic can run.

+ + Open VNC in a new tab + +
+ + +
`; +} + +export function createNativeLoginGate(options: NativeLoginGateOptions): Server { + return http.createServer((request, response) => { + if (request.method === 'POST' && request.url === '/complete') { + response.writeHead(202, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true, restarting: true })); + options.onComplete(); + return; + } + + if (request.method !== 'GET') { + response.writeHead(405).end(); + return; + } + + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(loginPage(options.publicVncUrl)); + }); +} + +function run(): void { + const port = Number.parseInt(process.env.WEB_PORT ?? '3000', 10); + const publicBase = (process.env.NOVNC_PUBLIC_URL ?? `http://localhost:${process.env.NOVNC_PORT ?? '6080'}`).replace(/\/$/, ''); + const publicVncUrl = `${publicBase}/vnc.html?autoconnect=1&resize=scale&reconnect=1`; + let server: Server; + server = createNativeLoginGate({ + publicVncUrl, + onComplete: () => setTimeout(() => server.close(() => process.exit(0)), 250).unref(), + }); + server.listen(port, () => { + process.stdout.write(`[native-login] waiting for human login on port ${port}\n`); + }); +} + +const entryUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''; +if (import.meta.url === entryUrl) run(); diff --git a/src/services/native-login-handoff.ts b/src/services/native-login-handoff.ts new file mode 100644 index 0000000..3c4de58 --- /dev/null +++ b/src/services/native-login-handoff.ts @@ -0,0 +1,28 @@ +export interface NativeLoginHandoff { + request(): void; +} + +export interface ProcessNativeLoginHandoffOptions { + exitCode?: number; + delayMs?: number; + exit?: (code: number) => void; +} + +export class ProcessNativeLoginHandoff implements NativeLoginHandoff { + private requested = false; + private readonly exitCode: number; + private readonly delayMs: number; + private readonly exit: (code: number) => void; + + constructor(options: ProcessNativeLoginHandoffOptions = {}) { + this.exitCode = options.exitCode ?? 75; + this.delayMs = options.delayMs ?? 250; + this.exit = options.exit ?? ((code) => process.exit(code)); + } + + request(): void { + if (this.requested) return; + this.requested = true; + setTimeout(() => this.exit(this.exitCode), this.delayMs).unref(); + } +} diff --git a/src/web/context.ts b/src/web/context.ts index 5efca2b..2865db7 100644 --- a/src/web/context.ts +++ b/src/web/context.ts @@ -14,6 +14,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js'; import type { StrategySelector } from '../services/strategy-selector.js'; import type { BrowserClient } from '../browser/client.js'; import type { AccountAuthContext } from '../services/account-auth-context.js'; +import type { NativeLoginHandoff } from '../services/native-login-handoff.js'; import type { ActivityLog } from './activity-log.js'; import type { LogBroadcaster } from './log-broadcaster.js'; import type { RuntimeConfig } from './types.js'; @@ -42,6 +43,7 @@ export interface WebContext { strategySelector?: StrategySelector; browser?: BrowserClient; accountAuth?: AccountAuthContext; + nativeLogin?: NativeLoginHandoff; }; /** Bot pause/resume toggle read by the main loop. */ diff --git a/src/web/routes/login.ts b/src/web/routes/login.ts index 120b93c..746a26b 100644 --- a/src/web/routes/login.ts +++ b/src/web/routes/login.ts @@ -41,24 +41,22 @@ export function createLoginRouter(ctx: WebContext): Router { } }); - // Navigate the headed Chromium running under Xvfb to reddit.com/login so - // the human watching the noVNC stream can complete the flow. + // Restart into a completely native Chrome process. The container supervisor + // does not enable DevTools until the human explicitly completes the handoff. router.post('/login/start', async (_req, res) => { - const browser = ctx.services.browser; - if (!browser) { - res.status(503).json({ ok: false, error: 'browser not initialised' }); + const nativeLogin = ctx.services.nativeLogin; + if (!nativeLogin) { + res.status(503).json({ ok: false, error: 'native login unavailable' }); return; } try { - ctx.services.accountAuth?.invalidate('Interactive Reddit login requested'); - const page = await browser.getPage(); - await page.bringToFront().catch(() => undefined); - await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' }); - ctx.logger.info('Opened Reddit login page for human completion via VNC'); - res.json({ ok: true }); + ctx.services.accountAuth?.invalidate('Native Reddit login requested'); + nativeLogin.request(); + ctx.logger.info('Restarting into native Chrome login mode; bot logic remains locked'); + res.status(202).json({ ok: true, restarting: true }); } catch (error) { - ctx.logger.error('Failed to open login page:', error); - res.status(500).json({ ok: false, error: 'navigation failed' }); + ctx.logger.error('Failed to start native login mode:', error); + res.status(500).json({ ok: false, error: 'handoff failed' }); } }); diff --git a/src/web/server.ts b/src/web/server.ts index cbbd9dd..61c7553 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -18,6 +18,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js'; import type { StrategySelector } from '../services/strategy-selector.js'; import type { BrowserClient } from '../browser/client.js'; import type { AccountAuthContext } from '../services/account-auth-context.js'; +import type { NativeLoginHandoff } from '../services/native-login-handoff.js'; import { ActivityLog } from './activity-log.js'; import { LogBroadcaster } from './log-broadcaster.js'; import type { WebContext } from './context.js'; @@ -180,6 +181,10 @@ export class WebServer { this.services.accountAuth = accountAuth; } + setNativeLoginHandoff(nativeLogin: NativeLoginHandoff): void { + this.services.nativeLogin = nativeLogin; + } + logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void { this.activity.push(type, message, recipient, imageUrl); } diff --git a/tests/browser-external-chrome.test.ts b/tests/browser-external-chrome.test.ts index fece3d6..1e354b9 100644 --- a/tests/browser-external-chrome.test.ts +++ b/tests/browser-external-chrome.test.ts @@ -57,7 +57,7 @@ describe('BrowserClient external Chrome', () => { expect(runtime.launchPersistentContext).toHaveBeenCalledOnce(); }); - it('opens the login page for a human without submitting credentials', async () => { + it('does not control external Chrome during native human login', async () => { process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222'; const page = { goto: vi.fn().mockResolvedValue(undefined), @@ -79,11 +79,8 @@ describe('BrowserClient external Chrome', () => { await client.initialize(); await expect(client.login()).resolves.toBe(false); - expect(page.bringToFront).toHaveBeenCalledOnce(); - expect(page.goto).toHaveBeenCalledWith( - 'https://www.reddit.com/login', - { waitUntil: 'domcontentloaded' }, - ); + expect(page.bringToFront).not.toHaveBeenCalled(); + expect(page.goto).not.toHaveBeenCalled(); expect(page.fill).not.toHaveBeenCalled(); expect(client.getSessionState()).toBe('login-required'); }); diff --git a/tests/login-status.test.ts b/tests/login-status.test.ts index 762146e..ae74b20 100644 --- a/tests/login-status.test.ts +++ b/tests/login-status.test.ts @@ -1,6 +1,6 @@ import express from 'express'; import type { AddressInfo } from 'node:net'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { classifyRedditSessionError } from '../src/browser/client.js'; import { createLoginRouter } from '../src/web/routes/login.js'; @@ -14,6 +14,14 @@ async function request(app: express.Express, path: string): Promise { return fetch(`http://127.0.0.1:${port}${path}`); } +async function post(app: express.Express, path: string): Promise { + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + return fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST' }); +} + afterEach(async () => { await Promise.all( servers.splice(0).map( @@ -105,4 +113,27 @@ describe('Reddit session status', () => { ).toBe('unreachable'); expect(classifyRedditSessionError(new Error('login form was not found'))).toBe('login-required'); }); + + it('starts a native Chrome login handoff without controlling the attached page', async () => { + const requestNativeLogin = vi.fn(); + const getPage = vi.fn(); + const invalidate = vi.fn(); + const app = express(); + app.use(createLoginRouter({ + services: { + accountAuth: { invalidate }, + browser: { getPage }, + nativeLogin: { request: requestNativeLogin }, + }, + logger: { info: vi.fn(), error: vi.fn() }, + } as never)); + + const response = await post(app, '/login/start'); + + expect(response.status).toBe(202); + expect(await response.json()).toEqual({ ok: true, restarting: true }); + expect(invalidate).toHaveBeenCalledWith('Native Reddit login requested'); + expect(requestNativeLogin).toHaveBeenCalledOnce(); + expect(getPage).not.toHaveBeenCalled(); + }); }); diff --git a/tests/native-login-gate.test.ts b/tests/native-login-gate.test.ts new file mode 100644 index 0000000..e3569d2 --- /dev/null +++ b/tests/native-login-gate.test.ts @@ -0,0 +1,36 @@ +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createNativeLoginGate } from '../src/native-login-gate.js'; + +const servers: Array> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => { + server.close(() => resolve()); + }))); +}); + +describe('native Reddit login gate', () => { + it('serves VNC guidance and completes only after the explicit handoff', async () => { + const onComplete = vi.fn(); + const server = createNativeLoginGate({ + publicVncUrl: 'https://red-vnc.example/vnc.html', + onComplete, + }).listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + + const page = await fetch(`http://127.0.0.1:${port}/`); + const html = await page.text(); + expect(html).toContain('Chrome is running without automation'); + expect(html).toContain('https://red-vnc.example/vnc.html'); + expect(html).toContain("fetch('/complete', { method: 'POST' })"); + expect(onComplete).not.toHaveBeenCalled(); + + const completed = await fetch(`http://127.0.0.1:${port}/complete`, { method: 'POST' }); + expect(completed.status).toBe(202); + expect(await completed.json()).toEqual({ ok: true, restarting: true }); + expect(onComplete).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/vnc-entrypoint.test.ts b/tests/vnc-entrypoint.test.ts index f03e5b7..61a3c60 100644 --- a/tests/vnc-entrypoint.test.ts +++ b/tests/vnc-entrypoint.test.ts @@ -23,17 +23,23 @@ describe('VNC container entrypoint', () => { expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot')); }); - it('launches regular Google Chrome before the bot and exposes its local CDP endpoint', () => { + it('supports a native login phase with no DevTools before the controlled browser starts', () => { const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8'); expect(script).toContain('google-chrome'); expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"'); + expect(script).toContain('start_native_login_chrome'); + expect(script).toContain('https://www.reddit.com/login/'); + expect(script).toContain('node dist/native-login-gate.js'); + expect(script).toContain('NATIVE_LOGIN_EXIT_CODE'); expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"'); expect(script).toContain('export CHROME_CDP_URL='); expect(script).not.toContain('--enable-automation'); - expect(script.indexOf('starting Google Chrome')).toBeLessThan( - script.indexOf('launching bot'), + const nativeFunction = script.slice( + script.indexOf('start_native_login_chrome()'), + script.indexOf('start_controlled_chrome()'), ); + expect(nativeFunction).not.toContain('--remote-debugging'); }); it('installs the stable Google Chrome package rather than Chrome for Testing', () => {