feat: secure React dashboard cutover
This commit is contained in:
1 parent
12a853fa42
commit
8b1ec85f1d
8 files changed
+408
-71
No files matched your search
+7
-1
@@ -151,7 +151,9 @@ export const ConfigSchema = z.object({
|
|||||||
web: z.object({
|
web: z.object({
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
port: z.number().default(3000),
|
port: z.number().default(3000),
|
||||||
}).default({ enabled: true, port: 3000 }),
|
authToken: z.string().min(8, 'Dashboard token must be at least 8 characters'),
|
||||||
|
debugEnabled: z.boolean().default(false),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
@@ -186,6 +188,10 @@ export function loadConfig(): Config {
|
|||||||
rawConfig.browser.headless = process.env.BROWSER_HEADLESS === 'true';
|
rawConfig.browser.headless = process.env.BROWSER_HEADLESS === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dashboard access is a deployment secret, not source-controlled config.
|
||||||
|
if (!rawConfig.web) rawConfig.web = {};
|
||||||
|
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN || rawConfig.web.authToken;
|
||||||
|
|
||||||
if (!rawConfig.openrouter.apiKey) {
|
if (!rawConfig.openrouter.apiKey) {
|
||||||
throw new Error('OPENROUTER_API_KEY environment variable is required');
|
throw new Error('OPENROUTER_API_KEY environment variable is required');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { Application, NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'redditbot_dashboard';
|
||||||
|
|
||||||
|
function tokenDigest(token: string): Buffer {
|
||||||
|
return createHash('sha256').update(token, 'utf8').digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokensMatch(candidate: string, expected: string): boolean {
|
||||||
|
return timingSafeEqual(tokenDigest(candidate), tokenDigest(expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearerToken(req: Request): string | null {
|
||||||
|
const match = /^Bearer\s+(.+)$/i.exec(req.get('authorization') ?? '');
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieToken(req: Request): string | null {
|
||||||
|
const cookieHeader = req.get('cookie');
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
|
||||||
|
for (const part of cookieHeader.split(';')) {
|
||||||
|
const [name, ...valueParts] = part.trim().split('=');
|
||||||
|
if (name === SESSION_COOKIE) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(valueParts.join('='));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestToken(req: Request): string | null {
|
||||||
|
return bearerToken(req) ?? cookieToken(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApiRequest(req: Request): boolean {
|
||||||
|
return req.path.startsWith('/api') || req.path.startsWith('/media');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDashboardAuthMiddleware(expectedToken: string) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const candidate = requestToken(req);
|
||||||
|
if (candidate !== null && tokensMatch(candidate, expectedToken)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isApiRequest(req)) {
|
||||||
|
res.status(401).json({ error: 'Authentication required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(302, '/auth/login');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureDashboardSessionRoutes(app: Application, expectedToken: string): void {
|
||||||
|
app.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>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||||
|
<title>Dashboard sign in</title><style>body{font:16px system-ui;max-width:28rem;margin:10vh auto;padding:1rem}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{font:inherit;padding:.75rem;margin-top:.5rem}</style></head>
|
||||||
|
<body><main><h1>Dashboard sign in</h1><form id="login"><label for="token">Access token</label><input id="token" name="token" type="password" required autocomplete="current-password"><button>Sign in</button><p id="error" role="alert"></p></form></main>
|
||||||
|
<script>document.getElementById('login').addEventListener('submit',async(e)=>{e.preventDefault();const token=document.getElementById('token').value;const response=await fetch('/auth/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token})});if(response.ok){location.href='/'}else{document.getElementById('error').textContent='Invalid access token';}});</script></body></html>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/session', (req, res) => {
|
||||||
|
const body = req.body as { token?: unknown } | undefined;
|
||||||
|
const candidate = typeof body?.token === 'string' ? body.token : '';
|
||||||
|
if (!tokensMatch(candidate, expectedToken)) {
|
||||||
|
res.status(401).json({ error: 'Invalid access token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedProtocol = req.get('x-forwarded-proto')?.split(',', 1)[0]?.trim();
|
||||||
|
res.cookie(SESSION_COOKIE, candidate, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: req.secure || forwardedProtocol === 'https',
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/logout', (_req, res) => {
|
||||||
|
res.clearCookie(SESSION_COOKIE, { httpOnly: true, sameSite: 'strict', path: '/' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
}
|
||||||
+3
-45
@@ -2,11 +2,6 @@ import { Router } from 'express';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import type { WebContext } from '../context.js';
|
import type { WebContext } from '../context.js';
|
||||||
import {
|
|
||||||
MatrixClient,
|
|
||||||
saveCredentials,
|
|
||||||
type MatrixCredentials,
|
|
||||||
} from '../../services/chat/index.js';
|
|
||||||
|
|
||||||
export function createDebugRouter(ctx: WebContext): Router {
|
export function createDebugRouter(ctx: WebContext): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -26,10 +21,10 @@ export function createDebugRouter(ctx: WebContext): Router {
|
|||||||
res.sendFile(p);
|
res.sendFile(p);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dumps the browser context's cookies (names + metadata only — values are
|
// Dumps the browser context's cookie names and non-secret metadata so we
|
||||||
// hashed) so we can tell which session/OAuth cookies are set after the
|
// can tell which session/OAuth cookies are set after the
|
||||||
// auto-login and which the chat surface might be missing.
|
// auto-login and which the chat surface might be missing.
|
||||||
router.get('/debug/cookies', async (req, res) => {
|
router.get('/debug/cookies', async (_req, res) => {
|
||||||
const browser = ctx.services.browser;
|
const browser = ctx.services.browser;
|
||||||
if (!browser) {
|
if (!browser) {
|
||||||
res.status(500).json({ error: 'Browser client not initialized' });
|
res.status(500).json({ error: 'Browser client not initialized' });
|
||||||
@@ -41,7 +36,6 @@ export function createDebugRouter(ctx: WebContext): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const reveal = req.query.reveal === '1';
|
|
||||||
const urls = ['https://www.reddit.com', 'https://chat.reddit.com', 'https://matrix.redditspace.com'];
|
const urls = ['https://www.reddit.com', 'https://chat.reddit.com', 'https://matrix.redditspace.com'];
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const u of urls) {
|
for (const u of urls) {
|
||||||
@@ -54,8 +48,6 @@ export function createDebugRouter(ctx: WebContext): Router {
|
|||||||
secure: c.secure,
|
secure: c.secure,
|
||||||
sameSite: c.sameSite,
|
sameSite: c.sameSite,
|
||||||
expires: c.expires,
|
expires: c.expires,
|
||||||
valueLen: c.value.length,
|
|
||||||
value: reveal ? c.value : undefined,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
res.json(out);
|
res.json(out);
|
||||||
@@ -65,40 +57,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) => {
|
router.post('/debug/inspect-dom', async (req, res) => {
|
||||||
if (!ctx.services.chat) {
|
if (!ctx.services.chat) {
|
||||||
res.status(500).json({ error: 'Chat service not initialized' });
|
res.status(500).json({ error: 'Chat service not initialized' });
|
||||||
|
|||||||
+43
-18
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
import type { Logger } from '../utils/logger.js';
|
import type { Logger } from '../utils/logger.js';
|
||||||
import type { Config } from '../config/index.js';
|
import type { Config } from '../config/index.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -31,6 +32,8 @@ import { createLogsRouter } from './routes/logs.js';
|
|||||||
import { createAnalyticsRouter } from './routes/analytics.js';
|
import { createAnalyticsRouter } from './routes/analytics.js';
|
||||||
import { createConfigRouter } from './routes/config.js';
|
import { createConfigRouter } from './routes/config.js';
|
||||||
import { createLoginRouter } from './routes/login.js';
|
import { createLoginRouter } from './routes/login.js';
|
||||||
|
import { resolveDashboardAssets } from './dashboard-assets.js';
|
||||||
|
import { configureDashboardSessionRoutes, createDashboardAuthMiddleware } from './dashboard-auth.js';
|
||||||
|
|
||||||
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
|
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ export class WebServer {
|
|||||||
private activityLogRepo: ActivityLogRepository,
|
private activityLogRepo: ActivityLogRepository,
|
||||||
private logger: Logger,
|
private logger: Logger,
|
||||||
broadcaster?: LogBroadcaster,
|
broadcaster?: LogBroadcaster,
|
||||||
|
private workspaceDirectory = process.cwd(),
|
||||||
) {
|
) {
|
||||||
this.broadcaster = broadcaster ?? new LogBroadcaster();
|
this.broadcaster = broadcaster ?? new LogBroadcaster();
|
||||||
this.activity = new ActivityLog(activityLogRepo);
|
this.activity = new ActivityLog(activityLogRepo);
|
||||||
@@ -105,19 +109,21 @@ export class WebServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupRoutes(): void {
|
private setupRoutes(): void {
|
||||||
// index.html serves the entire inline JS — never cache it.
|
const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory);
|
||||||
this.app.use((req, res, next) => {
|
this.app.use(express.json({ limit: '32kb' }));
|
||||||
if (req.path === '/' || req.path === '/index.html') {
|
configureDashboardSessionRoutes(this.app, this.config.web.authToken);
|
||||||
const p = path.join(process.cwd(), 'public', 'index.html');
|
this.app.use(createDashboardAuthMiddleware(this.config.web.authToken));
|
||||||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
return res.sendFile(p);
|
// The shell may change between deployments, so never cache index.html.
|
||||||
}
|
this.app.get(['/', '/index.html'], (_req, res) => {
|
||||||
next();
|
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.sendFile(dashboardAssets.indexFile);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.use('/media', express.static(path.join(process.cwd(), 'data', 'media')));
|
this.app.use('/media', express.static(path.join(this.workspaceDirectory, 'data', 'media'), {
|
||||||
this.app.use(express.static(path.join(process.cwd(), 'public')));
|
index: false,
|
||||||
this.app.use(express.json());
|
}));
|
||||||
|
this.app.use(express.static(dashboardAssets.directory, { index: false }));
|
||||||
|
|
||||||
const ctx = this.context();
|
const ctx = this.context();
|
||||||
this.app.use('/api', createStatsRouter(ctx));
|
this.app.use('/api', createStatsRouter(ctx));
|
||||||
@@ -125,15 +131,26 @@ export class WebServer {
|
|||||||
this.app.use('/api', createConversationsRouter(ctx));
|
this.app.use('/api', createConversationsRouter(ctx));
|
||||||
this.app.use('/api', createMediaRouter(ctx));
|
this.app.use('/api', createMediaRouter(ctx));
|
||||||
this.app.use('/api', createControlRouter(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', createLogsRouter(ctx));
|
||||||
this.app.use('/api', createAnalyticsRouter(ctx));
|
this.app.use('/api', createAnalyticsRouter(ctx));
|
||||||
this.app.use('/api', createConfigRouter(ctx));
|
this.app.use('/api', createConfigRouter(ctx));
|
||||||
this.app.use('/api', createLoginRouter(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.
|
// SPA catch-all.
|
||||||
this.app.get(/.*/, (_req, res) => {
|
this.app.get(/.*/, (req, res) => {
|
||||||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
if (!req.get('accept')?.includes('text/html')) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.sendFile(dashboardAssets.indexFile);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +217,18 @@ export class WebServer {
|
|||||||
this.messagesThisHour = messagesThisHour;
|
this.messagesThisHour = messagesThisHour;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): Promise<number> {
|
||||||
this.server = this.app.listen(this.port, () => {
|
return new Promise((resolve, reject) => {
|
||||||
this.logger.info(`Web dashboard running at http://localhost:${this.port}`);
|
const server = this.app.listen(this.port);
|
||||||
this.broadcaster.push('info', `Web dashboard started on port ${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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+64
-7
@@ -13,7 +13,7 @@ function minimalConfig(overrides: Record<string, unknown> = {}): Record<string,
|
|||||||
database: overrides.database ?? {},
|
database: overrides.database ?? {},
|
||||||
media: overrides.media ?? {},
|
media: overrides.media ?? {},
|
||||||
strategies: overrides.strategies ?? {},
|
strategies: overrides.strategies ?? {},
|
||||||
web: overrides.web ?? {},
|
web: { authToken: 'create01', ...(overrides.web as Record<string, unknown> | undefined) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ describe('ConfigSchema', () => {
|
|||||||
const result = ConfigSchema.safeParse({
|
const result = ConfigSchema.safeParse({
|
||||||
reddit: { subreddits: [{ name: 'test', tone }] },
|
reddit: { subreddits: [{ name: 'test', tone }] },
|
||||||
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||||
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: { authToken: 'create01' },
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ describe('ConfigSchema', () => {
|
|||||||
const result = ConfigSchema.safeParse({
|
const result = ConfigSchema.safeParse({
|
||||||
reddit: { subreddits: [{ name: 'test', tone: 'bananas' }] },
|
reddit: { subreddits: [{ name: 'test', tone: 'bananas' }] },
|
||||||
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||||
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: { authToken: 'create01' },
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ describe('ConfigSchema', () => {
|
|||||||
const result = ConfigSchema.safeParse({
|
const result = ConfigSchema.safeParse({
|
||||||
reddit: { subreddits: [{ name: 'test', scripted: 'Hello {author}' }] },
|
reddit: { subreddits: [{ name: 'test', scripted: 'Hello {author}' }] },
|
||||||
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||||
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: { authToken: 'create01' },
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -130,7 +130,7 @@ describe('ConfigSchema', () => {
|
|||||||
return {
|
return {
|
||||||
reddit: { subreddits: ['test'] },
|
reddit: { subreddits: ['test'] },
|
||||||
openrouter,
|
openrouter,
|
||||||
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: { authToken: 'create01' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +270,22 @@ describe('ConfigSchema', () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.data.web.enabled).toBe(true);
|
expect(result.data.web.enabled).toBe(true);
|
||||||
expect(result.data.web.port).toBe(3000);
|
expect(result.data.web.port).toBe(3000);
|
||||||
|
expect(result.data.web.debugEnabled).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an eight-character dashboard token', () => {
|
||||||
|
const result = ConfigSchema.safeParse(minimalConfig({ web: { authToken: 'create01' } }));
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects dashboard tokens shorter than eight characters', () => {
|
||||||
|
const result = ConfigSchema.safeParse(minimalConfig({ web: { authToken: 'short7!' } }));
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues.some((issue) => issue.path.join('.') === 'web.authToken')).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -284,7 +300,7 @@ describe('ConfigSchema', () => {
|
|||||||
database: {},
|
database: {},
|
||||||
media: {},
|
media: {},
|
||||||
strategies: {},
|
strategies: {},
|
||||||
web: {},
|
web: { authToken: 'create01' },
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -294,7 +310,13 @@ describe('ConfigSchema', () => {
|
|||||||
describe('loadConfig', () => {
|
describe('loadConfig', () => {
|
||||||
const originalCwd = process.cwd;
|
const originalCwd = process.cwd;
|
||||||
const savedEnv: Record<string, string | undefined> = {};
|
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(() => {
|
beforeEach(() => {
|
||||||
// Snapshot then clear any env vars that loadConfig consults — these may be
|
// Snapshot then clear any env vars that loadConfig consults — these may be
|
||||||
@@ -303,6 +325,7 @@ describe('loadConfig', () => {
|
|||||||
savedEnv[key] = process.env[key];
|
savedEnv[key] = process.env[key];
|
||||||
delete process.env[key];
|
delete process.env[key];
|
||||||
}
|
}
|
||||||
|
process.env.WEB_DASHBOARD_TOKEN = 'create01';
|
||||||
|
|
||||||
const mockDir = '/tmp/test-config-' + Date.now();
|
const mockDir = '/tmp/test-config-' + Date.now();
|
||||||
fs.mkdirSync(mockDir, { recursive: true });
|
fs.mkdirSync(mockDir, { recursive: true });
|
||||||
@@ -384,6 +407,40 @@ const config = loadConfig();
|
|||||||
expect(config.browser.headless).toBe(true);
|
expect(config.browser.headless).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads the dashboard token from WEB_DASHBOARD_TOKEN', () => {
|
||||||
|
process.env.WEB_DASHBOARD_TOKEN = 'create01';
|
||||||
|
const configPath = path.join(process.cwd(), 'config.yaml');
|
||||||
|
fs.writeFileSync(configPath, `
|
||||||
|
reddit:
|
||||||
|
subreddits:
|
||||||
|
- test
|
||||||
|
openrouter:
|
||||||
|
apiKey: key
|
||||||
|
imageAnalysisPrompt: analyze this
|
||||||
|
messageGenerationPrompt: write message
|
||||||
|
`);
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
expect(config.web.authToken).toBe('create01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a dashboard token shorter than eight characters', () => {
|
||||||
|
process.env.WEB_DASHBOARD_TOKEN = 'short7!';
|
||||||
|
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.authToken');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects config with invalid values', () => {
|
it('rejects config with invalid values', () => {
|
||||||
const configPath = path.join(process.cwd(), 'config.yaml');
|
const configPath = path.join(process.cwd(), 'config.yaml');
|
||||||
fs.writeFileSync(configPath, `
|
fs.writeFileSync(configPath, `
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ describe('Prompt Testing', () => {
|
|||||||
const rawConfig = yaml.parse(content);
|
const rawConfig = yaml.parse(content);
|
||||||
rawConfig.openrouter = rawConfig.openrouter || {};
|
rawConfig.openrouter = rawConfig.openrouter || {};
|
||||||
rawConfig.openrouter.apiKey = rawConfig.openrouter.apiKey || 'test';
|
rawConfig.openrouter.apiKey = rawConfig.openrouter.apiKey || 'test';
|
||||||
|
rawConfig.web = rawConfig.web || {};
|
||||||
|
rawConfig.web.authToken = 'create01';
|
||||||
return ConfigSchema.parse(rawConfig);
|
return ConfigSchema.parse(rawConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
|
|||||||
web: {
|
web: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
authToken: 'create01',
|
||||||
|
debugEnabled: false,
|
||||||
...overrides?.web,
|
...overrides?.web,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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 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 apiResponse = await fetch(`${running.baseUrl}/api/stats`);
|
||||||
|
const sseResponse = await fetch(`${running.baseUrl}/api/events/stream`);
|
||||||
|
const mediaResponse = await fetch(`${running.baseUrl}/media/sample.txt`);
|
||||||
|
const pageResponse = await fetch(running.baseUrl, { redirect: 'manual' });
|
||||||
|
|
||||||
|
expect(apiResponse.status).toBe(401);
|
||||||
|
await expect(apiResponse.json()).resolves.toEqual({ error: 'Authentication required' });
|
||||||
|
expect(sseResponse.status).toBe(401);
|
||||||
|
expect(mediaResponse.status).toBe(401);
|
||||||
|
expect(pageResponse.status).toBe(302);
|
||||||
|
expect(pageResponse.headers.get('location')).toBe('/auth/login');
|
||||||
|
|
||||||
|
const wrongBearer = await fetch(`${running.baseUrl}/api/stats`, {
|
||||||
|
headers: { Authorization: 'Bearer incorrect-token' },
|
||||||
|
});
|
||||||
|
expect(wrongBearer.status).toBe(401);
|
||||||
|
|
||||||
|
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
|
||||||
|
const authenticatedApi = await fetch(`${running.baseUrl}/api/stats`, { headers });
|
||||||
|
const authenticatedMedia = await fetch(`${running.baseUrl}/media/sample.txt`, { headers });
|
||||||
|
const authenticatedPage = await fetch(running.baseUrl, { headers });
|
||||||
|
const authenticatedAsset = await fetch(`${running.baseUrl}/assets/app.js`, { headers });
|
||||||
|
const authenticatedSpaRoute = await fetch(`${running.baseUrl}/conversations/example`, {
|
||||||
|
headers: { ...headers, Accept: 'text/html' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authenticatedApi.status).toBe(200);
|
||||||
|
expect(await authenticatedMedia.text()).toBe('private media');
|
||||||
|
expect(await authenticatedPage.text()).toContain('React dashboard');
|
||||||
|
expect(await authenticatedAsset.text()).toBe('react-build');
|
||||||
|
expect(await authenticatedSpaRoute.text()).toContain('React dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a secure browser session without accepting a wrong token', async () => {
|
||||||
|
const running = await startServer();
|
||||||
|
runningServers.push(running);
|
||||||
|
|
||||||
|
const rejected = await fetch(`${running.baseUrl}/auth/session`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: 'incorrect-token' }),
|
||||||
|
});
|
||||||
|
expect(rejected.status).toBe(401);
|
||||||
|
expect(rejected.headers.get('set-cookie')).toBeNull();
|
||||||
|
|
||||||
|
const accepted = await fetch(`${running.baseUrl}/auth/session`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
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('Path=/');
|
||||||
|
|
||||||
|
const cookie = setCookie?.split(';', 1)[0] ?? '';
|
||||||
|
const authenticated = await fetch(`${running.baseUrl}/api/stats`, { headers: { Cookie: cookie } });
|
||||||
|
expect(authenticated.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns JSON for unknown API routes and keeps debug routes opt-in', async () => {
|
||||||
|
const running = await startServer();
|
||||||
|
runningServers.push(running);
|
||||||
|
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
|
||||||
|
|
||||||
|
const missingApi = await fetch(`${running.baseUrl}/api/not-real`, { headers });
|
||||||
|
const disabledDebug = await fetch(`${running.baseUrl}/api/debug/cookies`, { headers });
|
||||||
|
|
||||||
|
expect(missingApi.status).toBe(404);
|
||||||
|
await expect(missingApi.json()).resolves.toEqual({ error: 'API endpoint not found' });
|
||||||
|
expect(disabledDebug.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reveals browser cookie values and does not accept uploaded Matrix credentials', async () => {
|
||||||
|
const running = await startServer({ debugEnabled: true });
|
||||||
|
runningServers.push(running);
|
||||||
|
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 cookiePayload = await cookies.text();
|
||||||
|
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(cookies.status).toBe(200);
|
||||||
|
expect(cookiePayload).not.toContain('never-return-this-secret');
|
||||||
|
expect(upload.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the legacy dashboard only when no React build exists', async () => {
|
||||||
|
const running = await startServer({ includeReactBuild: false });
|
||||||
|
runningServers.push(running);
|
||||||
|
|
||||||
|
const response = await fetch(running.baseUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${AUTH_TOKEN}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toContain('Legacy dashboard');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user