diff --git a/trios/agent-server/apps/server/src/api/routes/queen-report.ts b/trios/agent-server/apps/server/src/api/routes/queen-report.ts new file mode 100644 index 0000000000..903ea8a4b0 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/routes/queen-report.ts @@ -0,0 +1,212 @@ +/** + * A door for watchers outside the Queen to write into her report. + * + * WHY. Until this route the only writer of `queen_report` was her own tick + * (`queen-tick.ts`, the INSERT at the end of a round). A watcher that runs + * elsewhere - the first is a Railway cron in 999-multibots-telegraf that probes + * the bot-to-render relay roads every hour - had nowhere to put a finding the + * owner would see. The owner already reads `/queen/needs-you` and her report + * lines, so a finding belongs in the same table, not in a new one. + * + * NO MIGRATION. The source is stored as a `[source] ` prefix on the headline. + * `queen_report` has no column for it, the panel already shows the headline, + * and a prefix is enough to tell "the Queen said" from "the relay watch said". + * + * AUTH IS NOT HERE. This file is mounted inside a sub-app that carries + * `requireTrustedAppOrigin()` (see server.ts), exactly like `/queen/needs-you` + * and `/queen/lease`. In production that means `Authorization: Bearer + * `. + * + * WHAT IT WILL NOT SAY. A database failure answers one fixed sentence; the real + * error goes to the log. A sibling once returned a Railway internal hostname to + * any browser on any origin. + */ + +import { Hono } from 'hono' +import { createQueenPool } from '../../lib/db/queen-pool' +import { logger } from '../../lib/logger' + +interface QueryResult { + rowCount: number | null + rows: Array> +} + +interface ReportPool { + query(sql: string, values?: unknown[]): Promise + end(): Promise +} + +export interface QueenReportDeps { + databaseUrl?: () => string | undefined + createPool?: (url: string) => ReportPool + now?: () => number +} + +export interface QueenReportInput { + source: string + headline: string + body: string + needs_you: boolean +} + +const UNAVAILABLE = 'Queen report is unavailable' + +export const SOURCE_PATTERN = /^[a-z0-9-]{1,64}$/ +export const HEADLINE_MAX = 200 +export const BODY_MAX = 8000 +export const REPORTS_PER_SOURCE_PER_HOUR = 60 +export const MAX_LIVE_SOURCES = 64 +const HOUR_MS = 60 * 60 * 1000 + +const FIELDS = ['source', 'headline', 'body', 'needs_you'] as const + +// Closes with the backtick alone on its own line: the house convention that +// `tests/api/sql-template-literals.test.ts` reads. +const INSERT_SQL = ` + INSERT INTO queen_report (headline, body, needs_you) + VALUES ($1, $2, $3) + RETURNING id +` + +/** + * The body, or the reason it is refused. Exactly the four fields, each of its + * own type and length; anything else is a 400. An unknown field is refused + * rather than ignored so a client that misspells `needs_you` hears about it + * instead of silently writing `false`. + */ +export function parseReport( + raw: unknown, +): { ok: true; value: QueenReportInput } | { ok: false; error: string } { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, error: 'body must be a JSON object' } + } + const record = raw as Record + const extra = Object.keys(record).filter( + (key) => !(FIELDS as readonly string[]).includes(key), + ) + if (extra.length > 0) { + return { ok: false, error: `unknown field: ${extra[0].slice(0, 32)}` } + } + const { source, headline, body, needs_you } = record + if (typeof source !== 'string' || !SOURCE_PATTERN.test(source)) { + return { ok: false, error: 'source must match [a-z0-9-]{1,64}' } + } + if ( + typeof headline !== 'string' || + headline.trim().length === 0 || + headline.length > HEADLINE_MAX + ) { + return { ok: false, error: `headline must be 1..${HEADLINE_MAX} chars` } + } + if (typeof body !== 'string' || body.length > BODY_MAX) { + return { ok: false, error: `body must be 0..${BODY_MAX} chars` } + } + if (typeof needs_you !== 'boolean') { + return { ok: false, error: 'needs_you must be a boolean' } + } + return { ok: true, value: { source, headline, body, needs_you } } +} + +/** + * At most `limit` reports per source in any sliding hour, and at most + * `maxSources` sources live at once, in memory. + * + * In memory on purpose: one process serves this route, a restart forgets the + * window, and the worst a restart buys is one extra hour of a watcher that + * already holds the deployment token. + * + * BOUNDED, BOTH WAYS. `source` is free-form, so a per-name limit alone is no + * limit: a caller rotating the name never meets the per-source cap and grows + * this map forever (review of #530). Every call drops expired timestamps and + * deletes a source whose window is empty, and a NEW source is refused while + * `maxSources` are live. Memory is at most maxSources x limit timestamps, and + * writes are at most maxSources x limit per hour. + */ +export function createSourceLimiter( + limit = REPORTS_PER_SOURCE_PER_HOUR, + windowMs = HOUR_MS, + maxSources = MAX_LIVE_SOURCES, +) { + const seen = new Map() + + function prune(at: number) { + for (const [source, times] of seen) { + const recent = times.filter((t) => at - t < windowMs) + if (recent.length === 0) seen.delete(source) + else if (recent.length !== times.length) seen.set(source, recent) + } + } + + return { + /** Records the attempt and returns true, or returns false when full. */ + take(source: string, at: number): boolean { + prune(at) + const recent = seen.get(source) + if (!recent) { + if (seen.size >= maxSources) return false + seen.set(source, [at]) + return true + } + if (recent.length >= limit) return false + recent.push(at) + return true + }, + /** Live sources right now; for tests and nothing else. */ + size(): number { + return seen.size + }, + } +} + +export function createQueenReportRoute(deps: QueenReportDeps = {}) { + const databaseUrl = + deps.databaseUrl ?? + (() => process.env.QUEEN_LEASE_DATABASE_URL ?? process.env.DATABASE_URL) + const createPool = + deps.createPool ?? + ((url: string) => createQueenPool(url) as unknown as ReportPool) + const now = deps.now ?? (() => Date.now()) + const limiter = createSourceLimiter() + + return new Hono().post('/', async (c) => { + c.header('Cache-Control', 'no-store') + + let raw: unknown + try { + raw = await c.req.json() + } catch { + return c.json({ error: 'body must be a JSON object' }, 400) + } + const parsed = parseReport(raw) + if (!parsed.ok) return c.json({ error: parsed.error }, 400) + const report = parsed.value + + if (!limiter.take(report.source, now())) { + c.header('Retry-After', '3600') + return c.json({ error: 'Too many reports' }, 429) + } + + const url = databaseUrl() + if (!url) return c.json({ error: UNAVAILABLE }, 503) + + let pool: ReportPool | null = null + try { + pool = createPool(url) + const result = await pool.query(INSERT_SQL, [ + `[${report.source}] ${report.headline}`, + report.body, + report.needs_you, + ]) + const id = Number(result.rows[0]?.id) + return c.json({ id }, 201) + } catch (error) { + logger.warn('Queen report could not be stored', { + source: report.source, + error: error instanceof Error ? error.message : String(error), + }) + return c.json({ error: UNAVAILABLE }, 503) + } finally { + if (pool) await pool.end().catch(() => undefined) + } + }) +} diff --git a/trios/agent-server/apps/server/src/api/server.ts b/trios/agent-server/apps/server/src/api/server.ts index 30a13902ab..5f64bf4534 100644 --- a/trios/agent-server/apps/server/src/api/server.ts +++ b/trios/agent-server/apps/server/src/api/server.ts @@ -65,6 +65,7 @@ import { createQueenPublicResearchRoute } from './routes/queen-public-research' import { createQueenPublicStatusRoute } from './routes/queen-public-status' import { createQueenRegistryRoute } from './routes/queen-registry' import { createQueenRehearsalRoute } from './routes/queen-rehearsal' +import { createQueenReportRoute } from './routes/queen-report' import { createQueenRoadmapDataRoute, createQueenRoadmapRoute, @@ -266,6 +267,13 @@ export async function createHttpServer(config: HttpServerConfig) { .use('/*', requireTrustedAppOrigin()) .route('/', createQueenNeedsYouRoute()) + // Outside watchers write into her report here (a relay probe in the bot's + // repository is the first). It WRITES, so it is guarded inside its own + // sub-app exactly like needs-you above - never a bare factory mount. + const queenReportRoutes = new Hono() + .use('/*', requireTrustedAppOrigin()) + .route('/', createQueenReportRoute()) + const queenBoardRoutes = new Hono() .use('/*', requireTrustedAppOrigin()) .route('/', createQueenBoardRoute()) @@ -422,6 +430,7 @@ export async function createHttpServer(config: HttpServerConfig) { // than being served to any origin. The five escalations it exists to // surface are for the operator, not for a public page. .route('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/queen/needs-you', queenNeedsYouRoutes) + .route('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/queen/report', queenReportRoutes) .route('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/queen/board', queenBoardRoutes) .route('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/queen/roadmap', createQueenRoadmapRoute()) .route('/queen/roadmap/data', queenRoadmapDataRoutes) diff --git a/trios/agent-server/apps/server/tests/api/queen-report.test.ts b/trios/agent-server/apps/server/tests/api/queen-report.test.ts new file mode 100644 index 0000000000..2f9f6c742e --- /dev/null +++ b/trios/agent-server/apps/server/tests/api/queen-report.test.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { + classifyMounts, + readServerSource, +} from '../../../../../tools/route-guard-audit.mjs' +import { + createQueenReportRoute, + createSourceLimiter, + MAX_LIVE_SOURCES, + type QueenReportDeps, +} from '../../src/api/routes/queen-report' +import { requireTrustedAppOrigin } from '../../src/api/utils/request-auth' + +// What these pin, and why each one exists: +// +// - the route is mounted the way server.ts mounts it: inside a sub-app that +// carries requireTrustedAppOrigin(). `/queen/needs-you` was once mounted as +// a bare factory and answered 200 to a hostile Origin in production; a +// route that WRITES must not repeat that. +// - a bad body is a 400 and never reaches the database. +// - a good body is one INSERT into queen_report with the source as a +// `[source] ` headline prefix, and a 201 with the new id. +// - past 60 reports per source per hour the answer is 429, per source. + +const TOKEN = 'test-queen-report-token' +const savedToken = process.env.TRIOS_API_TOKEN + +afterEach(() => { + if (savedToken === undefined) delete process.env.TRIOS_API_TOKEN + else process.env.TRIOS_API_TOKEN = savedToken +}) + +function recordingPool(id = 41) { + const calls: Array<{ sql: string; values?: unknown[] }> = [] + let ended = 0 + return { + calls, + ended: () => ended, + pool: { + async query(sql: string, values?: unknown[]) { + calls.push({ sql, values }) + return { rowCount: 1, rows: [{ id: String(id) }] } + }, + async end() { + ended += 1 + }, + }, + } +} + +/** The route as server.ts mounts it: the guard inside its own sub-app. */ +function guarded(deps: QueenReportDeps) { + return new Hono().route( + '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/queen/report', + new Hono() + .use('/*', requireTrustedAppOrigin()) + .route('/', createQueenReportRoute(deps)), + ) +} + +const good = { + source: 'relay-watch', + headline: 'bot -> render relay: 1 of 3 roads down', + body: 'POST /api/agent/relay answered 401 at the render guard.', + needs_you: true, +} + +function post( + app: Hono, + body: unknown, + headers: Record = { Authorization: `Bearer ${TOKEN}` }, +) { + return app.request('http://localhost/queen/report', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }) +} + +describe('POST /queen/report - the guard', () => { + it('refuses a caller without the token and never touches the database', async () => { + process.env.TRIOS_API_TOKEN = TOKEN + const { calls, pool } = recordingPool() + const app = guarded({ + databaseUrl: () => 'postgres://x', + createPool: () => pool, + }) + + const none = await post(app, good, {}) + expect([401, 403]).toContain(none.status) + + const wrong = await post(app, good, { + Authorization: 'Bearer test-queen-report-tokeX', + }) + expect([401, 403]).toContain(wrong.status) + expect(calls).toHaveLength(0) + }) + + it('does not let a hostile Origin through, with or without a token configured', async () => { + const { calls, pool } = recordingPool() + const app = guarded({ + databaseUrl: () => 'postgres://x', + createPool: () => pool, + }) + + delete process.env.TRIOS_API_TOKEN + const unconfigured = await post(app, good, { + Origin: 'https://evil.example', + }) + expect(unconfigured.status).toBe(403) + + process.env.TRIOS_API_TOKEN = TOKEN + const configured = await post(app, good, { + Origin: 'https://evil.example', + }) + expect(configured.status).toBe(403) + // A trusted-looking Origin is a string anyone can write. + const spoofed = await post(app, good, { + Origin: 'chrome-extension://browseros', + }) + expect(spoofed.status).toBe(403) + expect(calls).toHaveLength(0) + }) + + it('is mounted in server.ts inside a guarded sub-app, not as a bare factory', () => { + const mount = classifyMounts(readServerSource()).find( + (m: { path: string }) => m.path === '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/queen/report', + ) + expect(mount?.classification).toBe('wrapper') + expect(mount?.via).toBe('queenReportRoutes') + }) +}) + +describe('POST /queen/report - the body', () => { + const bad: Array<[string, unknown]> = [ + ['not JSON', '{nope'], + ['an array', [good]], + ['null', null], + ['a missing source', { ...good, source: undefined }], + ['an empty source', { ...good, source: '' }], + ['an upper-case source', { ...good, source: 'Relay' }], + ['a source with a space', { ...good, source: 'relay watch' }], + ['a 65-char source', { ...good, source: 'a'.repeat(65) }], + ['an empty headline', { ...good, headline: '' }], + ['a blank headline', { ...good, headline: ' ' }], + ['a 201-char headline', { ...good, headline: 'h'.repeat(201) }], + ['a missing body', { ...good, body: undefined }], + ['an 8001-char body', { ...good, body: 'b'.repeat(8001) }], + ['a string needs_you', { ...good, needs_you: 'true' }], + ['a missing needs_you', { ...good, needs_you: undefined }], + ['an unknown field', { ...good, needsYou: true }], + ] + + for (const [label, body] of bad) { + it(`answers 400 for ${label}`, async () => { + process.env.TRIOS_API_TOKEN = TOKEN + const { calls, pool } = recordingPool() + const res = await post( + guarded({ databaseUrl: () => 'postgres://x', createPool: () => pool }), + body, + ) + expect(res.status).toBe(400) + expect(calls).toHaveLength(0) + }) + } + + it('accepts the edges: 64-char source, 200-char headline, empty and 8000-char body', async () => { + process.env.TRIOS_API_TOKEN = TOKEN + const { pool } = recordingPool() + const app = guarded({ + databaseUrl: () => 'postgres://x', + createPool: () => pool, + }) + for (const body of [ + { ...good, source: 'a'.repeat(64) }, + { ...good, headline: 'h'.repeat(200) }, + { ...good, body: '' }, + { ...good, body: 'b'.repeat(8000) }, + ]) { + expect((await post(app, body)).status).toBe(201) + } + }) +}) + +describe('POST /queen/report - the row', () => { + it('writes one queen_report row with the source prefixed and answers 201 {id}', async () => { + process.env.TRIOS_API_TOKEN = TOKEN + const rec = recordingPool(4242) + const res = await post( + guarded({ + databaseUrl: () => 'postgres://x', + createPool: () => rec.pool, + }), + good, + ) + expect(res.status).toBe(201) + expect(await res.json()).toEqual({ id: 4242 }) + + expect(rec.calls).toHaveLength(1) + expect(rec.calls[0].sql).toContain('INSERT INTO queen_report') + expect(rec.calls[0].sql).toContain('(headline, body, needs_you)') + expect(rec.calls[0].values).toEqual([ + '[relay-watch] bot -> render relay: 1 of 3 roads down', + good.body, + true, + ]) + expect(rec.ended()).toBe(1) + }) + + it('answers 503 with a fixed sentence when the database fails, and leaks nothing', async () => { + process.env.TRIOS_API_TOKEN = TOKEN + let ended = 0 + const res = await post( + guarded({ + databaseUrl: () => 'postgres://x', + createPool: () => ({ + async query() { + throw new Error( + 'getaddrinfo ENOTFOUND queen-postgres.railway.internal', + ) + }, + async end() { + ended += 1 + }, + }), + }), + good, + ) + expect(res.status).toBe(503) + const text = await res.text() + expect(text).toBe(JSON.stringify({ error: 'Queen report is unavailable' })) + expect(ended).toBe(1) + }) + + it('answers 503 when no database is configured', async () => { + process.env.TRIOS_API_TOKEN = TOKEN + const res = await post(guarded({ databaseUrl: () => undefined }), good) + expect(res.status).toBe(503) + }) +}) + +describe('POST /queen/report - the rate limit', () => { + it('answers 429 past 60 per source per hour, and only for that source', async () => { + process.env.TRIOS_API_TOKEN = TOKEN + let clock = 1_000_000 + const rec = recordingPool() + const app = guarded({ + databaseUrl: () => 'postgres://x', + createPool: () => rec.pool, + now: () => clock, + }) + + for (let i = 0; i < 60; i += 1) { + expect((await post(app, good)).status).toBe(201) + clock += 1000 + } + const over = await post(app, good) + expect(over.status).toBe(429) + expect(over.headers.get('Retry-After')).toBe('3600') + expect(rec.calls).toHaveLength(60) + + // Another source has its own hour. + expect((await post(app, { ...good, source: 'other-watch' })).status).toBe( + 201, + ) + + // The window slides: an hour after the first report, one slot frees up. + clock = 1_000_000 + 60 * 60 * 1000 + expect((await post(app, good)).status).toBe(201) + expect((await post(app, good)).status).toBe(429) + }) + + it('slides its window rather than resetting on the hour', () => { + const limiter = createSourceLimiter(2, 1000) + expect(limiter.take('a', 0)).toBe(true) + expect(limiter.take('a', 1)).toBe(true) + expect(limiter.take('a', 2)).toBe(false) + expect(limiter.take('a', 1000)).toBe(true) + }) + + // Review of #530: `source` is free-form, so a map keyed by it with no + // eviction grew forever, and rotating the name dodged the per-source cap. + it('keeps the map at or below the cap however many distinct sources arrive', () => { + const limiter = createSourceLimiter() + let accepted = 0 + for (let i = 0; i < 1000; i += 1) { + if (limiter.take(`rotating-${i}`, i)) accepted += 1 + expect(limiter.size()).toBeLessThanOrEqual(MAX_LIVE_SOURCES) + } + expect(MAX_LIVE_SOURCES).toBe(64) + expect(accepted).toBe(64) + }) + + it('evicts a source once its window is empty', () => { + let clock = 0 + const limiter = createSourceLimiter(60, 1000) + expect(limiter.take('quiet', clock)).toBe(true) + expect(limiter.size()).toBe(1) + clock = 1000 + // A call for a different source is enough to sweep the quiet one out. + expect(limiter.take('other', clock)).toBe(true) + expect(limiter.size()).toBe(1) + }) +}) + +describe('POST /queen/report - the source cap', () => { + it('answers 429 to source number 65 and frees the slot when the hour passes', async () => { + process.env.TRIOS_API_TOKEN = TOKEN + let clock = 5_000_000 + const rec = recordingPool() + const app = guarded({ + databaseUrl: () => 'postgres://x', + createPool: () => rec.pool, + now: () => clock, + }) + + for (let i = 1; i <= 64; i += 1) { + expect((await post(app, { ...good, source: `watch-${i}` })).status).toBe( + 201, + ) + } + const sixtyFifth = await post(app, { ...good, source: 'watch-65' }) + expect(sixtyFifth.status).toBe(429) + expect(sixtyFifth.headers.get('Retry-After')).toBe('3600') + expect(rec.calls).toHaveLength(64) + + // A live source still has its own budget while the cap is full. + expect((await post(app, { ...good, source: 'watch-1' })).status).toBe(201) + + clock += 60 * 60 * 1000 + expect((await post(app, { ...good, source: 'watch-65' })).status).toBe(201) + }) +}) diff --git a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts index 7499f80a3c..a838ffbfca 100644 --- a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts +++ b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts @@ -95,9 +95,13 @@ describe('route-guard audit over src/api/server.ts', () => { // RE-MEASURED 2026-10-03: 47 became 48 with /queen/public-credits, an // explicit publicReadCorsMiddleware() on which login's lane carried each // accepted issue (issue numbers and logins the leaderboard already shows). - expect(report.totalMounts).toBe(48) + // RE-MEASURED 2026-10-03 again: 48 became 49 with /queen/report, the door + // outside watchers write into her report through. It WRITES, so it is a + // guarded wrapper like /queen/needs-you, and `guardedSubAppCount` went 15 + // to 16. Public-read and prefix counts unchanged. + expect(report.totalMounts).toBe(49) expect(report.prefixGuardCount).toBe(18) - expect(report.guardedSubAppCount).toBe(15) + expect(report.guardedSubAppCount).toBe(16) expect(report.publicReadCount).toBe(10) }) @@ -116,7 +120,7 @@ describe('route-guard audit over src/api/server.ts', () => { ) }) - it('splits the twenty-five /queen mounts into 10 public-read, 8 wrapper-guarded and 7 allowlisted', () => { + it('splits the twenty-six /queen mounts into 10 public-read, 9 wrapper-guarded and 7 allowlisted', () => { const queenMounts = classifyMounts(source).filter( (mount) => mount.path === '/queen' || mount.path.startsWith('/queen/'), ) @@ -152,7 +156,10 @@ describe('route-guard audit over src/api/server.ts', () => { // public-read is /queen/public-credits - for each accepted issue, the // GitHub login that claimed the lane it ran on. No title, no worker text, // no credential. - expect(queenMounts.length).toBe(25) + // RE-MEASURED 2026-10-03: twenty-five became twenty-six. The ninth wrapper + // is /queen/report, where outside watchers write into her report; guarded + // inside its own sub-app because it writes. + expect(queenMounts.length).toBe(26) const counts: Record = { 'public-read': 0, @@ -168,7 +175,7 @@ describe('route-guard audit over src/api/server.ts', () => { expect(counts).toEqual({ 'public-read': 10, 'prefix-guard': 0, - wrapper: 8, + wrapper: 9, unguarded: 7, })