feat: self-refresh Matrix tokens from the account browser session

Expired Matrix bearers no longer pause an account until a new extension
dump is uploaded. BrowserMatrixTokenRefresher loads chat.reddit.com in
the account's persistent browser profile (whose Reddit cookies stay
valid), lifts the fresh bearer off the first authenticated Matrix
request, verifies it via whoami, swaps it into the live MatrixClient
and persists it to the account row.

Wired into the per-account runtime at boot verification, the auth-gate
authenticator, and ChatService's 401 retry path. A dump is now only
needed when the Reddit cookies themselves no longer authenticate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Fable 5 committed 2026-08-10 08:56:17 +01:00
1 parent f34129666e
commit 82be197b79
7 files changed
+426 -8

No files matched your search

+2 -2
View File
@@ -282,8 +282,8 @@ export async function bootstrap(
logger.info(`Backfilled ${backfilledImageOutcomes} received-image performance outcome(s) from the media index`);
}
// A 401 waits for a new extension dump instead of opening a browser login
// or token-sniff flow.
// Legacy single-account path: a 401 waits for a new extension dump. The
// per-account runtimes self-refresh via BrowserMatrixTokenRefresher instead.
const tokenRefresher = new MatrixTokenRefresher();
// 4. Username resolver — Matrix profiles expose Reddit display names using
+15
View File
@@ -104,6 +104,21 @@ export class RedditAccountsRepository {
return publicFromPrivate(this.getRequired(input.accountId));
}
/** Store a self-refreshed Matrix token without touching cookies or status. */
updateMatrixCredentials(
accountId: string,
matrixCredentialsJson: string,
now: number = Date.now(),
): boolean {
requireText(accountId, 'accountId');
requireJson(matrixCredentialsJson, 'matrixCredentialsJson');
return this.db.prepare(`
UPDATE reddit_accounts
SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ?
WHERE account_id = ? AND status != 'legacy-unassigned'
`).run(matrixCredentialsJson, now, now, accountId).changes === 1;
}
setActive(accountId: string, active = true, now: number = Date.now()): boolean {
const status = active ? 'verified' : 'disabled';
return this.db.prepare(`
+8
View File
@@ -24,5 +24,13 @@ export {
MatrixRemoteLoginRequiredError,
MatrixTokenRefresher,
} from './matrix/token-refresher.js';
export { BrowserMatrixTokenRefresher } from './matrix/browser-token-refresher.js';
export type {
BrowserMatrixTokenRefresherDeps,
BrowserMatrixTokenRefresherOptions,
TokenCaptureContext,
TokenCapturePage,
TokenCaptureRequest,
} from './matrix/browser-token-refresher.js';
export { MatrixMediaDownloader } from './matrix/http-media-downloader.js';
export { MatrixUsernameResolver } from './matrix/username-resolver.js';
@@ -0,0 +1,169 @@
import type { Logger } from '../../../utils/logger.js';
import type { TokenRefresher } from '../service.js';
import type { MatrixCredentials } from './types.js';
import { MatrixRemoteLoginRequiredError } from './token-refresher.js';
/**
* Narrow structural views of Playwright's BrowserContext/Page/Request so the
* refresher can be unit-tested without a real browser. Request events are
* observed on the context (not the page) so Matrix traffic issued from
* workers is still visible.
*/
export interface TokenCaptureRequest {
url(): string;
headers(): Record<string, string>;
}
export interface TokenCapturePage {
goto(url: string, options?: { waitUntil?: 'domcontentloaded'; timeout?: number }): Promise<unknown>;
close(): Promise<unknown>;
}
export interface TokenCaptureContext {
on(event: 'request', handler: (request: TokenCaptureRequest) => void): unknown;
off(event: 'request', handler: (request: TokenCaptureRequest) => void): unknown;
newPage(): Promise<TokenCapturePage>;
}
export interface BrowserMatrixTokenRefresherDeps {
/** The account's persistent browser context — its Reddit cookies mint the token. */
context: TokenCaptureContext;
/** Matrix user the refreshed token must authenticate as. */
expectedUserId: string;
/** whoami with candidate credentials, via a throwaway client. */
verifyCredentials(credentials: MatrixCredentials): Promise<string>;
/** Swap the live MatrixClient's bearer. */
applyCredentials(credentials: MatrixCredentials): void;
/** Store the refreshed credentials so restarts reuse them. */
persistCredentials(credentials: MatrixCredentials): void;
logger: Logger;
}
export interface BrowserMatrixTokenRefresherOptions {
chatUrl?: string;
captureTimeoutMs?: number;
/** Back-off after a failed refresh so auth loops don't hammer the browser. */
failureCooldownMs?: number;
now?: () => number;
}
const MATRIX_HOSTNAME = /^matrix\.(?:redditspace|reddit)\.com$/i;
/**
* Self-refreshes the Matrix access token the way the exporter extension
* captured it: load Reddit Chat with the account's own (still valid) Reddit
* cookies and lift the bearer off the first authenticated Matrix request.
* Falls back to requiring a fresh extension dump only when the browser
* session itself can no longer authenticate.
*/
export class BrowserMatrixTokenRefresher implements TokenRefresher {
private readonly chatUrl: string;
private readonly captureTimeoutMs: number;
private readonly failureCooldownMs: number;
private readonly now: () => number;
private inFlight: Promise<void> | null = null;
private nextAttemptAt = 0;
constructor(
private readonly deps: BrowserMatrixTokenRefresherDeps,
options: BrowserMatrixTokenRefresherOptions = {},
) {
this.chatUrl = options.chatUrl ?? 'https://chat.reddit.com/';
this.captureTimeoutMs = options.captureTimeoutMs ?? 45_000;
this.failureCooldownMs = options.failureCooldownMs ?? 60_000;
this.now = options.now ?? Date.now;
}
refresh(): Promise<void> {
if (this.inFlight) return this.inFlight;
if (this.now() < this.nextAttemptAt) {
return Promise.reject(new MatrixRemoteLoginRequiredError());
}
this.inFlight = this.doRefresh()
.then(() => {
this.nextAttemptAt = 0;
})
.catch((error: unknown) => {
this.nextAttemptAt = this.now() + this.failureCooldownMs;
throw error;
})
.finally(() => {
this.inFlight = null;
});
return this.inFlight;
}
private async doRefresh(): Promise<void> {
const captured = await this.captureBearer();
if (!captured) {
this.deps.logger.warn(
'[matrix] browser session did not produce a Matrix bearer — a fresh extension dump is required',
);
throw new MatrixRemoteLoginRequiredError();
}
const credentials: MatrixCredentials = {
homeserver: captured.homeserver,
accessToken: captured.accessToken,
userId: this.deps.expectedUserId,
capturedAt: this.now(),
};
const userId = await this.deps.verifyCredentials(credentials);
if (userId !== this.deps.expectedUserId) {
throw new Error(
`Browser session minted a Matrix token for ${userId}, expected ${this.deps.expectedUserId}`,
);
}
this.deps.applyCredentials(credentials);
this.deps.persistCredentials(credentials);
this.deps.logger.info('[matrix] refreshed the Matrix access token from the account browser session');
}
private async captureBearer(): Promise<{ homeserver: string; accessToken: string } | null> {
let capture: (value: { homeserver: string; accessToken: string }) => void;
const captured = new Promise<{ homeserver: string; accessToken: string }>((resolve) => {
capture = resolve;
});
const onRequest = (request: TokenCaptureRequest): void => {
try {
const url = new URL(request.url());
if (url.protocol !== 'https:' || !MATRIX_HOSTNAME.test(url.hostname)) return;
const authorization = request.headers()['authorization'];
if (!authorization?.toLowerCase().startsWith('bearer ')) return;
capture({
homeserver: `${url.protocol}//${url.host}`,
accessToken: authorization.slice('bearer '.length).trim(),
});
} catch {
// Malformed request URL — ignore.
}
};
this.deps.context.on('request', onRequest);
let page: TokenCapturePage | null = null;
try {
page = await this.deps.context.newPage();
await page.goto(this.chatUrl, { waitUntil: 'domcontentloaded', timeout: this.captureTimeoutMs });
return await withTimeout(captured, this.captureTimeoutMs);
} finally {
this.deps.context.off('request', onRequest);
await page?.close().catch(() => undefined);
}
}
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(null), timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
+29 -6
View File
@@ -12,7 +12,7 @@ import {
ActivityLogRepository,
RecipientHistoryRepository,
} from '../database/repositories.js';
import { AccountContentRepository } from '../database/account-repositories.js';
import { AccountContentRepository, RedditAccountsRepository } from '../database/account-repositories.js';
import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddit/api.js';
import { RedditRequestCoordinator } from '../reddit/request-coordinator.js';
import { OpenRouterClient } from '../ai/openrouter.js';
@@ -28,8 +28,9 @@ import {
} from './account-auth-context.js';
import { ChatCache } from './chat/cache.js';
import {
ChatService, MatrixClient, MatrixMediaDownloader, MatrixTokenRefresher,
MatrixUsernameResolver, type UsernameResolver,
BrowserMatrixTokenRefresher, ChatService, MatrixAuthError, MatrixClient,
MatrixMediaDownloader, MatrixUsernameResolver,
type MatrixCredentials, type UsernameResolver,
} from './chat/index.js';
import { MediaService } from './media.js';
import { MediaSyncService } from './media-sync.js';
@@ -116,7 +117,29 @@ async function buildRuntime(
}
const matrixClient = new MatrixClient(session.matrixCredentials, logger);
if (await matrixClient.whoami() !== session.matrixUserId) {
const accountsRepo = new RedditAccountsRepository(db);
const tokenRefresher = new BrowserMatrixTokenRefresher({
context: browserClient.getContext()!,
expectedUserId: session.matrixUserId,
verifyCredentials: (credentials) => new MatrixClient(credentials, logger).whoami(),
applyCredentials: (credentials) => matrixClient.updateCredentials(credentials),
persistCredentials: (credentials: MatrixCredentials) => {
session.matrixCredentials = credentials;
accountsRepo.updateMatrixCredentials(session.accountId, JSON.stringify(credentials));
},
logger,
});
const verifyMatrixSession = async (refreshOnAuthError: boolean): Promise<boolean> => {
try {
return await matrixClient.whoami() === session.matrixUserId;
} catch (error) {
if (!refreshOnAuthError || !(error instanceof MatrixAuthError)) throw error;
logger.warn(`Stored Matrix token for u/${session.redditUsername} was rejected — refreshing from the browser session`);
await tokenRefresher.refresh();
return await matrixClient.whoami() === session.matrixUserId;
}
};
if (!(await verifyMatrixSession(true))) {
throw new Error(`Stored Matrix session no longer verifies for u/${session.redditUsername}`);
}
@@ -125,7 +148,7 @@ async function buildRuntime(
accountAuth.setAuthenticator(async () => {
const current = await browserClient.getAuthenticatedIdentity();
if (!current || matrixRedditId(session.matrixUserId) !== current.accountId.toLowerCase().replace(/^t2_/, '')) return null;
return await matrixClient.whoami() === session.matrixUserId
return await verifyMatrixSession(true)
? runtimeAuthenticatedAccount(session, current)
: null;
});
@@ -182,7 +205,7 @@ async function buildRuntime(
const chatCache = new ChatCache(logger, undefined, accountContentRepo, session.accountId);
const chatService = new ChatService(
matrixClient, logger, successfulMessagesRepo, mediaService,
new MatrixTokenRefresher(), usernameResolver, chatCache,
tokenRefresher, usernameResolver, chatCache,
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo, conversationStateRepo,
);
await chatService.loadCache();