Files
RedditBot/src/web/routes/login.ts
T
Jamie BruntonandClaude Opus 5 142378df33 feat: show the conversation evidence behind each learning trajectory
Reviewers were asked to include or exclude learning trajectories while
only seeing metadata — credit class, confidence, status. The decisions
and their redacted context turns were already captured and stored, but
no endpoint returned them, so the evidence was unreachable from the UI.

Add GET /api/learning/trajectories/:id and an expandable row on the
learning page that fetches on demand.

Also widen SentMessageSchema to accept matrixEventId, imageOutcomeCredit
and imageOutcomeAt. The learning loop added these to SentMessageRecord
and they flow out of /api/messages, but the strict contract rejected
them, breaking the dashboard.

Enable generation with shadow mode off so eligible examples reach reply
prompts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:23:22 +01:00

185 lines
6.9 KiB
TypeScript

import { Router } from 'express';
import type { Cookie } from 'playwright';
import { z } from 'zod';
import type { WebContext } from '../context.js';
// Keep this server-side parser local: the server's tsconfig intentionally
// builds only src/, while the dashboard compiles the shared API contract.
const RedditCookieImportRequestSchema = z.strictObject({
cookies: z.array(z.strictObject({
name: z.string().min(1).max(256),
value: z.string().max(16_384),
domain: z.string().min(1).max(253),
path: z.string().min(1).max(4_096).default('/'),
expires: z.number().finite().optional(),
httpOnly: z.boolean().optional(),
secure: z.boolean().optional(),
sameSite: z.enum(['strict', 'lax', 'none', 'no_restriction', 'unspecified']).optional(),
})).min(1).max(100),
matrix: z.strictObject({
homeserver: z.string().url(),
accessToken: z.string().min(1).max(16_384),
userId: z.string().min(1).max(512),
capturedAt: z.number().finite(),
}),
});
function isRedditDomain(domain: string): boolean {
const normalised = domain.toLowerCase().replace(/^\./, '');
return normalised === 'reddit.com' || normalised.endsWith('.reddit.com');
}
function toPlaywrightCookie(cookie: { name: string; value: string; domain: string; path: string; expires?: number; httpOnly?: boolean; secure?: boolean; sameSite?: string }): Cookie {
return {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
expires: cookie.expires ?? -1,
httpOnly: cookie.httpOnly ?? false,
secure: cookie.secure ?? true,
sameSite: cookie.sameSite === 'strict' ? 'Strict' : cookie.sameSite === 'none' || cookie.sameSite === 'no_restriction' ? 'None' : 'Lax',
};
}
export function createLoginRouter(ctx: WebContext): Router {
const router = Router();
router.get('/login/status', async (_req, res) => {
const runningAccounts = ctx.services.redditAccounts?.list().filter((account) => account.isRunning) ?? [];
if (runningAccounts.length > 0) {
const only = runningAccounts.length === 1 ? runningAccounts[0] : null;
res.json({
ready: true,
loggedIn: true,
status: 'authenticated',
accountId: only?.accountId ?? null,
username: only?.redditUsername ?? null,
});
return;
}
const browser = ctx.services.browser;
if (!browser) {
res.status(503).json({ ready: false, error: 'browser not initialised' });
return;
}
try {
const accountAuth = ctx.services.accountAuth;
const fullyAuthenticated = accountAuth
? await accountAuth.ensureFullyAuthenticated()
: await browser.isSessionValid();
if (fullyAuthenticated) {
const snapshot = accountAuth?.getSnapshot();
const username = snapshot?.redditUsername
?? await browser.getAuthenticatedUsername();
res.json({
ready: true,
loggedIn: true,
status: 'authenticated',
...(snapshot?.accountId ? { accountId: snapshot.accountId } : {}),
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' });
}
});
router.post('/login/session-import', async (req, res) => {
const importer = ctx.services.sessionImporter;
if (!importer) {
res.status(503).json({ ok: false, error: 'session importer not initialised' });
return;
}
const payload = RedditCookieImportRequestSchema.safeParse(req.body);
if (!payload.success) {
res.status(400).json({ ok: false, error: 'Invalid Reddit session export' });
return;
}
const cookies = payload.data.cookies.filter((cookie) => isRedditDomain(cookie.domain));
if (cookies.length === 0) {
res.status(400).json({ ok: false, error: 'The export did not contain Reddit cookies' });
return;
}
try {
const accounts = ctx.services.redditAccounts;
if (!accounts) {
await importer.import(cookies.map(toPlaywrightCookie), payload.data.matrix);
ctx.logger.info(`Imported ${cookies.length} Reddit session cookies and verified the browser session`);
res.json({ ok: true, status: 'authenticated' });
return;
}
const account = await accounts.importSession(cookies.map(toPlaywrightCookie), payload.data.matrix);
ctx.logger.info(`Imported ${cookies.length} Reddit session cookies and verified the browser session`);
res.json({ ok: true, status: 'authenticated', account });
} catch (error) {
ctx.logger.error('Failed to import Reddit session cookies:', error);
const message = error instanceof Error ? error.message : 'Could not import Reddit session';
res.status(401).json({ ok: false, error: message });
}
});
router.get('/accounts', (_req, res) => {
const accounts = ctx.services.redditAccounts;
if (!accounts) {
res.status(503).json({ error: 'Reddit account service not initialised' });
return;
}
res.json(accounts.list());
});
router.post('/accounts/:accountId/activate', async (req, res) => {
const accounts = ctx.services.redditAccounts;
if (!accounts) {
res.status(503).json({ ok: false, error: 'Reddit account service not initialised' });
return;
}
try {
const account = await accounts.activate(decodeURIComponent(req.params.accountId));
ctx.logger.info(`Activated Reddit account u/${account.redditUsername}`);
res.json({ ok: true, account });
} catch (error) {
ctx.logger.error('Failed to activate Reddit account:', error);
res.status(401).json({ ok: false, error: error instanceof Error ? error.message : 'Could not activate Reddit account' });
}
});
router.delete('/accounts/:accountId', async (req, res) => {
const accounts = ctx.services.redditAccounts;
if (!accounts) {
res.status(503).json({ ok: false, error: 'Reddit account service not initialised' });
return;
}
const accountId = decodeURIComponent(req.params.accountId);
try {
const account = await accounts.remove(accountId);
ctx.logger.info(`Removed Reddit account u/${account.redditUsername} and the content it owned`);
res.json({ ok: true, account });
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not remove Reddit account';
if (message === 'Reddit account not found') {
res.status(404).json({ ok: false, error: message });
return;
}
ctx.logger.error('Failed to remove Reddit account:', error);
res.status(500).json({ ok: false, error: message });
}
});
return router;
}