diff --git a/apps/dashboard/src/features/media/media-card.tsx b/apps/dashboard/src/features/media/media-card.tsx index 1651f10..29e4c65 100644 --- a/apps/dashboard/src/features/media/media-card.tsx +++ b/apps/dashboard/src/features/media/media-card.tsx @@ -16,11 +16,15 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M return (
- {isRevealed ? ( - - ) : {`Media} + {!isRevealed ? Private media hidden : null}
diff --git a/src/bootstrap.ts b/src/bootstrap.ts index d85c461..4ef1e7f 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -37,6 +37,7 @@ import { fetchWithTimeout } from './utils/fetch-with-timeout.js'; import { WebServer } from './web/server.js'; import { reportReply } from './web/event-reporting.js'; import { RedditSessionImporter } from './services/reddit-session-importer.js'; +import { AccountAuthContext } from './services/account-auth-context.js'; export interface AppContainer { config: Config; @@ -44,6 +45,7 @@ export interface AppContainer { db: Database.Database; browserClient: BrowserClient; + accountAuth: AccountAuthContext; postsRepo: PostsRepository; messagesRepo: MessagesRepository; @@ -102,23 +104,17 @@ export async function bootstrap(config: Config, logger: Logger): Promise { + const redditUsername = await browserClient.getAuthenticatedUsername(); + if (!redditUsername) return null; + try { + const matrixUserId = await activeMatrixClient.whoami(); + if (!matrixUserId) return null; + return { accountId: matrixUserId, redditUsername, matrixUserId }; + } catch { + return null; + } + }); + const fullyAuthenticated = redditLoggedIn && await accountAuth.ensureFullyAuthenticated(); + if (fullyAuthenticated) logger.info('Reddit and Matrix authentication verified'); + broadcaster.dashboardEvents.redditSession({ + state: fullyAuthenticated ? 'authenticated' : 'login-required', + username: fullyAuthenticated ? accountAuth.getSnapshot().redditUsername ?? undefined : undefined, + }); // 2. Build the media downloader + service on top of the Matrix client so // `mxc://` URIs resolve through the same auth + rate-limiter pipeline. - const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger); + const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, logger); const mediaService = new MediaService(mediaDownloader, logger); // A 401 waits for a new extension dump instead of opening a browser login @@ -204,7 +218,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise messagesRepo.getSentTimestampsSince(since), }); @@ -225,9 +240,15 @@ export async function bootstrap(config: Config, logger: Logger): Promise new MatrixClient(credentials, logger), saveCredentials, + accountAuth, ); let webServer: WebServer | null = null; @@ -296,6 +317,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise logger.info('Runtime config updated via dashboard')); } @@ -316,6 +338,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise { config, logger, browserClient, + accountAuth, postsRepo, messagesRepo, redditApi, @@ -177,7 +178,9 @@ export async function runMainLoop(app: AppContainer): Promise { while (!isShuttingDown) { try { - if (!sessionRuntime.reconcile(browserClient.getSessionState())) { + const authenticated = accountAuth.isFullyAuthenticated() + || await accountAuth.ensureFullyAuthenticated(); + if (!sessionRuntime.reconcile(authenticated)) { if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) { logger.info('Reddit is logged out. Waiting for a session dump from the extension.'); lastPausedLog = Date.now(); diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index eed90ac..cfcaa5f 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -19,9 +19,8 @@ const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/'; export interface TokenRefresher { /** - * Re-extract a fresh Matrix bearer token (e.g. by re-running the browser - * sniff against the persistent session) and apply it to the underlying - * client. Resolves when the client is ready to be retried. + * Requests replacement Matrix credentials. Extension-only authentication + * deliberately rejects this until the user imports a fresh dump. */ refresh(): Promise; } @@ -175,9 +174,8 @@ export class ChatService { } /** - * Run a Matrix call; if it 401/403s, re-extract the bearer via the - * configured `TokenRefresher` and retry once. Single retry only — if the - * refresh itself fails, we propagate. + * Run a Matrix call; if it 401/403s, invalidate the shared account gate. + * The configured refresher reports that a new extension dump is required. */ private async withAuth(fn: () => Promise): Promise { if (!this.accountAuth?.isFullyAuthenticated()) { @@ -189,7 +187,7 @@ export class ChatService { return await fn(); } catch (err) { if (err instanceof MatrixAuthError && this.tokenRefresher) { - this.logger.warn('[chat] Matrix token rejected — refreshing via browser session'); + this.logger.warn('[chat] Matrix token rejected — waiting for a new extension dump'); try { await this.tokenRefresher.refresh(); } catch (refreshError) { diff --git a/src/services/reddit-session-importer.ts b/src/services/reddit-session-importer.ts index 7641b44..d805ace 100644 --- a/src/services/reddit-session-importer.ts +++ b/src/services/reddit-session-importer.ts @@ -1,10 +1,12 @@ import type { Cookie } from 'playwright'; import type { MatrixClient } from './chat/matrix/client.js'; import type { MatrixCredentials } from './chat/matrix/types.js'; +import type { AccountAuthContext } from './account-auth-context.js'; interface BrowserSessionTarget { importRedditCookies(cookies: Cookie[]): Promise; isSessionValid(): Promise; + getAuthenticatedUsername(): Promise; } interface ChatSessionTarget { @@ -20,6 +22,7 @@ export class RedditSessionImporter { private readonly chat: ChatSessionTarget, private readonly createMatrixClient: MatrixClientFactory, private readonly saveCredentials: CredentialStore, + private readonly accountAuth: AccountAuthContext, ) {} async import(cookies: Cookie[], credentials: MatrixCredentials): Promise { @@ -41,8 +44,15 @@ export class RedditSessionImporter { if (!(await this.browser.isSessionValid())) { throw new Error('Imported cookies did not authenticate with Reddit'); } + const redditUsername = await this.browser.getAuthenticatedUsername(); + if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity'); this.saveCredentials(credentials); this.chat.setMatrixClient(matrixClient); + this.accountAuth.markAuthenticated({ + accountId: userId, + redditUsername, + matrixUserId: userId, + }); } } diff --git a/src/services/response-checker.ts b/src/services/response-checker.ts index d936d9a..2c0720d 100644 --- a/src/services/response-checker.ts +++ b/src/services/response-checker.ts @@ -3,6 +3,7 @@ import type { Logger } from '../utils/logger.js'; import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js'; import type { PostsRepository } from '../database/repositories.js'; import { classifyConversationOutcome } from './conversation-outcome.js'; +import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js'; export interface ResponseCheckResult { messageId: number; @@ -18,11 +19,13 @@ export class ResponseCheckerService { private successfulMessagesRepo: SuccessfulMessagesRepository, private postsRepo: PostsRepository, private logger: Logger, + private accountAuth: AccountAuthGate = denyAccountAuth, private outcomesRepo?: ConversationOutcomesRepository, ) { } async checkForResponses(): Promise { const results: ResponseCheckResult[] = []; + if (!this.accountAuth.isFullyAuthenticated()) 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/services/session-bound-runtime.ts b/src/services/session-bound-runtime.ts index bac41e8..b9114da 100644 --- a/src/services/session-bound-runtime.ts +++ b/src/services/session-bound-runtime.ts @@ -1,5 +1,3 @@ -import type { RedditSessionState } from '../browser/client.js'; - /** Starts and stops account-bound work as authentication changes. */ export class SessionBoundRuntime { private active = false; @@ -9,8 +7,7 @@ export class SessionBoundRuntime { private readonly stop: () => void, ) {} - reconcile(state: RedditSessionState): boolean { - const authenticated = state === 'authenticated'; + reconcile(authenticated: boolean): boolean { if (authenticated && !this.active) { this.active = true; this.start(); diff --git a/src/web/context.ts b/src/web/context.ts index ab3f1d6..4471a00 100644 --- a/src/web/context.ts +++ b/src/web/context.ts @@ -18,6 +18,7 @@ import type { ActivityLog } from './activity-log.js'; import type { LogBroadcaster } from './log-broadcaster.js'; import type { RuntimeConfig } from './types.js'; import type { RedditSessionImporter } from '../services/reddit-session-importer.js'; +import type { AccountAuthContext } from '../services/account-auth-context.js'; /** Bag of everything the route modules might need. */ export interface WebContext { @@ -43,6 +44,7 @@ export interface WebContext { strategySelector?: StrategySelector; browser?: BrowserClient; sessionImporter?: RedditSessionImporter; + accountAuth?: AccountAuthContext; conversationState?: ConversationStateRepository; }; diff --git a/src/web/routes/login.ts b/src/web/routes/login.ts index 12ef1fa..331aea8 100644 --- a/src/web/routes/login.ts +++ b/src/web/routes/login.ts @@ -52,9 +52,25 @@ export function createLoginRouter(ctx: WebContext): Router { return; } try { - const loggedIn = await browser.isSessionValid(); - const username = loggedIn ? await browser.getAuthenticatedUsername() : null; - res.json({ ready: true, loggedIn, status: browser.getSessionState(), username }); + const accountAuth = ctx.services.accountAuth; + const fullyAuthenticated = accountAuth + ? await accountAuth.ensureFullyAuthenticated() + : await browser.isSessionValid(); + if (fullyAuthenticated) { + const username = accountAuth?.getSnapshot().redditUsername + ?? await browser.getAuthenticatedUsername(); + res.json({ ready: true, loggedIn: true, status: 'authenticated', username }); + return; + } + + await browser.isSessionValid(); + const browserState = browser.getSessionState(); + res.json({ + ready: true, + loggedIn: false, + status: browserState === 'unreachable' ? 'unreachable' : 'login-required', + username: null, + }); } catch (error) { ctx.logger.error('Failed to check login status:', error); res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' }); diff --git a/src/web/server.ts b/src/web/server.ts index f22f7a7..fd52000 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -20,6 +20,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 { RedditSessionImporter } from '../services/reddit-session-importer.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'; @@ -205,6 +206,10 @@ export class WebServer { this.services.sessionImporter = sessionImporter; } + setAccountAuth(accountAuth: AccountAuthContext): void { + this.services.accountAuth = accountAuth; + } + setConversationStateRepository(conversationState: ConversationStateRepository): void { this.services.conversationState = conversationState; } diff --git a/tests/dashboard/settings.test.tsx b/tests/dashboard/settings.test.tsx index 5b3acef..0f15765 100644 --- a/tests/dashboard/settings.test.tsx +++ b/tests/dashboard/settings.test.tsx @@ -270,7 +270,7 @@ describe('settings and access', () => { renderSettings('/settings?section=access'); expect(await screen.findByText('Login required')).toBeInTheDocument(); - expect(screen.getByText(/waiting for a session dump/i)).toBeInTheDocument(); + expect(screen.getByText(/waiting for a fresh session dump/i)).toBeInTheDocument(); expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument(); expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument(); diff --git a/tests/login-status.test.ts b/tests/login-status.test.ts index c56fc40..765cffe 100644 --- a/tests/login-status.test.ts +++ b/tests/login-status.test.ts @@ -27,6 +27,10 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable app.use( createLoginRouter({ services: { + accountAuth: { + ensureFullyAuthenticated: async () => state === 'authenticated', + getSnapshot: () => ({ redditUsername: state === 'authenticated' ? 'example-account' : null }), + }, browser: { isSessionValid: async () => state === 'authenticated', getAuthenticatedUsername: async () => state === 'authenticated' ? 'example-account' : null, diff --git a/tests/reddit-session-importer.test.ts b/tests/reddit-session-importer.test.ts index 3737ed7..d39f878 100644 --- a/tests/reddit-session-importer.test.ts +++ b/tests/reddit-session-importer.test.ts @@ -15,11 +15,16 @@ const credentials = { describe('RedditSessionImporter', () => { it('verifies both parts of the dump before activating the account', async () => { - const browser = { importRedditCookies: vi.fn(), isSessionValid: vi.fn(async () => true) }; + const browser = { + importRedditCookies: vi.fn(), + isSessionValid: vi.fn(async () => true), + getAuthenticatedUsername: vi.fn(async () => 'bot-account'), + }; const matrix = { whoami: vi.fn(async () => credentials.userId) }; const chat = { setMatrixClient: vi.fn() }; const save = vi.fn(); - const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save); + const accountAuth = { markAuthenticated: vi.fn() }; + const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never); await importer.import(cookies, credentials); @@ -27,15 +32,21 @@ describe('RedditSessionImporter', () => { expect(browser.importRedditCookies).toHaveBeenCalledWith(cookies); expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix); expect(save).toHaveBeenCalledWith(credentials); + expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({ + accountId: credentials.userId, + redditUsername: 'bot-account', + matrixUserId: credentials.userId, + }); }); it('rejects non-Reddit Matrix homeservers without making a request', async () => { const createMatrix = vi.fn(); const importer = new RedditSessionImporter( - { importRedditCookies: vi.fn(), isSessionValid: vi.fn() }, + { importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedUsername: vi.fn() }, { setMatrixClient: vi.fn() } as never, createMatrix, vi.fn(), + { markAuthenticated: vi.fn() } as never, ); await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' })) diff --git a/tests/session-bound-runtime.test.ts b/tests/session-bound-runtime.test.ts index 21959d5..f3ca80a 100644 --- a/tests/session-bound-runtime.test.ts +++ b/tests/session-bound-runtime.test.ts @@ -7,22 +7,19 @@ describe('SessionBoundRuntime', () => { const stop = vi.fn(); const runtime = new SessionBoundRuntime(start, stop); - expect(runtime.reconcile('login-required')).toBe(false); + expect(runtime.reconcile(false)).toBe(false); expect(start).not.toHaveBeenCalled(); - expect(runtime.reconcile('authenticated')).toBe(true); - expect(runtime.reconcile('authenticated')).toBe(true); + expect(runtime.reconcile(true)).toBe(true); + expect(runtime.reconcile(true)).toBe(true); expect(start).toHaveBeenCalledOnce(); - expect(runtime.reconcile('login-required')).toBe(false); + expect(runtime.reconcile(false)).toBe(false); expect(stop).toHaveBeenCalledOnce(); }); - it.each(['unknown', 'login-required', 'unreachable'] as const)( - 'treats %s as waiting for an extension dump', - (state) => { - const runtime = new SessionBoundRuntime(vi.fn(), vi.fn()); - expect(runtime.reconcile(state)).toBe(false); - }, - ); + it('treats incomplete authentication as waiting for an extension dump', () => { + const runtime = new SessionBoundRuntime(vi.fn(), vi.fn()); + expect(runtime.reconcile(false)).toBe(false); + }); }); diff --git a/tests/web/control-realtime.test.ts b/tests/web/control-realtime.test.ts index 4981abb..5eb82ba 100644 --- a/tests/web/control-realtime.test.ts +++ b/tests/web/control-realtime.test.ts @@ -10,6 +10,7 @@ describe('control route realtime events', () => { const bot = vi.fn(); const context = { config: { browser: { dryRun: true } }, + services: { accountAuth: { isFullyAuthenticated: () => true } }, setBotRunning: (next: boolean) => { running = next; }, activity: { push: vi.fn() }, logger: { info: vi.fn() },