/** * dashboard-api.ts — REST router for Side Info Panel. * Mounted at /api/local/dashboard. * * Routes: * GET /widgets — list current user's widgets * POST /widgets — create * PATCH /widgets/:id — update title/content * DELETE /widgets/:id — delete * PUT /widgets/reorder — reorder by id list * GET /workers — worker idle/running (no job details) * * Auth: all routes require req.user (or fall back to 'local' when authActive=false). * Owner: every operation scopes to req.user.id; cross-user access returns 404. */ import { Router, type Request, type Response } from 'express'; import { createHash } from 'crypto'; import { isDashboardWidgetKind, type DashboardWidgetKind, type Repository } from '../db/repository.js'; import type { WorkerDef } from '../config.js'; import { collectWorkerStatuses } from './dashboard-workers.js'; import type { BackendStatusRegistry } from '../engine/backend-status-registry.js'; import { logger } from '../logger.js'; const SLUG_PATTERN = /^[a-z0-9-]+$/; const MAX_SLUG_LEN = 32; const MAX_TITLE_LEN = 64; const MAX_CONTENT_BYTES = 64 * 1024; interface AuthedUser { id: string; role: string; } function getUser(req: Request): AuthedUser | null { return (req.user as AuthedUser | undefined) ?? null; } export interface DashboardApiDeps { repo: Repository; getWorkers: () => WorkerDef[]; authActive?: boolean; /** * Optional BackendStatusRegistry. When supplied, the API exposes * GET /node-status; when omitted (e.g. in unit tests that don't care * about node status), the route 503s. */ backendStatusRegistry?: BackendStatusRegistry | null; } export function createDashboardApi(deps: DashboardApiDeps): Router { const { repo, getWorkers } = deps; const authActive = deps.authActive ?? true; const r = Router(); r.use((req: Request, res: Response, next) => { if (!authActive && !getUser(req)) { (req as any).user = { id: 'local', role: 'user' }; } if (!getUser(req)) { res.status(401).json({ error: 'Unauthenticated' }); return; } next(); }); r.get('/widgets', async (req, res) => { const u = getUser(req)!; try { const widgets = await repo.listDashboardWidgets(u.id); res.json({ widgets }); } catch (err) { logger.error(`[dashboard-api] GET /widgets failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to list widgets' }); } }); r.post('/widgets', async (req, res) => { const u = getUser(req)!; const { slug, title, content, kind } = (req.body ?? {}) as { slug?: string; title?: string; content?: string; kind?: string; }; if (!slug || !SLUG_PATTERN.test(slug) || slug.length > MAX_SLUG_LEN) { res.status(400).json({ error: 'invalid slug (lowercase a-z, 0-9, hyphen; max 32 chars)' }); return; } if (!title || title.length > MAX_TITLE_LEN) { res.status(400).json({ error: `title required and <= ${MAX_TITLE_LEN} chars` }); return; } if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) { res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` }); return; } // kind is optional; defaults to 'markdown' for backward compat. let resolvedKind: DashboardWidgetKind = 'markdown'; if (kind !== undefined) { if (!isDashboardWidgetKind(kind)) { res.status(400).json({ error: 'invalid kind (allowed: markdown, node-status)' }); return; } resolvedKind = kind; } try { const widget = await repo.createDashboardWidget({ userId: u.id, slug, title, content: content ?? '', kind: resolvedKind, }); res.status(201).json({ widget }); } catch (err: any) { if (String(err?.message ?? err).includes('UNIQUE')) { res.status(409).json({ error: 'slug already exists' }); return; } logger.error(`[dashboard-api] POST /widgets failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to create widget' }); } }); r.patch('/widgets/:id', async (req, res) => { const u = getUser(req)!; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'invalid id' }); return; } const existing = await repo.getDashboardWidget(id, u.id); if (!existing) { res.status(404).json({ error: 'not found' }); return; } const { title, content } = (req.body ?? {}) as { title?: string; content?: string }; // Non-markdown widget kinds (currently just 'node-status') render // data live from a backing source instead of stored markdown — any // content the caller sends would be dead state at best and a // confusing surprise on the next render at worst. Title remains // editable so the user can rename the panel. if (existing.kind !== 'markdown' && content !== undefined) { res.status(400).json({ error: `cannot edit content of ${existing.kind} widget (title-only updates allowed)`, }); return; } if (title !== undefined && (title.length === 0 || title.length > MAX_TITLE_LEN)) { res.status(400).json({ error: `title must be 1..${MAX_TITLE_LEN} chars` }); return; } if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) { res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` }); return; } try { const widget = await repo.updateDashboardWidget(id, u.id, { title, content }); res.json({ widget }); } catch (err) { logger.error(`[dashboard-api] PATCH /widgets/${id} failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to update widget' }); } }); r.delete('/widgets/:id', async (req, res) => { const u = getUser(req)!; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'invalid id' }); return; } const existing = await repo.getDashboardWidget(id, u.id); if (!existing) { res.status(404).json({ error: 'not found' }); return; } try { await repo.deleteDashboardWidget(id, u.id); res.status(204).end(); } catch (err) { logger.error(`[dashboard-api] DELETE /widgets/${id} failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to delete widget' }); } }); r.put('/widgets/reorder', async (req, res) => { const u = getUser(req)!; const { ids } = (req.body ?? {}) as { ids?: number[] }; if (!Array.isArray(ids) || !ids.every(n => Number.isFinite(n))) { res.status(400).json({ error: 'ids must be array of numbers' }); return; } try { await repo.reorderDashboardWidgets(u.id, ids); res.json({ ok: true }); } catch (err) { logger.error(`[dashboard-api] reorder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to reorder' }); } }); r.get('/workers', async (_req, res) => { try { const workers = await collectWorkerStatuses(repo, getWorkers(), deps.backendStatusRegistry ?? null); res.json({ workers }); } catch (err) { logger.error(`[dashboard-api] GET /workers failed err=${err}`); res.status(500).json({ error: 'Failed to list worker status' }); } }); // GET /node-status // // Returns the latest BackendStatusRegistry snapshot. The registry is // already polling in the background at a fixed cadence, so this // handler is a cheap cache read. // // Caching headers (Phase C): // - `Cache-Control: no-store` — multiple AAO instances might sit // behind a shared proxy/CDN with body-level caching defaults; the // snapshot is per-process state and must never be cached // intermediately. // - Weak ETag of the JSON payload + 304 short-circuit — when 5s polls // land on an unchanged registry (idle pool, no probes flipped) the // response avoids re-serialising the body and the browser refetch // skips the JSON parse, halving the per-tick CPU under N-tab loads. // // The registry tick also notifies the registry that a subscriber is // active so the polling cadence can fall back to the idle interval // when no UI is open (see BackendStatusRegistry.noteSubscriberActivity). r.get('/node-status', async (req, res) => { const reg = deps.backendStatusRegistry ?? null; if (!reg) { // The registry is started by server.ts; when running under tests // that don't bother to construct one, we'd rather signal "feature // disabled" than crash. res.status(503).json({ nodes: [], error: 'node-status registry not configured' }); return; } try { const nodes = reg.getAll(); // Signal to the registry that a UI is actively watching so the // polling cadence stays in the active (5s) band; without any // recent GET the registry falls back to the idle (30s) cadence. if (typeof reg.noteSubscriberActivity === 'function') { try { reg.noteSubscriberActivity(); } catch { /* never fail the GET on metrics */ } } const body = JSON.stringify({ nodes }); // Weak ETag: payload identity is the only thing that matters for // 304 short-circuiting; we don't care about byte-for-byte // equivalence (no Content-Encoding negotiation here). const etag = `W/"${createHash('sha1').update(body).digest('hex').slice(0, 16)}"`; res.setHeader('Cache-Control', 'no-store'); res.setHeader('ETag', etag); const inm = req.headers['if-none-match']; // RFC 9110 §13.1.2: If-None-Match may carry a comma-separated // list of entity tags (browsers' BFCache restore and HTTP // intermediaries can both produce multi-tag headers). Strict // equality on the whole header would silently miss matches and // re-send the body unnecessarily — splitting + per-tag compare // is the spec-compliant behaviour. if (typeof inm === 'string') { const tags = inm.split(',').map(s => s.trim()); if (tags.includes(etag)) { res.status(304).end(); return; } } res.type('application/json').send(body); } catch (err) { logger.error(`[dashboard-api] GET /node-status failed err=${err}`); res.status(500).json({ error: 'Failed to read node status' }); } }); return r; }