fix(auth): unify imported sessions with account gating

This commit is contained in:
jjbrunton committed 2026-08-08 16:53:55 +01:00
1 parent 654bbda457
commit ea92a1a92b
15 files changed
+123 -49

No files matched your search

+37 -14
View File
@@ -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<AppCont
// Browser
const browserClient = new BrowserClient(config, logger);
const accountAuth = new AccountAuthContext(logger);
await browserClient.initialize();
// The extension import is the only session acquisition path. A missing
// session is recoverable: the dashboard remains available while all
// account-bound work waits for a verified dump.
logger.info('Verifying Reddit login status...');
const loggedIn = await browserClient.isSessionValid();
if (!loggedIn) {
const redditLoggedIn = await browserClient.isSessionValid();
if (!redditLoggedIn) {
logger.warn('Reddit session is logged out. Account work is paused until a session dump is imported from the extension.');
}
if (loggedIn) {
logger.info('Reddit login verified');
}
broadcaster.dashboardEvents.redditSession({
state: loggedIn ? 'authenticated' : 'login-required',
username: loggedIn ? await browserClient.getAuthenticatedUsername() ?? undefined : undefined,
});
// Reddit API client (shares browser context for auth)
const redditApi = new RedditApiClient(config, logger);
@@ -143,6 +139,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
successfulMessagesRepo,
postsRepo,
logger,
accountAuth,
conversationOutcomesRepo,
);
@@ -161,11 +158,28 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
capturedAt: Date.now(),
};
}
const matrixClient = new MatrixClient(matrixCreds, logger);
let activeMatrixClient = new MatrixClient(matrixCreds, logger);
accountAuth.setAuthenticator(async () => {
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<AppCont
};
const chatService = new ChatService(
matrixClient,
activeMatrixClient,
logger,
successfulMessagesRepo,
mediaService,
@@ -213,9 +227,10 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
undefined,
messagesRepo,
redditUsersRepo,
accountAuth,
);
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth);
const rateLimiter = new RateLimiter(config, logger, undefined, {
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
});
@@ -225,9 +240,15 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
const sessionImporter = new RedditSessionImporter(
browserClient,
chatService,
{
setMatrixClient(client) {
activeMatrixClient = client;
chatService.setMatrixClient(client);
},
},
(credentials) => new MatrixClient(credentials, logger),
saveCredentials,
accountAuth,
);
let webServer: WebServer | null = null;
@@ -296,6 +317,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setStrategySelector(strategySelector);
webServer.setBrowserClient(browserClient);
webServer.setSessionImporter(sessionImporter);
webServer.setAccountAuth(accountAuth);
webServer.setConversationStateRepository(conversationStateRepo);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}
@@ -316,6 +338,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
logger,
db,
browserClient,
accountAuth,
postsRepo,
messagesRepo,
successfulMessagesRepo,
+4 -1
View File
@@ -77,6 +77,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
config,
logger,
browserClient,
accountAuth,
postsRepo,
messagesRepo,
redditApi,
@@ -177,7 +178,9 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
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();
+5 -7
View File
@@ -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<void>;
}
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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) {
+10
View File
@@ -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<void>;
isSessionValid(): Promise<boolean>;
getAuthenticatedUsername(): Promise<string | null>;
}
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<void> {
@@ -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,
});
}
}
+3
View File
@@ -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<ResponseCheckResult[]> {
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
+1 -4
View File
@@ -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();
+2
View File
@@ -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;
};
+19 -3
View File
@@ -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' });
+5
View File
@@ -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;
}