feat: secure React dashboard cutover

This commit is contained in:
jjbrunton committed 2026-08-08 15:51:06 +01:00
1 parent a51e0b6b31
commit ad770a792a
9 files changed
+477 -73

No files matched your search

+14 -1
View File
@@ -151,7 +151,11 @@ export const ConfigSchema = z.object({
web: z.object({
enabled: z.boolean().default(true),
port: z.number().default(3000),
}).default({ enabled: true, port: 3000 }),
/** Prefer WEB_DASHBOARD_TOKEN so secrets do not live in config.yaml. */
authToken: z.string().min(8).optional(),
/** Dangerous diagnostic endpoints are disabled unless explicitly enabled. */
debugEnabled: z.boolean().default(false),
}).default({ enabled: true, port: 3000, debugEnabled: false }),
});
export type Config = z.infer<typeof ConfigSchema>;
@@ -186,6 +190,11 @@ export function loadConfig(): Config {
rawConfig.browser.headless = process.env.BROWSER_HEADLESS === 'true';
}
if (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
if (!rawConfig.web) rawConfig.web = {};
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
}
if (!rawConfig.openrouter.apiKey) {
throw new Error('OPENROUTER_API_KEY environment variable is required');
}
@@ -199,5 +208,9 @@ export function loadConfig(): Config {
throw new Error(`Invalid configuration:\n${errors}`);
}
if (result.data.web.enabled && !result.data.web.authToken) {
throw new Error('WEB_DASHBOARD_TOKEN environment variable or web.authToken configuration is required when the dashboard is enabled');
}
return result.data;
}
+86
View File
@@ -0,0 +1,86 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import { Router, type RequestHandler } from 'express';
import { z } from 'zod';
const SessionRequestSchema = z.object({ token: z.string().min(1).max(4096) }).strict();
const SESSION_COOKIE = 'reddit_bot_dashboard_session';
function tokenDigest(token: string): Buffer {
return createHash('sha256').update(token, 'utf8').digest();
}
function matchesToken(candidate: string | undefined, expected: string): boolean {
return candidate !== undefined && timingSafeEqual(tokenDigest(candidate), tokenDigest(expected));
}
function parseCookies(cookieHeader: string | undefined): Record<string, string> {
if (!cookieHeader) return {};
const cookies: Record<string, string> = {};
for (const part of cookieHeader.split(';')) {
const [name, ...value] = part.trim().split('=');
if (!name) continue;
try {
cookies[name] = decodeURIComponent(value.join('='));
} catch {
// Ignore malformed cookies and continue checking the remaining values.
}
}
return cookies;
}
function requestToken(authorization: string | undefined): string | undefined {
if (!authorization) return undefined;
const bearer = /^Bearer\s+(.+)$/i.exec(authorization);
return bearer?.[1];
}
export function createDashboardAuth(token: string): RequestHandler {
if (!token.trim()) throw new Error('A non-empty dashboard authentication token is required');
return (req, res, next) => {
if (req.path === '/auth/session' || req.path === '/auth/login') return next();
const supplied = requestToken(req.header('authorization'))
?? parseCookies(req.header('cookie'))[SESSION_COOKIE];
if (matchesToken(supplied, token)) return next();
if (!req.path.startsWith('/api') && !req.path.startsWith('/media')) {
res.redirect(302, '/auth/login');
return;
}
res.status(401).set('WWW-Authenticate', 'Bearer realm="RedditBot Dashboard"')
.json({ error: 'Dashboard authentication required' });
};
}
export function createDashboardSessionRouter(token: string): Router {
const router = Router();
router.get('/auth/login', (_req, res) => {
res
.set('Cache-Control', 'no-cache, no-store, must-revalidate')
.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'")
.type('html')
.send(`<!doctype html><title>Dashboard sign in</title><form id="login"><label>Dashboard token <input name="token" type="password" required autofocus></label><button>Sign in</button></form><script>document.getElementById('login').addEventListener('submit',async(e)=>{e.preventDefault();const r=await fetch('/auth/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:new FormData(e.target).get('token')})});if(r.ok)location='/';else alert('Invalid dashboard token');});</script>`);
});
router.post('/auth/session', (req, res) => {
const payload = SessionRequestSchema.safeParse(req.body);
if (!payload.success) {
res.status(400).json({ error: 'Invalid dashboard authentication payload' });
return;
}
if (!matchesToken(payload.data.token, token)) {
res.status(401).json({ error: 'Dashboard authentication required' });
return;
}
const forwardedProtocol = req.get('x-forwarded-proto')?.split(',', 1)[0]?.trim();
res.cookie(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'strict',
secure: req.secure || forwardedProtocol === 'https',
path: '/',
});
res.status(204).end();
});
router.post('/auth/logout', (_req, res) => {
res.clearCookie(SESSION_COOKIE, { httpOnly: true, sameSite: 'strict', path: '/' });
res.status(204).end();
});
return router;
}
-60
View File
@@ -2,11 +2,6 @@ import { Router } from 'express';
import path from 'path';
import fs from 'fs';
import type { WebContext } from '../context.js';
import {
MatrixClient,
saveCredentials,
type MatrixCredentials,
} from '../../services/chat/index.js';
export function createDebugRouter(ctx: WebContext): Router {
const router = Router();
@@ -41,7 +36,6 @@ export function createDebugRouter(ctx: WebContext): Router {
return;
}
try {
const reveal = req.query.reveal === '1';
const urls = ['https://www.reddit.com', 'https://chat.reddit.com', 'https://matrix.redditspace.com'];
const out: Record<string, unknown> = {};
for (const u of urls) {
@@ -55,7 +49,6 @@ export function createDebugRouter(ctx: WebContext): Router {
sameSite: c.sameSite,
expires: c.expires,
valueLen: c.value.length,
value: reveal ? c.value : undefined,
}));
}
res.json(out);
@@ -65,59 +58,6 @@ export function createDebugRouter(ctx: WebContext): Router {
}
});
// Accepts a Matrix credentials JSON body, persists it to the cache file,
// and swaps it into the running ChatService. Lets us seed the deployed bot
// from a sniff run on a developer's machine when the in-container sniff
// can't reach an authed /chat/ surface and noVNC isn't an option.
//
// Body shape: { homeserver, accessToken, userId?, capturedAt? }
router.post('/debug/matrix-credentials', (req, res) => {
const body = (req.body ?? {}) as Partial<MatrixCredentials>;
if (!body.homeserver || !body.accessToken) {
res.status(400).json({ error: 'homeserver and accessToken are required' });
return;
}
const creds: MatrixCredentials = {
homeserver: body.homeserver,
accessToken: body.accessToken,
userId: body.userId ?? '',
capturedAt: body.capturedAt ?? Date.now(),
};
try {
saveCredentials(creds);
ctx.logger.info(
`[matrix] credentials uploaded via /api/debug/matrix-credentials (user=${creds.userId || 'unknown'})`,
);
if (ctx.services.chat) {
ctx.services.chat.setMatrixClient(new MatrixClient(creds, ctx.logger));
ctx.logger.info('[matrix] uploaded credentials swapped into running ChatService');
}
res.json({ success: true, homeserver: creds.homeserver, userId: creds.userId });
} catch (error) {
ctx.logger.error('Matrix credential upload failed:', error);
res.status(500).json({ error: String(error) });
}
});
router.post('/debug/inspect-dom', async (req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const chatUrl = req.body?.url;
if (!chatUrl) {
res.status(400).json({ error: 'url is required' });
return;
}
const result = await ctx.services.chat.debugInspectDom(chatUrl);
res.json(result);
} catch (error) {
ctx.logger.error('DOM inspection failed:', error);
res.status(500).json({ error: String(error) });
}
});
router.post('/debug/check-responses', async (_req, res) => {
if (!ctx.services.responseChecker) {
res.status(500).json({ error: 'Response checker not initialized' });
+45 -11
View File
@@ -1,6 +1,7 @@
import express from 'express';
import path from 'path';
import type { Server } from 'http';
import type { AddressInfo } from 'node:net';
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type {
@@ -31,6 +32,8 @@ import { createLogsRouter } from './routes/logs.js';
import { createAnalyticsRouter } from './routes/analytics.js';
import { createConfigRouter } from './routes/config.js';
import { createLoginRouter } from './routes/login.js';
import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
import { resolveDashboardAssets } from './dashboard-assets.js';
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
@@ -63,6 +66,7 @@ export class WebServer {
private activityLogRepo: ActivityLogRepository,
private logger: Logger,
broadcaster?: LogBroadcaster,
private workspaceDirectory = process.cwd(),
) {
this.broadcaster = broadcaster ?? new LogBroadcaster();
this.activity = new ActivityLog(activityLogRepo);
@@ -105,19 +109,29 @@ export class WebServer {
}
private setupRoutes(): void {
const dashboardToken = this.config.web.authToken;
if (!dashboardToken) {
throw new Error('Web dashboard requires web.authToken or WEB_DASHBOARD_TOKEN');
}
this.app.use(express.json({ limit: '64kb' }));
this.app.use(createDashboardSessionRouter(dashboardToken));
this.app.use(createDashboardAuth(dashboardToken));
const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory);
// index.html serves the entire inline JS — never cache it.
this.app.use((req, res, next) => {
if (req.path === '/' || req.path === '/index.html') {
const p = path.join(process.cwd(), 'public', 'index.html');
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
return res.sendFile(p);
return res.sendFile(dashboardAssets.indexFile);
}
next();
});
this.app.use('/media', express.static(path.join(process.cwd(), 'data', 'media')));
this.app.use(express.static(path.join(process.cwd(), 'public')));
this.app.use(express.json());
// Media can contain private chat attachments, so it is intentionally behind
// the same dashboard authentication middleware as the API and UI.
this.app.use('/media', express.static(path.join(this.workspaceDirectory, 'data', 'media'), { index: false }));
this.app.use(express.static(dashboardAssets.directory, { index: false }));
const ctx = this.context();
this.app.use('/api', createStatsRouter(ctx));
@@ -125,15 +139,21 @@ export class WebServer {
this.app.use('/api', createConversationsRouter(ctx));
this.app.use('/api', createMediaRouter(ctx));
this.app.use('/api', createControlRouter(ctx));
this.app.use('/api', createDebugRouter(ctx));
if (this.config.web.debugEnabled) {
this.app.use('/api', createDebugRouter(ctx));
}
this.app.use('/api', createLogsRouter(ctx));
this.app.use('/api', createAnalyticsRouter(ctx));
this.app.use('/api', createConfigRouter(ctx));
this.app.use('/api', createLoginRouter(ctx));
this.app.use('/api', (_req, res) => {
res.status(404).json({ error: 'API endpoint not found' });
});
// SPA catch-all.
this.app.get(/.*/, (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
res.sendFile(dashboardAssets.indexFile);
});
}
@@ -200,10 +220,18 @@ export class WebServer {
this.messagesThisHour = messagesThisHour;
}
start(): void {
this.server = this.app.listen(this.port, () => {
this.logger.info(`Web dashboard running at http://localhost:${this.port}`);
this.broadcaster.push('info', `Web dashboard started on port ${this.port}`);
start(): Promise<number> {
return new Promise((resolve, reject) => {
const server = this.app.listen(this.port);
this.server = server;
server.once('error', reject);
server.once('listening', () => {
const address = server.address() as AddressInfo | null;
const listeningPort = address?.port ?? this.port;
this.logger.info(`Web dashboard running at http://localhost:${listeningPort}`);
this.broadcaster.push('info', `Web dashboard started on port ${listeningPort}`);
resolve(listeningPort);
});
});
}
@@ -214,4 +242,10 @@ export class WebServer {
this.server = null;
}
}
/** Exposed for health probes and integration tests when configured with port 0. */
getListeningPort(): number | null {
const address = this.server?.address();
return address && typeof address !== 'string' ? (address as AddressInfo).port : null;
}
}
+32 -1
View File
@@ -294,7 +294,7 @@ describe('ConfigSchema', () => {
describe('loadConfig', () => {
const originalCwd = process.cwd;
const savedEnv: Record<string, string | undefined> = {};
const trackedEnvKeys = ['OPENROUTER_API_KEY', 'REDDIT_USERNAME', 'REDDIT_PASSWORD', 'BROWSER_HEADLESS'];
const trackedEnvKeys = ['OPENROUTER_API_KEY', 'REDDIT_USERNAME', 'REDDIT_PASSWORD', 'BROWSER_HEADLESS', 'WEB_DASHBOARD_TOKEN'];
beforeEach(() => {
// Snapshot then clear any env vars that loadConfig consults — these may be
@@ -303,6 +303,7 @@ describe('loadConfig', () => {
savedEnv[key] = process.env[key];
delete process.env[key];
}
process.env.WEB_DASHBOARD_TOKEN = 'test-dashboard-token';
const mockDir = '/tmp/test-config-' + Date.now();
fs.mkdirSync(mockDir, { recursive: true });
@@ -411,4 +412,34 @@ openrouter:
expect(() => loadConfig()).toThrow('OPENROUTER_API_KEY environment variable is required');
});
it('requires a dashboard token when the dashboard is enabled', () => {
delete process.env.WEB_DASHBOARD_TOKEN;
const configPath = path.join(process.cwd(), 'config.yaml');
fs.writeFileSync(configPath, `
reddit:
subreddits: [test]
openrouter:
apiKey: key
imageAnalysisPrompt: analyze this
messageGenerationPrompt: write message
`);
expect(() => loadConfig()).toThrow('WEB_DASHBOARD_TOKEN');
});
it('injects the dashboard token from the environment', () => {
process.env.WEB_DASHBOARD_TOKEN = 'a-secure-dashboard-token';
const configPath = path.join(process.cwd(), 'config.yaml');
fs.writeFileSync(configPath, `
reddit:
subreddits: [test]
openrouter:
apiKey: key
imageAnalysisPrompt: analyze this
messageGenerationPrompt: write message
`);
expect(loadConfig().web.authToken).toBe('a-secure-dashboard-token');
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { ConfigSchema } from '../src/config/index.js';
describe('dashboard token configuration', () => {
it('accepts an eight-character local dashboard token', () => {
const result = ConfigSchema.safeParse({
reddit: { subreddits: ['test'] },
openrouter: {
apiKey: 'test-key',
imageAnalysisPrompt: 'test',
messageGenerationPrompt: 'test',
},
web: { enabled: true, authToken: 'create01' },
});
expect(result.success).toBe(true);
});
it('still rejects trivially short dashboard tokens', () => {
const result = ConfigSchema.safeParse({
reddit: { subreddits: ['test'] },
openrouter: {
apiKey: 'test-key',
imageAnalysisPrompt: 'test',
messageGenerationPrompt: 'test',
},
web: { enabled: true, authToken: 'short' },
});
expect(result.success).toBe(false);
});
});
+48
View File
@@ -0,0 +1,48 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest';
import { createDashboardAuth } from '../src/web/auth.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
),
);
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe('private media authentication', () => {
it('rejects anonymous media requests and permits an authenticated session', async () => {
const mediaDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-media-auth-'));
temporaryDirectories.push(mediaDirectory);
fs.writeFileSync(path.join(mediaDirectory, 'private.txt'), 'private-media');
const app = express();
app.use(createDashboardAuth('create01'));
app.use('/media', express.static(mediaDirectory));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const url = `http://127.0.0.1:${port}/media/private.txt`;
const anonymousResponse = await fetch(url);
const authenticatedResponse = await fetch(url, {
headers: { Authorization: 'Bearer create01' },
});
expect(anonymousResponse.status).toBe(401);
expect(authenticatedResponse.status).toBe(200);
expect(await authenticatedResponse.text()).toBe('private-media');
});
});
+119
View File
@@ -0,0 +1,119 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { BrowserContext } from 'playwright';
import { afterEach, describe, expect, it } from 'vitest';
import type { BrowserClient } from '../src/browser/client.js';
import { ActivityLogRepository } from '../src/database/repositories.js';
import { WebServer } from '../src/web/server.js';
import { createMockLogger } from './mockLogger.js';
import { createTestDb, createTestRepositories, getTestConfig } from './testUtils.js';
const AUTH_TOKEN = 'create01';
interface RunningServer {
baseUrl: string;
server: WebServer;
workspace: string;
}
function createWorkspace(includeReactBuild = true): string {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-web-'));
fs.mkdirSync(path.join(workspace, 'public'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'public', 'index.html'), '<h1>Legacy dashboard</h1>');
fs.mkdirSync(path.join(workspace, 'data', 'media'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'data', 'media', 'sample.txt'), 'private media');
if (includeReactBuild) {
fs.mkdirSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'), '<h1>React dashboard</h1><script src="/assets/app.js"></script>');
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets', 'app.js'), 'react-build');
}
return workspace;
}
async function startServer(options: { debugEnabled?: boolean; includeReactBuild?: boolean } = {}): Promise<RunningServer> {
const database = createTestDb();
const repositories = createTestRepositories(database);
const workspace = createWorkspace(options.includeReactBuild);
const server = new WebServer(
0,
getTestConfig({ web: { enabled: true, port: 0, authToken: AUTH_TOKEN, debugEnabled: options.debugEnabled ?? false } }),
repositories.postsRepo,
repositories.messagesRepo,
repositories.successfulMessagesRepo,
new ActivityLogRepository(database),
createMockLogger(),
undefined,
workspace,
);
const port = await server.start();
return { baseUrl: `http://127.0.0.1:${port}`, server, workspace };
}
describe('authenticated React dashboard cutover', () => {
const runningServers: RunningServer[] = [];
afterEach(() => {
for (const running of runningServers) {
running.server.stop();
fs.rmSync(running.workspace, { recursive: true, force: true });
}
runningServers.length = 0;
});
it('protects API, SSE, media, React assets, and SPA navigation', async () => {
const running = await startServer();
runningServers.push(running);
const anonymous = await Promise.all([
fetch(`${running.baseUrl}/api/stats`),
fetch(`${running.baseUrl}/api/events/stream`),
fetch(`${running.baseUrl}/media/sample.txt`),
fetch(running.baseUrl, { redirect: 'manual' }),
]);
expect(anonymous.map((response) => response.status)).toEqual([401, 401, 401, 302]);
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
const api = await fetch(`${running.baseUrl}/api/stats`, { headers });
const media = await fetch(`${running.baseUrl}/media/sample.txt`, { headers });
const root = await fetch(running.baseUrl, { headers });
const asset = await fetch(`${running.baseUrl}/assets/app.js`, { headers });
const spa = await fetch(`${running.baseUrl}/conversations/example`, { headers: { ...headers, Accept: 'text/html' } });
expect(api.status).toBe(200);
expect(await media.text()).toBe('private media');
expect(await root.text()).toContain('React dashboard');
expect(await asset.text()).toBe('react-build');
expect(await spa.text()).toContain('React dashboard');
});
it('creates a secure session and keeps debug credentials unavailable', async () => {
const running = await startServer({ debugEnabled: true });
runningServers.push(running);
const accepted = await fetch(`${running.baseUrl}/auth/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Forwarded-Proto': 'https' },
body: JSON.stringify({ token: AUTH_TOKEN }),
});
const setCookie = accepted.headers.get('set-cookie');
expect(accepted.status).toBe(204);
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('SameSite=Strict');
expect(setCookie).toContain('Secure');
const browserContext = { cookies: async () => [{ name: 'reddit_session', value: 'never-return-this-secret', domain: '.reddit.com', path: '/', expires: -1, httpOnly: true, secure: true, sameSite: 'Lax' as const }] } as unknown as BrowserContext;
running.server.setBrowserClient({ getContext: () => browserContext } as unknown as BrowserClient);
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
const cookies = await fetch(`${running.baseUrl}/api/debug/cookies?reveal=1`, { headers });
const upload = await fetch(`${running.baseUrl}/api/debug/matrix-credentials`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ homeserver: 'https://matrix.example', accessToken: 'secret' }) });
expect(await cookies.text()).not.toContain('never-return-this-secret');
expect(upload.status).toBe(404);
});
it('uses the legacy dashboard only when a React build is absent', async () => {
const running = await startServer({ includeReactBuild: false });
runningServers.push(running);
const response = await fetch(running.baseUrl, { headers: { Authorization: `Bearer ${AUTH_TOKEN}` } });
expect(await response.text()).toContain('Legacy dashboard');
});
});
+101
View File
@@ -0,0 +1,101 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest';
import { createDashboardAuth, createDashboardSessionRouter } from '../src/web/auth.js';
import { WebServer } from '../src/web/server.js';
import { ConfigSchema } from '../src/config/index.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(
app: express.Express,
path: string,
init: RequestInit = {},
): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`, init);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
});
describe('dashboard authentication', () => {
it('rejects protected dashboard and API requests without a token', async () => {
const app = express();
app.use(createDashboardAuth('dashboard-secret'));
app.get('/api/stats', (_req, res) => res.json({ ok: true }));
const response = await request(app, '/api/stats');
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'Dashboard authentication required' });
});
it('accepts a bearer token without exposing it in the URL', async () => {
const app = express();
app.use(createDashboardAuth('dashboard-secret'));
app.get('/api/stats', (_req, res) => res.json({ ok: true }));
const response = await request(app, '/api/stats', {
headers: { Authorization: 'Bearer dashboard-secret' },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
});
it('issues an HttpOnly session only for a schema-valid token payload', async () => {
const app = express();
app.use(express.json());
app.use(createDashboardSessionRouter('dashboard-secret'));
const invalid = await request(app, '/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 123 }),
});
const valid = await request(app, '/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'dashboard-secret' }),
});
expect(invalid.status).toBe(400);
expect(valid.status).toBe(204);
expect(valid.headers.get('set-cookie')).toMatch(/HttpOnly/);
expect(valid.headers.get('set-cookie')).toMatch(/SameSite=Strict/);
});
it('does not mount debug endpoints unless explicitly enabled', async () => {
const config = ConfigSchema.parse({
reddit: { subreddits: ['test'] },
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
web: { enabled: true, port: 0, authToken: 'dashboard-secret-token' },
});
const activityRepo = { getRecent: () => [], insert: () => undefined, prune: () => undefined, countSentSkipped: () => 0 };
const server = new WebServer(
0,
config,
{} as never,
{} as never,
{} as never,
activityRepo as never,
{ info: () => undefined } as never,
);
server.start();
const port = server.getListeningPort();
expect(port).not.toBeNull();
const response = await fetch(`http://127.0.0.1:${port}/api/debug/cookies`, {
headers: { Authorization: 'Bearer dashboard-secret-token' },
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: 'API endpoint not found' });
server.stop();
});
});