From 73bdfa3e3a2cf02c62d4ca0d7f336d83c2c7d5b6 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Sun, 19 Jul 2026 01:42:56 +0300 Subject: [PATCH 1/8] fix: add canonical incident projections --- fixtures/todos-incident-projection-v1.json | 36 ++ package.json | 2 + scripts/generate-sdk.ts | 30 +- scripts/verify-incident-projection-pg.ts | 502 ++++++++++++++++ src/cli/commands/messaging.test.ts | 95 +++ src/cli/commands/messaging.ts | 5 + src/index.ts | 5 + src/lib/channels.test.ts | 39 +- src/lib/channels.ts | 23 +- src/lib/db.test.ts | 42 ++ src/lib/db.ts | 203 +++++++ src/lib/incident-projection-contract.test.ts | 160 ++++++ src/lib/incident-projection-contract.ts | 410 +++++++++++++ src/lib/incident-projections.test.ts | 334 +++++++++++ src/lib/incident-projections.ts | 262 +++++++++ src/lib/messages.test.ts | 1 + src/lib/messages.ts | 315 +++++++--- src/lib/pg-migrations.test.ts | 19 + src/lib/pg-migrations.ts | 231 +++++++- src/lib/store/api-store.test.ts | 107 ++++ src/lib/store/api-store.ts | 21 +- src/lib/store/index.ts | 15 + src/sdk/incident-projection.test.ts | 125 ++++ src/sdk/index.ts | 51 +- src/server/api.test.ts | 251 +++++++- src/server/api.ts | 576 ++++++++++++++++--- src/server/incident-projections.ts | 273 +++++++++ src/server/openapi.test.ts | 34 ++ src/server/openapi.ts | 142 +++++ src/types.ts | 97 ++++ 30 files changed, 4241 insertions(+), 165 deletions(-) create mode 100644 fixtures/todos-incident-projection-v1.json create mode 100644 scripts/verify-incident-projection-pg.ts create mode 100644 src/lib/incident-projection-contract.test.ts create mode 100644 src/lib/incident-projection-contract.ts create mode 100644 src/lib/incident-projections.test.ts create mode 100644 src/lib/incident-projections.ts create mode 100644 src/sdk/incident-projection.test.ts create mode 100644 src/server/incident-projections.ts create mode 100644 src/server/openapi.test.ts diff --git a/fixtures/todos-incident-projection-v1.json b/fixtures/todos-incident-projection-v1.json new file mode 100644 index 0000000..192b182 --- /dev/null +++ b/fixtures/todos-incident-projection-v1.json @@ -0,0 +1,36 @@ +{ + "schema_version": 1, + "source": "todos", + "event_id": "iev_adf149b3daa8a314dd30b92b188f0024", + "projection_key": "todos:incident:todos.hasna.xyz:v1:11111111-1111-4111-8111-111111111111:v1", + "authority_id": "todos.hasna.xyz:v1", + "incident_id": "11111111-1111-4111-8111-111111111111", + "transition_id": "itr_adf149b3daa8a314dd30b92b188f0024", + "incident_version": 1, + "occurred_at": "2026-07-18T20:01:00.000Z", + "incident": { + "id": "11111111-1111-4111-8111-111111111111", + "title": "Canonical cross-service incident fixture", + "severity": "high", + "status": "investigating", + "owner": "projector-01", + "affected_scopes": [ + "service:conversations" + ], + "blocked_scopes": [ + "agent:projector-01", + "channel:incidents", + "project:wks_8vJJzXTiFo6sxwRkpPqoI" + ], + "containment": null, + "next_action": "Project and acknowledge the canonical incident state", + "deadline": null, + "closure_evidence": [], + "supersedes_id": null, + "superseded_by_id": null, + "resolved_at": null, + "version": 1, + "created_at": "2026-07-18T20:01:00.000Z", + "updated_at": "2026-07-18T20:01:00.000Z" + } +} diff --git a/package.json b/package.json index c08a790..4f1ad97 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,14 @@ "build": "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/serve-entry.ts --outfile ./bin/serve.js --target bun && bun build ./src/hooks/blocker-hook.ts --outfile ./bin/hook.js --target bun && bun build ./src/index.ts ./src/sdk/index.ts --outdir ./dist --target bun && (tsc --emitDeclarationOnly --declaration --outDir dist || true)", "build:dashboard": "cd dashboard && bun install && bun run build", "test": "bun test", + "test:incident-pg": "bun run ./scripts/verify-incident-projection-pg.ts", "dev": "bun run ./src/cli/index.tsx", "serve": "bun run ./src/server/serve-entry.ts", "migrate": "bun run ./src/server/migrate.ts", "kit:vendor": "bunx @hasna/contracts vendor-kit", "kit:check": "bunx @hasna/contracts vendor-kit --check", "sdk:generate": "bun run ./scripts/generate-sdk.ts", + "sdk:check": "bun run ./scripts/generate-sdk.ts --check", "typecheck": "tsc --noEmit", "prepublishOnly": "bun run build:dashboard && bun run build", "postinstall": "mkdir -p $HOME/.hasna/conversations $HOME/.hasna/conversations/training 2>/dev/null || true" diff --git a/scripts/generate-sdk.ts b/scripts/generate-sdk.ts index efb0707..fd3ab7b 100644 --- a/scripts/generate-sdk.ts +++ b/scripts/generate-sdk.ts @@ -5,7 +5,7 @@ * the generated file; re-run `bun run sdk:generate` after changing the spec. */ -import { mkdirSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { generateSdkFromOpenApi } from "@hasna/contracts/sdk"; @@ -20,11 +20,37 @@ const result = generateSdkFromOpenApi(openapiSpec as any, { apiKeyHeader: "x-api-key", }); +// The shared generator intentionally widens single-value enums. For this +// cross-service append-only wire, preserve the schema discriminants as literal +// types so producer drift is rejected at compile time as well as at runtime. +let generatedCode = result.code; +for (const interfaceName of ["IncidentProjectionEventV1", "IncidentProjectionRecord"]) { + const widened = new RegExp(`(export interface ${interfaceName} \\{[^\\n]*?)"schema_version": number; "source": string;`); + const literal = new RegExp(`export interface ${interfaceName} \\{[^\\n]*?"schema_version": 1; "source": "todos";`); + if (widened.test(generatedCode)) { + generatedCode = generatedCode.replace(widened, '$1"schema_version": 1; "source": "todos";'); + } else if (!literal.test(generatedCode)) { + throw new Error(`SDK generator output for ${interfaceName} no longer contains the expected discriminants`); + } +} + const header = "// @generated from src/server/openapi.ts by scripts/generate-sdk.ts — DO NOT EDIT.\n" + "// Regenerate: bun run sdk:generate\n\n"; -writeFileSync(join(outDir, "index.ts"), header + result.code); +const outFile = join(outDir, "index.ts"); +const generated = header + generatedCode; +if (process.argv.includes("--check")) { + const current = existsSync(outFile) ? readFileSync(outFile, "utf8") : ""; + if (current !== generated) { + console.error("generated SDK is stale; run: bun run sdk:generate"); + process.exit(1); + } + console.log(`ok generated SDK is current (${result.operations.length} operations)`); + process.exit(0); +} + +writeFileSync(outFile, generated); console.log(`ok generated SDK -> src/sdk/index.ts (${result.operations.length} operations)`); if (result.warnings.length) { diff --git a/scripts/verify-incident-projection-pg.ts b/scripts/verify-incident-projection-pg.ts new file mode 100644 index 0000000..89160d0 --- /dev/null +++ b/scripts/verify-incident-projection-pg.ts @@ -0,0 +1,502 @@ +#!/usr/bin/env bun +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { Pool } from "pg"; +import { ApiKeyStore, mintApiKey, verifyApiKey } from "@hasna/contracts/auth"; +import { createQueryClient } from "../src/generated/storage-kit/query.js"; +import { PG_MIGRATIONS } from "../src/lib/pg-migrations.js"; +import { + computeIncidentProjectionIds, + IncidentProjectionConflictError, +} from "../src/lib/incident-projection-contract.js"; +import { appendIncidentProjectionPg } from "../src/server/incident-projections.js"; +import { renameChannelServer, startApiServer } from "../src/server/api.js"; +import { ConversationsClient } from "../src/sdk/index.js"; +import type { IncidentProjectionRequestV1, IncidentProjectorContext } from "../src/types.js"; + +const socket = "/var/run/postgresql"; +const port = 5432; +const user = process.env.USER || "hasna"; +const database = `oc_incident_52e65cba_${process.pid}_${randomBytes(4).toString("hex")}`; +if (!/^[a-z0-9_]+$/.test(database)) throw new Error("unsafe temporary database name"); +const quotedDatabase = `"${database}"`; + +function assertPasswordlessLocalPgEnvironment(env: NodeJS.ProcessEnv): void { + for (const name of ["PGPASSWORD", "PGPASSFILE", "PGSERVICE", "PGSERVICEFILE"] as const) { + if (env[name]) throw new Error(`refusing ambient PostgreSQL credential or service setting: ${name}`); + } +} + +function localPoolConfig(targetDatabase: string, max: number) { + return { host: socket, port, database: targetDatabase, user, max }; +} + +async function createOwnedDatabase(pool: Pool, quotedName: string): Promise { + await pool.query(`CREATE DATABASE ${quotedName}`); + return true; +} + +assert.throws( + () => assertPasswordlessLocalPgEnvironment({ PGPASSWORD: "hostile-test-sentinel" }), + /PGPASSWORD/, +); +const hostileTargetConfig = localPoolConfig("hostile_target_sentinel", 1); +assert.deepEqual( + { host: hostileTargetConfig.host, port: hostileTargetConfig.port, database: hostileTargetConfig.database }, + { host: socket, port, database: "hostile_target_sentinel" }, +); +assertPasswordlessLocalPgEnvironment(process.env); + +const admin = new Pool(localPoolConfig("postgres", 1)); +let taskClient: ReturnType | null = null; +let guardReuseClient: ReturnType | null = null; +let apiServer: ReturnType | null = null; +let databaseCreated = false; + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function eventVersion( + base: IncidentProjectionRequestV1, + incidentId: string, + version: number, + occurredAt: string, +): IncidentProjectionRequestV1 { + const event = clone(base); + const ids = computeIncidentProjectionIds(base.authority_id, incidentId, version); + event.incident_id = incidentId; + event.incident.id = incidentId; + event.incident_version = version; + event.incident.version = version; + event.event_id = ids.event_id; + event.transition_id = ids.transition_id; + event.projection_key = ids.projection_key; + event.occurred_at = occurredAt; + event.incident.updated_at = occurredAt; + return event; +} + +try { + const collisionDatabase = `${database}_collision`; + const quotedCollisionDatabase = `"${collisionDatabase}"`; + let collisionFixtureCreated = false; + let collidingAttemptOwnedDatabase = false; + try { + await admin.query(`CREATE DATABASE ${quotedCollisionDatabase}`); + collisionFixtureCreated = true; + await assert.rejects( + async () => { + collidingAttemptOwnedDatabase = await createOwnedDatabase(admin, quotedCollisionDatabase); + }, + (error: unknown) => (error as { code?: string }).code === "42P04", + ); + assert.equal(collidingAttemptOwnedDatabase, false); + assert.equal( + Number((await admin.query( + "SELECT COUNT(*) AS n FROM pg_database WHERE datname=$1", + [collisionDatabase], + )).rows[0]?.n), + 1, + ); + } finally { + if (collisionFixtureCreated) { + await admin.query(`DROP DATABASE ${quotedCollisionDatabase} WITH (FORCE)`); + } + } + + databaseCreated = await createOwnedDatabase(admin, quotedDatabase); + const pool = new Pool(localPoolConfig(database, 6)); + taskClient = createQueryClient(pool); + for (const migration of PG_MIGRATIONS) await taskClient.execute(migration); + + const fixture = JSON.parse( + readFileSync(new URL("../fixtures/todos-incident-projection-v1.json", import.meta.url), "utf8"), + ) as IncidentProjectionRequestV1; + const context: IncidentProjectorContext = { + tenant_id: "tenant-a", + authority_id: fixture.authority_id, + routing: { channel: "incidents", project_id: "wks_8vJJzXTiFo6sxwRkpPqoI" }, + }; + + const signingSecret = "x".repeat(48); + const keys = new ApiKeyStore(taskClient); + const verifier = verifyApiKey({ + app: "conversations", + signingSecret, + isRevoked: async () => false, + }); + apiServer = startApiServer({ + host: "127.0.0.1", + port: 0, + deps: { client: taskClient, keys, verifier, incidentProjector: context }, + }); + const projectorKey = mintApiKey({ + app: "conversations", + agent: "todos-projector", + scopes: ["conversations:incident-project"], + signingSecret, + }).token; + const sdk = new ConversationsClient({ + baseUrl: `http://127.0.0.1:${apiServer.port}`, + apiKey: projectorKey, + }); + const httpCreated = await sdk.appendIncidentProjection(fixture); + const httpReplay = await sdk.appendIncidentProjection(fixture); + assert.equal(httpCreated.projection.replayed, false); + assert.equal(httpReplay.projection.replayed, true); + assert.equal(httpCreated.projection.message_id, httpReplay.projection.message_id); + + await taskClient.query( + `INSERT INTO channels (name, created_by) VALUES ('incidents', 'verifier') ON CONFLICT (name) DO NOTHING`, + ); + await taskClient.query( + `INSERT INTO channel_members (channel, agent) VALUES ('incidents', 'projector-02') ON CONFLICT DO NOTHING`, + ); + const readerOneKey = mintApiKey({ + app: "conversations", + agent: "projector-01", + scopes: ["conversations:read", "conversations:write"], + signingSecret, + }).token; + const readerTwoKey = mintApiKey({ + app: "conversations", + agent: "projector-02", + scopes: ["conversations:read", "conversations:write"], + signingSecret, + }).token; + const apiBase = `http://127.0.0.1:${apiServer.port}`; + const blockersFor = async (agent: string, apiKey: string): Promise> => { + const response = await fetch(`${apiBase}/v1/messages/blockers?agent=${encodeURIComponent(agent)}`, { + headers: { "x-api-key": apiKey }, + }); + assert.equal(response.status, 200); + return (await response.json() as { messages: Array<{ id: number }> }).messages; + }; + assert((await blockersFor("projector-01", readerOneKey)).some((message) => message.id === httpCreated.projection.message_id)); + assert((await blockersFor("projector-02", readerTwoKey)).some((message) => message.id === httpCreated.projection.message_id)); + const spoofedBlockerRead = await fetch(`${apiBase}/v1/messages/blockers?agent=projector-02`, { + headers: { "x-api-key": readerOneKey }, + }); + assert.equal(spoofedBlockerRead.status, 403); + const acknowledged = await fetch(`${apiBase}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": readerOneKey, "content-type": "application/json" }, + body: JSON.stringify({ ids: [httpCreated.projection.message_id], reader: "projector-01" }), + }); + assert.equal(acknowledged.status, 200); + assert.equal((await acknowledged.json() as { marked: number }).marked, 1); + const repeatedAcknowledgement = await fetch(`${apiBase}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": readerOneKey, "content-type": "application/json" }, + body: JSON.stringify({ ids: [httpCreated.projection.message_id] }), + }); + assert.equal(repeatedAcknowledgement.status, 200); + const repeatedReceipt = await fetch(`${apiBase}/v1/messages/${httpCreated.projection.message_id}/receipts`, { + method: "POST", + headers: { "x-api-key": readerOneKey, "content-type": "application/json" }, + body: JSON.stringify({ agent: "projector-01" }), + }); + assert.equal(repeatedReceipt.status, 201); + const outsiderKey = mintApiKey({ + app: "conversations", + agent: "outsider", + scopes: ["conversations:read", "conversations:write"], + signingSecret, + }).token; + const outsiderAck = await fetch(`${apiBase}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": outsiderKey, "content-type": "application/json" }, + body: JSON.stringify({ ids: [httpCreated.projection.message_id] }), + }); + assert.equal(outsiderAck.status, 403); + assert(!(await blockersFor("projector-01", readerOneKey)).some((message) => message.id === httpCreated.projection.message_id)); + assert((await blockersFor("projector-02", readerTwoKey)).some((message) => message.id === httpCreated.projection.message_id)); + + const identicalEvent = eventVersion( + fixture, + "66666666-6666-4666-8666-666666666666", + 1, + "2026-07-18T20:02:00.000Z", + ); + identicalEvent.incident.created_at = identicalEvent.occurred_at; + const identical = await Promise.all([ + appendIncidentProjectionPg(taskClient, identicalEvent, context), + appendIncidentProjectionPg(taskClient, identicalEvent, context), + ]); + assert.deepEqual(identical.map((result) => result.replayed).sort(), [false, true]); + assert.equal(identical[0].message_id, identical[1].message_id); + assert.equal(Number((await taskClient.get<{ n: string }>("SELECT COUNT(*) AS n FROM incident_projections"))?.n), 2); + assert.equal(Number((await taskClient.get<{ n: string }>("SELECT COUNT(*) AS n FROM messages"))?.n), 2); + assert.equal(Number((await taskClient.get<{ n: string }>("SELECT COUNT(*) AS n FROM incident_projection_scopes"))?.n), 8); + + const raceIncident = "77777777-7777-4777-8777-777777777777"; + const raceA = eventVersion(fixture, raceIncident, 1, "2026-07-18T20:03:00.000Z"); + raceA.incident.created_at = raceA.occurred_at; + raceA.incident.title = "race candidate A"; + const raceB = clone(raceA); + raceB.incident.title = "race candidate B"; + const raced = await Promise.allSettled([ + appendIncidentProjectionPg(taskClient, raceA, context), + appendIncidentProjectionPg(taskClient, raceB, context), + ]); + assert.equal(raced.filter((result) => result.status === "fulfilled").length, 1); + const rejected = raced.find((result) => result.status === "rejected"); + assert(rejected && rejected.status === "rejected"); + assert(rejected.reason instanceof IncidentProjectionConflictError); + assert.equal(Number((await taskClient.get<{ n: string }>("SELECT COUNT(*) AS n FROM messages"))?.n), 3); + + const v2 = eventVersion(fixture, fixture.incident_id, 2, "2026-07-18T20:04:00.000Z"); + const routed = await appendIncidentProjectionPg(taskClient, v2, { + ...context, + routing: { channel: "incident-archive", project_id: "platform-hirefast" }, + }); + assert.equal(routed.message.reply_to, httpCreated.projection.message_id); + assert.equal(routed.message.channel, "incidents"); + assert.equal(routed.message.project_id, "wks_8vJJzXTiFo6sxwRkpPqoI"); + + const oldIncidentId = "99999999-9999-4999-8999-999999999991"; + const replacementIncidentId = "99999999-9999-4999-8999-999999999992"; + const handoffV1 = eventVersion(fixture, oldIncidentId, 1, "2026-07-18T20:10:00.000Z"); + handoffV1.incident.created_at = handoffV1.occurred_at; + handoffV1.incident.blocked_scopes = ["agent:handoff-agent"]; + await appendIncidentProjectionPg(taskClient, handoffV1, context); + const handoffV2 = eventVersion(handoffV1, oldIncidentId, 2, "2026-07-18T20:11:00.000Z"); + handoffV2.incident.status = "superseded"; + handoffV2.incident.next_action = null; + handoffV2.incident.resolved_at = handoffV2.occurred_at; + handoffV2.incident.superseded_by_id = replacementIncidentId; + const pendingHandoff = await appendIncidentProjectionPg(taskClient, handoffV2, context); + const handoffKey = mintApiKey({ + app: "conversations", + agent: "handoff-agent", + scopes: ["conversations:read", "conversations:write"], + signingSecret, + }).token; + assert.deepEqual((await blockersFor("handoff-agent", handoffKey)).map((message) => message.id), [pendingHandoff.message_id]); + const handoffAck = await fetch(`${apiBase}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": handoffKey, "content-type": "application/json" }, + body: JSON.stringify({ ids: [pendingHandoff.message_id] }), + }); + assert.equal(handoffAck.status, 200); + assert.deepEqual((await blockersFor("handoff-agent", handoffKey)).map((message) => message.id), [pendingHandoff.message_id]); + const replacement = eventVersion(fixture, replacementIncidentId, 1, "2026-07-18T20:12:00.000Z"); + replacement.incident.created_at = replacement.occurred_at; + replacement.incident.blocked_scopes = ["agent:handoff-agent"]; + replacement.incident.supersedes_id = oldIncidentId; + const projectedReplacement = await appendIncidentProjectionPg(taskClient, replacement, context); + assert.deepEqual((await blockersFor("handoff-agent", handoffKey)).map((message) => message.id), [projectedReplacement.message_id]); + + const missingV3 = eventVersion(fixture, fixture.incident_id, 4, "2026-07-18T20:05:00.000Z"); + await assert.rejects( + appendIncidentProjectionPg(taskClient, missingV3, context), + (error: unknown) => error instanceof IncidentProjectionConflictError, + ); + + const beforeAtomic = Number((await taskClient.get<{ n: string }>("SELECT COUNT(*) AS n FROM messages"))?.n); + const missingSupersession = eventVersion( + fixture, + "88888888-8888-4888-8888-888888888888", + 1, + "2026-07-18T20:06:00.000Z", + ); + missingSupersession.incident.created_at = missingSupersession.occurred_at; + missingSupersession.incident.supersedes_id = "99999999-9999-4999-8999-999999999999"; + await assert.rejects( + appendIncidentProjectionPg(taskClient, missingSupersession, context), + (error: unknown) => error instanceof IncidentProjectionConflictError, + ); + assert.equal(Number((await taskClient.get<{ n: string }>("SELECT COUNT(*) AS n FROM messages"))?.n), beforeAtomic); + + await assert.rejects(taskClient.query( + `INSERT INTO messages (session_id, from_agent, to_agent, channel, project_id, content, reply_to) + VALUES ('channel:incidents','child','incidents','incidents','p','orphan',999999)`, + )); + const parent = await taskClient.one<{ id: string }>( + `INSERT INTO messages (session_id, from_agent, to_agent, channel, project_id, content) + VALUES ('channel:ops','alice','ops','ops','p','root') RETURNING id`, + ); + await assert.rejects(taskClient.query( + `INSERT INTO messages (session_id, from_agent, to_agent, channel, project_id, content, reply_to) + VALUES ('channel:other','bob','other','other','p','cross-scope',$1)`, + [parent.id], + )); + await taskClient.query( + `INSERT INTO messages (session_id, from_agent, to_agent, channel, project_id, content, reply_to) + VALUES ('channel:ops','bob','ops','ops','p','reply',$1)`, + [parent.id], + ); + await assert.rejects(taskClient.query("UPDATE messages SET project_id='other' WHERE id=$1", [parent.id])); + await assert.rejects(taskClient.query("DELETE FROM messages WHERE id=$1", [parent.id])); + await assert.rejects(taskClient.query( + "UPDATE messages SET content='rewrite' WHERE id=$1", + [httpCreated.projection.message_id], + )); + + await taskClient.query( + `INSERT INTO channels (name, created_by) VALUES ('pg-thread-old', 'verifier')`, + ); + const threadParent = await taskClient.one<{ id: string }>( + `INSERT INTO messages (session_id, from_agent, to_agent, channel, project_id, content) + VALUES ('channel:pg-thread-old','alice','pg-thread-old','pg-thread-old','project-a','thread root') + RETURNING id`, + ); + const threadReply = await taskClient.one<{ id: string }>( + `INSERT INTO messages (session_id, from_agent, to_agent, channel, project_id, content, reply_to) + VALUES ('channel:pg-thread-old','bob','pg-thread-old','pg-thread-old','project-a','thread reply',$1) + RETURNING id`, + [threadParent.id], + ); + const sessionOnlyParent = await taskClient.one<{ id: string }>( + `INSERT INTO messages (session_id, from_agent, to_agent, project_id, content) + VALUES ('channel:pg-thread-old','legacy-a','legacy-b','project-a','session-only root') RETURNING id`, + ); + const sessionOnlyReply = await taskClient.one<{ id: string }>( + `INSERT INTO messages (session_id, from_agent, to_agent, project_id, content, reply_to) + VALUES ('channel:pg-thread-old','legacy-b','legacy-a','project-a','session-only reply',$1) RETURNING id`, + [sessionOnlyParent.id], + ); + assert.deepEqual(await renameChannelServer(taskClient, "pg-thread-old", "pg-thread-new"), { + ok: true, + name: "pg-thread-new", + }); + const renamedThread = await taskClient.many<{ + id: string; session_id: string; channel: string; to_agent: string; project_id: string; reply_to: string | null; content: string; + }>( + `SELECT id, session_id, channel, to_agent, project_id, reply_to, content + FROM messages WHERE id IN ($1,$2) ORDER BY id`, + [threadParent.id, threadReply.id], + ); + assert.equal(renamedThread.length, 2); + assert(renamedThread.every((message) => message.session_id === "channel:pg-thread-new")); + assert(renamedThread.every((message) => message.channel === "pg-thread-new")); + assert(renamedThread.every((message) => message.to_agent === "pg-thread-new")); + assert(renamedThread.every((message) => message.project_id === "project-a")); + assert.equal(String(renamedThread[1].reply_to), String(threadParent.id)); + assert.deepEqual(renamedThread.map((message) => message.content), ["thread root", "thread reply"]); + const renamedSessionOnly = await taskClient.many<{ + id: string; session_id: string; channel: string | null; project_id: string; reply_to: string | null; + }>( + `SELECT id, session_id, channel, project_id, reply_to FROM messages WHERE id IN ($1,$2) ORDER BY id`, + [sessionOnlyParent.id, sessionOnlyReply.id], + ); + assert.equal(renamedSessionOnly.length, 2); + assert(renamedSessionOnly.every((message) => message.session_id === "channel:pg-thread-new")); + assert(renamedSessionOnly.every((message) => message.channel === null)); + assert(renamedSessionOnly.every((message) => message.project_id === "project-a")); + assert.equal(String(renamedSessionOnly[1].reply_to), String(sessionOnlyParent.id)); + await assert.rejects(taskClient.query("UPDATE messages SET project_id='other' WHERE id=$1", [threadParent.id])); + + await taskClient.query(`INSERT INTO channels (name, created_by) VALUES ('pg-fail-old', 'verifier')`); + const failedParent = await taskClient.one<{ id: string }>( + `INSERT INTO messages (session_id, from_agent, to_agent, channel, project_id, content) + VALUES ('channel:pg-fail-old','alice','pg-fail-old','pg-fail-old','project-a','failed root') RETURNING id`, + ); + await taskClient.query( + `INSERT INTO messages (session_id, from_agent, to_agent, channel, project_id, content, reply_to) + VALUES ('channel:pg-fail-old','bob','pg-fail-old','pg-fail-old','project-a','failed reply',$1)`, + [failedParent.id], + ); + await taskClient.execute(` + CREATE OR REPLACE FUNCTION fail_verifier_channel_rename() RETURNS trigger AS $$ + BEGIN + IF NEW.channel = 'pg-fail-new' THEN RAISE EXCEPTION 'injected rename failure'; END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER fail_verifier_channel_rename_trigger + BEFORE UPDATE OF channel ON messages FOR EACH ROW + EXECUTE FUNCTION fail_verifier_channel_rename(); + `); + const guardReusePool = new Pool(localPoolConfig(database, 1)); + guardReuseClient = createQueryClient(guardReusePool); + await assert.rejects(renameChannelServer(guardReuseClient, "pg-fail-old", "pg-fail-new")); + await guardReuseClient.transaction(async (tx) => { + const guard = await tx.get<{ value: string | null }>( + `SELECT current_setting('hasna.conversations.channel_scope_rewrite', TRUE) AS value`, + ); + assert(!guard?.value); + await assert.rejects(tx.query( + `UPDATE messages + SET session_id='channel:pg-fail-new', channel='pg-fail-new', to_agent='pg-fail-new' + WHERE id=$1`, + [failedParent.id], + )); + }); + assert(await taskClient.get("SELECT name FROM channels WHERE name='pg-fail-old'")); + assert.equal(await taskClient.get("SELECT name FROM channels WHERE name='pg-fail-new'"), null); + const failedMessages = await taskClient.many<{ session_id: string; channel: string; to_agent: string }>( + "SELECT session_id, channel, to_agent FROM messages WHERE channel='pg-fail-old' ORDER BY id", + ); + assert.equal(failedMessages.length, 2); + assert(failedMessages.every((message) => message.session_id === "channel:pg-fail-old")); + assert(failedMessages.every((message) => message.to_agent === "pg-fail-old")); + await taskClient.execute(` + DROP TRIGGER fail_verifier_channel_rename_trigger ON messages; + DROP FUNCTION fail_verifier_channel_rename(); + `); + + const projectedBeforeRename = await taskClient.one>( + "SELECT * FROM messages WHERE id=$1", + [httpCreated.projection.message_id], + ); + const projectedReplyBeforeRename = await taskClient.one>( + "SELECT * FROM messages WHERE id=$1", + [routed.message_id], + ); + assert.deepEqual(await renameChannelServer(taskClient, "incidents", "incident-log"), { + ok: true, + name: "incident-log", + }); + const projectedAfterRename = await taskClient.one>( + "SELECT * FROM messages WHERE id=$1", + [httpCreated.projection.message_id], + ); + const projectedReplyAfterRename = await taskClient.one>( + "SELECT * FROM messages WHERE id=$1", + [routed.message_id], + ); + assert.equal(projectedAfterRename.session_id, "channel:incident-log"); + assert.equal(projectedAfterRename.channel, "incident-log"); + assert.equal(projectedAfterRename.to_agent, "incident-log"); + for (const field of [ + "uuid", "from_agent", "project_id", "content", "priority", "working_dir", "repository", "branch", + "metadata", "created_at", "read_at", "edited_at", "pinned_at", "blocking", "attachments", "reply_to", + ]) { + assert.deepEqual(projectedAfterRename[field], projectedBeforeRename[field], `projected field changed: ${field}`); + } + assert.equal(projectedReplyAfterRename.session_id, "channel:incident-log"); + assert.equal(projectedReplyAfterRename.channel, "incident-log"); + assert.equal(projectedReplyAfterRename.to_agent, "incident-log"); + assert.equal(String(projectedReplyAfterRename.reply_to), String(httpCreated.projection.message_id)); + for (const field of [ + "uuid", "from_agent", "project_id", "content", "priority", "working_dir", "repository", "branch", + "metadata", "created_at", "read_at", "edited_at", "pinned_at", "blocking", "attachments", "reply_to", + ]) { + assert.deepEqual( + projectedReplyAfterRename[field], + projectedReplyBeforeRename[field], + `projected reply field changed: ${field}`, + ); + } + await assert.rejects(taskClient.query( + "UPDATE messages SET content='rewrite after rename' WHERE id=$1", + [httpCreated.projection.message_id], + )); + + console.log(`ok incident projection PG integration database=${database} cleanup=pending`); +} finally { + if (apiServer) apiServer.stop(true); + if (guardReuseClient) await guardReuseClient.close(); + if (taskClient) await taskClient.close(); + try { + if (databaseCreated) { + await admin.query(`DROP DATABASE ${quotedDatabase} WITH (FORCE)`); + } + } finally { + await admin.end(); + } + console.log(`ok incident projection PG integration cleanup database=${database}`); +} diff --git a/src/cli/commands/messaging.test.ts b/src/cli/commands/messaging.test.ts index 6274b5d..cd74ff7 100644 --- a/src/cli/commands/messaging.test.ts +++ b/src/cli/commands/messaging.test.ts @@ -1,6 +1,37 @@ import { describe, test, expect } from "bun:test"; import { Command } from "commander"; import { formatDigestContinuationCommand, registerMessagingCommands } from "./messaging"; +import { sendMessage, getThreadReplies } from "../../lib/messages"; +import { closeDb } from "../../lib/db"; +import { unlinkSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { resetStoreForTests } from "../../lib/store"; + +const ROUTE_ENV_KEYS = [ + "HASNA_CONVERSATIONS_STORAGE_MODE", + "HASNA_CONVERSATIONS_MODE", + "CONVERSATIONS_STORAGE_MODE", + "CONVERSATIONS_MODE", + "HASNA_CONVERSATIONS_API_URL", + "CONVERSATIONS_API_URL", + "HASNA_CONVERSATIONS_API_KEY", + "CONVERSATIONS_API_KEY", + "HASNA_CONVERSATIONS_DB_PATH", + "CONVERSATIONS_DB_PATH", +] as const; + +function snapshotRouteEnv(): Map { + return new Map(ROUTE_ENV_KEYS.map((key) => [key, process.env[key]])); +} + +function restoreRouteEnv(snapshot: Map): void { + for (const key of ROUTE_ENV_KEYS) { + const value = snapshot.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} describe("registerMessagingCommands", () => { test("registers send command", () => { @@ -125,6 +156,70 @@ describe("registerMessagingCommands", () => { expect(reply).toBeDefined(); }); + test("reply command persists reply_to on the original message", async () => { + const dbPath = join(tmpdir(), `conversations-cli-reply-${Date.now()}-${Math.random()}.db`); + const routeEnv = snapshotRouteEnv(); + const originalFetch = globalThis.fetch; + let httpRequests = 0; + globalThis.fetch = (async () => { + httpRequests++; + throw new Error("reply regression must not make an HTTP request"); + }) as unknown as typeof fetch; + + // Deliberately poison every cloud selector except the explicit canonical + // local mode. This proves the regression never leaks into a live parent + // route even when the invoking shell is configured for remote storage. + process.env.HASNA_CONVERSATIONS_API_URL = "https://poisoned.invalid"; + process.env.CONVERSATIONS_API_URL = "https://poisoned-alias.invalid"; + process.env.HASNA_CONVERSATIONS_API_KEY = "test-not-a-real-key"; + process.env.CONVERSATIONS_API_KEY = "test-not-a-real-alias-key"; + process.env.HASNA_CONVERSATIONS_STORAGE_MODE = "local"; + process.env.HASNA_CONVERSATIONS_MODE = "local"; + process.env.CONVERSATIONS_STORAGE_MODE = "local"; + process.env.CONVERSATIONS_MODE = "local"; + delete process.env.HASNA_CONVERSATIONS_DB_PATH; + process.env.CONVERSATIONS_DB_PATH = dbPath; + closeDb(); + resetStoreForTests(); + try { + const original = sendMessage({ + from: "alice", + to: "incidents", + channel: "incidents", + project_id: "engineering", + content: "incident root", + }); + const program = new Command(); + program.exitOverride(); + registerMessagingCommands(program); + await program.parseAsync([ + "node", + "conversations", + "reply", + "threaded follow-up", + "--to", + String(original.id), + "--from", + "bob", + "--json", + ]); + + const replies = getThreadReplies(original.id as number); + expect(replies).toHaveLength(1); + expect(replies[0].reply_to).toBe(original.id); + expect(replies[0].project_id).toBe("engineering"); + expect(httpRequests).toBe(0); + } finally { + closeDb(); + resetStoreForTests(); + globalThis.fetch = originalFetch; + restoreRouteEnv(routeEnv); + for (const suffix of ["", "-wal", "-shm"]) { + try { unlinkSync(dbPath + suffix); } catch {} + } + } + }); + test("registers mark-read command", () => { const program = new Command(); registerMessagingCommands(program); diff --git a/src/cli/commands/messaging.ts b/src/cli/commands/messaging.ts index 8eda135..cb2bc29 100644 --- a/src/cli/commands/messaging.ts +++ b/src/cli/commands/messaging.ts @@ -410,6 +410,11 @@ export function registerMessagingCommands(program: Command): void { session_id: original.session_id, priority: opts.priority, channel, + project_id: original.project_id ?? undefined, + working_dir: original.working_dir ?? undefined, + repository: original.repository ?? undefined, + branch: original.branch ?? undefined, + reply_to: opts.to, }); if (opts.json) { diff --git a/src/index.ts b/src/index.ts index 2e95099..458936b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,11 @@ export * from "./types.js"; // The Store abstraction: getStore(), ConversationsStore, LocalStore, the mode // resolvers (isCloudStore/cloudApiUrl/…) and normalizeChannelName. export * from "./lib/store/index.js"; +export { + computeIncidentProjectionIds, + validateIncidentProjection, + type ValidatedIncidentProjection, +} from "./lib/incident-projection-contract.js"; // Contract-valid project dashboard panel, aggregated through the active Store. export { diff --git a/src/lib/channels.test.ts b/src/lib/channels.test.ts index 5e1c6c2..d784acc 100644 --- a/src/lib/channels.test.ts +++ b/src/lib/channels.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { createChannel, updateChannel, renameChannel, archiveChannel, unarchiveChannel, listChannels, getChannel, joinChannel, leaveChannel, getChannelMembers, isChannelMember } from "./channels"; import { createProject } from "./projects"; -import { sendMessage, readMessages } from "./messages"; -import { closeDb } from "./db"; +import { sendMessage, readMessages, getThreadReplies } from "./messages"; +import { closeDb, getDb } from "./db"; import { unlinkSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; @@ -275,6 +275,41 @@ describe("renameChannel", () => { expect(newMsgs.every((m) => m.channel === "new-name")).toBe(true); }); + test("preserves a threaded reply scope across the rename", () => { + createChannel("old-name", "alice"); + const parent = sendMessage({ from: "alice", to: "old-name", content: "root", channel: "old-name" }); + const reply = sendMessage({ from: "bob", to: "old-name", content: "reply", channel: "old-name", reply_to: parent.id }); + + renameChannel("old-name", "new-name"); + + const replies = getThreadReplies(parent.id); + expect(replies.map((message) => message.id)).toEqual([reply.id]); + expect(replies[0].channel).toBe("new-name"); + expect(replies[0].session_id).toBe("channel:new-name"); + expect(getDb().prepare("SELECT COUNT(*) AS n FROM message_scope_rewrite_guard").get()).toEqual({ n: 0 }); + }); + + test("rolls back the internal rewrite guard and keeps generic parent mutation blocked", () => { + createChannel("old-name", "alice"); + const parent = sendMessage({ from: "alice", to: "old-name", content: "root", channel: "old-name" }); + sendMessage({ from: "bob", to: "old-name", content: "reply", channel: "old-name", reply_to: parent.id }); + const db = getDb(); + db.exec(` + CREATE TRIGGER fail_test_channel_rename + BEFORE UPDATE OF channel ON messages + WHEN NEW.channel = 'failed-rename' + BEGIN SELECT RAISE(ABORT, 'injected rename failure'); END + `); + + expect(() => renameChannel("old-name", "failed-rename")).toThrow("injected rename failure"); + expect(getChannel("old-name")?.name).toBe("old-name"); + expect(getChannel("failed-rename")).toBeNull(); + expect(db.prepare("SELECT COUNT(*) AS n FROM message_scope_rewrite_guard").get()).toEqual({ n: 0 }); + expect(() => db.prepare("UPDATE messages SET project_id = 'other' WHERE id = ?").run(parent.id)).toThrow( + "reply parent scope is immutable", + ); + }); + test("preserves members across the rename", () => { createChannel("old-name", "alice"); joinChannel("old-name", "bob"); diff --git a/src/lib/channels.ts b/src/lib/channels.ts index 7ead3dd..090ffa5 100644 --- a/src/lib/channels.ts +++ b/src/lib/channels.ts @@ -291,6 +291,15 @@ export function renameChannel(oldName: string, newName: string): Channel { db.exec("BEGIN"); try { + // Foreign-key enforcement is enabled for reply/incident durability. Channel + // rename rewrites the parent key and every dependent row in this same + // transaction, so defer those checks until all references are consistent. + db.exec("PRAGMA defer_foreign_keys = ON"); + db.prepare( + `INSERT INTO message_scope_rewrite_guard ( + token, old_session_id, new_session_id, old_channel, new_channel, old_to_agent, new_to_agent + ) VALUES (1, ?, ?, ?, ?, ?, ?)`, + ).run(`channel:${from}`, `channel:${to}`, from, to, from, to); // Channel row itself (PK is the name column). db.prepare("UPDATE channels SET name = ? WHERE name = ?").run(to, from); @@ -298,10 +307,16 @@ export function renameChannel(oldName: string, newName: string): Channel { // field (channel messages address the channel name as recipient). if (localHasColumn(db, "messages", "channel")) { db.prepare( - "UPDATE messages SET channel = ?, to_agent = CASE WHEN to_agent = ? THEN ? ELSE to_agent END WHERE channel = ?", - ).run(to, from, to, from); + `UPDATE messages + SET channel = ?, + session_id = CASE WHEN session_id = ? THEN ? ELSE session_id END, + to_agent = CASE WHEN to_agent = ? THEN ? ELSE to_agent END + WHERE channel = ?`, + ).run(to, `channel:${from}`, `channel:${to}`, from, to, from); } - db.prepare("UPDATE messages SET session_id = ? WHERE session_id = ?").run(`channel:${to}`, `channel:${from}`); + db.prepare( + "UPDATE messages SET session_id = ? WHERE session_id = ? AND (channel IS NULL OR channel <> ?)", + ).run(`channel:${to}`, `channel:${from}`, to); // Membership and subscriptions. if (localTableExists(db, "channel_members")) { @@ -328,6 +343,8 @@ export function renameChannel(oldName: string, newName: string): Channel { db.prepare("UPDATE resource_locks SET resource_id = ? WHERE resource_type = 'channel' AND resource_id = ?").run(to, from); } + db.prepare("DELETE FROM message_scope_rewrite_guard WHERE token = 1").run(); + db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); diff --git a/src/lib/db.test.ts b/src/lib/db.test.ts index f657bba..6d73de6 100644 --- a/src/lib/db.test.ts +++ b/src/lib/db.test.ts @@ -46,6 +46,8 @@ describe("db", () => { expect(tableNames).toContain("channel_subscriptions"); expect(tableNames).toContain("channel_notification_reads"); expect(tableNames).toContain("projects"); + expect(tableNames).toContain("incident_projections"); + expect(tableNames).toContain("incident_projection_scopes"); }); test("getDb returns singleton", () => { @@ -73,6 +75,9 @@ describe("db", () => { expect(names).toContain("idx_channels_project"); expect(names).toContain("idx_channel_subscriptions_agent"); expect(names).toContain("idx_channel_notification_reads_agent"); + expect(names).toContain("idx_incident_projections_active_scope"); + expect(names).toContain("idx_incident_projections_message"); + expect(names).toContain("idx_incident_projection_scopes_lookup"); }); test("closeDb closes and resets singleton", () => { @@ -398,4 +403,41 @@ describe("db", () => { const lock = db.prepare("SELECT resource_type, resource_id FROM resource_locks").get() as { resource_type: string; resource_id: string }; expect(lock).toEqual({ resource_type: "channel", resource_id: "platform-mcps" }); }); + + test("preserves legacy orphan reply values while rejecting new orphan writes", () => { + closeDb(); + const legacyDb = new Database(TEST_DB); + legacyDb.exec(` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + from_agent TEXT NOT NULL, + to_agent TEXT NOT NULL, + channel TEXT, + project_id TEXT, + content TEXT NOT NULL, + priority TEXT NOT NULL DEFAULT 'normal', + working_dir TEXT, + repository TEXT, + branch TEXT, + metadata TEXT, + blocking INTEGER NOT NULL DEFAULT 0, + attachments TEXT, + reply_to INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')), + read_at TEXT + ); + INSERT INTO messages (session_id, from_agent, to_agent, channel, content, reply_to) + VALUES ('channel:incidents', 'legacy', 'incidents', 'incidents', 'orphan history', 999); + `); + legacyDb.close(); + + const db = getDb(); + expect(db.prepare("SELECT reply_to FROM messages WHERE id = 1").get()).toEqual({ reply_to: 999 }); + expect(() => db.prepare( + `INSERT INTO messages (session_id, from_agent, to_agent, channel, content, reply_to) + VALUES ('channel:incidents', 'new', 'incidents', 'incidents', 'new orphan', 998)`, + ).run()).toThrow("reply parent is missing or outside the message scope"); + expect(db.prepare("SELECT reply_to FROM messages WHERE id = 1").get()).toEqual({ reply_to: 999 }); + }); }); diff --git a/src/lib/db.ts b/src/lib/db.ts index 99d3dcb..4bde38d 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -619,6 +619,7 @@ export function getDb(): Database { db = new ConversationsDatabase(dbPath); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA busy_timeout = 5000"); + db.exec("PRAGMA foreign_keys = ON"); // Messages table (new DBs get 'channel' column; existing DBs migrate below) db.exec(` @@ -832,6 +833,76 @@ export function getDb(): Database { db.exec("ALTER TABLE messages ADD COLUMN project_id TEXT"); db.exec("CREATE INDEX IF NOT EXISTS idx_messages_project ON messages(project_id)"); } + db.exec(` + CREATE TABLE IF NOT EXISTS message_scope_rewrite_guard ( + token INTEGER PRIMARY KEY CHECK (token = 1), + old_session_id TEXT NOT NULL, + new_session_id TEXT NOT NULL, + old_channel TEXT, + new_channel TEXT, + old_to_agent TEXT NOT NULL, + new_to_agent TEXT NOT NULL + ) + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS messages_reply_scope_insert + BEFORE INSERT ON messages + WHEN NEW.reply_to IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM messages parent + WHERE parent.id = NEW.reply_to + AND parent.session_id = NEW.session_id + AND parent.channel IS NEW.channel + AND parent.project_id IS NEW.project_id + ) + BEGIN SELECT RAISE(ABORT, 'reply parent is missing or outside the message scope'); END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS messages_reply_scope_update + BEFORE UPDATE OF reply_to, session_id, channel, project_id ON messages + WHEN NEW.reply_to IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM messages parent + WHERE parent.id = NEW.reply_to + AND parent.session_id = NEW.session_id + AND parent.channel IS NEW.channel + AND parent.project_id IS NEW.project_id + ) + AND NOT EXISTS ( + SELECT 1 FROM message_scope_rewrite_guard guard + WHERE OLD.session_id = guard.old_session_id + AND NEW.session_id = guard.new_session_id + AND ( + (OLD.channel IS guard.old_channel AND NEW.channel IS guard.new_channel) + OR OLD.channel IS NEW.channel + ) + AND ( + (OLD.to_agent = guard.old_to_agent AND NEW.to_agent = guard.new_to_agent) + OR OLD.to_agent IS NEW.to_agent + ) + AND OLD.project_id IS NEW.project_id + ) + BEGIN SELECT RAISE(ABORT, 'reply parent is missing or outside the message scope'); END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS messages_reply_parent_scope_no_update + BEFORE UPDATE OF session_id, channel, project_id ON messages + WHEN EXISTS (SELECT 1 FROM messages child WHERE child.reply_to = OLD.id) + AND (NEW.session_id IS NOT OLD.session_id OR NEW.channel IS NOT OLD.channel OR NEW.project_id IS NOT OLD.project_id) + AND NOT EXISTS ( + SELECT 1 FROM message_scope_rewrite_guard guard + WHERE OLD.session_id = guard.old_session_id + AND NEW.session_id = guard.new_session_id + AND ( + (OLD.channel IS guard.old_channel AND NEW.channel IS guard.new_channel) + OR OLD.channel IS NEW.channel + ) + AND ( + (OLD.to_agent = guard.old_to_agent AND NEW.to_agent = guard.new_to_agent) + OR OLD.to_agent IS NEW.to_agent + ) + AND OLD.project_id IS NEW.project_id + ) + BEGIN SELECT RAISE(ABORT, 'reply parent scope is immutable while replies exist'); END + `); if (!colNames2.includes("uuid")) { db.exec("ALTER TABLE messages ADD COLUMN uuid TEXT"); // Backfill existing rows with unique UUIDs @@ -889,6 +960,138 @@ export function getDb(): Database { db.exec("CREATE INDEX IF NOT EXISTS idx_read_receipts_message ON message_read_receipts(message_id)"); db.exec("CREATE INDEX IF NOT EXISTS idx_read_receipts_agent ON message_read_receipts(agent)"); + // Canonical incident state is an append-only, structurally indexed projection + // ledger. Message content and metadata remain display/audit material only. + db.exec(` + CREATE TABLE IF NOT EXISTS incident_projections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + projection_key TEXT NOT NULL, + message_id INTEGER NOT NULL UNIQUE REFERENCES messages(id), + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + source TEXT NOT NULL CHECK (source = 'todos'), + tenant_id TEXT NOT NULL, + authority_id TEXT NOT NULL, + incident_id TEXT NOT NULL, + transition_id TEXT NOT NULL, + incident_version INTEGER NOT NULL CHECK (incident_version > 0), + occurred_at TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('open','investigating','contained','monitoring','resolved','superseded')), + severity TEXT NOT NULL CHECK (severity IN ('info','low','medium','high','critical')), + blocking INTEGER NOT NULL DEFAULT 0, + affected_scopes TEXT NOT NULL, + blocked_scopes TEXT NOT NULL, + supersedes_transition_id TEXT, + supersedes_incident_id TEXT, + superseded_by_incident_id TEXT, + canonical_payload TEXT NOT NULL, + payload_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')), + CHECK (NOT (status IN ('resolved','superseded') AND blocking = 1)), + UNIQUE (tenant_id, event_id), + UNIQUE (tenant_id, projection_key), + UNIQUE (tenant_id, authority_id, incident_id, transition_id), + UNIQUE (tenant_id, authority_id, incident_id, incident_version) + ) + `); + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_incident_projections_message ON incident_projections(message_id)"); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_incident_projections_active_scope + ON incident_projections(tenant_id, authority_id, incident_id, incident_version DESC) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_incident_projections_blocking_scope + ON incident_projections(tenant_id, authority_id, blocking, incident_id, incident_version DESC) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS incident_projection_scopes ( + projection_id INTEGER NOT NULL REFERENCES incident_projections(id), + scope_type TEXT NOT NULL CHECK (scope_type IN ('affected','blocked')), + scope TEXT NOT NULL, + PRIMARY KEY (projection_id, scope_type, scope) + ) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_incident_projection_scopes_lookup + ON incident_projection_scopes(scope_type, scope, projection_id) + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS incident_projections_no_update + BEFORE UPDATE ON incident_projections + BEGIN SELECT RAISE(ABORT, 'incident projections are append-only'); END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS incident_projections_no_delete + BEFORE DELETE ON incident_projections + BEGIN SELECT RAISE(ABORT, 'incident projections are append-only'); END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS incident_projection_scopes_no_update + BEFORE UPDATE ON incident_projection_scopes + BEGIN SELECT RAISE(ABORT, 'incident projection scopes are append-only'); END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS incident_projection_scopes_no_delete + BEFORE DELETE ON incident_projection_scopes + BEGIN SELECT RAISE(ABORT, 'incident projection scopes are append-only'); END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS incident_projection_messages_no_mutation + BEFORE UPDATE ON messages + WHEN EXISTS (SELECT 1 FROM incident_projections WHERE message_id = OLD.id) + AND ( + NEW.uuid IS NOT OLD.uuid OR + NEW.session_id IS NOT OLD.session_id OR + NEW.from_agent IS NOT OLD.from_agent OR + NEW.to_agent IS NOT OLD.to_agent OR + NEW.channel IS NOT OLD.channel OR + NEW.project_id IS NOT OLD.project_id OR + NEW.content IS NOT OLD.content OR + NEW.priority IS NOT OLD.priority OR + NEW.working_dir IS NOT OLD.working_dir OR + NEW.repository IS NOT OLD.repository OR + NEW.branch IS NOT OLD.branch OR + NEW.metadata IS NOT OLD.metadata OR + NEW.edited_at IS NOT OLD.edited_at OR + NEW.blocking IS NOT OLD.blocking OR + NEW.attachments IS NOT OLD.attachments OR + NEW.reply_to IS NOT OLD.reply_to OR + NEW.created_at IS NOT OLD.created_at + ) + AND NOT EXISTS ( + SELECT 1 FROM message_scope_rewrite_guard guard + WHERE OLD.session_id = guard.old_session_id + AND NEW.session_id = guard.new_session_id + AND OLD.channel IS guard.old_channel + AND NEW.channel IS guard.new_channel + AND OLD.to_agent = guard.old_to_agent + AND NEW.to_agent = guard.new_to_agent + AND NEW.uuid IS OLD.uuid + AND NEW.from_agent IS OLD.from_agent + AND NEW.project_id IS OLD.project_id + AND NEW.content IS OLD.content + AND NEW.priority IS OLD.priority + AND NEW.working_dir IS OLD.working_dir + AND NEW.repository IS OLD.repository + AND NEW.branch IS OLD.branch + AND NEW.metadata IS OLD.metadata + AND NEW.created_at IS OLD.created_at + AND NEW.read_at IS OLD.read_at + AND NEW.edited_at IS OLD.edited_at + AND NEW.pinned_at IS OLD.pinned_at + AND NEW.blocking IS OLD.blocking + AND NEW.attachments IS OLD.attachments + AND NEW.reply_to IS OLD.reply_to + ) + BEGIN SELECT RAISE(ABORT, 'incident projection messages are append-only'); END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS incident_projection_messages_no_delete + BEFORE DELETE ON messages + WHEN EXISTS (SELECT 1 FROM incident_projections WHERE message_id = OLD.id) + BEGIN SELECT RAISE(ABORT, 'incident projection messages are append-only'); END + `); + // Message mentions table — @agent notifications db.exec(` CREATE TABLE IF NOT EXISTS message_mentions ( diff --git a/src/lib/incident-projection-contract.test.ts b/src/lib/incident-projection-contract.test.ts new file mode 100644 index 0000000..45d5eee --- /dev/null +++ b/src/lib/incident-projection-contract.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "crypto"; +import { readFileSync } from "fs"; +import { + computeIncidentProjectionIds, + metadataSpoofsIncidentProjection, + validateIncidentProjection, +} from "./incident-projection-contract"; +import type { IncidentProjectionRequestV1, IncidentProjectorContext } from "../types"; + +const context: IncidentProjectorContext = { tenant_id: "tenant-a", authority_id: "todos.hasna.xyz:v1" }; +const incidentId = "11111111-1111-4111-8111-111111111111"; + +function fixture(version = 1): IncidentProjectionRequestV1 { + const ids = computeIncidentProjectionIds(context.authority_id, incidentId, version); + return { + schema_version: 1, + source: "todos", + authority_id: context.authority_id, + incident_id: incidentId, + transition_id: ids.transition_id, + incident_version: version, + occurred_at: `2026-07-18T20:0${version}:00Z`, + event_id: ids.event_id, + projection_key: ids.projection_key, + incident: { + id: incidentId, + title: "Conversations projection incident", + severity: "high", + status: "investigating", + owner: "Friday", + affected_scopes: ["service:conversations"], + blocked_scopes: ["agent:friday", "channel:internal-engineering"], + containment: null, + next_action: "Ship the projection primitive", + deadline: null, + closure_evidence: [], + supersedes_id: null, + superseded_by_id: null, + resolved_at: null, + version, + created_at: "2026-07-18T20:01:00Z", + updated_at: `2026-07-18T20:0${version}:00Z`, + }, + }; +} + +describe("Todos v1 incident projection contract", () => { + test("accepts the byte-shared canonical Todos fixture with the frozen payload hash", () => { + const raw = readFileSync(new URL("../../fixtures/todos-incident-projection-v1.json", import.meta.url), "utf8"); + expect(createHash("sha256").update(raw).digest("hex")).toBe( + "63cb9fafe606006003d033fbd2060d35ca10b6a520afa6a960a8e639c2be48ef", + ); + const shared = JSON.parse(raw); + const result = validateIncidentProjection(shared, context); + expect(result.payload_hash).toBe("a89862d57860d06b0d53cae4d720830042a38fa90ece0cbab1b363a19384e4cd"); + expect(result.request.event_id).toBe("iev_adf149b3daa8a314dd30b92b188f0024"); + }); + + test("validates the frozen wire and deterministic identifiers", () => { + const result = validateIncidentProjection(fixture(), context); + expect(result.request.incident.severity).toBe("high"); + expect(result.request.incident.status).toBe("investigating"); + expect(result.blocking).toBe(true); + expect(result.supersedes_transition_id).toBeNull(); + expect(result.payload_hash).toHaveLength(64); + }); + + test("derives the N-1 transition without relying on a numeric reply id", () => { + const result = validateIncidentProjection(fixture(2), context); + expect(result.supersedes_transition_id).toBe( + computeIncidentProjectionIds(context.authority_id, incidentId, 1).transition_id, + ); + }); + + test("rejects enum drift, top-level drift, spoofed authority, and mismatched ids", () => { + const invalidSeverity = fixture() as any; + invalidSeverity.incident.severity = "sev1"; + expect(() => validateIncidentProjection(invalidSeverity, context)).toThrow("incident.severity"); + + const invalidStatus = fixture() as any; + invalidStatus.incident.status = "closed"; + expect(() => validateIncidentProjection(invalidStatus, context)).toThrow("incident.status"); + + const authoritySpoof = { ...fixture(), authority_id: "attacker" } as any; + expect(() => validateIncidentProjection(authoritySpoof, context)).toThrow("selected Conversations authority"); + + const badId = fixture() as any; + badId.event_id = "iev_attacker"; + expect(() => validateIncidentProjection(badId, context)).toThrow("deterministic Todos v1 value"); + + expect(() => computeIncidentProjectionIds("todos.hasna.xyz/v1", incidentId, 1)).toThrow( + "letters, digits, dot, underscore, colon, or hyphen", + ); + }); + + test("accepts only the frozen recipient-scope grammar", () => { + const accepted = fixture(); + accepted.incident.blocked_scopes = [ + "agent:projector-01", + "channel:incidents", + "project:wks_8vJJzXTiFo6sxwRkpPqoI", + ]; + expect(validateIncidentProjection(accepted, context).request.incident.blocked_scopes).toEqual( + accepted.incident.blocked_scopes, + ); + for (const scope of ["agent-coordination", "channel:Internal_Engineering", "project:bad/id", "team:all"]) { + const rejected = fixture(); + rejected.incident.blocked_scopes = [scope]; + expect(() => validateIncidentProjection(rejected, context)).toThrow("frozen recipient grammar"); + } + }); + + test("matches the producer's 128-character blocked-scope boundary", () => { + const accepted = fixture(); + accepted.incident.blocked_scopes = [`agent:A${"a".repeat(121)}`]; + expect(accepted.incident.blocked_scopes[0]).toHaveLength(128); + expect(validateIncidentProjection(accepted, context).request.incident.blocked_scopes).toEqual( + accepted.incident.blocked_scopes, + ); + const rejected = fixture(); + rejected.incident.blocked_scopes = [`agent:A${"a".repeat(122)}`]; + expect(rejected.incident.blocked_scopes[0]).toHaveLength(129); + expect(() => validateIncidentProjection(rejected, context)).toThrow("at most 128 characters"); + }); + + test("mirrors resolved invariants and rejects source/snapshot identity drift", () => { + const terminal = fixture() as any; + terminal.incident.status = "resolved"; + terminal.incident.resolved_at = "2026-07-18T20:05:00Z"; + expect(() => validateIncidentProjection(terminal, context)).toThrow("cannot retain incident.blocked_scopes"); + + const identity = fixture() as any; + identity.incident.version = 2; + expect(() => validateIncidentProjection(identity, context)).toThrow("must match incident.version"); + }); + + test("rejects lifecycle shapes the Todos create path cannot emit", () => { + const timestampDrift = fixture(); + timestampDrift.incident.created_at = "2026-07-18T20:00:00Z"; + expect(() => validateIncidentProjection(timestampDrift, context)).toThrow("version 1 requires"); + + const terminal = fixture(); + terminal.incident.status = "resolved"; + terminal.incident.blocked_scopes = []; + terminal.incident.next_action = null; + terminal.incident.closure_evidence = ["closed"]; + terminal.incident.resolved_at = terminal.incident.updated_at; + expect(() => validateIncidentProjection(terminal, context)).toThrow("version 1 must contain an active"); + }); + + test("detects reserved projection metadata in object and serialized forms", () => { + expect(metadataSpoofsIncidentProjection({ event_id: "iev_fake" })).toBe(true); + expect(metadataSpoofsIncidentProjection('{"canonical_incident_projection":{"event_id":"iev_fake"}}')).toBe(true); + expect(metadataSpoofsIncidentProjection({ display: { severity: "high" } })).toBe(false); + expect(metadataSpoofsIncidentProjection("plain text")).toBe(false); + }); +}); + +export { context as incidentProjectionTestContext, fixture as incidentProjectionFixture }; diff --git a/src/lib/incident-projection-contract.ts b/src/lib/incident-projection-contract.ts new file mode 100644 index 0000000..64d32c5 --- /dev/null +++ b/src/lib/incident-projection-contract.ts @@ -0,0 +1,410 @@ +import { createHash } from "crypto"; +import { normalizeChannelName } from "./channel-names.js"; +import type { + IncidentProjectionDisplay, + IncidentProjectionRequestV1, + IncidentProjectorContext, + IncidentSeverity, + IncidentSnapshotV1, + IncidentStatus, + Priority, +} from "../types.js"; + +export const INCIDENT_SCHEMA_VERSION = 1 as const; +export const INCIDENT_SOURCE = "todos" as const; +export const INCIDENT_STATUSES = ["open", "investigating", "contained", "monitoring", "resolved", "superseded"] as const; +export const ACTIVE_INCIDENT_STATUSES = ["open", "investigating", "contained", "monitoring"] as const; +export const INCIDENT_SEVERITIES = ["info", "low", "medium", "high", "critical"] as const; + +const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const AUTHORITY_ID = /^[A-Za-z0-9._:-]{1,128}$/; + +/** Frozen Todos v1 blocked-scope grammar. Keep byte-for-byte aligned with the producer. */ +export const INCIDENT_BLOCKED_SCOPE_PATTERNS = { + agent: /^agent:([A-Za-z0-9][A-Za-z0-9._@/-]{0,127})$/, + channel: /^channel:([a-z0-9]+(?:-[a-z0-9]+)*)$/, + project: /^project:([A-Za-z0-9][A-Za-z0-9_-]{0,119})$/, +} as const; + +export type IncidentBlockedScope = + | { kind: "agent"; value: string } + | { kind: "channel"; value: string } + | { kind: "project"; value: string }; + +const REQUEST_KEYS = [ + "schema_version", "source", "authority_id", "incident_id", "transition_id", "incident_version", + "occurred_at", "event_id", "projection_key", "incident", +] as const; +const INCIDENT_KEYS = [ + "id", "title", "severity", "status", "owner", "affected_scopes", "blocked_scopes", + "containment", "next_action", "deadline", "closure_evidence", "supersedes_id", + "superseded_by_id", "resolved_at", "version", "created_at", "updated_at", +] as const; + +export const RESERVED_INCIDENT_METADATA_KEYS = new Set([ + "canonical_incident_projection", + "incident_projection", + "incident_id", + "incident_version", + "transition_id", + "event_id", + "projection_key", + "authority_id", + "tenant_id", +]); + +export class IncidentProjectionValidationError extends Error { + readonly code = "INVALID_INCIDENT_PROJECTION"; + constructor(message: string) { + super(message); + this.name = "IncidentProjectionValidationError"; + } +} + +export class IncidentProjectionConflictError extends Error { + readonly code = "INCIDENT_PROJECTION_CONFLICT"; + constructor(message: string) { + super(message); + this.name = "IncidentProjectionConflictError"; + } +} + +export class IncidentProjectorConfigurationError extends Error { + readonly code = "INCIDENT_PROJECTOR_CONFIGURATION_ERROR"; + constructor(message: string) { + super(message); + this.name = "IncidentProjectorConfigurationError"; + } +} + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +function fail(message: string): never { + throw new IncidentProjectionValidationError(message); +} + +function object(value: unknown, path: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${path} must be an object`); + return value as Record; +} + +function exactKeys(value: Record, allowed: readonly string[], path: string): void { + const allowedSet = new Set(allowed); + const unknown = Object.keys(value).filter((key) => !allowedSet.has(key)); + if (unknown.length) fail(`${path} contains unsupported field(s): ${unknown.sort().join(", ")}`); + const missing = allowed.filter((key) => !(key in value)); + if (missing.length) fail(`${path} is missing field(s): ${missing.join(", ")}`); +} + +function boundedString(value: unknown, path: string, max: number): string { + if (typeof value !== "string" || !value.trim()) fail(`${path} must be a non-empty string`); + const normalized = value.trim(); + if (normalized.length > max) fail(`${path} must be at most ${max} characters`); + return normalized; +} + +function nonempty(value: unknown, path: string): string { + return boundedString(value, path, 4_000); +} + +function nullableString(value: unknown, path: string, max = 4_000): string | null { + if (value === null) return null; + if (typeof value !== "string") fail(`${path} must be a string or null`); + return value.trim() ? boundedString(value, path, max) : null; +} + +function uuid(value: unknown, path: string): string { + const id = nonempty(value, path).toLowerCase(); + if (!UUID.test(id)) fail(`${path} must be a UUID`); + return id; +} + +export function validateIncidentAuthorityId(value: unknown, path: string): string { + const id = boundedString(value, path, 128); + if (!AUTHORITY_ID.test(id)) { + fail(`${path} must contain only letters, digits, dot, underscore, colon, or hyphen`); + } + return id; +} + +export function validateIncidentProjectorBinding( + tenantId: unknown, + authority: unknown, +): { tenant_id: string; authority_id: string } { + try { + return { + tenant_id: boundedString(tenantId, "context.tenant_id", 256), + authority_id: validateIncidentAuthorityId(authority, "context.authority_id"), + }; + } catch (error) { + throw new IncidentProjectorConfigurationError((error as Error).message); + } +} + +export function parseIncidentBlockedScope(value: string, path = "incident.blocked_scopes"): IncidentBlockedScope { + if (value.length > 128) fail(`${path} must be at most 128 characters`); + const agent = INCIDENT_BLOCKED_SCOPE_PATTERNS.agent.exec(value); + if (agent) return { kind: "agent", value: agent[1] }; + const channel = INCIDENT_BLOCKED_SCOPE_PATTERNS.channel.exec(value); + if (channel) return { kind: "channel", value: channel[1] }; + const project = INCIDENT_BLOCKED_SCOPE_PATTERNS.project.exec(value); + if (project) return { kind: "project", value: project[1] }; + fail( + `${path} must use the frozen recipient grammar: agent:, ` + + "channel:, or project:", + ); +} + +function nullableUuid(value: unknown, path: string): string | null { + return value === null ? null : uuid(value, path); +} + +function positiveInteger(value: unknown, path: string): number { + if (!Number.isSafeInteger(value) || Number(value) <= 0) fail(`${path} must be a positive integer`); + return Number(value); +} + +function timestamp(value: unknown, path: string): string { + const raw = nonempty(value, path); + if (!RFC3339.test(raw) || !Number.isFinite(Date.parse(raw))) fail(`${path} must be an RFC3339 timestamp`); + return new Date(raw).toISOString(); +} + +function nullableTimestamp(value: unknown, path: string): string | null { + return value === null ? null : timestamp(value, path); +} + +function stringSet(value: unknown, path: string, nonemptyRequired = false): string[] { + if (!Array.isArray(value)) fail(`${path} must be an array of strings`); + if (value.length > 64) fail(`${path} must contain at most 64 items`); + const normalized: string[] = []; + const seen = new Set(); + value.forEach((item, index) => { + const text = boundedString(item, `${path}[${index}]`, 256); + if (!seen.has(text)) { + seen.add(text); + normalized.push(text); + } + }); + if (nonemptyRequired && normalized.length === 0) fail(`${path} must contain at least one scope`); + return normalized; +} + +function oneOf(value: unknown, allowed: readonly T[], path: string): T { + if (typeof value !== "string" || !allowed.includes(value as T)) { + fail(`${path} must be one of: ${allowed.join(", ")}`); + } + return value as T; +} + +export function canonicalJson(value: JsonValue): string { + const canonicalize = (entry: JsonValue): JsonValue => { + if (Array.isArray(entry)) return entry.map(canonicalize); + if (entry && typeof entry === "object") { + return Object.fromEntries( + Object.keys(entry).sort().map((key) => [key, canonicalize(entry[key])]), + ) as { [key: string]: JsonValue }; + } + return entry; + }; + return JSON.stringify(canonicalize(value)); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function computeIncidentProjectionIds( + authorityId: string, + incidentId: string, + incidentVersion: number, +): { event_id: string; transition_id: string; projection_key: string } { + const authority = validateIncidentAuthorityId(authorityId, "context.authority_id"); + const id = uuid(incidentId, "incident_id"); + const version = positiveInteger(incidentVersion, "incident_version"); + const digest = sha256(canonicalJson([authority, id, version])); + return { + event_id: `iev_${digest.slice(0, 32)}`, + transition_id: `itr_${digest.slice(0, 32)}`, + projection_key: `todos:incident:${authority}:${id}:v${version}`, + }; +} + +function validateSnapshot(raw: unknown): IncidentSnapshotV1 { + const value = object(raw, "incident"); + exactKeys(value, INCIDENT_KEYS, "incident"); + const status = oneOf(value.status, INCIDENT_STATUSES, "incident.status"); + const severity = oneOf(value.severity, INCIDENT_SEVERITIES, "incident.severity"); + const id = uuid(value.id, "incident.id"); + const affectedScopes = stringSet(value.affected_scopes, "incident.affected_scopes", true); + const blockedScopes = stringSet(value.blocked_scopes, "incident.blocked_scopes"); + blockedScopes.forEach((scope, index) => parseIncidentBlockedScope(scope, `incident.blocked_scopes[${index}]`)); + const supersedesId = nullableUuid(value.supersedes_id, "incident.supersedes_id"); + const supersededById = nullableUuid(value.superseded_by_id, "incident.superseded_by_id"); + const resolvedAt = nullableTimestamp(value.resolved_at, "incident.resolved_at"); + const createdAt = timestamp(value.created_at, "incident.created_at"); + const updatedAt = timestamp(value.updated_at, "incident.updated_at"); + + if (Date.parse(updatedAt) < Date.parse(createdAt)) fail("incident.updated_at cannot precede incident.created_at"); + if (supersedesId === id || supersededById === id) fail("an incident cannot supersede itself"); + if ((status === "resolved" || status === "superseded") && !resolvedAt) { + fail(`${status} incidents require incident.resolved_at`); + } + if (ACTIVE_INCIDENT_STATUSES.includes(status as (typeof ACTIVE_INCIDENT_STATUSES)[number]) && resolvedAt) { + fail("active incidents cannot set incident.resolved_at"); + } + if (status === "superseded" && !supersededById) fail("superseded incidents require incident.superseded_by_id"); + if (status !== "superseded" && supersededById) fail("only superseded incidents may set incident.superseded_by_id"); + const containment = nullableString(value.containment, "incident.containment"); + const nextAction = nullableString(value.next_action, "incident.next_action"); + const closureEvidence = stringSet(value.closure_evidence, "incident.closure_evidence"); + if ((status === "contained" || status === "monitoring") && !containment) { + fail(`${status} incidents require incident.containment`); + } + if (ACTIVE_INCIDENT_STATUSES.includes(status as (typeof ACTIVE_INCIDENT_STATUSES)[number]) && !nextAction) { + fail("active incidents require incident.next_action"); + } + if (status === "resolved") { + if (blockedScopes.length) fail("resolved incidents cannot retain incident.blocked_scopes"); + if (!closureEvidence.length) fail("resolved incidents require incident.closure_evidence"); + if (nextAction) fail("resolved incidents require incident.next_action to be null"); + } + if (status === "superseded" && nextAction) { + fail("superseded incidents require incident.next_action to be null"); + } + + return { + id, + title: boundedString(value.title, "incident.title", 200), + severity, + status, + owner: boundedString(value.owner, "incident.owner", 128), + affected_scopes: affectedScopes, + blocked_scopes: blockedScopes, + containment, + next_action: nextAction, + deadline: nullableTimestamp(value.deadline, "incident.deadline"), + closure_evidence: closureEvidence, + supersedes_id: supersedesId, + superseded_by_id: supersededById, + resolved_at: resolvedAt, + version: positiveInteger(value.version, "incident.version"), + created_at: createdAt, + updated_at: updatedAt, + }; +} + +export function buildIncidentProjectionDisplay( + request: IncidentProjectionRequestV1, + context: IncidentProjectorContext, +): IncidentProjectionDisplay { + const routing = context.routing ?? {}; + const channel = normalizeChannelName(routing.channel?.trim() || "incidents"); + const from = routing.from?.trim() || "todos-projector"; + const to = routing.to?.trim() || channel; + const incident = request.incident; + const blockedLine = incident.status === "superseded" + ? `Blocked scopes pending transfer: ${incident.blocked_scopes.length}` + : `Blocked scopes: ${ACTIVE_INCIDENT_STATUSES.includes(incident.status as (typeof ACTIVE_INCIDENT_STATUSES)[number]) + ? incident.blocked_scopes.length + : 0}`; + const content = [ + `[INCIDENT ${incident.severity.toUpperCase()} ${incident.status.toUpperCase()}] ${incident.title}`, + `Incident: ${incident.id} v${incident.version}`, + `Owner: ${incident.owner}`, + blockedLine, + incident.status === "superseded" ? `Replacement incident: ${incident.superseded_by_id}` : null, + incident.containment ? `Containment: ${incident.containment}` : null, + incident.next_action ? `Next action: ${incident.next_action}` : null, + ].filter(Boolean).join("\n"); + const priority: Priority = incident.severity === "critical" ? "urgent" : incident.severity === "high" ? "high" : "normal"; + return { + from, + to, + content, + channel, + ...(routing.project_id?.trim() ? { project_id: routing.project_id.trim() } : {}), + ...(routing.session_id?.trim() ? { session_id: routing.session_id.trim() } : {}), + priority, + }; +} + +export interface ValidatedIncidentProjection { + context: IncidentProjectorContext; + request: IncidentProjectionRequestV1; + canonical_payload: string; + payload_hash: string; + blocking: boolean; + supersedes_transition_id: string | null; +} + +export function validateIncidentProjection( + raw: unknown, + rawContext: IncidentProjectorContext, +): ValidatedIncidentProjection { + const context = { + tenant_id: nonempty(rawContext?.tenant_id, "context.tenant_id"), + authority_id: validateIncidentAuthorityId(rawContext?.authority_id, "context.authority_id"), + routing: rawContext?.routing, + }; + const value = object(raw, "projection"); + exactKeys(value, REQUEST_KEYS, "projection"); + if (value.schema_version !== INCIDENT_SCHEMA_VERSION) fail("projection.schema_version must be 1"); + if (value.source !== INCIDENT_SOURCE) fail("projection.source must be todos"); + const authorityId = validateIncidentAuthorityId(value.authority_id, "projection.authority_id"); + if (authorityId !== context.authority_id) fail("projection.authority_id does not match the selected Conversations authority"); + const incident = validateSnapshot(value.incident); + const incidentId = uuid(value.incident_id, "projection.incident_id"); + const incidentVersion = positiveInteger(value.incident_version, "projection.incident_version"); + if (incident.id !== incidentId) fail("projection.incident_id must match incident.id"); + if (incident.version !== incidentVersion) fail("projection.incident_version must match incident.version"); + const ids = computeIncidentProjectionIds(context.authority_id, incidentId, incidentVersion); + for (const key of ["event_id", "transition_id", "projection_key"] as const) { + if (value[key] !== ids[key]) fail(`projection.${key} does not match the deterministic Todos v1 value`); + } + const occurredAt = timestamp(value.occurred_at, "projection.occurred_at"); + if (occurredAt !== incident.updated_at) { + fail("projection.occurred_at must match incident.updated_at"); + } + if (incidentVersion === 1) { + if (!ACTIVE_INCIDENT_STATUSES.includes(incident.status as (typeof ACTIVE_INCIDENT_STATUSES)[number])) { + fail("projection version 1 must contain an active incident state"); + } + if (incident.created_at !== occurredAt) { + fail("projection version 1 requires incident.created_at, incident.updated_at, and projection.occurred_at to match"); + } + } + const request: IncidentProjectionRequestV1 = { + schema_version: INCIDENT_SCHEMA_VERSION, + source: INCIDENT_SOURCE, + authority_id: authorityId, + incident_id: incidentId, + transition_id: ids.transition_id, + incident_version: incidentVersion, + occurred_at: occurredAt, + event_id: ids.event_id, + projection_key: ids.projection_key, + incident, + }; + const canonicalPayload = canonicalJson(request as unknown as JsonValue); + return { + context, + request, + canonical_payload: canonicalPayload, + payload_hash: sha256(canonicalPayload), + blocking: ACTIVE_INCIDENT_STATUSES.includes(incident.status as (typeof ACTIVE_INCIDENT_STATUSES)[number]) && incident.blocked_scopes.length > 0, + supersedes_transition_id: incidentVersion > 1 + ? computeIncidentProjectionIds(context.authority_id, incidentId, incidentVersion - 1).transition_id + : null, + }; +} + +export function metadataSpoofsIncidentProjection(raw: unknown): boolean { + let value = raw; + if (typeof value === "string") { + try { value = JSON.parse(value); } catch { return false; } + } + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return Object.keys(value as Record).some((key) => RESERVED_INCIDENT_METADATA_KEYS.has(key)); +} diff --git a/src/lib/incident-projections.test.ts b/src/lib/incident-projections.test.ts new file mode 100644 index 0000000..337bbb4 --- /dev/null +++ b/src/lib/incident-projections.test.ts @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { unlinkSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { closeDb, getDb } from "./db"; +import { deleteMessage, editMessage, getMessageById, getUnreadBlockers, markRead, recordReadReceipt, sendMessage } from "./messages"; +import { appendIncidentProjection } from "./incident-projections"; +import { createChannel, renameChannel } from "./channels"; +import { computeIncidentProjectionIds } from "./incident-projection-contract"; +import type { IncidentProjectionRequestV1, IncidentProjectorContext, IncidentStatus } from "../types"; + +const savedDbPath = process.env.CONVERSATIONS_DB_PATH; +const savedHasnaDbPath = process.env.HASNA_CONVERSATIONS_DB_PATH; +const savedTenantId = process.env.HASNA_CONVERSATIONS_TENANT_ID; +const savedAuthorityId = process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID; +let dbPath = ""; +const context: IncidentProjectorContext = { tenant_id: "tenant-a", authority_id: "todos.hasna.xyz:v1" }; +const incidentId = "11111111-1111-4111-8111-111111111111"; + +function fixture( + version = 1, + options: { id?: string; status?: IncidentStatus; blocked?: string[]; supersedes?: string | null; supersededBy?: string | null } = {}, +): IncidentProjectionRequestV1 { + const id = options.id ?? incidentId; + const ids = computeIncidentProjectionIds(context.authority_id, id, version); + const status = options.status ?? "investigating"; + return { + schema_version: 1, + source: "todos", + authority_id: context.authority_id, + incident_id: id, + transition_id: ids.transition_id, + incident_version: version, + occurred_at: `2026-07-18T20:${String(version).padStart(2, "0")}:00Z`, + event_id: ids.event_id, + projection_key: ids.projection_key, + incident: { + id, + title: "Projection incident", + severity: "high", + status, + owner: "Friday", + affected_scopes: ["service:conversations"], + blocked_scopes: options.blocked ?? (status === "resolved" || status === "superseded" ? [] : ["agent:friday"]), + containment: null, + next_action: status === "resolved" || status === "superseded" ? null : "Repair projection", + deadline: null, + closure_evidence: status === "resolved" ? ["regression green"] : [], + supersedes_id: options.supersedes ?? null, + superseded_by_id: options.supersededBy ?? null, + resolved_at: status === "resolved" || status === "superseded" ? `2026-07-18T20:${String(version).padStart(2, "0")}:00Z` : null, + version, + created_at: "2026-07-18T20:01:00Z", + updated_at: `2026-07-18T20:${String(version).padStart(2, "0")}:00Z`, + }, + }; +} + +beforeEach(() => { + dbPath = join(tmpdir(), `conversations-incident-projection-${Date.now()}-${Math.random()}.db`); + delete process.env.HASNA_CONVERSATIONS_DB_PATH; + process.env.CONVERSATIONS_DB_PATH = dbPath; + process.env.HASNA_CONVERSATIONS_TENANT_ID = context.tenant_id; + process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID = context.authority_id; + closeDb(); +}); + +afterEach(() => { + closeDb(); + if (savedDbPath === undefined) delete process.env.CONVERSATIONS_DB_PATH; + else process.env.CONVERSATIONS_DB_PATH = savedDbPath; + if (savedHasnaDbPath === undefined) delete process.env.HASNA_CONVERSATIONS_DB_PATH; + else process.env.HASNA_CONVERSATIONS_DB_PATH = savedHasnaDbPath; + if (savedTenantId === undefined) delete process.env.HASNA_CONVERSATIONS_TENANT_ID; + else process.env.HASNA_CONVERSATIONS_TENANT_ID = savedTenantId; + if (savedAuthorityId === undefined) delete process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID; + else process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID = savedAuthorityId; + for (const suffix of ["", "-wal", "-shm"]) { + try { unlinkSync(dbPath + suffix); } catch {} + } +}); + +describe("append-only incident projections", () => { + test("identical replay returns the exact existing projection and message", () => { + const first = appendIncidentProjection(fixture(), context); + const replay = appendIncidentProjection(fixture(), context); + expect(first.replayed).toBe(false); + expect(replay.replayed).toBe(true); + expect(replay.id).toBe(first.id); + expect(replay.message.id).toBe(first.message.id); + expect(getDb().prepare("SELECT COUNT(*) AS n FROM messages").get()).toEqual({ n: 1 }); + }); + + test("same event with a different payload conflicts without an orphan message", () => { + appendIncidentProjection(fixture(), context); + const changed = fixture(); + changed.incident.title = "tampered source snapshot"; + expect(() => appendIncidentProjection(changed, context)).toThrow("different canonical payload"); + expect(getDb().prepare("SELECT COUNT(*) AS n FROM messages").get()).toEqual({ n: 1 }); + }); + + test("requires N-1 and keeps every transition attached to the immutable root", () => { + expect(() => appendIncidentProjection(fixture(2), context)).toThrow("predecessor version 1"); + const root = appendIncidentProjection(fixture(1), context); + const second = appendIncidentProjection(fixture(2), context); + const third = appendIncidentProjection(fixture(3), context); + expect(root.message.reply_to).toBeNull(); + expect(second.message.reply_to).toBe(root.message.id); + expect(third.message.reply_to).toBe(root.message.id); + expect(second.supersedes_transition_id).toBe(root.transition_id); + }); + + test("later versions inherit the immutable v1 thread when routing config changes", () => { + const originalContext: IncidentProjectorContext = { + ...context, + routing: { channel: "incidents", project_id: "11111111-1111-4111-8111-111111111111" }, + }; + const changedContext: IncidentProjectorContext = { + ...context, + routing: { channel: "incident-archive", project_id: "22222222-2222-4222-8222-222222222222" }, + }; + const root = appendIncidentProjection(fixture(1), originalContext); + const update = appendIncidentProjection(fixture(2), changedContext); + expect(update.message.reply_to).toBe(root.message.id); + expect(update.message.channel).toBe("incidents"); + expect(update.message.project_id).toBe("11111111-1111-4111-8111-111111111111"); + expect(update.message.session_id).toBe(root.message.session_id); + }); + + test("expands every frozen recipient scope and lets offline recipients become visible later", () => { + const db = getDb(); + const invalid = fixture(1, { id: "22222222-2222-4222-8222-222222222222", blocked: ["agent-coordination"] }); + expect(() => appendIncidentProjection(invalid, context)).toThrow("frozen recipient grammar"); + + const emptyChannel = fixture(1, { id: "33333333-3333-4333-8333-333333333333", blocked: ["channel:incidents"] }); + const byChannel = appendIncidentProjection(emptyChannel, context); + expect(getUnreadBlockers("channel-agent")).toEqual([]); + + db.prepare("INSERT INTO channels (name, created_by) VALUES (?, ?)").run("incidents", "test"); + db.prepare("INSERT INTO channel_members (channel, agent) VALUES (?, ?)").run("incidents", "channel-agent"); + expect(getUnreadBlockers("channel-agent").map((message) => message.id)).toEqual([byChannel.message.id]); + + const projectId = "wks_ZXg7liK4CFJ1KZjC_Fg_b"; + const legacyProjectId = "platform-hirefast"; + const emptyProject = fixture(1, { + id: "55555555-5555-4555-8555-555555555555", + blocked: [`project:${projectId}`, `project:${legacyProjectId}`], + }); + const byProject = appendIncidentProjection(emptyProject, context); + expect(getUnreadBlockers("project-agent")).toEqual([]); + db.prepare( + "INSERT INTO agent_presence (id, agent, project_id, status) VALUES (?, ?, ?, ?)", + ).run("presence-1", "project-agent", projectId, "online"); + expect(getUnreadBlockers("project-agent").map((message) => message.id)).toEqual([byProject.message.id]); + db.prepare( + "INSERT INTO agent_presence (id, agent, project_id, status) VALUES (?, ?, ?, ?)", + ).run("presence-2", "legacy-project-agent", legacyProjectId, "online"); + expect(getUnreadBlockers("legacy-project-agent").map((message) => message.id)).toEqual([byProject.message.id]); + db.prepare( + "INSERT INTO agent_presence (id, agent, project_id, status) VALUES (?, ?, ?, ?)", + ).run("presence-3", "wrong-case-agent", projectId.toLowerCase(), "online"); + expect(getUnreadBlockers("wrong-case-agent")).toEqual([]); + + const direct = appendIncidentProjection(fixture(1, { + id: "66666666-6666-4666-8666-666666666666", + blocked: ["agent:direct-agent"], + }), context); + expect(getUnreadBlockers("direct-agent").map((message) => message.id)).toEqual([direct.message.id]); + expect(db.prepare("SELECT COUNT(*) AS n FROM agent_presence WHERE agent = ?").get("direct-agent")).toEqual({ n: 0 }); + }); + + test("missing reciprocal supersession source rolls back message, ledger, and scopes together", () => { + const replacementId = "22222222-2222-4222-8222-222222222222"; + const missingId = "33333333-3333-4333-8333-333333333333"; + expect(() => appendIncidentProjection(fixture(1, { id: replacementId, supersedes: missingId }), context)).toThrow( + "outside this tenant/authority", + ); + expect(getDb().prepare("SELECT COUNT(*) AS n FROM messages").get()).toEqual({ n: 0 }); + expect(getDb().prepare("SELECT COUNT(*) AS n FROM incident_projections").get()).toEqual({ n: 0 }); + expect(getDb().prepare("SELECT COUNT(*) AS n FROM incident_projection_scopes").get()).toEqual({ n: 0 }); + }); + + test("accepts old-first forward supersession and requires reciprocal replacement", () => { + const oldId = "22222222-2222-4222-8222-222222222222"; + const replacementId = "33333333-3333-4333-8333-333333333333"; + appendIncidentProjection(fixture(1, { id: oldId, blocked: ["agent:friday"] }), context); + const old = appendIncidentProjection(fixture(2, { + id: oldId, + status: "superseded", + blocked: ["agent:friday"], + supersededBy: replacementId, + }), context); + expect(old.superseded_by_incident_id).toBe(replacementId); + expect(old.blocking).toBe(false); + expect(old.message.content).toContain("Blocked scopes pending transfer: 1"); + expect(old.message.content).toContain(`Replacement incident: ${replacementId}`); + expect(getUnreadBlockers("friday").map((message) => message.id)).toEqual([old.message.id]); + recordReadReceipt(old.message.id, "friday"); + expect(getUnreadBlockers("friday").map((message) => message.id)).toEqual([old.message.id]); + + const mismatchId = "44444444-4444-4444-8444-444444444444"; + expect(() => appendIncidentProjection(fixture(1, { id: mismatchId, supersedes: oldId }), context)).toThrow( + "must reciprocate", + ); + + const replacement = appendIncidentProjection(fixture(1, { id: replacementId, supersedes: oldId }), context); + expect(replacement.supersedes_incident_id).toBe(oldId); + expect(getUnreadBlockers("friday").map((message) => message.id)).toEqual([replacement.message.id]); + expect(appendIncidentProjection(fixture(1, { id: replacementId, supersedes: oldId }), context).replayed).toBe(true); + }); + + test("isolates tenants and validates the server-bound authority", () => { + const first = appendIncidentProjection(fixture(), context); + const second = appendIncidentProjection(fixture(), { ...context, tenant_id: "tenant-b" }); + expect(second.id).not.toBe(first.id); + expect(second.message.id).not.toBe(first.message.id); + expect(() => appendIncidentProjection(fixture(), { ...context, authority_id: "attacker:v1" })).toThrow( + "selected Conversations authority", + ); + }); + + test("projected display and scope history reject mutation while receipts remain separate", () => { + createChannel("incidents", "test"); + const projection = appendIncidentProjection(fixture(), context); + expect(() => editMessage(projection.message.id, "todos-projector", "rewrite history")).toThrow("append-only"); + expect(() => deleteMessage(projection.message.id, "todos-projector")).toThrow("append-only"); + const db = getDb(); + expect(() => db.prepare("UPDATE messages SET channel = 'other' WHERE id = ?").run(projection.message.id)).toThrow("append-only"); + renameChannel("incidents", "incident-log"); + const renamed = getMessageById(projection.message.id)!; + expect(renamed.channel).toBe("incident-log"); + expect(renamed.session_id).toBe("channel:incident-log"); + expect(renamed.to_agent).toBe("incident-log"); + expect(renamed.content).toBe(projection.message.content); + expect(renamed.project_id).toBe(projection.message.project_id); + expect(renamed.reply_to).toBe(projection.message.reply_to); + expect(() => db.prepare("UPDATE messages SET project_id = 'other' WHERE id = ?").run(projection.message.id)).toThrow("append-only"); + expect(() => db.prepare("UPDATE incident_projection_scopes SET scope = 'agent:other'").run()).toThrow("append-only"); + expect(() => db.prepare("DELETE FROM incident_projection_scopes").run()).toThrow("append-only"); + db.prepare("INSERT INTO message_read_receipts (message_id, agent) VALUES (?, ?)").run(projection.message.id, "friday"); + expect(db.prepare("SELECT agent FROM message_read_receipts WHERE message_id = ?").get(projection.message.id)).toEqual({ agent: "friday" }); + }); + + test("selects latest incident state before filtering blockers and keeps receipts per agent", () => { + const active = fixture(1); + active.incident.blocked_scopes = ["agent:friday", "agent:saturday"]; + const first = appendIncidentProjection(active, context); + expect(getUnreadBlockers("friday").map((message) => message.id)).toEqual([first.message.id]); + expect(getUnreadBlockers("saturday").map((message) => message.id)).toEqual([first.message.id]); + + recordReadReceipt(first.message.id, "friday"); + expect(getUnreadBlockers("friday")).toEqual([]); + expect(getUnreadBlockers("saturday").map((message) => message.id)).toEqual([first.message.id]); + + const resolved = appendIncidentProjection(fixture(2, { status: "resolved", blocked: [] }), context); + expect(resolved.message.content).toContain("Blocked scopes: 0"); + expect(getUnreadBlockers("friday")).toEqual([]); + expect(getUnreadBlockers("saturday")).toEqual([]); + }); + + test("CLI mark-read semantics acknowledge a projection per agent without global mutation", () => { + const event = fixture(1); + event.incident.blocked_scopes = ["agent:friday", "agent:saturday"]; + const projection = appendIncidentProjection(event, context); + expect(markRead([projection.message.id], "friday")).toBe(1); + expect(markRead([projection.message.id], "friday")).toBe(0); + expect(markRead([projection.message.id], "outsider")).toBe(0); + expect(getUnreadBlockers("friday")).toEqual([]); + expect(getUnreadBlockers("saturday").map((message) => message.id)).toEqual([projection.message.id]); + expect(getDb().prepare("SELECT read_at FROM messages WHERE id = ?").get(projection.message.id)).toEqual({ read_at: null }); + expect(getDb().prepare( + "SELECT agent FROM message_read_receipts WHERE message_id = ? AND agent = ?", + ).get(projection.message.id, "friday")).toEqual({ agent: "friday" }); + }); + + test("local blocker reads bind tenant and authority from the selected deployment", () => { + const projection = appendIncidentProjection(fixture(), context); + expect(getUnreadBlockers("friday").map((message) => message.id)).toEqual([projection.message.id]); + delete process.env.HASNA_CONVERSATIONS_TENANT_ID; + delete process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID; + expect(() => getUnreadBlockers("friday")).toThrow("Canonical blocker reads require"); + process.env.HASNA_CONVERSATIONS_TENANT_ID = "tenant-b"; + process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID = context.authority_id; + expect(() => getUnreadBlockers("friday")).toThrow("does not match stored canonical projections"); + process.env.HASNA_CONVERSATIONS_TENANT_ID = context.tenant_id; + process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID = "engineering"; + expect(() => getUnreadBlockers("friday")).toThrow("does not match stored canonical projections"); + }); + + test("keeps legacy blocker reads working without projector config when no canonical rows exist", () => { + delete process.env.HASNA_CONVERSATIONS_TENANT_ID; + delete process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID; + const legacy = sendMessage({ from: "alice", to: "bob", content: "legacy blocker", blocking: true }); + expect(getUnreadBlockers("bob").map((message) => message.id)).toEqual([legacy.id]); + }); + + test("generic ingress cannot spoof projection metadata and replies remain durably scoped", () => { + expect(() => sendMessage({ + from: "attacker", + to: "incidents", + content: "spoof", + metadata: { canonical_incident_projection: { event_id: "iev_fake" } }, + })).toThrow("reserved for the dedicated projector"); + + const root = sendMessage({ + from: "alice", + to: "incidents", + channel: "incidents", + project_id: "engineering", + content: "root", + }); + const reply = sendMessage({ + from: "bob", + to: "incidents", + channel: "incidents", + project_id: "engineering", + content: "reply", + reply_to: root.id, + }); + expect(reply.reply_to).toBe(root.id); + expect(() => sendMessage({ + from: "bob", + to: "other", + channel: "other", + project_id: "engineering", + content: "cross-scope", + reply_to: root.id, + })).toThrow("outside the message scope"); + expect(() => getDb().prepare("UPDATE messages SET project_id = 'other' WHERE id = ?").run(root.id)).toThrow( + "reply parent scope is immutable", + ); + expect(() => getDb().prepare("DELETE FROM messages WHERE id = ?").run(root.id)).toThrow(); + }); +}); diff --git a/src/lib/incident-projections.ts b/src/lib/incident-projections.ts new file mode 100644 index 0000000..01b2506 --- /dev/null +++ b/src/lib/incident-projections.ts @@ -0,0 +1,262 @@ +import { randomUUID } from "crypto"; +import { getDb } from "./db.js"; +import { parseMessage } from "./messages.js"; +import { + buildIncidentProjectionDisplay, + IncidentProjectionConflictError, + IncidentProjectorConfigurationError, + validateIncidentProjectorBinding, + validateIncidentProjection, +} from "./incident-projection-contract.js"; +import type { + IncidentProjectionRecord, + IncidentProjectionRequestV1, + IncidentProjectorContext, + Message, +} from "../types.js"; + +type ProjectionRow = Record; + +export function resolveIncidentProjectorContext( + env: Record = process.env, +): IncidentProjectorContext { + const tenant_id = env.HASNA_CONVERSATIONS_TENANT_ID?.trim(); + const authority_id = env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID?.trim(); + if (!tenant_id || !authority_id) { + throw new IncidentProjectorConfigurationError( + "Incident projector is not configured. Set HASNA_CONVERSATIONS_TENANT_ID and " + + "HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID on the selected Conversations authority.", + ); + } + const binding = validateIncidentProjectorBinding(tenant_id, authority_id); + return { + ...binding, + routing: { + from: env.HASNA_CONVERSATIONS_INCIDENT_FROM, + to: env.HASNA_CONVERSATIONS_INCIDENT_TO, + channel: env.HASNA_CONVERSATIONS_INCIDENT_CHANNEL, + project_id: env.HASNA_CONVERSATIONS_INCIDENT_PROJECT_ID, + session_id: env.HASNA_CONVERSATIONS_INCIDENT_SESSION_ID, + }, + }; +} + +function projectionRecord(row: ProjectionRow, message: Message, replayed: boolean): IncidentProjectionRecord { + return { + id: Number(row.id), + event_id: String(row.event_id), + projection_key: String(row.projection_key), + message_id: Number(row.message_id), + schema_version: 1, + source: "todos", + tenant_id: String(row.tenant_id), + authority_id: String(row.authority_id), + incident_id: String(row.incident_id), + transition_id: String(row.transition_id), + incident_version: Number(row.incident_version), + occurred_at: String(row.occurred_at), + status: row.status as IncidentProjectionRecord["status"], + severity: row.severity as IncidentProjectionRecord["severity"], + blocking: Boolean(row.blocking), + supersedes_transition_id: row.supersedes_transition_id == null ? null : String(row.supersedes_transition_id), + supersedes_incident_id: row.supersedes_incident_id == null ? null : String(row.supersedes_incident_id), + superseded_by_incident_id: row.superseded_by_incident_id == null ? null : String(row.superseded_by_incident_id), + canonical_payload: String(row.canonical_payload), + payload_hash: String(row.payload_hash), + created_at: String(row.created_at), + message, + replayed, + }; +} + +function loadProjectionRecord(row: ProjectionRow, replayed: boolean): IncidentProjectionRecord { + const db = getDb(); + const messageRow = db.prepare("SELECT * FROM messages WHERE id = ?").get(Number(row.message_id)) as ProjectionRow | null; + if (!messageRow) throw new Error(`Incident projection ${String(row.event_id)} has no display message`); + return projectionRecord(row, parseMessage(messageRow), replayed); +} + +function findProjectionByEvent(tenantId: string, eventId: string): ProjectionRow | null { + return getDb().prepare( + "SELECT * FROM incident_projections WHERE tenant_id = ? AND event_id = ?", + ).get(tenantId, eventId) as ProjectionRow | null; +} + +/** + * Atomically append one canonical Todos incident projection and its immutable + * display message. This low-level local helper requires an explicit authority + * context; LocalStore binds that context from the selected deployment env. + */ +export function appendIncidentProjection( + raw: IncidentProjectionRequestV1, + context: IncidentProjectorContext, +): IncidentProjectionRecord { + const validated = validateIncidentProjection(raw, context); + const { request } = validated; + const display = buildIncidentProjectionDisplay(request, context); + const db = getDb(); + + const write = db.transaction((): IncidentProjectionRecord => { + const existing = findProjectionByEvent(context.tenant_id, request.event_id); + if (existing) { + if (String(existing.payload_hash) !== validated.payload_hash) { + throw new IncidentProjectionConflictError( + `Event ${request.event_id} already exists with a different canonical payload`, + ); + } + return loadProjectionRecord(existing, true); + } + + const latest = db.prepare( + `SELECT * FROM incident_projections + WHERE tenant_id = ? AND authority_id = ? AND incident_id = ? + ORDER BY incident_version DESC LIMIT 1`, + ).get(context.tenant_id, context.authority_id, request.incident_id) as ProjectionRow | null; + + if (request.incident_version === 1) { + if (latest) { + throw new IncidentProjectionConflictError( + `Incident ${request.incident_id} already has projection version ${String(latest.incident_version)}`, + ); + } + } else { + if (!latest || Number(latest.incident_version) !== request.incident_version - 1 || + String(latest.transition_id) !== validated.supersedes_transition_id) { + throw new IncidentProjectionConflictError( + `Incident ${request.incident_id} requires canonical predecessor version ${request.incident_version - 1}`, + ); + } + if (Date.parse(request.occurred_at) < Date.parse(String(latest.occurred_at))) { + throw new IncidentProjectionConflictError("Incident projection occurred_at cannot move backwards"); + } + } + + const assertSupersededSource = (id: string | null): void => { + if (!id) return; + const target = db.prepare( + `SELECT * FROM incident_projections + WHERE tenant_id = ? AND authority_id = ? AND incident_id = ? + ORDER BY incident_version DESC LIMIT 1`, + ).get(context.tenant_id, context.authority_id, id); + if (!target) { + throw new IncidentProjectionConflictError("incident.supersedes_id references an incident outside this tenant/authority or not yet projected"); + } + const row = target as ProjectionRow; + if (row.status !== "superseded" || row.superseded_by_incident_id !== request.incident_id) { + throw new IncidentProjectionConflictError( + "incident.supersedes_id must reciprocate a superseded incident whose superseded_by_id is this incident", + ); + } + }; + // superseded_by_id is a forward reference by design: Todos emits the old + // incident's terminal event before the replacement v1. The replacement then + // closes the relation by supplying reciprocal supersedes_id. + assertSupersededSource(request.incident.supersedes_id); + + let channel = display.channel ?? null; + let projectId = display.project_id ?? null; + let sessionId = channel + ? `channel:${channel}` + : display.session_id ?? `incident:${context.authority_id}:${request.incident_id}`; + let toAgent = channel ?? display.to; + let replyTo: number | null = null; + if (request.incident_version > 1) { + const root = db.prepare( + `SELECT m.id, m.session_id, m.channel, m.project_id, m.to_agent + FROM incident_projections p JOIN messages m ON m.id = p.message_id + WHERE p.tenant_id = ? AND p.authority_id = ? AND p.incident_id = ? AND p.incident_version = 1`, + ).get(context.tenant_id, context.authority_id, request.incident_id) as ProjectionRow | null; + if (!root) throw new IncidentProjectionConflictError("Incident projection root is missing"); + sessionId = String(root.session_id); + channel = root.channel == null ? null : String(root.channel); + projectId = root.project_id == null ? null : String(root.project_id); + toAgent = String(root.to_agent); + replyTo = Number(root.id); + } + + const pointer = JSON.stringify({ + canonical_incident_projection: { + schema_version: 1, + source: "todos", + authority_id: context.authority_id, + incident_id: request.incident_id, + incident_version: request.incident_version, + transition_id: request.transition_id, + event_id: request.event_id, + projection_key: request.projection_key, + }, + }); + const messageRow = db.prepare(` + INSERT INTO messages ( + uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, + working_dir, repository, branch, metadata, blocking, reply_to + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING * + `).get( + randomUUID().replace(/-/g, ""), + sessionId, + display.from, + toAgent, + channel, + projectId, + display.content, + display.priority ?? "normal", + display.working_dir ?? null, + display.repository ?? null, + display.branch ?? null, + pointer, + validated.blocking ? 1 : 0, + replyTo, + ) as ProjectionRow; + + const incident = request.incident; + const projectionRow = db.prepare(` + INSERT INTO incident_projections ( + event_id, projection_key, message_id, schema_version, source, tenant_id, authority_id, + incident_id, transition_id, incident_version, occurred_at, status, severity, blocking, + affected_scopes, blocked_scopes, supersedes_transition_id, supersedes_incident_id, + superseded_by_incident_id, canonical_payload, payload_hash + ) VALUES (?, ?, ?, 1, 'todos', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING * + `).get( + request.event_id, + request.projection_key, + Number(messageRow.id), + context.tenant_id, + context.authority_id, + request.incident_id, + request.transition_id, + request.incident_version, + request.occurred_at, + incident.status, + incident.severity, + validated.blocking ? 1 : 0, + JSON.stringify(incident.affected_scopes), + JSON.stringify(incident.blocked_scopes), + validated.supersedes_transition_id, + incident.supersedes_id, + incident.superseded_by_id, + validated.canonical_payload, + validated.payload_hash, + ) as ProjectionRow; + + const scopeInsert = db.prepare( + "INSERT INTO incident_projection_scopes (projection_id, scope_type, scope) VALUES (?, ?, ?)", + ); + for (const scope of incident.affected_scopes) scopeInsert.run(Number(projectionRow.id), "affected", scope); + for (const scope of incident.blocked_scopes) scopeInsert.run(Number(projectionRow.id), "blocked", scope); + + return projectionRecord(projectionRow, parseMessage(messageRow), false); + }); + + return write; +} + +export function getIncidentProjection( + eventId: string, + context: IncidentProjectorContext, +): IncidentProjectionRecord | null { + const row = findProjectionByEvent(context.tenant_id, eventId); + if (!row || String(row.authority_id) !== context.authority_id) return null; + return loadProjectionRecord(row, false); +} diff --git a/src/lib/messages.test.ts b/src/lib/messages.test.ts index 4d8c568..83aae1e 100644 --- a/src/lib/messages.test.ts +++ b/src/lib/messages.test.ts @@ -668,6 +668,7 @@ describe("threaded replies", () => { const parent = sendMessage({ from: "alice", to: "bob", content: "original" }); const reply = sendMessage({ from: "bob", to: "alice", content: "reply", reply_to: parent.id }); expect(reply.reply_to).toBe(parent.id); + expect(reply.session_id).toBe(parent.session_id); }); test("sendMessage without reply_to defaults to null", () => { diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 731022a..6a4f589 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -6,6 +6,11 @@ import { join, basename, resolve } from "path"; import { fireWebhooks } from "./webhooks.js"; import { normalizeChannelName } from "./channel-names.js"; import { markChannelNotificationsRead } from "./channel-notifications.js"; +import { + IncidentProjectorConfigurationError, + metadataSpoofsIncidentProjection, + validateIncidentProjectorBinding, +} from "./incident-projection-contract.js"; /** Strip null/undefined fields from a message for compact output. */ export function compactMessage(msg: Message): Partial { @@ -130,6 +135,10 @@ function checkRateLimit(agentId: string): void { export function sendMessage(opts: SendMessageOptions): Message { assertMessageSize(opts.content); + if (metadataSpoofsIncidentProjection(opts.metadata)) { + throw new Error("Canonical incident projection metadata is reserved for the dedicated projector"); + } + checkRateLimit(opts.from); const validatedAttachments = opts.attachments && opts.attachments.length > 0 @@ -137,12 +146,8 @@ export function sendMessage(opts: SendMessageOptions): Message { : []; const db = getDb(); - const channelName = opts.channel ? normalizeChannelName(opts.channel) : null; + const requestedChannel = opts.channel ? normalizeChannelName(opts.channel) : null; const explicitSession = opts.session_id && opts.session_id.trim().length > 0 ? opts.session_id : undefined; - const sessionId = channelName - ? `channel:${channelName}` - : explicitSession ?? `${[opts.from, opts.to].sort().join("-")}-${randomUUID().slice(0, 8)}`; - const toAgent = channelName ?? opts.to; const metadata = opts.metadata ? JSON.stringify(opts.metadata) : null; const normalizedPriority = (opts.priority === "low" || opts.priority === "normal" || opts.priority === "high" || opts.priority === "urgent") ? opts.priority @@ -150,34 +155,66 @@ export function sendMessage(opts: SendMessageOptions): Message { const blocking = opts.blocking ? 1 : 0; - const replyTo = opts.reply_to || null; + const replyTo = opts.reply_to ?? null; + if (replyTo != null && (!Number.isSafeInteger(replyTo) || replyTo <= 0)) { + throw new Error("reply_to must be a positive integer"); + } const msgUuid = randomUUID().replace(/-/g, ""); - const stmt = db.prepare(` - INSERT INTO messages (uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, working_dir, repository, branch, metadata, blocking, reply_to) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - RETURNING * - `); - - const row = stmt.get( - msgUuid, - sessionId, - opts.from, - toAgent, - channelName, - opts.project_id || null, - opts.content, - normalizedPriority, - opts.working_dir || null, - opts.repository || null, - opts.branch || null, - metadata, - blocking, - replyTo - ) as Record; + const inserted = db.transaction(() => { + let channelName = requestedChannel; + let projectId = opts.project_id ?? null; + let sessionId: string; + + if (replyTo != null) { + const parent = db.prepare( + "SELECT session_id, channel, project_id FROM messages WHERE id = ?", + ).get(replyTo) as { session_id: string; channel: string | null; project_id: string | null } | undefined; + if (!parent) throw new Error("reply parent not found"); + if (requestedChannel != null && requestedChannel !== parent.channel) { + throw new Error("reply parent is outside the message scope"); + } + if (opts.project_id != null && opts.project_id !== parent.project_id) { + throw new Error("reply parent is outside the message scope"); + } + if (explicitSession != null && explicitSession !== parent.session_id) { + throw new Error("reply parent is outside the message scope"); + } + channelName = parent.channel; + projectId = parent.project_id; + sessionId = parent.session_id; + } else { + sessionId = channelName + ? `channel:${channelName}` + : explicitSession ?? `${[opts.from, opts.to].sort().join("-")}-${randomUUID().slice(0, 8)}`; + } + + const toAgent = channelName ?? opts.to; + const row = db.prepare(` + INSERT INTO messages (uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, working_dir, repository, branch, metadata, blocking, reply_to) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING * + `).get( + msgUuid, + sessionId, + opts.from, + toAgent, + channelName, + projectId, + opts.content, + normalizedPriority, + opts.working_dir || null, + opts.repository || null, + opts.branch || null, + metadata, + blocking, + replyTo, + ) as Record; + return { message: parseMessage(row), channelName }; + }); - const message = parseMessage(row); + const { message, channelName } = inserted; // Handle file attachments if (validatedAttachments.length > 0) { @@ -368,35 +405,91 @@ export function countMessages(opts: CountMessagesOptions = {}): number { return row?.n ?? 0; } -export function markRead(ids: number[], reader: string): number { +function visibleProjectionMessageIds(agent: string): Message[] { const db = getDb(); - if (ids.length === 0) return 0; + const isProjection = db.prepare("SELECT 1 AS present FROM incident_projections WHERE message_id = ?"); + return getUnreadBlockers(agent).filter((message) => Boolean(isProjection.get(message.id))); +} - const placeholders = ids.map(() => "?").join(", "); - const stmt = db.prepare( - `UPDATE messages SET read_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE id IN (${placeholders}) AND to_agent = ? AND read_at IS NULL` +function insertProjectionReceipts(ids: number[], reader: string): number { + if (ids.length === 0) return 0; + const db = getDb(); + const insert = db.prepare( + `INSERT OR IGNORE INTO message_read_receipts (message_id, agent, read_at) + VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))`, ); - const result = stmt.run(...ids, reader); - return result.changes; + const normalized = reader.toLowerCase(); + let inserted = 0; + for (const id of new Set(ids)) inserted += insert.run(id, normalized).changes; + return inserted; +} + +function markExplicitRead(ids: number[], reader: string, requireRecipient: boolean): number { + const db = getDb(); + const uniqueIds = [...new Set(ids.filter((id) => Number.isSafeInteger(id) && id > 0))]; + if (uniqueIds.length === 0) return 0; + return db.transaction(() => { + const visibleProjected = new Set(visibleProjectionMessageIds(reader).map((message) => message.id)); + const messageLookup = db.prepare(` + SELECT m.to_agent, + EXISTS (SELECT 1 FROM incident_projections p WHERE p.message_id = m.id) AS projected + FROM messages m WHERE m.id = ? + `); + const receipt = db.prepare( + `INSERT OR IGNORE INTO message_read_receipts (message_id, agent, read_at) + VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))`, + ); + const markLegacy = db.prepare( + `UPDATE messages SET read_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') + WHERE id = ? AND read_at IS NULL`, + ); + const normalized = reader.toLowerCase(); + let marked = 0; + for (const id of uniqueIds) { + const row = messageLookup.get(id) as { to_agent: string; projected: number } | undefined; + if (!row) continue; + const projected = Boolean(row.projected); + if (projected && !visibleProjected.has(id)) continue; + if (!projected && requireRecipient && row.to_agent.toLowerCase() !== normalized) continue; + const receiptChanged = projected ? receipt.run(id, normalized).changes > 0 : false; + const globalChanged = projected ? false : markLegacy.run(id).changes > 0; + if (receiptChanged || globalChanged) marked += 1; + } + return marked; + }); +} + +export function markRead(ids: number[], reader: string): number { + return markExplicitRead(ids, reader, true); } export function markSessionRead(sessionId: string, reader: string): number { const db = getDb(); - const stmt = db.prepare( - `UPDATE messages SET read_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE session_id = ? AND to_agent = ? AND read_at IS NULL` - ); - const result = stmt.run(sessionId, reader); - return result.changes; + return db.transaction(() => { + const projected = visibleProjectionMessageIds(reader).filter((message) => message.session_id === sessionId); + const acknowledged = insertProjectionReceipts(projected.map((message) => message.id), reader); + const result = db.prepare( + `UPDATE messages SET read_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') + WHERE session_id = ? AND to_agent = ? AND read_at IS NULL + AND NOT EXISTS (SELECT 1 FROM incident_projections p WHERE p.message_id = messages.id)` + ).run(sessionId, reader); + return acknowledged + result.changes; + }); } export function markChannelRead(channelName: string, reader: string): number { const db = getDb(); const normalized = normalizeChannelName(channelName); - const stmt = db.prepare( - `UPDATE messages SET read_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE channel = ? AND from_agent != ? AND read_at IS NULL` - ); - const result = stmt.run(normalized, reader); - return result.changes; + return db.transaction(() => { + const projected = visibleProjectionMessageIds(reader).filter((message) => message.channel === normalized); + const acknowledged = insertProjectionReceipts(projected.map((message) => message.id), reader); + const result = db.prepare( + `UPDATE messages SET read_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') + WHERE channel = ? AND from_agent != ? AND read_at IS NULL + AND NOT EXISTS (SELECT 1 FROM incident_projections p WHERE p.message_id = messages.id)` + ).run(normalized, reader); + return acknowledged + result.changes; + }); } export function getMessageById(id: number): Message | null { @@ -409,21 +502,7 @@ export function markReadByIds(ids: number[], agent?: string): number { const db = getDb(); if (ids.length === 0) return 0; - if (agent) { - // Use per-agent read receipts so other agents' unread status is preserved - const stmt = db.prepare( - `INSERT OR REPLACE INTO message_read_receipts (message_id, agent, read_at) - VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))` - ); - const normalized = agent.toLowerCase(); - for (const id of ids) stmt.run(id, normalized); - // Also update global read_at for backward compat - const placeholders = ids.map(() => "?").join(", "); - const update = db.prepare( - `UPDATE messages SET read_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE id IN (${placeholders}) AND read_at IS NULL` - ); - return update.run(...ids).changes; - } + if (agent) return markExplicitRead(ids, agent, false); // Legacy: no agent — update global read_at only const placeholders = ids.map(() => "?").join(", "); @@ -436,11 +515,16 @@ export function markReadByIds(ids: number[], agent?: string): number { export function markAllRead(agent: string): number { const db = getDb(); - const stmt = db.prepare( - `UPDATE messages SET read_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE to_agent = ? AND read_at IS NULL` - ); - const result = stmt.run(agent); - return result.changes; + return db.transaction(() => { + const projected = visibleProjectionMessageIds(agent); + const acknowledged = insertProjectionReceipts(projected.map((message) => message.id), agent); + const result = db.prepare( + `UPDATE messages SET read_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') + WHERE to_agent = ? AND read_at IS NULL + AND NOT EXISTS (SELECT 1 FROM incident_projections p WHERE p.message_id = messages.id)` + ).run(agent); + return acknowledged + result.changes; + }); } export interface DigestMessage { @@ -1036,6 +1120,25 @@ export function getPinnedMessages(opts?: { channel?: string; session_id?: string export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset?: number }): Message[] { const db = getDb(); + const tenantId = process.env.HASNA_CONVERSATIONS_TENANT_ID?.trim(); + const authorityId = process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID?.trim(); + const anyProjection = db.prepare("SELECT 1 AS present FROM incident_projections LIMIT 1").get(); + if ((!tenantId || !authorityId) && (anyProjection || tenantId || authorityId)) { + throw new IncidentProjectorConfigurationError( + "Canonical blocker reads require HASNA_CONVERSATIONS_TENANT_ID and HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID", + ); + } + const binding = tenantId && authorityId ? validateIncidentProjectorBinding(tenantId, authorityId) : null; + const selectedProjection = binding + ? db.prepare( + "SELECT 1 AS present FROM incident_projections WHERE tenant_id = ? AND authority_id = ? LIMIT 1", + ).get(binding.tenant_id, binding.authority_id) + : null; + if (anyProjection && binding && !selectedProjection) { + throw new IncidentProjectorConfigurationError( + "Configured incident projector tenant/authority does not match stored canonical projections", + ); + } const safeLimit = Number.isFinite(opts?.limit) && (opts!.limit as number) > 0 ? Math.floor(opts!.limit as number) : 0; @@ -1045,15 +1148,81 @@ export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset const limitClause = safeLimit > 0 ? `LIMIT ${safeLimit}` : safeOffset > 0 ? "LIMIT -1" : ""; const offsetClause = safeOffset > 0 ? `OFFSET ${safeOffset}` : ""; const rows = db.prepare(` - SELECT * FROM messages - WHERE blocking = 1 AND read_at IS NULL - AND ( - to_agent = ? - OR channel IN (SELECT channel FROM channel_members WHERE agent = ?) + WITH latest AS ( + SELECT p.* + FROM incident_projections p + JOIN ( + SELECT tenant_id, authority_id, incident_id, MAX(incident_version) AS incident_version + FROM incident_projections + WHERE tenant_id = ? AND authority_id = ? + GROUP BY tenant_id, authority_id, incident_id + ) current + ON current.tenant_id = p.tenant_id + AND current.authority_id = p.authority_id + AND current.incident_id = p.incident_id + AND current.incident_version = p.incident_version + ), + blocker_candidates AS ( + SELECT p.*, + CASE WHEN p.status = 'superseded' + AND p.superseded_by_incident_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM incident_projections replacement + WHERE replacement.tenant_id = p.tenant_id + AND replacement.authority_id = p.authority_id + AND replacement.incident_id = p.superseded_by_incident_id + AND replacement.supersedes_incident_id = p.incident_id + ) + THEN 1 ELSE 0 END AS pending_handoff + FROM latest p + ), + projected_ids AS ( + SELECT DISTINCT m.id + FROM blocker_candidates p + JOIN messages m ON m.id = p.message_id + JOIN incident_projection_scopes scope + ON scope.projection_id = p.id AND scope.scope_type = 'blocked' + WHERE ( + (p.status IN ('open','investigating','contained','monitoring') AND p.blocking = 1) + OR p.pending_handoff = 1 + ) + AND ( + p.pending_handoff = 1 + OR NOT EXISTS ( + SELECT 1 FROM message_read_receipts receipt + WHERE receipt.message_id = m.id AND lower(receipt.agent) = lower(?) + ) + ) + AND ( + lower(scope.scope) = 'agent:' || lower(?) + OR lower(scope.scope) IN ( + SELECT 'channel:' || lower(channel) FROM channel_members WHERE lower(agent) = lower(?) + ) + OR scope.scope IN ( + SELECT 'project:' || project_id FROM agent_presence + WHERE lower(agent) = lower(?) AND project_id <> '' + ) + ) + ), + legacy_ids AS ( + SELECT m.id + FROM messages m + LEFT JOIN incident_projections p ON p.message_id = m.id + WHERE p.id IS NULL AND m.blocking = 1 AND m.read_at IS NULL + AND ( + lower(m.to_agent) = lower(?) + OR m.channel IN (SELECT channel FROM channel_members WHERE lower(agent) = lower(?)) + ) + ), + eligible_ids AS ( + SELECT id FROM projected_ids + UNION + SELECT id FROM legacy_ids ) - ORDER BY created_at ASC, id ASC + SELECT m.* FROM messages m JOIN eligible_ids eligible ON eligible.id = m.id + ORDER BY m.created_at ASC, m.id ASC ${limitClause} ${offsetClause} - `).all(agent, agent) as Record[]; + `).all(binding?.tenant_id ?? null, binding?.authority_id ?? null, agent, agent, agent, agent, agent, agent) as Record[]; return rows.map(parseMessage); } diff --git a/src/lib/pg-migrations.test.ts b/src/lib/pg-migrations.test.ts index c59193f..5fdf66c 100644 --- a/src/lib/pg-migrations.test.ts +++ b/src/lib/pg-migrations.test.ts @@ -23,6 +23,8 @@ describe("PG_MIGRATIONS", () => { expect(sql).toContain("create table if not exists resource_locks"); expect(sql).toContain("create table if not exists feedback"); expect(sql).toContain("create table if not exists _migrations"); + expect(sql).toContain("create table if not exists incident_projections"); + expect(sql).toContain("create table if not exists incident_projection_scopes"); expect(sql).toContain("metadata text"); expect(sql).toContain("tags text"); const channelsDefinition = sql.slice( @@ -37,6 +39,23 @@ describe("PG_MIGRATIONS", () => { expect(sql).toContain("create index"); expect(sql).toContain("idx_projects_name"); expect(sql).toContain("idx_messages_search"); + expect(sql).toContain("idx_incident_projections_active_scope"); + expect(sql).toContain("idx_incident_projection_scopes_lookup"); + expect(sql).toContain("incident_projections_no_update"); + expect(sql).toContain("incident_projections_no_delete"); + expect(sql).toContain("incident_projection_scopes_no_update"); + expect(sql).toContain("incident_projection_scopes_no_delete"); + expect(sql).toContain("incident_projection_messages_no_mutation"); + expect(sql).toContain("incident_projection_messages_no_delete"); + expect(sql).toContain("messages_reply_to_fkey"); + expect(sql).toContain("not valid"); + expect(sql).toContain("legacy_reply_orphans"); + expect(sql).not.toContain("update messages child set reply_to = null"); + expect(sql).not.toContain("validate constraint messages_reply_to_fkey"); + expect(sql).toContain("enforce_message_reply_scope"); + expect(sql).toContain("messages_reply_parent_scope_no_update"); + expect(sql).toContain("'info','low','medium','high','critical'"); + expect(sql).toContain("'open','investigating','contained','monitoring','resolved','superseded'"); }); test("first migration sets up full-text search", () => { diff --git a/src/lib/pg-migrations.ts b/src/lib/pg-migrations.ts index 1695cd3..cd9bbca 100644 --- a/src/lib/pg-migrations.ts +++ b/src/lib/pg-migrations.ts @@ -81,7 +81,7 @@ export const PG_MIGRATIONS: string[] = [ pinned_at TEXT, blocking BOOLEAN NOT NULL DEFAULT FALSE, attachments TEXT, - reply_to BIGINT, + reply_to BIGINT REFERENCES messages(id) ON DELETE RESTRICT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), read_at TEXT ); @@ -93,6 +93,31 @@ export const PG_MIGRATIONS: string[] = [ ALTER TABLE messages ADD COLUMN IF NOT EXISTS blocking BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE messages ADD COLUMN IF NOT EXISTS attachments TEXT; ALTER TABLE messages ADD COLUMN IF NOT EXISTS reply_to BIGINT; + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'messages'::regclass AND conname = 'messages_reply_to_fkey' + ) THEN + ALTER TABLE messages + ADD CONSTRAINT messages_reply_to_fkey FOREIGN KEY (reply_to) + REFERENCES messages(id) ON DELETE RESTRICT NOT VALID; + END IF; + END $$; + -- Preserve historical reply values exactly. A NOT VALID FK protects all new + -- writes while an operator can audit legacy orphans before a later explicit + -- VALIDATE. Migration must never rewrite correlation history silently. + DO $$ + DECLARE legacy_reply_orphans BIGINT; + BEGIN + SELECT COUNT(*) INTO legacy_reply_orphans + FROM messages child + WHERE child.reply_to IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM messages parent WHERE parent.id = child.reply_to); + IF legacy_reply_orphans > 0 THEN + RAISE NOTICE 'messages_reply_to_fkey remains NOT VALID; % legacy orphan reply values require audit', legacy_reply_orphans; + END IF; + END $$; CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_agent); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); @@ -105,6 +130,210 @@ export const PG_MIGRATIONS: string[] = [ -- The CREATE TABLE above declares uuid UNIQUE, but older tables may have had -- uuid added via ALTER without the constraint; this guarantees it either way. CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid); + + CREATE OR REPLACE FUNCTION message_channel_scope_rewrite_allowed( + old_session TEXT, new_session TEXT, + old_channel TEXT, new_channel TEXT, + old_to_agent TEXT, new_to_agent TEXT, + old_project TEXT, new_project TEXT, + old_reply_to BIGINT, new_reply_to BIGINT + ) RETURNS BOOLEAN AS $$ + DECLARE + guard_text TEXT := current_setting('hasna.conversations.channel_scope_rewrite', TRUE); + guard JSONB; + BEGIN + IF guard_text IS NULL OR guard_text = '' THEN RETURN FALSE; END IF; + guard := guard_text::jsonb; + RETURN old_session = guard->>'old_session_id' + AND new_session = guard->>'new_session_id' + AND ( + (old_channel IS NOT DISTINCT FROM guard->>'old_channel' + AND new_channel IS NOT DISTINCT FROM guard->>'new_channel') + OR old_channel IS NOT DISTINCT FROM new_channel + ) + AND ( + (old_to_agent = guard->>'old_to_agent' AND new_to_agent = guard->>'new_to_agent') + OR old_to_agent IS NOT DISTINCT FROM new_to_agent + ) + AND old_project IS NOT DISTINCT FROM new_project + AND old_reply_to IS NOT DISTINCT FROM new_reply_to; + END; + $$ LANGUAGE plpgsql STABLE; + + CREATE OR REPLACE FUNCTION enforce_message_reply_scope() RETURNS trigger AS $$ + BEGIN + IF NEW.reply_to IS NOT NULL THEN + IF TG_OP = 'UPDATE' AND message_channel_scope_rewrite_allowed( + OLD.session_id, NEW.session_id, OLD.channel, NEW.channel, + OLD.to_agent, NEW.to_agent, OLD.project_id, NEW.project_id, + OLD.reply_to, NEW.reply_to + ) THEN + RETURN NEW; + END IF; + PERFORM 1 FROM messages parent + WHERE parent.id = NEW.reply_to + AND parent.session_id = NEW.session_id + AND parent.channel IS NOT DISTINCT FROM NEW.channel + AND parent.project_id IS NOT DISTINCT FROM NEW.project_id + FOR KEY SHARE; + IF NOT FOUND THEN + RAISE EXCEPTION 'reply parent is missing or outside the message scope'; + END IF; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + DROP TRIGGER IF EXISTS messages_reply_scope_insert ON messages; + CREATE TRIGGER messages_reply_scope_insert + BEFORE INSERT ON messages FOR EACH ROW EXECUTE FUNCTION enforce_message_reply_scope(); + DROP TRIGGER IF EXISTS messages_reply_scope_update ON messages; + CREATE TRIGGER messages_reply_scope_update + BEFORE UPDATE OF reply_to, session_id, channel, project_id ON messages + FOR EACH ROW EXECUTE FUNCTION enforce_message_reply_scope(); + CREATE OR REPLACE FUNCTION reject_reply_parent_scope_mutation() RETURNS trigger AS $$ + BEGIN + IF (NEW.session_id IS DISTINCT FROM OLD.session_id + OR NEW.channel IS DISTINCT FROM OLD.channel + OR NEW.project_id IS DISTINCT FROM OLD.project_id) + AND EXISTS (SELECT 1 FROM messages child WHERE child.reply_to = OLD.id) + AND NOT message_channel_scope_rewrite_allowed( + OLD.session_id, NEW.session_id, OLD.channel, NEW.channel, + OLD.to_agent, NEW.to_agent, OLD.project_id, NEW.project_id, + OLD.reply_to, NEW.reply_to + ) THEN + RAISE EXCEPTION 'reply parent scope is immutable while replies exist'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + DROP TRIGGER IF EXISTS messages_reply_parent_scope_no_update ON messages; + CREATE TRIGGER messages_reply_parent_scope_no_update + BEFORE UPDATE OF session_id, channel, project_id ON messages + FOR EACH ROW EXECUTE FUNCTION reject_reply_parent_scope_mutation(); + + -- Append-only canonical incident projection ledger. The message is a display + -- projection; current incident state comes from these typed indexed columns. + CREATE TABLE IF NOT EXISTS incident_projections ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + event_id TEXT NOT NULL, + projection_key TEXT NOT NULL, + message_id BIGINT NOT NULL UNIQUE REFERENCES messages(id), + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + source TEXT NOT NULL CHECK (source = 'todos'), + tenant_id TEXT NOT NULL, + authority_id TEXT NOT NULL, + incident_id TEXT NOT NULL, + transition_id TEXT NOT NULL, + incident_version INTEGER NOT NULL CHECK (incident_version > 0), + occurred_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL CHECK (status IN ('open','investigating','contained','monitoring','resolved','superseded')), + severity TEXT NOT NULL CHECK (severity IN ('info','low','medium','high','critical')), + blocking BOOLEAN NOT NULL DEFAULT FALSE, + affected_scopes TEXT NOT NULL, + blocked_scopes TEXT NOT NULL, + supersedes_transition_id TEXT, + supersedes_incident_id TEXT, + superseded_by_incident_id TEXT, + canonical_payload TEXT NOT NULL, + payload_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (NOT (status IN ('resolved','superseded') AND blocking)), + UNIQUE (tenant_id, event_id), + UNIQUE (tenant_id, projection_key), + UNIQUE (tenant_id, authority_id, incident_id, transition_id), + UNIQUE (tenant_id, authority_id, incident_id, incident_version) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_incident_projections_message ON incident_projections(message_id); + CREATE INDEX IF NOT EXISTS idx_incident_projections_active_scope + ON incident_projections(tenant_id, authority_id, incident_id, incident_version DESC); + CREATE INDEX IF NOT EXISTS idx_incident_projections_blocking_scope + ON incident_projections(tenant_id, authority_id, blocking, incident_id, incident_version DESC); + CREATE TABLE IF NOT EXISTS incident_projection_scopes ( + projection_id BIGINT NOT NULL REFERENCES incident_projections(id), + scope_type TEXT NOT NULL CHECK (scope_type IN ('affected','blocked')), + scope TEXT NOT NULL, + PRIMARY KEY (projection_id, scope_type, scope) + ); + CREATE INDEX IF NOT EXISTS idx_incident_projection_scopes_lookup + ON incident_projection_scopes(scope_type, scope, projection_id); + CREATE OR REPLACE FUNCTION reject_incident_projection_mutation() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'incident projections are append-only'; + END; + $$ LANGUAGE plpgsql; + DROP TRIGGER IF EXISTS incident_projections_no_update ON incident_projections; + CREATE TRIGGER incident_projections_no_update + BEFORE UPDATE ON incident_projections FOR EACH ROW EXECUTE FUNCTION reject_incident_projection_mutation(); + DROP TRIGGER IF EXISTS incident_projections_no_delete ON incident_projections; + CREATE TRIGGER incident_projections_no_delete + BEFORE DELETE ON incident_projections FOR EACH ROW EXECUTE FUNCTION reject_incident_projection_mutation(); + DROP TRIGGER IF EXISTS incident_projection_scopes_no_update ON incident_projection_scopes; + CREATE TRIGGER incident_projection_scopes_no_update + BEFORE UPDATE ON incident_projection_scopes FOR EACH ROW EXECUTE FUNCTION reject_incident_projection_mutation(); + DROP TRIGGER IF EXISTS incident_projection_scopes_no_delete ON incident_projection_scopes; + CREATE TRIGGER incident_projection_scopes_no_delete + BEFORE DELETE ON incident_projection_scopes FOR EACH ROW EXECUTE FUNCTION reject_incident_projection_mutation(); + CREATE OR REPLACE FUNCTION reject_incident_projection_message_mutation() RETURNS trigger AS $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM incident_projections WHERE message_id = OLD.id) THEN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; + END IF; + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'incident projection messages are append-only'; + END IF; + IF message_channel_scope_rewrite_allowed( + OLD.session_id, NEW.session_id, OLD.channel, NEW.channel, + OLD.to_agent, NEW.to_agent, OLD.project_id, NEW.project_id, + OLD.reply_to, NEW.reply_to + ) + AND NEW.uuid IS NOT DISTINCT FROM OLD.uuid + AND NEW.from_agent IS NOT DISTINCT FROM OLD.from_agent + AND NEW.content IS NOT DISTINCT FROM OLD.content + AND NEW.priority IS NOT DISTINCT FROM OLD.priority + AND NEW.working_dir IS NOT DISTINCT FROM OLD.working_dir + AND NEW.repository IS NOT DISTINCT FROM OLD.repository + AND NEW.branch IS NOT DISTINCT FROM OLD.branch + AND NEW.metadata IS NOT DISTINCT FROM OLD.metadata + AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at + AND NEW.read_at IS NOT DISTINCT FROM OLD.read_at + AND NEW.edited_at IS NOT DISTINCT FROM OLD.edited_at + AND NEW.pinned_at IS NOT DISTINCT FROM OLD.pinned_at + AND NEW.blocking IS NOT DISTINCT FROM OLD.blocking + AND NEW.attachments IS NOT DISTINCT FROM OLD.attachments THEN + RETURN NEW; + END IF; + IF NEW.uuid IS DISTINCT FROM OLD.uuid + OR NEW.session_id IS DISTINCT FROM OLD.session_id + OR NEW.from_agent IS DISTINCT FROM OLD.from_agent + OR NEW.to_agent IS DISTINCT FROM OLD.to_agent + OR NEW.channel IS DISTINCT FROM OLD.channel + OR NEW.project_id IS DISTINCT FROM OLD.project_id + OR NEW.content IS DISTINCT FROM OLD.content + OR NEW.priority IS DISTINCT FROM OLD.priority + OR NEW.working_dir IS DISTINCT FROM OLD.working_dir + OR NEW.repository IS DISTINCT FROM OLD.repository + OR NEW.branch IS DISTINCT FROM OLD.branch + OR NEW.metadata IS DISTINCT FROM OLD.metadata + OR NEW.edited_at IS DISTINCT FROM OLD.edited_at + OR NEW.blocking IS DISTINCT FROM OLD.blocking + OR NEW.attachments IS DISTINCT FROM OLD.attachments + OR NEW.reply_to IS DISTINCT FROM OLD.reply_to + OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN + RAISE EXCEPTION 'incident projection messages are append-only'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + DROP TRIGGER IF EXISTS incident_projection_messages_no_mutation ON messages; + CREATE TRIGGER incident_projection_messages_no_mutation + BEFORE UPDATE ON messages FOR EACH ROW + EXECUTE FUNCTION reject_incident_projection_message_mutation(); + DROP TRIGGER IF EXISTS incident_projection_messages_no_delete ON messages; + CREATE TRIGGER incident_projection_messages_no_delete + BEFORE DELETE ON messages FOR EACH ROW + EXECUTE FUNCTION reject_incident_projection_message_mutation(); + UPDATE channel_subscriptions ss SET since_message_id = COALESCE( (SELECT MAX(m.id) FROM messages m WHERE m.channel = ss.channel), diff --git a/src/lib/store/api-store.test.ts b/src/lib/store/api-store.test.ts index 5649854..d471243 100644 --- a/src/lib/store/api-store.test.ts +++ b/src/lib/store/api-store.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect } from "bun:test"; +import { readFileSync } from "fs"; import { ApiStore } from "./api-store.js"; import type { HasnaStorageClient } from "../contracts-client/storage.js"; @@ -21,6 +22,33 @@ function fakeClient(getBody: unknown): HasnaStorageClient { } as unknown as HasnaStorageClient; } +function capturingClient(response: unknown): { + client: HasnaStorageClient; + calls: Array<{ resource: string; body: unknown; options: unknown }>; +} { + const calls: Array<{ resource: string; body: unknown; options: unknown }> = []; + const transport = { + baseUrl: "https://conversations.hasna.xyz/v1", + get: async () => response, + post: async (resource: string, body: unknown, options: unknown) => { + calls.push({ resource, body, options }); + return response; + }, + patch: async () => response, + del: async () => undefined, + } as unknown as HasnaStorageClient["transport"]; + const client = { + name: "conversations", + baseUrl: "https://conversations.hasna.xyz/v1", + transport, + create: async (resource: string, body: unknown, options: unknown) => { + calls.push({ resource, body, options }); + return response; + }, + } as unknown as HasnaStorageClient; + return { client, calls }; +} + /** A client whose transport rejects every read with a 404 HasnaHttpError. */ function throwing404Client(): HasnaStorageClient { const err = Object.assign(new Error("Not Found"), { name: "HasnaHttpError", status: 404 }); @@ -92,3 +120,82 @@ describe("ApiStore project normalization", () => { expect(await store.getChannel("nope")).toBeNull(); }); }); + +describe("ApiStore message transport", () => { + test("forwards reply correlation, metadata, and source context to cloud create", async () => { + const { client, calls } = capturingClient({ + message: { + id: 2, + uuid: "reply-2", + session_id: "channel:incidents", + from_agent: "friday", + to_agent: "incidents", + channel: "incidents", + project_id: "engineering", + content: "projection display", + priority: "high", + blocking: true, + reply_to: 1, + metadata: JSON.stringify({ display: { severity: "sev1" } }), + }, + }); + const store = new ApiStore(client); + + const message = await store.sendMessage({ + from: "friday", + to: "incidents", + channel: "incidents", + project_id: "engineering", + content: "projection display", + priority: "high", + blocking: true, + reply_to: 1, + metadata: { display: { severity: "sev1" } }, + working_dir: "/worktree", + repository: "hasna/conversations", + branch: "fix/incident-projection", + }); + + expect(calls).toHaveLength(1); + expect(calls[0].resource).toBe("messages"); + expect(calls[0].body).toEqual({ + from: "friday", + to: "incidents", + content: "projection display", + channel: "incidents", + project_id: "engineering", + session_id: undefined, + priority: "high", + blocking: true, + reply_to: 1, + metadata: { display: { severity: "sev1" } }, + working_dir: "/worktree", + repository: "hasna/conversations", + branch: "fix/incident-projection", + attachments: undefined, + }); + expect(message.reply_to).toBe(1); + expect(message.metadata).toEqual({ display: { severity: "sev1" } }); + }); + + test("posts the exact canonical Todos event only to the dedicated projector route", async () => { + const fixture = JSON.parse( + readFileSync(new URL("../../../fixtures/todos-incident-projection-v1.json", import.meta.url), "utf8"), + ); + const { client, calls } = capturingClient({ projection: { + event_id: fixture.event_id, + projection_key: fixture.projection_key, + authority_id: fixture.authority_id, + incident_id: fixture.incident_id, + transition_id: fixture.transition_id, + incident_version: fixture.incident_version, + message_id: 42, + replayed: false, + } }); + const store = new ApiStore(client); + const projection = await store.appendIncidentProjection(fixture); + expect(calls).toEqual([{ resource: "/incident-projections", body: fixture, options: undefined }]); + expect(projection.event_id).toBe(fixture.event_id); + expect(projection.message_id).toBe(42); + }); +}); diff --git a/src/lib/store/api-store.ts b/src/lib/store/api-store.ts index abc1515..53fff5f 100644 --- a/src/lib/store/api-store.ts +++ b/src/lib/store/api-store.ts @@ -497,6 +497,12 @@ export class ApiStore implements ConversationsStore { from: opts.from, to: opts.to, content: opts.content, channel: opts.channel, project_id: opts.project_id, session_id: opts.session_id, priority: opts.priority, blocking: opts.blocking === true, + reply_to: opts.reply_to, + metadata: opts.metadata, + working_dir: opts.working_dir, + repository: opts.repository, + branch: opts.branch, + attachments: opts.attachments, }); return parseMessage(body.message) as never; }; @@ -602,7 +608,7 @@ export class ApiStore implements ConversationsStore { return (body?.messages ?? []).map(parseMessage) as never; }; getUnreadBlockers: ConversationsStore["getUnreadBlockers"] = async (agent, opts) => { - const body = await this.get<{ messages?: Record[] }>("/messages", { to: agent, unread_only: true, blocking_only: true, ...(opts as Q) }); + const body = await this.get<{ messages?: Record[] }>("/messages/blockers", { agent, ...(opts as Q) }); return (body?.messages ?? []).map(parseMessage) as never; }; getMessagesForAgent: ConversationsStore["getMessagesForAgent"] = async (agent, opts) => { @@ -681,4 +687,17 @@ export class ApiStore implements ConversationsStore { const res = await this.get<{ receipts?: unknown[] }>(`/messages/${encodeURIComponent(String(messageId))}/receipts`); return (res?.receipts ?? []) as never; }; + appendIncidentProjection: ConversationsStore["appendIncidentProjection"] = async (request) => { + const res = await this.post<{ projection: unknown }>("/incident-projections", request); + return res.projection as never; + }; + getIncidentProjection: ConversationsStore["getIncidentProjection"] = async (eventId) => { + try { + const res = await this.get<{ projection: unknown }>(`/incident-projections/${encodeURIComponent(eventId)}`); + return (res?.projection ?? null) as never; + } catch (error) { + if (isHttpStatus(error, 404)) return null; + throw error; + } + }; } diff --git a/src/lib/store/index.ts b/src/lib/store/index.ts index cece86d..ca98ea3 100644 --- a/src/lib/store/index.ts +++ b/src/lib/store/index.ts @@ -42,6 +42,8 @@ import * as notificationsLib from "../channel-notifications.js"; import * as summaryLib from "../summary.js"; import * as hotLib from "../hot.js"; import * as messagesLib from "../messages.js"; +import * as incidentProjectionsLib from "../incident-projections.js"; +import type { IncidentProjectionRecord, IncidentProjectionRequestV1 } from "../../types.js"; const APP = "conversations"; @@ -250,6 +252,10 @@ export interface ConversationsStore { recordReadReceipt: Async; recordReadReceiptsBatch: Async; getReadReceipts: Async; + + // canonical incident projections (authority/tenant are transport-bound) + appendIncidentProjection: (request: IncidentProjectionRequestV1) => Promise; + getIncidentProjection: (eventId: string) => Promise; } // ── LocalStore ──────────────────────────────────────────────────────────────── @@ -398,12 +404,21 @@ export class LocalStore implements ConversationsStore { recordReadReceipt: ConversationsStore["recordReadReceipt"] = async (...a) => messagesLib.recordReadReceipt(...a); recordReadReceiptsBatch: ConversationsStore["recordReadReceiptsBatch"] = async (...a) => messagesLib.recordReadReceiptsBatch(...a); getReadReceipts: ConversationsStore["getReadReceipts"] = async (...a) => messagesLib.getReadReceipts(...a); + appendIncidentProjection: ConversationsStore["appendIncidentProjection"] = async (request) => + incidentProjectionsLib.appendIncidentProjection(request, incidentProjectionsLib.resolveIncidentProjectorContext()); + getIncidentProjection: ConversationsStore["getIncidentProjection"] = async (eventId) => + incidentProjectionsLib.getIncidentProjection(eventId, incidentProjectionsLib.resolveIncidentProjectorContext()); } // ── Resolver ────────────────────────────────────────────────────────────────── let localSingleton: LocalStore | null = null; +/** Clear the stateless transport singleton between hermetic route tests. */ +export function resetStoreForTests(): void { + localSingleton = null; +} + /** * Resolve the active {@link ConversationsStore} for the current environment. * Returns an {@link ApiStore} when the client-flip contract resolves to cloud-http diff --git a/src/sdk/incident-projection.test.ts b/src/sdk/incident-projection.test.ts new file mode 100644 index 0000000..069ea6c --- /dev/null +++ b/src/sdk/incident-projection.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { + ApiError, + ConversationsClient, + type IncidentProjectionEventV1, + type IncidentProjectionRecord, +} from "./index"; + +const fixtureArtifact = JSON.parse( + readFileSync(new URL("../../fixtures/todos-incident-projection-v1.json", import.meta.url), "utf8"), +); +const fixture = { + schema_version: 1, + source: "todos", + event_id: "iev_adf149b3daa8a314dd30b92b188f0024", + projection_key: "todos:incident:todos.hasna.xyz:v1:11111111-1111-4111-8111-111111111111:v1", + authority_id: "todos.hasna.xyz:v1", + incident_id: "11111111-1111-4111-8111-111111111111", + transition_id: "itr_adf149b3daa8a314dd30b92b188f0024", + incident_version: 1, + occurred_at: "2026-07-18T20:01:00.000Z", + incident: { + id: "11111111-1111-4111-8111-111111111111", + title: "Canonical cross-service incident fixture", + severity: "high", + status: "investigating", + owner: "projector-01", + affected_scopes: ["service:conversations"], + blocked_scopes: [ + "agent:projector-01", + "channel:incidents", + "project:wks_8vJJzXTiFo6sxwRkpPqoI", + ], + containment: null, + next_action: "Project and acknowledge the canonical incident state", + deadline: null, + closure_evidence: [], + supersedes_id: null, + superseded_by_id: null, + resolved_at: null, + version: 1, + created_at: "2026-07-18T20:01:00.000Z", + updated_at: "2026-07-18T20:01:00.000Z", + }, +} satisfies IncidentProjectionEventV1; + +function responseProjection(replayed: boolean): IncidentProjectionRecord { + return { + id: 7, + event_id: fixture.event_id, + projection_key: fixture.projection_key, + message_id: 42, + schema_version: 1, + source: "todos", + tenant_id: "tenant-a", + authority_id: fixture.authority_id, + incident_id: fixture.incident_id, + transition_id: fixture.transition_id, + incident_version: fixture.incident_version, + occurred_at: fixture.occurred_at, + status: fixture.incident.status, + severity: fixture.incident.severity, + blocking: true, + supersedes_transition_id: null, + supersedes_incident_id: null, + superseded_by_incident_id: null, + canonical_payload: "canonical", + payload_hash: "a89862d57860d06b0d53cae4d720830042a38fa90ece0cbab1b363a19384e4cd", + created_at: fixture.occurred_at, + message: { id: 42, content: "display only" }, + replayed, + }; +} + +describe("generated projector client", () => { + test("posts the exact Todos event with the supported key header and verifies new/replay responses", async () => { + expect(fixtureArtifact).toEqual(fixture); + const requests: Array<{ url: string; init?: RequestInit }> = []; + const client = new ConversationsClient({ + baseUrl: "https://conversations.invalid", + apiKey: "projector-test-key", + fetch: (async (input: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(input), init }); + const replayed = requests.length > 1; + return new Response(JSON.stringify({ projection: responseProjection(replayed) }), { + status: replayed ? 200 : 201, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch, + }); + + const created = await client.appendIncidentProjection(fixture); + const replay = await client.appendIncidentProjection(fixture); + expect(created.projection.replayed).toBe(false); + expect(replay.projection.replayed).toBe(true); + expect(created.projection.event_id).toBe(fixture.event_id); + expect(created.projection.message_id).toBe(42); + expect(requests[0].url).toBe("https://conversations.invalid/v1/incident-projections"); + expect(requests[0].init?.method).toBe("POST"); + expect((requests[0].init?.headers as Record)["x-api-key"]).toBe("projector-test-key"); + expect(JSON.parse(String(requests[0].init?.body))).toEqual(fixture); + }); + + test("preserves typed projector failures for deterministic reconciler handling", async () => { + const client = new ConversationsClient({ + baseUrl: "https://conversations.invalid", + fetch: (async () => new Response(JSON.stringify({ + error: "canonical version conflict", + code: "INCIDENT_PROJECTION_CONFLICT", + }), { status: 409, headers: { "content-type": "application/json" } })) as unknown as typeof fetch, + }); + try { + await client.appendIncidentProjection(fixture); + throw new Error("expected conflict"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(409); + expect((error as ApiError).body).toEqual({ + error: "canonical version conflict", + code: "INCIDENT_PROJECTION_CONFLICT", + }); + } + }); +}); diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 42692f7..565890f 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -2,9 +2,9 @@ // Regenerate: bun run sdk:generate // @generated from OpenAPI by @hasna/contracts SDK generator — DO NOT EDIT. -// Source: ConversationsClient 0.4.0 +// Source: ConversationsClient 0.5.6 -export interface Message { "id"?: number; "uuid"?: string; "session_id"?: string; "from_agent"?: string; "to_agent"?: string; "channel"?: string | null; "project_id"?: string | null; "content"?: string; "priority"?: string; "blocking"?: boolean; "created_at"?: string } +export interface Message { "id"?: number; "uuid"?: string; "session_id"?: string; "from_agent"?: string; "to_agent"?: string; "channel"?: string | null; "project_id"?: string | null; "content"?: string; "priority"?: string; "blocking"?: boolean; "reply_to"?: number | null; "working_dir"?: string | null; "repository"?: string | null; "branch"?: string | null; "metadata"?: Record | null; "attachments"?: Array> | null; "created_at"?: string } export interface Channel { "name"?: string; "description"?: string | null; "topic"?: string | null; "project_id"?: string | null; "created_by"?: string; "created_at"?: string; "archived_at"?: string | null } @@ -12,6 +12,16 @@ export interface Project { "id"?: string; "name"?: string; "description"?: strin export interface Agent { "agent"?: string; "session_id"?: string | null; "role"?: string; "project_id"?: string; "status"?: string; "last_seen_at"?: string } +export interface IncidentSnapshotV1 { "id": string; "title": string; "severity": "info" | "low" | "medium" | "high" | "critical"; "status": "open" | "investigating" | "contained" | "monitoring" | "resolved" | "superseded"; "owner": string; "affected_scopes": Array; "blocked_scopes": Array; "containment": string | null; "next_action": string | null; "deadline": string | null; "closure_evidence": Array; "supersedes_id": string | null; "superseded_by_id": string | null; "resolved_at": string | null; "version": number; "created_at": string; "updated_at": string } + +export interface IncidentProjectionEventV1 { "schema_version": 1; "source": "todos"; "authority_id": string; "incident_id": string; "transition_id": string; "incident_version": number; "occurred_at": string; "event_id": string; "projection_key": string; "incident": IncidentSnapshotV1 } + +export interface IncidentProjectionRecord { "id": number; "event_id": string; "projection_key": string; "message_id": number; "schema_version": 1; "source": "todos"; "tenant_id": string; "authority_id": string; "incident_id": string; "transition_id": string; "incident_version": number; "occurred_at": string; "status": "open" | "investigating" | "contained" | "monitoring" | "resolved" | "superseded"; "severity": "info" | "low" | "medium" | "high" | "critical"; "blocking": boolean; "supersedes_transition_id": string | null; "supersedes_incident_id": string | null; "superseded_by_incident_id": string | null; "canonical_payload": string; "payload_hash": string; "created_at": string; "message": Message; "replayed": boolean } + +export interface IncidentProjectionResponse { "projection": IncidentProjectionRecord } + +export interface IncidentProjectionError { "error": string; "code"?: string | null } + export interface ConversationsClientOptions { /** Base URL, e.g. process.env.APP_API_URL. */ baseUrl: string; @@ -117,6 +127,14 @@ export class ConversationsClient { }); } + async listMemberChannels(query?: { "agent": string }, init?: RequestInit): Promise> { + return this.request("GET", `/v1/channels/mine`, { + body: undefined, + query, + init, + }); + } + async getChannel(name: string, init?: RequestInit): Promise> { return this.request("GET", `/v1/channels/${encodeURIComponent(String(name))}`, { body: undefined, @@ -133,6 +151,24 @@ export class ConversationsClient { }); } + /** Append a canonical Todos incident projection */ + async appendIncidentProjection(body: IncidentProjectionEventV1, init?: RequestInit): Promise { + return this.request("POST", `/v1/incident-projections`, { + body, + query: undefined, + init, + }); + } + + /** Read one canonical incident projection */ + async getIncidentProjection(eventId: string, init?: RequestInit): Promise { + return this.request("GET", `/v1/incident-projections/${encodeURIComponent(String(eventId))}`, { + body: undefined, + query: undefined, + init, + }); + } + /** List messages */ async listMessages(query?: { "to"?: string; "from"?: string; "channel"?: string; "session"?: string; "limit"?: number; "count"?: boolean }, init?: RequestInit): Promise> { return this.request("GET", `/v1/messages`, { @@ -143,7 +179,7 @@ export class ConversationsClient { } /** Send a message */ - async sendMessage(body: { "from"?: string; "to": string; "content": string; "channel"?: string; "project_id"?: string; "session_id"?: string; "priority"?: string; "blocking"?: boolean }, init?: RequestInit): Promise> { + async sendMessage(body: { "from"?: string; "to": string; "content": string; "channel"?: string; "project_id"?: string; "session_id"?: string; "priority"?: string; "blocking"?: boolean; "reply_to"?: number; "metadata"?: Record; "working_dir"?: string; "repository"?: string; "branch"?: string; "attachments"?: Array> }, init?: RequestInit): Promise> { return this.request("POST", `/v1/messages`, { body, query: undefined, @@ -151,6 +187,15 @@ export class ConversationsClient { }); } + /** List canonical current blockers visible to one agent */ + async listUnreadBlockers(query?: { "agent": string; "limit"?: number; "offset"?: number }, init?: RequestInit): Promise> { + return this.request("GET", `/v1/messages/blockers`, { + body: undefined, + query, + init, + }); + } + /** Bulk-ingest messages (idempotent backfill) */ async bulkIngestMessages(body: { "messages": Array<{ "uuid": string; "from": string; "to": string; "content": string; "channel"?: string; "project_id"?: string; "session_id"?: string; "priority"?: string; "blocking"?: boolean; "created_at"?: string; "read_at"?: string; "edited_at"?: string; "pinned_at"?: string; "working_dir"?: string; "repository"?: string; "branch"?: string; "metadata"?: string; "attachments"?: string; "reply_to"?: number }> }, init?: RequestInit): Promise> { return this.request("POST", `/v1/messages/bulk`, { diff --git a/src/server/api.test.ts b/src/server/api.test.ts index 3f0b4e2..75b8c1d 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -2,10 +2,11 @@ import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { startApiServer, type ApiServerDeps } from "./api.js"; import { mintApiKey } from "@hasna/contracts/auth"; import { verifyApiKey, ApiKeyStore } from "@hasna/contracts/auth"; +import { readFileSync } from "node:fs"; // In-memory query shim standing in for the vendored kit's TypedQueryClient. // Exercises the router + auth without a live Postgres. -function makeFakeClient() { +function makeFakeClient(incidentProjectionCount = 0) { const channels: Record = {}; const messages: any[] = []; let nextId = 1; @@ -38,6 +39,7 @@ function makeFakeClient() { }, async get(sql: string, p: readonly unknown[] = []): Promise { if (/SELECT 1 AS ok/i.test(sql)) return { ok: 1 }; + if (/count\(\*\).*incident_projections/is.test(sql)) return { n: incidentProjectionCount }; if (/count\(\*\)/i.test(sql)) return { n: messages.length }; if (/INSERT INTO channels/i.test(sql)) { const [name, description, topic, project_id, created_by] = p as any[]; @@ -51,38 +53,62 @@ function makeFakeClient() { if (/SELECT \* FROM channels WHERE name/i.test(sql) || /SELECT name, description/i.test(sql)) { return channels[(p as any[])[0]] ?? null; } + if (/SELECT id, session_id, channel, project_id FROM messages WHERE id/i.test(sql)) { + return messages.find((m) => m.id === Number((p as any[])[0])) ?? null; + } if (/INSERT INTO messages/i.test(sql)) { - const [session_id, from_agent, to_agent, channel, project_id, content, priority, blocking] = p as any[]; - const row = { id: nextId++, uuid: `u${nextId}`, session_id, from_agent, to_agent, channel, project_id, content, priority, blocking, created_at: new Date().toISOString() }; + const [ + session_id, from_agent, to_agent, channel, project_id, content, priority, blocking, + reply_to, metadata, working_dir, repository, branch, attachments, + ] = p as any[]; + const row = { + id: nextId++, uuid: `u${nextId}`, session_id, from_agent, to_agent, channel, project_id, + content, priority, blocking, reply_to, metadata, working_dir, repository, branch, attachments, + created_at: new Date().toISOString(), + }; messages.push(row); return row; } return null; }, async execute(_sql: string, _p: readonly unknown[] = []): Promise {}, + async transaction(fn: (tx: any) => Promise): Promise { + return fn(client); + }, }; return client; } const SIGNING = "test-signing-secret-0123456789"; -function makeDeps(): ApiServerDeps { - const client = makeFakeClient(); +function makeDeps(options: { incidentProjectionCount?: number } = {}): ApiServerDeps { + const client = makeFakeClient(options.incidentProjectionCount ?? 0); const keys = new ApiKeyStore(client as any); const verifier = verifyApiKey({ app: "conversations", signingSecret: SIGNING, isRevoked: async () => false }); - return { client: client as any, keys, verifier }; + return { + client: client as any, + keys, + verifier, + incidentProjector: { + tenant_id: "tenant-a", + authority_id: "todos.hasna.xyz:v1", + routing: { channel: "incidents", project_id: "engineering" }, + }, + }; } let server: ReturnType; let base: string; let rwKey: string; let roKey: string; +let projectorKey: string; beforeAll(() => { server = startApiServer({ port: 0, host: "127.0.0.1", deps: makeDeps() }); base = `http://127.0.0.1:${server.port}`; rwKey = mintApiKey({ app: "conversations", agent: "test", scopes: ["conversations:read", "conversations:write"], signingSecret: SIGNING }).token; roKey = mintApiKey({ app: "conversations", agent: "ro", scopes: ["conversations:read"], signingSecret: SIGNING }).token; + projectorKey = mintApiKey({ app: "conversations", agent: "todos-projector", scopes: ["conversations:incident-project"], signingSecret: SIGNING }).token; }); afterAll(() => { server.stop(true); }); @@ -129,6 +155,111 @@ describe("conversations-serve", () => { expect(post.status).toBe(403); }); + test("dedicated incident projector route requires its narrow scope", async () => { + const denied = await fetch(`${base}/v1/incident-projections`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(denied.status).toBe(403); + const admitted = await fetch(`${base}/v1/incident-projections`, { + method: "POST", + headers: { "x-api-key": projectorKey, "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(admitted.status).toBe(400); + expect((await admitted.json()).code).toBe("INVALID_INCIDENT_PROJECTION"); + }); + + test("incident projector maps unexpected storage failures to a sanitized retryable 503", async () => { + const deps = makeDeps(); + deps.client.transaction = async () => { + throw new Error("postgres password=must-not-leak host=internal"); + }; + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + try { + const fixture = JSON.parse(readFileSync( + new URL("../../fixtures/todos-incident-projection-v1.json", import.meta.url), + "utf8", + )); + const response = await fetch(`http://127.0.0.1:${isolated.port}/v1/incident-projections`, { + method: "POST", + headers: { "x-api-key": projectorKey, "content-type": "application/json" }, + body: JSON.stringify(fixture), + }); + expect(response.status).toBe(503); + const body = await response.json(); + expect(body).toEqual({ + error: "Incident projection service is temporarily unavailable", + code: "INCIDENT_PROJECTION_UNAVAILABLE", + }); + expect(JSON.stringify(body)).not.toContain("password"); + expect(JSON.stringify(body)).not.toContain("internal"); + } finally { + isolated.stop(true); + } + }); + + test("blocker reads and acknowledgements cannot impersonate another agent", async () => { + const blockers = await fetch(`${base}/v1/messages/blockers?agent=other`, { + headers: { "x-api-key": rwKey }, + }); + expect(blockers.status).toBe(403); + const spoofed = await fetch(`${base}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ ids: [999], reader: "other" }), + }); + expect(spoofed.status).toBe(403); + const spoofedReceipt = await fetch(`${base}/v1/messages/999/receipts`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ agent: "other" }), + }); + expect(spoofedReceipt.status).toBe(403); + const matching = await fetch(`${base}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ ids: [999], reader: "test" }), + }); + expect(matching.status).toBe(200); + const omitted = await fetch(`${base}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ ids: [998] }), + }); + expect(omitted.status).toBe(200); + }); + + test("cloud blocker route preserves legacy-only installs without projector config", async () => { + const deps = makeDeps(); + deps.incidentProjector = null; + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + try { + const response = await fetch(`http://127.0.0.1:${isolated.port}/v1/messages/blockers?agent=test`, { + headers: { "x-api-key": rwKey }, + }); + expect(response.status).toBe(200); + } finally { + isolated.stop(true); + } + }); + + test("cloud blocker route fails closed when canonical rows exist but projector binding is absent", async () => { + const deps = makeDeps({ incidentProjectionCount: 1 }); + deps.incidentProjector = null; + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + try { + const response = await fetch(`http://127.0.0.1:${isolated.port}/v1/messages/blockers?agent=test`, { + headers: { "x-api-key": rwKey }, + }); + expect(response.status).toBe(503); + expect((await response.json()).code).toBe("INCIDENT_PROJECTOR_CONFIGURATION_ERROR"); + } finally { + isolated.stop(true); + } + }); + test("read-write key completes a channel + message roundtrip", async () => { const created = await fetch(`${base}/v1/channels`, { method: "POST", @@ -162,6 +293,114 @@ describe("conversations-serve", () => { expect(r.status).toBe(400); }); + test("generic single and bulk ingress reject object or serialized projection metadata", async () => { + for (const metadata of [ + { canonical_incident_projection: { event_id: "iev_fake" } }, + JSON.stringify({ event_id: "iev_fake" }), + ]) { + const response = await fetch(`${base}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ from: "attacker", to: "incidents", content: "spoof", metadata }), + }); + expect(response.status).toBe(409); + } + const bulk = await fetch(`${base}/v1/messages/bulk`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ messages: [{ + uuid: "spoof-bulk", + from: "attacker", + to: "incidents", + content: "spoof", + metadata: JSON.stringify({ projection_key: "todos:incident:fake" }), + }] }), + }); + expect(bulk.status).toBe(409); + }); + + test("POST /v1/messages preserves ordinary metadata and same-scope reply correlation", async () => { + const parent = await fetch(`${base}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ + from: "a", + to: "incidents", + content: "parent", + channel: "incidents", + project_id: "engineering", + }), + }); + expect(parent.status).toBe(201); + const parentMessage = (await parent.json()).message; + + const reply = await fetch(`${base}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ + from: "b", + to: "incidents", + content: "reply", + channel: "incidents", + project_id: "engineering", + reply_to: parentMessage.id, + metadata: { display: { severity: "sev2" } }, + working_dir: "/worktree", + repository: "hasna/conversations", + branch: "fix/reply", + }), + }); + expect(reply.status).toBe(201); + const message = (await reply.json()).message; + expect(message.reply_to).toBe(parentMessage.id); + expect(message.metadata).toEqual({ display: { severity: "sev2" } }); + expect(message.working_dir).toBe("/worktree"); + expect(message.repository).toBe("hasna/conversations"); + expect(message.branch).toBe("fix/reply"); + }); + + test("POST /v1/messages rejects reply parents from a different channel or project", async () => { + const parent = await fetch(`${base}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ from: "a", to: "ops", content: "ops root", channel: "ops", project_id: "p1" }), + }); + const parentId = (await parent.json()).message.id; + + const crossed = await fetch(`${base}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ + from: "b", + to: "incidents", + content: "must not cross scope", + channel: "incidents", + project_id: "p2", + reply_to: parentId, + }), + }); + expect(crossed.status).toBe(409); + }); + + test("POST /v1/messages atomically inherits a DM reply parent session", async () => { + const parent = await fetch(`${base}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ from: "test", to: "alice", content: "dm root" }), + }); + expect(parent.status).toBe(201); + const parentMessage = (await parent.json()).message; + const reply = await fetch(`${base}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ from: "test", to: "alice", content: "dm reply", reply_to: parentMessage.id }), + }); + expect(reply.status).toBe(201); + const replyMessage = (await reply.json()).message; + expect(replyMessage.reply_to).toBe(parentMessage.id); + expect(replyMessage.session_id).toBe(parentMessage.session_id); + }); + test("POST /v1/messages/bulk is idempotent (ON CONFLICT by uuid)", async () => { const batch = { messages: [ diff --git a/src/server/api.ts b/src/server/api.ts index f3a2831..e03bc68 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -25,10 +25,20 @@ import { version as pkgVersion } from "../../package.json"; import { openapiSpec } from "./openapi.js"; import { normalizeChannelName } from "../lib/channel-names.js"; import { extractTopics } from "../lib/topic-extract.js"; +import { + IncidentProjectionConflictError, + IncidentProjectionValidationError, + IncidentProjectorConfigurationError, + metadataSpoofsIncidentProjection, + validateIncidentProjectorBinding, +} from "../lib/incident-projection-contract.js"; +import { appendIncidentProjectionPg, getIncidentProjectionPg } from "./incident-projections.js"; +import type { IncidentProjectionRequestV1, IncidentProjectorContext } from "../types.js"; export const APP = "conversations"; const SCOPE_READ = `${APP}:read`; const SCOPE_WRITE = `${APP}:write`; +export const SCOPE_INCIDENT_PROJECT = `${APP}:incident-project`; const SECURITY_HEADERS: Record = { "X-Content-Type-Options": "nosniff", @@ -64,6 +74,31 @@ export interface ApiServerDeps { client: PoolQueryClient; keys: ApiKeyStore; verifier: ApiKeyVerifier; + /** Stable deployment binding; never derived from an API key or request body. */ + incidentProjector: IncidentProjectorContext | null; +} + +function incidentProjectorContextFromEnv(): IncidentProjectorContext | null { + const tenant_id = process.env.HASNA_CONVERSATIONS_TENANT_ID?.trim(); + const authority_id = process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID?.trim(); + if (!tenant_id && !authority_id) return null; + if (!tenant_id || !authority_id) { + throw new IncidentProjectorConfigurationError( + "Incident projector configuration is incomplete: set both HASNA_CONVERSATIONS_TENANT_ID " + + "and HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID.", + ); + } + const binding = validateIncidentProjectorBinding(tenant_id, authority_id); + return { + ...binding, + routing: { + from: process.env.HASNA_CONVERSATIONS_INCIDENT_FROM, + to: process.env.HASNA_CONVERSATIONS_INCIDENT_TO, + channel: process.env.HASNA_CONVERSATIONS_INCIDENT_CHANNEL, + project_id: process.env.HASNA_CONVERSATIONS_INCIDENT_PROJECT_ID, + session_id: process.env.HASNA_CONVERSATIONS_INCIDENT_SESSION_ID, + }, + }; } /** Build the request-handling deps from the environment (cloud Postgres). */ @@ -80,11 +115,145 @@ export function buildDeps(): ApiServerDeps { } }, }); - return { client, keys, verifier }; + return { client, keys, verifier, incidentProjector: incidentProjectorContextFromEnv() }; } // ---- helpers ---------------------------------------------------------------- +type IncidentBlockerFilter = { ids?: number[]; channel?: string; session?: string; includeAcknowledged?: boolean }; + +async function projectedMessageIds(client: TypedQueryClient, ids: number[]): Promise> { + if (ids.length === 0) return new Set(); + const rows = await client.many<{ message_id: string | number }>( + "SELECT message_id FROM incident_projections WHERE message_id = ANY($1::bigint[])", + [ids], + ); + return new Set(rows.map((row) => Number(row.message_id))); +} + +async function visibleIncidentBlockerIds( + client: TypedQueryClient, + projector: IncidentProjectorContext | null, + agent: string, + filter: IncidentBlockerFilter = {}, +): Promise { + if (!projector) return []; + const params: unknown[] = [projector.tenant_id, projector.authority_id, agent]; + const filters: string[] = []; + if (filter.ids?.length) { + params.push(filter.ids); + filters.push(`m.id = ANY($${params.length}::bigint[])`); + } + if (filter.channel) { + params.push(normalizeChannelName(filter.channel)); + filters.push(`m.channel = $${params.length}`); + } + if (filter.session) { + params.push(filter.session); + filters.push(`m.session_id = $${params.length}`); + } + const extra = filters.length ? `AND ${filters.join(" AND ")}` : ""; + const receiptFilter = filter.includeAcknowledged + ? "" + : `AND ( + p.pending_handoff + OR NOT EXISTS ( + SELECT 1 FROM message_read_receipts receipt + WHERE receipt.message_id = m.id AND lower(receipt.agent) = lower($3) + ) + )`; + const rows = await client.many<{ id: string | number }>( + `WITH latest AS ( + SELECT p.* + FROM incident_projections p + JOIN ( + SELECT tenant_id, authority_id, incident_id, MAX(incident_version) AS incident_version + FROM incident_projections + WHERE tenant_id = $1 AND authority_id = $2 + GROUP BY tenant_id, authority_id, incident_id + ) current + ON current.tenant_id = p.tenant_id + AND current.authority_id = p.authority_id + AND current.incident_id = p.incident_id + AND current.incident_version = p.incident_version + ), + blocker_candidates AS ( + SELECT p.*, + CASE WHEN p.status = 'superseded' + AND p.superseded_by_incident_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM incident_projections replacement + WHERE replacement.tenant_id = p.tenant_id + AND replacement.authority_id = p.authority_id + AND replacement.incident_id = p.superseded_by_incident_id + AND replacement.supersedes_incident_id = p.incident_id + ) + THEN TRUE ELSE FALSE END AS pending_handoff + FROM latest p + ) + SELECT DISTINCT m.id + FROM blocker_candidates p + JOIN messages m ON m.id = p.message_id + JOIN incident_projection_scopes scope + ON scope.projection_id = p.id AND scope.scope_type = 'blocked' + WHERE ( + (p.status IN ('open','investigating','contained','monitoring') AND p.blocking = TRUE) + OR p.pending_handoff + ) + ${receiptFilter} + AND ( + lower(scope.scope) = 'agent:' || lower($3) + OR lower(scope.scope) IN ( + SELECT 'channel:' || lower(channel) FROM channel_members WHERE lower(agent) = lower($3) + ) + OR scope.scope IN ( + SELECT 'project:' || project_id FROM agent_presence + WHERE lower(agent) = lower($3) AND project_id <> '' + ) + ) + ${extra}`, + params, + ); + return rows.map((row) => Number(row.id)); +} + +async function upsertReadReceipts(client: TypedQueryClient, ids: number[], agent: string): Promise { + if (ids.length === 0) return 0; + const result = await client.query( + `INSERT INTO message_read_receipts (message_id, agent, read_at) + SELECT message_id, $2, NOW() FROM unnest($1::bigint[]) AS message_id + ON CONFLICT (message_id, agent) DO UPDATE SET read_at = EXCLUDED.read_at`, + [ids, agent.toLowerCase()], + ); + return result.rowCount; +} + +async function requireIncidentBlockerContext( + client: TypedQueryClient, + context: IncidentProjectorContext | null, +): Promise { + const any = await client.get<{ n: string | number }>("SELECT COUNT(*)::bigint AS n FROM incident_projections"); + if (!context) { + if (Number(any?.n ?? 0) === 0) return null; + throw new IncidentProjectorConfigurationError( + "Canonical blocker reads require a configured incident projector tenant and authority", + ); + } + const binding = validateIncidentProjectorBinding(context.tenant_id, context.authority_id); + if (Number(any?.n ?? 0) > 0) { + const selected = await client.get<{ n: string | number }>( + "SELECT COUNT(*)::bigint AS n FROM incident_projections WHERE tenant_id = $1 AND authority_id = $2", + [binding.tenant_id, binding.authority_id], + ); + if (Number(selected?.n ?? 0) === 0) { + throw new IncidentProjectorConfigurationError( + "Configured incident projector tenant/authority does not match stored canonical projections", + ); + } + } + return { ...context, ...binding }; +} + async function readJson(req: Request): Promise> { const text = await req.text(); if (!text.trim()) return {}; @@ -253,39 +422,67 @@ async function processMentions( * channel_members/channel_subscriptions → channels(name) FK, so the new channel * row is created first, children are moved, then the old row is dropped. */ -async function renameChannelServer( +export async function renameChannelServer( client: PoolQueryClient, oldName: string, newName: string, ): Promise<{ ok: true; name: string } | { ok: false; error: string; status: number }> { const from = normalizeChannelName(oldName); const to = normalizeChannelName(newName); - const existing = await client.get(`SELECT name FROM channels WHERE name = $1`, [from]); - if (!existing) return { ok: false, error: `Channel not found: ${from}`, status: 404 }; - if (from === to) return { ok: true, name: from }; - const conflict = await client.get(`SELECT name FROM channels WHERE name = $1`, [to]); - if (conflict) return { ok: false, error: `Channel #${to} already exists.`, status: 409 }; - await client.transaction(async (tx) => { - await tx.query( - `INSERT INTO channels (name, description, topic, project_id, created_by, created_at, archived_at, metadata, tags) - SELECT $1, description, topic, project_id, created_by, created_at, archived_at, metadata, tags FROM channels WHERE name = $2`, - [to, from], - ); - await tx.query(`UPDATE channel_members SET channel = $1 WHERE channel = $2`, [to, from]); - await tx.query(`UPDATE channel_subscriptions SET channel = $1 WHERE channel = $2`, [to, from]); - await tx.query( - `UPDATE messages SET channel = $1, to_agent = CASE WHEN to_agent = $2 THEN $1 ELSE to_agent END WHERE channel = $2`, - [to, from], - ); - await tx.query(`UPDATE messages SET session_id = $1 WHERE session_id = $2`, [`channel:${to}`, `channel:${from}`]); - await tx.query(`UPDATE message_mentions SET channel = $1 WHERE channel = $2`, [to, from]); - await tx.query(`UPDATE tasks SET channel = $1 WHERE channel = $2`, [to, from]); - await tx.query(`UPDATE graph_edges SET from_id = $1 WHERE from_type = 'channel' AND from_id = $2`, [to, from]); - await tx.query(`UPDATE graph_edges SET to_id = $1 WHERE to_type = 'channel' AND to_id = $2`, [to, from]); - await tx.query(`UPDATE resource_locks SET resource_id = $1 WHERE resource_type = 'channel' AND resource_id = $2`, [to, from]); - await tx.query(`DELETE FROM channels WHERE name = $1`, [from]); - }); - return { ok: true, name: to }; + try { + return await client.transaction(async (tx) => { + const existing = await tx.get(`SELECT name FROM channels WHERE name = $1 FOR UPDATE`, [from]); + if (!existing) return { ok: false as const, error: `Channel not found: ${from}`, status: 404 }; + if (from === to) return { ok: true as const, name: from }; + const conflict = await tx.get(`SELECT name FROM channels WHERE name = $1 FOR UPDATE`, [to]); + if (conflict) return { ok: false as const, error: `Channel #${to} already exists.`, status: 409 }; + + await tx.get( + `SELECT set_config('hasna.conversations.channel_scope_rewrite', $1, TRUE) AS configured`, + [JSON.stringify({ + old_session_id: `channel:${from}`, + new_session_id: `channel:${to}`, + old_channel: from, + new_channel: to, + old_to_agent: from, + new_to_agent: to, + })], + ); + await tx.query( + `INSERT INTO channels (name, description, topic, project_id, created_by, created_at, archived_at, metadata, tags) + SELECT $1, description, topic, project_id, created_by, created_at, archived_at, metadata, tags + FROM channels WHERE name = $2`, + [to, from], + ); + await tx.query(`UPDATE channel_members SET channel = $1 WHERE channel = $2`, [to, from]); + await tx.query(`UPDATE channel_subscriptions SET channel = $1 WHERE channel = $2`, [to, from]); + await tx.query( + `UPDATE messages + SET channel = $1, + session_id = CASE WHEN session_id = $3 THEN $4 ELSE session_id END, + to_agent = CASE WHEN to_agent = $2 THEN $1 ELSE to_agent END + WHERE channel = $2`, + [to, from, `channel:${from}`, `channel:${to}`], + ); + await tx.query( + `UPDATE messages SET session_id = $1 + WHERE session_id = $2 AND (channel IS NULL OR channel <> $3)`, + [`channel:${to}`, `channel:${from}`, to], + ); + await tx.query(`UPDATE message_mentions SET channel = $1 WHERE channel = $2`, [to, from]); + await tx.query(`UPDATE tasks SET channel = $1 WHERE channel = $2`, [to, from]); + await tx.query(`UPDATE graph_edges SET from_id = $1 WHERE from_type = 'channel' AND from_id = $2`, [to, from]); + await tx.query(`UPDATE graph_edges SET to_id = $1 WHERE to_type = 'channel' AND to_id = $2`, [to, from]); + await tx.query(`UPDATE resource_locks SET resource_id = $1 WHERE resource_type = 'channel' AND resource_id = $2`, [to, from]); + await tx.query(`DELETE FROM channels WHERE name = $1`, [from]); + return { ok: true as const, name: to }; + }); + } catch (error) { + if ((error as { code?: string }).code === "23505") { + return { ok: false, error: `Channel #${to} already exists.`, status: 409 }; + } + throw error; + } } // ---- task helpers ------------------------------------------------------------ @@ -489,10 +686,11 @@ export function startApiServer(options: StartApiServerOptions = {}) { // ---- versioned API (authenticated) ---- if (path === "/v1" || path.startsWith("/v1/")) { const writing = method !== "GET" && method !== "HEAD"; + const incidentProjectionWrite = path === "/v1/incident-projections" && method === "POST"; const decision = await verifier.authenticate(req.headers, { method, path, - requiredScopes: [writing ? SCOPE_WRITE : SCOPE_READ], + requiredScopes: [incidentProjectionWrite ? SCOPE_INCIDENT_PROJECT : writing ? SCOPE_WRITE : SCOPE_READ], }); if (!decision.ok) { return json({ error: decision.message, reason: decision.reason }, decision.status, { @@ -530,7 +728,138 @@ async function handleV1( const { client } = deps; const sub = path.slice("/v1/".length); + // ---- canonical Todos incident projections ---- + if (sub === "incident-projections" && method === "POST") { + if (!deps.incidentProjector) return json({ error: "Incident projector authority is not configured" }, 503); + const body = await readJson(req); + try { + const projection = await appendIncidentProjectionPg( + client, + body as unknown as IncidentProjectionRequestV1, + deps.incidentProjector, + ); + return json({ projection }, projection.replayed ? 200 : 201); + } catch (error) { + if (error instanceof IncidentProjectionConflictError) { + return json({ error: error.message, code: error.code }, 409); + } + if (error instanceof IncidentProjectionValidationError) { + return json({ error: error.message, code: error.code }, 400); + } + console.error("[incident-projector] append failed with an unexpected storage/runtime error"); + return json({ + error: "Incident projection service is temporarily unavailable", + code: "INCIDENT_PROJECTION_UNAVAILABLE", + }, 503); + } + } + + const projectionMatch = sub.match(/^incident-projections\/(iev_[0-9a-f]{32})$/); + if (projectionMatch && method === "GET") { + if (!deps.incidentProjector) return json({ error: "Incident projector authority is not configured" }, 503); + const projection = await getIncidentProjectionPg(client, projectionMatch[1], deps.incidentProjector); + if (!projection) return json({ error: "Incident projection not found" }, 404); + return json({ projection }); + } + // ---- messages ---- + if (sub === "messages/blockers" && method === "GET") { + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const requestedAgent = str(url.searchParams.get("agent")); + if (requestedAgent && requestedAgent.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "blocker agent must match the authenticated agent" }, 403); + } + const who = agent; + const limit = clampLimit(url.searchParams.get("limit"), 50, 500); + const offsetRaw = Number(url.searchParams.get("offset") ?? 0); + const offset = Number.isSafeInteger(offsetRaw) && offsetRaw > 0 ? offsetRaw : 0; + let projector: IncidentProjectorContext | null; + try { + projector = await requireIncidentBlockerContext(client, deps.incidentProjector); + } catch (error) { + if (error instanceof IncidentProjectorConfigurationError) { + return json({ error: error.message, code: error.code }, 503); + } + throw error; + } + const rows = await client.many>( + `WITH latest AS ( + SELECT p.* + FROM incident_projections p + JOIN ( + SELECT tenant_id, authority_id, incident_id, MAX(incident_version) AS incident_version + FROM incident_projections + WHERE $1::text IS NOT NULL AND $2::text IS NOT NULL + AND tenant_id = $1 AND authority_id = $2 + GROUP BY tenant_id, authority_id, incident_id + ) current + ON current.tenant_id = p.tenant_id + AND current.authority_id = p.authority_id + AND current.incident_id = p.incident_id + AND current.incident_version = p.incident_version + ), + blocker_candidates AS ( + SELECT p.*, + CASE WHEN p.status = 'superseded' + AND p.superseded_by_incident_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM incident_projections replacement + WHERE replacement.tenant_id = p.tenant_id + AND replacement.authority_id = p.authority_id + AND replacement.incident_id = p.superseded_by_incident_id + AND replacement.supersedes_incident_id = p.incident_id + ) + THEN TRUE ELSE FALSE END AS pending_handoff + FROM latest p + ), + projected_ids AS ( + SELECT DISTINCT m.id + FROM blocker_candidates p + JOIN messages m ON m.id = p.message_id + JOIN incident_projection_scopes scope + ON scope.projection_id = p.id AND scope.scope_type = 'blocked' + WHERE ( + (p.status IN ('open','investigating','contained','monitoring') AND p.blocking = TRUE) + OR p.pending_handoff + ) + AND ( + p.pending_handoff + OR NOT EXISTS ( + SELECT 1 FROM message_read_receipts receipt + WHERE receipt.message_id = m.id AND lower(receipt.agent) = lower($3) + ) + ) + AND ( + lower(scope.scope) = 'agent:' || lower($3) + OR lower(scope.scope) IN ( + SELECT 'channel:' || lower(channel) FROM channel_members WHERE lower(agent) = lower($3) + ) + OR scope.scope IN ( + SELECT 'project:' || project_id FROM agent_presence + WHERE lower(agent) = lower($3) AND project_id <> '' + ) + ) + ), + legacy_ids AS ( + SELECT m.id + FROM messages m + LEFT JOIN incident_projections p ON p.message_id = m.id + WHERE p.id IS NULL AND m.blocking = TRUE AND m.read_at IS NULL + AND ( + lower(m.to_agent) = lower($3) + OR m.channel IN (SELECT channel FROM channel_members WHERE lower(agent) = lower($3)) + ) + ), + eligible_ids AS ( + SELECT id FROM projected_ids UNION SELECT id FROM legacy_ids + ) + SELECT m.* FROM messages m JOIN eligible_ids eligible ON eligible.id = m.id + ORDER BY m.created_at ASC, m.id ASC LIMIT $4 OFFSET $5`, + [projector?.tenant_id ?? null, projector?.authority_id ?? null, who, limit, offset], + ); + return json({ messages: rows.map(parseServerMessage) }); + } + if (sub === "messages" && method === "GET") { const to = str(url.searchParams.get("to")); const from = str(url.searchParams.get("from")); @@ -601,7 +930,12 @@ async function handleV1( // semantics so read state routes to the cloud identically. if (sub === "messages/read" && method === "POST") { const body = await readJson(req); - const reader = str(body.reader) ?? str(body.agent) ?? agent ?? undefined; + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const requestedReader = str(body.reader) ?? str(body.agent); + if (requestedReader && requestedReader.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "reader must match the authenticated agent" }, 403); + } + const reader = agent; const ids = Array.isArray(body.ids) ? (body.ids as unknown[]).map(Number).filter((n) => Number.isFinite(n)) : []; @@ -611,7 +945,7 @@ async function handleV1( // markMentionsRead: stamp notified_at on the agent's @mentions (optionally // scoped to one channel). Routed here because the client posts it to // /messages/read with mentions_only=true. - if (body.mentions_only && reader) { + if (body.mentions_only) { const res = channel ? await client.query( `UPDATE message_mentions SET notified_at = NOW()::text WHERE mentioned_agent = $1 AND channel = $2 AND notified_at IS NULL`, @@ -625,43 +959,53 @@ async function handleV1( } let marked = 0; if (ids.length) { - if (reader) { - const rParams: unknown[] = []; - const rowsSql: string[] = []; - const lower = reader.toLowerCase(); - for (const id of ids) { - rParams.push(id, lower); - rowsSql.push(`($${rParams.length - 1}, $${rParams.length}, NOW())`); - } - await client.query( - `INSERT INTO message_read_receipts (message_id, agent, read_at) VALUES ${rowsSql.join(", ")} - ON CONFLICT (message_id, agent) DO UPDATE SET read_at = EXCLUDED.read_at`, - rParams, - ); + const projected = await projectedMessageIds(client, ids); + const visible = new Set(await visibleIncidentBlockerIds( + client, + deps.incidentProjector, + reader, + { ids, includeAcknowledged: true }, + )); + if ([...projected].some((id) => !visible.has(id))) { + return json({ error: "one or more incident blockers are not visible to the authenticated agent" }, 403); } + await upsertReadReceipts(client, ids, reader); + const ordinary = ids.filter((id) => !projected.has(id)); const res = await client.query( - `UPDATE messages SET read_at = NOW()::text WHERE id = ANY($1::bigint[]) AND read_at IS NULL`, - [ids], + `UPDATE messages SET read_at = NOW()::text + WHERE id = ANY($1::bigint[]) AND read_at IS NULL + AND NOT EXISTS (SELECT 1 FROM incident_projections p WHERE p.message_id = messages.id)`, + [ordinary], ); - marked = res.rowCount; - } else if (all && reader) { + marked = projected.size + res.rowCount; + } else if (all) { + const projected = await visibleIncidentBlockerIds(client, deps.incidentProjector, reader); + const acknowledged = await upsertReadReceipts(client, projected, reader); const res = await client.query( - `UPDATE messages SET read_at = NOW()::text WHERE to_agent = $1 AND read_at IS NULL`, + `UPDATE messages SET read_at = NOW()::text WHERE to_agent = $1 AND read_at IS NULL + AND NOT EXISTS (SELECT 1 FROM incident_projections p WHERE p.message_id = messages.id)`, [reader], ); - marked = res.rowCount; - } else if (channel && reader) { + marked = acknowledged + res.rowCount; + } else if (channel) { + const normalizedChannel = normalizeChannelName(channel); + const projected = await visibleIncidentBlockerIds(client, deps.incidentProjector, reader, { channel: normalizedChannel }); + const acknowledged = await upsertReadReceipts(client, projected, reader); const res = await client.query( - `UPDATE messages SET read_at = NOW()::text WHERE channel = $1 AND from_agent <> $2 AND read_at IS NULL`, - [channel, reader], + `UPDATE messages SET read_at = NOW()::text WHERE channel = $1 AND from_agent <> $2 AND read_at IS NULL + AND NOT EXISTS (SELECT 1 FROM incident_projections p WHERE p.message_id = messages.id)`, + [normalizedChannel, reader], ); - marked = res.rowCount; - } else if (session && reader) { + marked = acknowledged + res.rowCount; + } else if (session) { + const projected = await visibleIncidentBlockerIds(client, deps.incidentProjector, reader, { session }); + const acknowledged = await upsertReadReceipts(client, projected, reader); const res = await client.query( - `UPDATE messages SET read_at = NOW()::text WHERE session_id = $1 AND to_agent = $2 AND read_at IS NULL`, + `UPDATE messages SET read_at = NOW()::text WHERE session_id = $1 AND to_agent = $2 AND read_at IS NULL + AND NOT EXISTS (SELECT 1 FROM incident_projections p WHERE p.message_id = messages.id)`, [session, reader], ); - marked = res.rowCount; + marked = acknowledged + res.rowCount; } else { return json({ error: "provide ids, or all/channel/session with reader" }, 400); } @@ -820,31 +1164,88 @@ async function handleV1( const body = await readJson(req); const from = str(body.from) ?? agent ?? undefined; const content = str(body.content); - const channelName = body.channel ? normalizeChannelName(String(body.channel)) : null; + const requestedChannel = body.channel ? normalizeChannelName(String(body.channel)) : null; // A channel message addresses the channel itself; a DM needs an explicit `to`. - const toAgent = channelName ?? str(body.to); + const requestedTo = str(body.to); + const toAgent = requestedChannel ?? requestedTo; if (!from || !toAgent || !content) return json({ error: "from, to (or channel), and content are required" }, 400); - const projectId = str(body.project_id); + const requestedProject = str(body.project_id); + const requestedSession = str(body.session_id); // Mirror the local sendMessage session derivation so channel history and // notifications group identically on the cloud. - const sessionId = channelName - ? `channel:${channelName}` - : str(body.session_id) ?? `${[from, toAgent].sort().join("-")}-${randomUUID().slice(0, 8)}`; + const derivedSession = requestedChannel + ? `channel:${requestedChannel}` + : requestedSession ?? `${[from, toAgent].sort().join("-")}-${randomUUID().slice(0, 8)}`; let priority = str(body.priority)?.toLowerCase() ?? "normal"; if (!VALID_PRIORITIES.includes(priority)) return json({ error: "Invalid priority" }, 400); const blocking = body.blocking === true; - const row = await client.get<{ id: number }>( - `INSERT INTO messages (session_id, from_agent, to_agent, channel, project_id, content, priority, blocking) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) - RETURNING id, uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, blocking, created_at`, - [sessionId, from, toAgent, channelName ?? null, projectId ?? null, content, priority, blocking], - ); + if (metadataSpoofsIncidentProjection(body.metadata)) { + return json({ error: "Canonical incident projection metadata is reserved for the dedicated projector" }, 409); + } + const replyToRaw = body.reply_to; + const replyTo = replyToRaw == null ? null : Number(replyToRaw); + if (replyTo != null && (!Number.isSafeInteger(replyTo) || replyTo <= 0)) { + return json({ error: "reply_to must be a positive integer" }, 400); + } + const metadata = body.metadata == null + ? null + : typeof body.metadata === "string" + ? body.metadata + : JSON.stringify(body.metadata); + const attachments = body.attachments == null + ? null + : typeof body.attachments === "string" + ? body.attachments + : JSON.stringify(body.attachments); + const workingDir = str(body.working_dir) ?? null; + const repository = str(body.repository) ?? null; + const branch = str(body.branch) ?? null; + const outcome = await client.transaction(async (tx) => { + let channelName = requestedChannel; + let projectId = requestedProject ?? null; + let sessionId = derivedSession; + if (replyTo != null) { + const parent = await tx.get>( + `SELECT id, session_id, channel, project_id FROM messages WHERE id = $1 FOR KEY SHARE`, + [replyTo], + ); + if (!parent) return { error: "reply parent not found", status: 404 as const, row: null }; + const parentChannel = str(parent.channel) ?? null; + const parentProject = str(parent.project_id) ?? null; + if ((requestedSession != null && parent.session_id !== requestedSession) + || (requestedChannel != null && parentChannel !== requestedChannel) + || (requestedProject != null && parentProject !== requestedProject)) { + return { error: "reply parent is outside the message channel/project/session scope", status: 409 as const, row: null }; + } + sessionId = String(parent.session_id); + channelName = parentChannel; + projectId = parentProject; + } + const finalToAgent = channelName ?? requestedTo!; + const row = await tx.get>( + `INSERT INTO messages ( + session_id, from_agent, to_agent, channel, project_id, content, priority, blocking, + reply_to, metadata, working_dir, repository, branch, attachments + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + RETURNING id, uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, + blocking, reply_to, metadata, working_dir, repository, branch, attachments, created_at`, + [ + sessionId, from, finalToAgent, channelName, projectId, content, priority, blocking, + replyTo, metadata, workingDir, repository, branch, attachments, + ], + ); + return { error: null, status: 201 as const, row }; + }); + if (outcome.error) return json({ error: outcome.error }, outcome.status); + const row = outcome.row; // @mentions in channel messages create mention rows + notification DMs, so // mentions_only reads and mention counts work in cloud mode too. - if (channelName && row?.id != null) { - try { await processMentions(client, Number(row.id), from, channelName, content); } catch { /* best-effort */ } + const insertedChannel = row ? str(row.channel) : null; + if (insertedChannel && row?.id != null) { + try { await processMentions(client, Number(row.id), from, insertedChannel, content); } catch { /* best-effort */ } } - return json({ message: row }, 201); + return json({ message: row ? parseServerMessage(row) : null }, 201); } // ---- bulk message ingest (backfill local -> cloud to parity) ---- @@ -885,6 +1286,9 @@ async function handleV1( if (!uuid || !from || !to || content === undefined) { return json({ error: `messages[${i}] requires uuid, from, to, and content` }, 400); } + if (metadataSpoofsIncidentProjection(m.metadata)) { + return json({ error: `messages[${i}].metadata contains reserved canonical incident projection fields` }, 409); + } let priority = str(m.priority)?.toLowerCase() ?? "normal"; if (!VALID_PRIORITIES.includes(priority)) priority = "normal"; const values: unknown[] = [ @@ -939,8 +1343,24 @@ async function handleV1( } if (method === "POST") { const body = await readJson(req); - const who = str(body.agent) ?? agent ?? undefined; - if (!who) return json({ error: "agent is required" }, 400); + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const requestedAgent = str(body.agent); + if (requestedAgent && requestedAgent.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "receipt agent must match the authenticated agent" }, 403); + } + const who = agent; + const projected = await projectedMessageIds(client, [id]); + if (projected.has(id)) { + const visible = await visibleIncidentBlockerIds( + client, + deps.incidentProjector, + who, + { ids: [id], includeAcknowledged: true }, + ); + if (!visible.includes(id)) { + return json({ error: "incident blocker is not visible to the authenticated agent" }, 403); + } + } const row = await client.get( `INSERT INTO message_read_receipts (message_id, agent, read_at) VALUES ($1, $2, NOW()) ON CONFLICT (message_id, agent) DO UPDATE SET read_at = EXCLUDED.read_at @@ -1049,6 +1469,10 @@ async function handleV1( } if (method === "PATCH") { // Edit content — only the original sender may edit; stamps edited_at. + const projection = await client.get("SELECT 1 AS present FROM incident_projections WHERE message_id = $1", [id]); + if (projection) { + return json({ error: "incident projection messages are append-only", code: "INCIDENT_PROJECTION_CONFLICT" }, 409); + } const body = await readJson(req); const from = str(body.from) ?? agent ?? undefined; const content = typeof body.content === "string" ? body.content : undefined; @@ -1062,6 +1486,10 @@ async function handleV1( return json({ message: row }); } if (method === "DELETE") { + const projection = await client.get("SELECT 1 AS present FROM incident_projections WHERE message_id = $1", [id]); + if (projection) { + return json({ error: "incident projection messages are append-only", code: "INCIDENT_PROJECTION_CONFLICT" }, 409); + } const from = str(url.searchParams.get("from")) ?? agent ?? undefined; if (!from) return json({ error: "'from' is required to delete a message" }, 400); const row = await client.get(`DELETE FROM messages WHERE id = $1 AND from_agent = $2 RETURNING id`, [id, from]); diff --git a/src/server/incident-projections.ts b/src/server/incident-projections.ts new file mode 100644 index 0000000..fde6ccc --- /dev/null +++ b/src/server/incident-projections.ts @@ -0,0 +1,273 @@ +import { randomUUID } from "crypto"; +import type { PoolQueryClient, TypedQueryClient } from "../generated/storage-kit/query.js"; +import { parseMessage } from "../lib/messages.js"; +import { + buildIncidentProjectionDisplay, + IncidentProjectionConflictError, + validateIncidentProjection, +} from "../lib/incident-projection-contract.js"; +import type { + IncidentProjectionRecord, + IncidentProjectionRequestV1, + IncidentProjectorContext, + Message, +} from "../types.js"; + +type Row = Record; + +async function loadRecord( + client: TypedQueryClient, + row: Row, + replayed: boolean, +): Promise { + const messageRow = await client.get("SELECT * FROM messages WHERE id = $1", [Number(row.message_id)]); + if (!messageRow) throw new Error(`Incident projection ${String(row.event_id)} has no display message`); + return { + id: Number(row.id), + event_id: String(row.event_id), + projection_key: String(row.projection_key), + message_id: Number(row.message_id), + schema_version: 1, + source: "todos", + tenant_id: String(row.tenant_id), + authority_id: String(row.authority_id), + incident_id: String(row.incident_id), + transition_id: String(row.transition_id), + incident_version: Number(row.incident_version), + occurred_at: new Date(String(row.occurred_at)).toISOString(), + status: row.status as IncidentProjectionRecord["status"], + severity: row.severity as IncidentProjectionRecord["severity"], + blocking: Boolean(row.blocking), + supersedes_transition_id: row.supersedes_transition_id == null ? null : String(row.supersedes_transition_id), + supersedes_incident_id: row.supersedes_incident_id == null ? null : String(row.supersedes_incident_id), + superseded_by_incident_id: row.superseded_by_incident_id == null ? null : String(row.superseded_by_incident_id), + canonical_payload: String(row.canonical_payload), + payload_hash: String(row.payload_hash), + created_at: new Date(String(row.created_at)).toISOString(), + message: parseMessage(messageRow) as Message, + replayed, + }; +} + +/** PG implementation of the projector. All display, ledger, and scope writes commit together. */ +export async function appendIncidentProjectionPg( + client: PoolQueryClient, + raw: IncidentProjectionRequestV1, + context: IncidentProjectorContext, +): Promise { + const validated = validateIncidentProjection(raw, context); + const { request } = validated; + const display = buildIncidentProjectionDisplay(request, context); + + try { + return await client.transaction(async (tx) => { + const existing = await tx.get( + "SELECT * FROM incident_projections WHERE tenant_id = $1 AND event_id = $2", + [context.tenant_id, request.event_id], + ); + if (existing) { + if (String(existing.payload_hash) !== validated.payload_hash) { + throw new IncidentProjectionConflictError( + `Event ${request.event_id} already exists with a different canonical payload`, + ); + } + return loadRecord(tx, existing, true); + } + + const latest = await tx.get( + `SELECT * FROM incident_projections + WHERE tenant_id = $1 AND authority_id = $2 AND incident_id = $3 + ORDER BY incident_version DESC LIMIT 1 FOR UPDATE`, + [context.tenant_id, context.authority_id, request.incident_id], + ); + if (request.incident_version === 1) { + if (latest) { + throw new IncidentProjectionConflictError( + `Incident ${request.incident_id} already has projection version ${String(latest.incident_version)}`, + ); + } + } else { + if (!latest || Number(latest.incident_version) !== request.incident_version - 1 || + String(latest.transition_id) !== validated.supersedes_transition_id) { + throw new IncidentProjectionConflictError( + `Incident ${request.incident_id} requires canonical predecessor version ${request.incident_version - 1}`, + ); + } + if (Date.parse(request.occurred_at) < Date.parse(String(latest.occurred_at))) { + throw new IncidentProjectionConflictError("Incident projection occurred_at cannot move backwards"); + } + } + + const assertSupersededSource = async (id: string | null): Promise => { + if (!id) return; + const target = await tx.get( + `SELECT * FROM incident_projections + WHERE tenant_id = $1 AND authority_id = $2 AND incident_id = $3 + ORDER BY incident_version DESC LIMIT 1`, + [context.tenant_id, context.authority_id, id], + ); + if (!target) { + throw new IncidentProjectionConflictError("incident.supersedes_id references an incident outside this tenant/authority or not yet projected"); + } + if (target.status !== "superseded" || target.superseded_by_incident_id !== request.incident_id) { + throw new IncidentProjectionConflictError( + "incident.supersedes_id must reciprocate a superseded incident whose superseded_by_id is this incident", + ); + } + }; + await assertSupersededSource(request.incident.supersedes_id); + + let channel = display.channel ?? null; + let projectId = display.project_id ?? null; + let sessionId = channel + ? `channel:${channel}` + : display.session_id ?? `incident:${context.authority_id}:${request.incident_id}`; + let toAgent = channel ?? display.to; + let replyTo: number | null = null; + if (request.incident_version > 1) { + const root = await tx.get( + `SELECT m.id, m.session_id, m.channel, m.project_id, m.to_agent + FROM incident_projections p JOIN messages m ON m.id = p.message_id + WHERE p.tenant_id = $1 AND p.authority_id = $2 AND p.incident_id = $3 AND p.incident_version = 1`, + [context.tenant_id, context.authority_id, request.incident_id], + ); + if (!root) throw new IncidentProjectionConflictError("Incident projection root is missing"); + sessionId = String(root.session_id); + channel = root.channel == null ? null : String(root.channel); + projectId = root.project_id == null ? null : String(root.project_id); + toAgent = String(root.to_agent); + replyTo = Number(root.id); + } + + const pointer = JSON.stringify({ + canonical_incident_projection: { + schema_version: 1, + source: "todos", + authority_id: context.authority_id, + incident_id: request.incident_id, + incident_version: request.incident_version, + transition_id: request.transition_id, + event_id: request.event_id, + projection_key: request.projection_key, + }, + }); + const messageRow = await tx.get( + `INSERT INTO messages ( + uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, + working_dir, repository, branch, metadata, blocking, reply_to + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + RETURNING *`, + [ + randomUUID().replace(/-/g, ""), + sessionId, + display.from, + toAgent, + channel, + projectId, + display.content, + display.priority ?? "normal", + display.working_dir ?? null, + display.repository ?? null, + display.branch ?? null, + pointer, + validated.blocking, + replyTo, + ], + ); + if (!messageRow) throw new Error("Failed to create incident projection display message"); + + const incident = request.incident; + const projectionRow = await tx.get( + `INSERT INTO incident_projections ( + event_id, projection_key, message_id, schema_version, source, tenant_id, authority_id, + incident_id, transition_id, incident_version, occurred_at, status, severity, blocking, + affected_scopes, blocked_scopes, supersedes_transition_id, supersedes_incident_id, + superseded_by_incident_id, canonical_payload, payload_hash + ) VALUES ($1,$2,$3,1,'todos',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) + ON CONFLICT (tenant_id, event_id) DO NOTHING + RETURNING *`, + [ + request.event_id, + request.projection_key, + Number(messageRow.id), + context.tenant_id, + context.authority_id, + request.incident_id, + request.transition_id, + request.incident_version, + request.occurred_at, + incident.status, + incident.severity, + validated.blocking, + JSON.stringify(incident.affected_scopes), + JSON.stringify(incident.blocked_scopes), + validated.supersedes_transition_id, + incident.supersedes_id, + incident.superseded_by_id, + validated.canonical_payload, + validated.payload_hash, + ], + ); + + if (!projectionRow) { + // A concurrent identical event won the unique insert. Candidate display + // remains unlinked and can be removed inside this same transaction. + await tx.query("DELETE FROM messages WHERE id = $1", [Number(messageRow.id)]); + const winner = await tx.get( + "SELECT * FROM incident_projections WHERE tenant_id = $1 AND event_id = $2", + [context.tenant_id, request.event_id], + ); + if (!winner) throw new IncidentProjectionConflictError("Concurrent incident projection winner is unavailable"); + if (String(winner.payload_hash) !== validated.payload_hash) { + throw new IncidentProjectionConflictError( + `Event ${request.event_id} already exists with a different canonical payload`, + ); + } + return loadRecord(tx, winner, true); + } + + for (const scope of incident.affected_scopes) { + await tx.query( + "INSERT INTO incident_projection_scopes (projection_id, scope_type, scope) VALUES ($1, 'affected', $2)", + [Number(projectionRow.id), scope], + ); + } + for (const scope of incident.blocked_scopes) { + await tx.query( + "INSERT INTO incident_projection_scopes (projection_id, scope_type, scope) VALUES ($1, 'blocked', $2)", + [Number(projectionRow.id), scope], + ); + } + + return loadRecord(tx, projectionRow, false); + }); + } catch (error) { + if ((error as { code?: string } | null)?.code !== "23505") throw error; + const winner = await client.get( + `SELECT * FROM incident_projections + WHERE tenant_id = $1 AND authority_id = $2 + AND (event_id = $3 OR (incident_id = $4 AND incident_version = $5)) + ORDER BY CASE WHEN event_id = $3 THEN 0 ELSE 1 END LIMIT 1`, + [context.tenant_id, context.authority_id, request.event_id, request.incident_id, request.incident_version], + ); + if (winner && winner.event_id === request.event_id && winner.projection_key === request.projection_key && + winner.payload_hash === validated.payload_hash) { + return loadRecord(client, winner, true); + } + throw new IncidentProjectionConflictError( + `Incident ${request.incident_id} version ${request.incident_version} already has a different canonical projection`, + ); + } +} + +export async function getIncidentProjectionPg( + client: TypedQueryClient, + eventId: string, + context: IncidentProjectorContext, +): Promise { + const row = await client.get( + "SELECT * FROM incident_projections WHERE tenant_id = $1 AND authority_id = $2 AND event_id = $3", + [context.tenant_id, context.authority_id, eventId], + ); + return row ? loadRecord(client, row, false) : null; +} diff --git a/src/server/openapi.test.ts b/src/server/openapi.test.ts new file mode 100644 index 0000000..8f71cc4 --- /dev/null +++ b/src/server/openapi.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { openapiSpec } from "./openapi"; + +describe("incident projection public contract", () => { + test("publishes the dedicated route, projector scope, and strict wire schemas", () => { + const spec = openapiSpec as any; + const append = spec.paths["/v1/incident-projections"].post; + expect(append.operationId).toBe("appendIncidentProjection"); + expect(append["x-required-scope"]).toBe("conversations:incident-project"); + expect(append.requestBody.content["application/json"].schema.$ref).toBe( + "#/components/schemas/IncidentProjectionEventV1", + ); + expect(Object.keys(append.responses).sort()).toEqual(["200", "201", "400", "409", "503"]); + expect(spec.paths["/v1/incident-projections/{event_id}"].get.operationId).toBe("getIncidentProjection"); + expect(spec.paths["/v1/messages/blockers"].get.operationId).toBe("listUnreadBlockers"); + expect(spec.components.schemas.IncidentProjectionEventV1.additionalProperties).toBe(false); + expect(spec.components.schemas.IncidentSnapshotV1.additionalProperties).toBe(false); + expect(spec.components.schemas.IncidentProjectionEventV1.properties.authority_id.pattern).toBe( + "^[A-Za-z0-9._:-]{1,128}$", + ); + }); + + test("keeps the generated SDK checked in with the public projector operations", () => { + const sdk = readFileSync(new URL("../sdk/index.ts", import.meta.url), "utf8"); + expect(sdk).toContain("async appendIncidentProjection(body: IncidentProjectionEventV1"); + expect(sdk).toContain("async getIncidentProjection(eventId: string"); + expect(sdk).toContain("async listUnreadBlockers"); + expect(sdk).toContain('"blocked_scopes": Array'); + expect(sdk).toContain('"schema_version": 1; "source": "todos"'); + expect(sdk).toContain("reply_to"); + expect(sdk).toContain("metadata"); + }); +}); diff --git a/src/server/openapi.ts b/src/server/openapi.ts index 561a92a..ec25e4d 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -34,6 +34,12 @@ export const openapiSpec = { content: { type: "string" }, priority: { type: "string" }, blocking: { type: "boolean" }, + reply_to: { type: "integer", nullable: true }, + working_dir: { type: "string", nullable: true }, + repository: { type: "string", nullable: true }, + branch: { type: "string", nullable: true }, + metadata: { type: "object", nullable: true, additionalProperties: true }, + attachments: { type: "array", nullable: true, items: { type: "object", additionalProperties: true } }, created_at: { type: "string" }, }, }, @@ -73,6 +79,95 @@ export const openapiSpec = { last_seen_at: { type: "string" }, }, }, + IncidentSnapshotV1: { + type: "object", + additionalProperties: false, + required: [ + "id", "title", "severity", "status", "owner", "affected_scopes", "blocked_scopes", + "containment", "next_action", "deadline", "closure_evidence", "supersedes_id", + "superseded_by_id", "resolved_at", "version", "created_at", "updated_at", + ], + properties: { + id: { type: "string", format: "uuid" }, + title: { type: "string", maxLength: 200 }, + severity: { type: "string", enum: ["info", "low", "medium", "high", "critical"] }, + status: { type: "string", enum: ["open", "investigating", "contained", "monitoring", "resolved", "superseded"] }, + owner: { type: "string", maxLength: 128 }, + affected_scopes: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", maxLength: 256 } }, + blocked_scopes: { + type: "array", + maxItems: 64, + items: { + type: "string", + maxLength: 128, + pattern: "^(?:agent:[A-Za-z0-9][A-Za-z0-9._@/-]{0,127}|channel:[a-z0-9]+(?:-[a-z0-9]+)*|project:[A-Za-z0-9][A-Za-z0-9_-]{0,119})$", + }, + }, + containment: { type: "string", nullable: true, maxLength: 4000 }, + next_action: { type: "string", nullable: true, maxLength: 4000 }, + deadline: { type: "string", format: "date-time", nullable: true }, + closure_evidence: { type: "array", maxItems: 64, items: { type: "string", maxLength: 256 } }, + supersedes_id: { type: "string", format: "uuid", nullable: true }, + superseded_by_id: { type: "string", format: "uuid", nullable: true }, + resolved_at: { type: "string", format: "date-time", nullable: true }, + version: { type: "integer", minimum: 1 }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + }, + IncidentProjectionEventV1: { + type: "object", + additionalProperties: false, + required: [ + "schema_version", "source", "authority_id", "incident_id", "transition_id", + "incident_version", "occurred_at", "event_id", "projection_key", "incident", + ], + properties: { + schema_version: { type: "integer", enum: [1] }, + source: { type: "string", enum: ["todos"] }, + authority_id: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" }, + incident_id: { type: "string", format: "uuid" }, + transition_id: { type: "string", pattern: "^itr_[0-9a-f]{32}$" }, + incident_version: { type: "integer", minimum: 1 }, + occurred_at: { type: "string", format: "date-time" }, + event_id: { type: "string", pattern: "^iev_[0-9a-f]{32}$" }, + projection_key: { type: "string", pattern: "^todos:incident:" }, + incident: { $ref: "#/components/schemas/IncidentSnapshotV1" }, + }, + }, + IncidentProjectionRecord: { + type: "object", + required: [ + "id", "event_id", "projection_key", "message_id", "schema_version", "source", "tenant_id", + "authority_id", "incident_id", "transition_id", "incident_version", "occurred_at", "status", + "severity", "blocking", "supersedes_transition_id", "supersedes_incident_id", + "superseded_by_incident_id", "canonical_payload", "payload_hash", "created_at", "message", "replayed", + ], + properties: { + id: { type: "integer" }, event_id: { type: "string" }, projection_key: { type: "string" }, + message_id: { type: "integer" }, schema_version: { type: "integer", enum: [1] }, source: { type: "string", enum: ["todos"] }, + tenant_id: { type: "string" }, authority_id: { type: "string" }, incident_id: { type: "string", format: "uuid" }, + transition_id: { type: "string" }, incident_version: { type: "integer" }, occurred_at: { type: "string", format: "date-time" }, + status: { type: "string", enum: ["open", "investigating", "contained", "monitoring", "resolved", "superseded"] }, + severity: { type: "string", enum: ["info", "low", "medium", "high", "critical"] }, + blocking: { type: "boolean" }, supersedes_transition_id: { type: "string", nullable: true }, + supersedes_incident_id: { type: "string", format: "uuid", nullable: true }, + superseded_by_incident_id: { type: "string", format: "uuid", nullable: true }, + canonical_payload: { type: "string" }, payload_hash: { type: "string", pattern: "^[0-9a-f]{64}$" }, + created_at: { type: "string", format: "date-time" }, message: { $ref: "#/components/schemas/Message" }, + replayed: { type: "boolean" }, + }, + }, + IncidentProjectionResponse: { + type: "object", + required: ["projection"], + properties: { projection: { $ref: "#/components/schemas/IncidentProjectionRecord" } }, + }, + IncidentProjectionError: { + type: "object", + required: ["error"], + properties: { error: { type: "string" }, code: { type: "string", nullable: true } }, + }, }, }, security: [{ apiKey: [] }], @@ -101,6 +196,50 @@ export const openapiSpec = { responses: { "200": { description: "version", content: { "application/json": { schema: okObject } } } }, }, }, + "/v1/incident-projections": { + post: { + operationId: "appendIncidentProjection", + summary: "Append a canonical Todos incident projection", + description: + "Dedicated append-only projector route. Requires the conversations:incident-project scope. " + + "Returns 201 for a new event and 200 for an identical idempotent replay.", + "x-required-scope": "conversations:incident-project", + requestBody: { + required: true, + content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionEventV1" } } }, + }, + responses: { + "200": { description: "identical replay", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionResponse" } } } }, + "201": { description: "projection appended", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionResponse" } } } }, + "400": { description: "invalid projection", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionError" } } } }, + "409": { description: "canonical projection conflict", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionError" } } } }, + "503": { description: "projector authority or storage temporarily unavailable", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionError" } } } }, + }, + }, + }, + "/v1/incident-projections/{event_id}": { + get: { + operationId: "getIncidentProjection", + summary: "Read one canonical incident projection", + parameters: [{ name: "event_id", in: "path", required: true, schema: { type: "string", pattern: "^iev_[0-9a-f]{32}$" } }], + responses: { + "200": { description: "projection", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionResponse" } } } }, + "404": { description: "not found", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionError" } } } }, + }, + }, + }, + "/v1/messages/blockers": { + get: { + operationId: "listUnreadBlockers", + summary: "List canonical current blockers visible to one agent", + parameters: [ + { name: "agent", in: "query", required: true, schema: { type: "string" } }, + { name: "limit", in: "query", schema: { type: "integer" } }, + { name: "offset", in: "query", schema: { type: "integer" } }, + ], + responses: { "200": { description: "blockers", content: { "application/json": { schema: okObject } } } }, + }, + }, "/v1/messages": { get: { operationId: "listMessages", @@ -127,6 +266,9 @@ export const openapiSpec = { from: { type: "string" }, to: { type: "string" }, content: { type: "string" }, channel: { type: "string" }, project_id: { type: "string" }, session_id: { type: "string" }, priority: { type: "string" }, blocking: { type: "boolean" }, + reply_to: { type: "integer" }, metadata: { type: "object", additionalProperties: true }, + working_dir: { type: "string" }, repository: { type: "string" }, branch: { type: "string" }, + attachments: { type: "array", items: { type: "object", additionalProperties: true } }, }, } } }, }, diff --git a/src/types.ts b/src/types.ts index 9f146a9..caa5278 100644 --- a/src/types.ts +++ b/src/types.ts @@ -124,6 +124,103 @@ export interface SendMessageOptions { reply_to?: number; } +// ── Canonical Todos incident projections ──────────────────────────────────── + +export type IncidentSeverity = "info" | "low" | "medium" | "high" | "critical"; +export type IncidentStatus = "open" | "investigating" | "contained" | "monitoring" | "resolved" | "superseded"; + +/** Frozen Todos v1 incident snapshot. Message text is never canonical state. */ +export interface IncidentSnapshotV1 { + id: string; + title: string; + severity: IncidentSeverity; + status: IncidentStatus; + owner: string; + affected_scopes: string[]; + blocked_scopes: string[]; + containment: string | null; + next_action: string | null; + deadline: string | null; + closure_evidence: string[]; + supersedes_id: string | null; + superseded_by_id: string | null; + resolved_at: string | null; + version: number; + created_at: string; + updated_at: string; +} + +export interface IncidentProjectionDisplay { + from: string; + to: string; + content: string; + channel?: string; + project_id?: string; + session_id?: string; + priority?: Priority; + working_dir?: string; + repository?: string; + branch?: string; +} + +/** Trusted Conversations-side rendering/routing configuration. */ +export interface IncidentProjectionRouting { + from?: string; + to?: string; + channel?: string; + project_id?: string; + session_id?: string; +} + +/** + * Projector input. authority_id is supplied by Todos and must equal the stable + * Conversations deployment binding; tenant_id is never accepted from the wire. + */ +export interface IncidentProjectionRequestV1 { + schema_version: 1; + source: "todos"; + authority_id: string; + incident_id: string; + transition_id: string; + incident_version: number; + occurred_at: string; + event_id: string; + projection_key: string; + incident: IncidentSnapshotV1; +} + +export interface IncidentProjectorContext { + tenant_id: string; + authority_id: string; + routing?: IncidentProjectionRouting; +} + +export interface IncidentProjectionRecord { + id: number; + event_id: string; + projection_key: string; + message_id: number; + schema_version: 1; + source: "todos"; + tenant_id: string; + authority_id: string; + incident_id: string; + transition_id: string; + incident_version: number; + occurred_at: string; + status: IncidentStatus; + severity: IncidentSeverity; + blocking: boolean; + supersedes_transition_id: string | null; + supersedes_incident_id: string | null; + superseded_by_incident_id: string | null; + canonical_payload: string; + payload_hash: string; + created_at: string; + message: Message; + replayed: boolean; +} + export interface ReadMessagesOptions { session_id?: string; from?: string; From 38acb18a02fa5e6d66157e1de256ce70a8353d97 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Sun, 19 Jul 2026 02:12:44 +0300 Subject: [PATCH 2/8] fix: harden incident projection routing --- scripts/verify-incident-projection-pg.ts | 479 ++++++++++++++++++++++- src/lib/channels.test.ts | 1 + src/lib/channels.ts | 35 ++ src/lib/db.test.ts | 2 + src/lib/db.ts | 9 + src/lib/incident-projections.test.ts | 63 +++ src/lib/incident-projections.ts | 7 +- src/lib/messages.ts | 18 +- src/lib/pg-migrations.test.ts | 2 + src/lib/pg-migrations.ts | 9 + src/server/api.test.ts | 34 ++ src/server/api.ts | 164 ++++++-- src/server/incident-projections.ts | 40 +- src/server/openapi.test.ts | 4 +- src/server/openapi.ts | 1 + 15 files changed, 822 insertions(+), 46 deletions(-) diff --git a/scripts/verify-incident-projection-pg.ts b/scripts/verify-incident-projection-pg.ts index 89160d0..0dc5732 100644 --- a/scripts/verify-incident-projection-pg.ts +++ b/scripts/verify-incident-projection-pg.ts @@ -10,7 +10,10 @@ import { computeIncidentProjectionIds, IncidentProjectionConflictError, } from "../src/lib/incident-projection-contract.js"; -import { appendIncidentProjectionPg } from "../src/server/incident-projections.js"; +import { + appendIncidentProjectionPg, + CHANNEL_IDENTITY_ADVISORY_LOCK, +} from "../src/server/incident-projections.js"; import { renameChannelServer, startApiServer } from "../src/server/api.js"; import { ConversationsClient } from "../src/sdk/index.js"; import type { IncidentProjectionRequestV1, IncidentProjectorContext } from "../src/types.js"; @@ -58,6 +61,17 @@ function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +async function waitForPg( + predicate: () => Promise, + description: string, +): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (await predicate()) return; + await Bun.sleep(10); + } + throw new Error(`timed out waiting for PostgreSQL verifier state: ${description}`); +} + function eventVersion( base: IncidentProjectionRequestV1, incidentId: string, @@ -438,6 +452,368 @@ try { DROP FUNCTION fail_verifier_channel_rename(); `); + // Deterministic projector-first race: the projection holds the source + // channel row lock while a trigger pauses its message insert. Rename must + // wait, then rewrite the committed projection to the new current channel. + await taskClient.query(`INSERT INTO channels (name, created_by) VALUES ('pg-race-old', 'verifier')`); + await taskClient.query( + `INSERT INTO channel_members (channel, agent) VALUES ('pg-race-old', 'race-reader')`, + ); + await taskClient.execute(` + CREATE OR REPLACE FUNCTION pause_verifier_projection_insert() RETURNS trigger AS $$ + BEGIN + IF NEW.content LIKE '%PG channel rename race%' THEN + PERFORM pg_advisory_xact_lock(520048); + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER pause_verifier_projection_insert_trigger + BEFORE INSERT ON messages FOR EACH ROW + EXECUTE FUNCTION pause_verifier_projection_insert(); + `); + const raceEvent = eventVersion( + fixture, + "13131313-1313-4313-8313-131313131313", + 1, + "2026-07-18T20:13:00.000Z", + ); + raceEvent.incident.created_at = raceEvent.occurred_at; + raceEvent.incident.title = "PG channel rename race"; + raceEvent.incident.blocked_scopes = ["channel:pg-race-old"]; + const raceContext: IncidentProjectorContext = { + ...context, + routing: { channel: "pg-race-old", project_id: "wks_8vJJzXTiFo6sxwRkpPqoI" }, + }; + let releaseAdvisory = (): void => {}; + let signalAdvisoryHeld = (): void => {}; + const advisoryHeld = new Promise((resolve) => { signalAdvisoryHeld = resolve; }); + const advisoryRelease = new Promise((resolve) => { releaseAdvisory = resolve; }); + const advisoryController = taskClient.transaction(async (tx) => { + await tx.get("SELECT pg_advisory_xact_lock(520048) AS locked"); + signalAdvisoryHeld(); + await advisoryRelease; + }); + let raceProjectionPromise: ReturnType | null = null; + let raceRenamePromise: ReturnType | null = null; + let raceProjection: Awaited> | null = null; + try { + await advisoryHeld; + raceProjectionPromise = appendIncidentProjectionPg(taskClient, raceEvent, raceContext); + await waitForPg(async () => Number((await taskClient.get<{ n: string }>( + `SELECT COUNT(*) AS n FROM pg_locks + WHERE locktype='advisory' AND granted=FALSE AND objid=520048`, + ))?.n ?? 0) > 0, "projection waiting on verifier advisory lock"); + raceRenamePromise = renameChannelServer(taskClient, "pg-race-old", "pg-race-new"); + await waitForPg(async () => Number((await taskClient.get<{ n: string }>( + `SELECT COUNT(*) AS n FROM pg_locks + WHERE locktype='advisory' AND granted=FALSE AND objid=$1`, + [CHANNEL_IDENTITY_ADVISORY_LOCK], + ))?.n ?? 0) > 0, "rename waiting on projector channel identity lock"); + releaseAdvisory(); + raceProjection = await raceProjectionPromise; + assert.deepEqual(await raceRenamePromise, { ok: true, name: "pg-race-new" }); + await advisoryController; + } finally { + releaseAdvisory(); + await Promise.allSettled([ + advisoryController, + ...(raceProjectionPromise ? [raceProjectionPromise] : []), + ...(raceRenamePromise ? [raceRenamePromise] : []), + ]); + await taskClient.execute(` + DROP TRIGGER IF EXISTS pause_verifier_projection_insert_trigger ON messages; + DROP FUNCTION IF EXISTS pause_verifier_projection_insert(); + `); + } + assert(raceProjection); + const racedMessage = await taskClient.one<{ channel: string; session_id: string; to_agent: string }>( + "SELECT channel, session_id, to_agent FROM messages WHERE id=$1", + [raceProjection.message_id], + ); + assert.deepEqual(racedMessage, { + channel: "pg-race-new", + session_id: "channel:pg-race-new", + to_agent: "pg-race-new", + }); + const raceReaderKey = mintApiKey({ + app: "conversations", + agent: "race-reader", + scopes: ["conversations:read", "conversations:write"], + signingSecret, + }).token; + assert((await blockersFor("race-reader", raceReaderKey)).some( + (message) => message.id === raceProjection!.message_id, + )); + + await taskClient.execute(` + CREATE OR REPLACE FUNCTION pause_verifier_v2_insert() RETURNS trigger AS $$ + BEGIN + IF NEW.content LIKE '%PG channel rename race%' AND NEW.reply_to IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(520051); + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER pause_verifier_v2_insert_trigger + BEFORE INSERT ON messages FOR EACH ROW + EXECUTE FUNCTION pause_verifier_v2_insert(); + `); + const raceV2Event = eventVersion( + raceEvent, + raceEvent.incident_id, + 2, + "2026-07-18T20:15:00.000Z", + ); + const changedRaceContext: IncidentProjectorContext = { + ...context, + routing: { channel: "unrelated-config-route", project_id: "other-project" }, + }; + let releaseV2Advisory = (): void => {}; + let signalV2AdvisoryHeld = (): void => {}; + const v2AdvisoryHeld = new Promise((resolve) => { signalV2AdvisoryHeld = resolve; }); + const v2AdvisoryRelease = new Promise((resolve) => { releaseV2Advisory = resolve; }); + const v2AdvisoryController = taskClient.transaction(async (tx) => { + await tx.get("SELECT pg_advisory_xact_lock(520051) AS locked"); + signalV2AdvisoryHeld(); + await v2AdvisoryRelease; + }); + let raceV2Promise: ReturnType | null = null; + let raceV2RenamePromise: ReturnType | null = null; + let raceV2: Awaited> | null = null; + try { + await v2AdvisoryHeld; + raceV2Promise = appendIncidentProjectionPg(taskClient, raceV2Event, changedRaceContext); + await waitForPg(async () => Number((await taskClient.get<{ n: string }>( + `SELECT COUNT(*) AS n FROM pg_locks + WHERE locktype='advisory' AND granted=FALSE AND objid=520051`, + ))?.n ?? 0) > 0, "v2 projection waiting after inheriting root routing"); + raceV2RenamePromise = renameChannelServer(taskClient, "pg-race-new", "pg-race-final"); + await waitForPg(async () => Number((await taskClient.get<{ n: string }>( + `SELECT COUNT(*) AS n FROM pg_locks + WHERE locktype='advisory' AND granted=FALSE AND objid=$1`, + [CHANNEL_IDENTITY_ADVISORY_LOCK], + ))?.n ?? 0) > 0, "root-channel rename waiting on v2 projector identity fence"); + releaseV2Advisory(); + raceV2 = await raceV2Promise; + assert.deepEqual(await raceV2RenamePromise, { ok: true, name: "pg-race-final" }); + await v2AdvisoryController; + } finally { + releaseV2Advisory(); + await Promise.allSettled([ + v2AdvisoryController, + ...(raceV2Promise ? [raceV2Promise] : []), + ...(raceV2RenamePromise ? [raceV2RenamePromise] : []), + ]); + await taskClient.execute(` + DROP TRIGGER IF EXISTS pause_verifier_v2_insert_trigger ON messages; + DROP FUNCTION IF EXISTS pause_verifier_v2_insert(); + `); + } + assert(raceV2); + const racedThread = await taskClient.many<{ + id: string; channel: string; session_id: string; to_agent: string; project_id: string; reply_to: string | null; + }>( + `SELECT id, channel, session_id, to_agent, project_id, reply_to + FROM messages WHERE id IN ($1,$2) ORDER BY id`, + [raceProjection.message_id, raceV2.message_id], + ); + assert.equal(racedThread.length, 2); + assert(racedThread.every((message) => message.channel === "pg-race-final")); + assert(racedThread.every((message) => message.session_id === "channel:pg-race-final")); + assert(racedThread.every((message) => message.to_agent === "pg-race-final")); + assert(racedThread.every((message) => message.project_id === "wks_8vJJzXTiFo6sxwRkpPqoI")); + assert.equal(String(racedThread[1].reply_to), String(raceProjection.message_id)); + assert((await blockersFor("race-reader", raceReaderKey)).some( + (message) => message.id === raceV2!.message_id, + )); + + // Deterministic rename-first race: rename holds the old channel row and + // pauses before creating the replacement. The projector waits on that row, + // then must re-read the committed alias and insert directly into the new + // channel rather than leaving a stale post-scan message behind. + await taskClient.query(`INSERT INTO channels (name, created_by) VALUES ('pg-race2-old', 'verifier')`); + await taskClient.query( + `INSERT INTO channel_members (channel, agent) VALUES ('pg-race2-old', 'race2-reader')`, + ); + await taskClient.execute(` + CREATE OR REPLACE FUNCTION pause_verifier_channel_create() RETURNS trigger AS $$ + BEGIN + IF NEW.name = 'pg-race2-new' THEN + PERFORM pg_advisory_xact_lock(520049); + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER pause_verifier_channel_create_trigger + BEFORE INSERT ON channels FOR EACH ROW + EXECUTE FUNCTION pause_verifier_channel_create(); + `); + const race2Event = eventVersion( + fixture, + "14141414-1414-4414-8414-141414141414", + 1, + "2026-07-18T20:14:00.000Z", + ); + race2Event.incident.created_at = race2Event.occurred_at; + race2Event.incident.blocked_scopes = ["channel:pg-race2-old"]; + const race2Context: IncidentProjectorContext = { + ...context, + routing: { channel: "pg-race2-old", project_id: "wks_8vJJzXTiFo6sxwRkpPqoI" }, + }; + let releaseRenameAdvisory = (): void => {}; + let signalRenameAdvisoryHeld = (): void => {}; + const renameAdvisoryHeld = new Promise((resolve) => { signalRenameAdvisoryHeld = resolve; }); + const renameAdvisoryRelease = new Promise((resolve) => { releaseRenameAdvisory = resolve; }); + const renameAdvisoryController = taskClient.transaction(async (tx) => { + await tx.get("SELECT pg_advisory_xact_lock(520049) AS locked"); + signalRenameAdvisoryHeld(); + await renameAdvisoryRelease; + }); + let race2RenamePromise: ReturnType | null = null; + let race2ProjectionPromise: ReturnType | null = null; + let race2Projection: Awaited> | null = null; + try { + await renameAdvisoryHeld; + race2RenamePromise = renameChannelServer(taskClient, "pg-race2-old", "pg-race2-new"); + await waitForPg(async () => Number((await taskClient.get<{ n: string }>( + `SELECT COUNT(*) AS n FROM pg_locks + WHERE locktype='advisory' AND granted=FALSE AND objid=520049`, + ))?.n ?? 0) > 0, "rename waiting on verifier advisory lock"); + race2ProjectionPromise = appendIncidentProjectionPg(taskClient, race2Event, race2Context); + await waitForPg(async () => Number((await taskClient.get<{ n: string }>( + `SELECT COUNT(*) AS n FROM pg_locks + WHERE locktype='advisory' AND granted=FALSE AND objid=$1`, + [CHANNEL_IDENTITY_ADVISORY_LOCK], + ))?.n ?? 0) > 0, "projector waiting on rename channel identity lock"); + releaseRenameAdvisory(); + assert.deepEqual(await race2RenamePromise, { ok: true, name: "pg-race2-new" }); + race2Projection = await race2ProjectionPromise; + await renameAdvisoryController; + } finally { + releaseRenameAdvisory(); + await Promise.allSettled([ + renameAdvisoryController, + ...(race2RenamePromise ? [race2RenamePromise] : []), + ...(race2ProjectionPromise ? [race2ProjectionPromise] : []), + ]); + await taskClient.execute(` + DROP TRIGGER IF EXISTS pause_verifier_channel_create_trigger ON channels; + DROP FUNCTION IF EXISTS pause_verifier_channel_create(); + `); + } + assert(race2Projection); + const race2Message = await taskClient.one<{ channel: string; session_id: string; to_agent: string }>( + "SELECT channel, session_id, to_agent FROM messages WHERE id=$1", + [race2Projection.message_id], + ); + assert.deepEqual(race2Message, { + channel: "pg-race2-new", + session_id: "channel:pg-race2-new", + to_agent: "pg-race2-new", + }); + const race2ReaderKey = mintApiKey({ + app: "conversations", + agent: "race2-reader", + scopes: ["conversations:read", "conversations:write"], + signingSecret, + }).token; + assert((await blockersFor("race2-reader", race2ReaderKey)).some( + (message) => message.id === race2Projection!.message_id, + )); + + // Missing-channel race: a projection may legitimately arrive before the + // routed channel exists. The shared global identity fence must make channel + // creation wait even though no row exists to lock; the following rename then + // rewrites the committed display atomically instead of leaving stale routing. + await taskClient.execute(` + CREATE OR REPLACE FUNCTION pause_verifier_absent_projection() RETURNS trigger AS $$ + BEGIN + IF NEW.content LIKE '%PG absent channel race%' THEN + PERFORM pg_advisory_xact_lock(520052); + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER pause_verifier_absent_projection_trigger + BEFORE INSERT ON messages FOR EACH ROW + EXECUTE FUNCTION pause_verifier_absent_projection(); + `); + const absentEvent = eventVersion( + fixture, + "15151515-1515-4515-8515-151515151515", + 1, + "2026-07-18T20:16:00.000Z", + ); + absentEvent.incident.created_at = absentEvent.occurred_at; + absentEvent.incident.title = "PG absent channel race"; + absentEvent.incident.blocked_scopes = ["channel:pg-absent-old"]; + const absentContext: IncidentProjectorContext = { + ...context, + routing: { channel: "pg-absent-old", project_id: "wks_8vJJzXTiFo6sxwRkpPqoI" }, + }; + let releaseAbsentAdvisory = (): void => {}; + let signalAbsentAdvisoryHeld = (): void => {}; + const absentAdvisoryHeld = new Promise((resolve) => { signalAbsentAdvisoryHeld = resolve; }); + const absentAdvisoryRelease = new Promise((resolve) => { releaseAbsentAdvisory = resolve; }); + const absentAdvisoryController = taskClient.transaction(async (tx) => { + await tx.get("SELECT pg_advisory_xact_lock(520052) AS locked"); + signalAbsentAdvisoryHeld(); + await absentAdvisoryRelease; + }); + let absentProjectionPromise: ReturnType | null = null; + let absentCreatePromise: Promise | null = null; + let absentProjection: Awaited> | null = null; + try { + await absentAdvisoryHeld; + absentProjectionPromise = appendIncidentProjectionPg(taskClient, absentEvent, absentContext); + await waitForPg(async () => Number((await taskClient.get<{ n: string }>( + `SELECT COUNT(*) AS n FROM pg_locks + WHERE locktype='advisory' AND granted=FALSE AND objid=520052`, + ))?.n ?? 0) > 0, "absent-channel projection waiting after routing resolution"); + absentCreatePromise = fetch(`${apiBase}/v1/channels`, { + method: "POST", + headers: { "x-api-key": readerTwoKey, "content-type": "application/json" }, + body: JSON.stringify({ name: "pg-absent-old", created_by: "projector-02" }), + }); + await waitForPg(async () => Number((await taskClient.get<{ n: string }>( + `SELECT COUNT(*) AS n FROM pg_locks + WHERE locktype='advisory' AND granted=FALSE AND objid=$1`, + [CHANNEL_IDENTITY_ADVISORY_LOCK], + ))?.n ?? 0) > 0, "channel create waiting on absent-channel projector identity fence"); + releaseAbsentAdvisory(); + absentProjection = await absentProjectionPromise; + const createdAbsentChannel = await absentCreatePromise; + assert.equal(createdAbsentChannel.status, 201); + assert.deepEqual(await renameChannelServer(taskClient, "pg-absent-old", "pg-absent-new"), { + ok: true, + name: "pg-absent-new", + }); + await absentAdvisoryController; + } finally { + releaseAbsentAdvisory(); + await Promise.allSettled([ + absentAdvisoryController, + ...(absentProjectionPromise ? [absentProjectionPromise] : []), + ...(absentCreatePromise ? [absentCreatePromise] : []), + ]); + await taskClient.execute(` + DROP TRIGGER IF EXISTS pause_verifier_absent_projection_trigger ON messages; + DROP FUNCTION IF EXISTS pause_verifier_absent_projection(); + `); + } + assert(absentProjection); + assert.deepEqual( + await taskClient.one<{ channel: string; session_id: string; to_agent: string }>( + "SELECT channel, session_id, to_agent FROM messages WHERE id=$1", + [absentProjection.message_id], + ), + { channel: "pg-absent-new", session_id: "channel:pg-absent-new", to_agent: "pg-absent-new" }, + ); + assert((await blockersFor("projector-02", readerTwoKey)).some( + (message) => message.id === absentProjection!.message_id, + )); + const projectedBeforeRename = await taskClient.one>( "SELECT * FROM messages WHERE id=$1", [httpCreated.projection.message_id], @@ -446,6 +822,14 @@ try { "SELECT * FROM messages WHERE id=$1", [routed.message_id], ); + const canonicalChannelScopesBeforeRename = await taskClient.many<{ scope: string }>( + `SELECT scope FROM incident_projection_scopes + WHERE projection_id=$1 AND scope_type='blocked' ORDER BY scope`, + [routed.id], + ); + assert((await blockersFor("projector-02", readerTwoKey)).some( + (message) => message.id === routed.message_id, + )); assert.deepEqual(await renameChannelServer(taskClient, "incidents", "incident-log"), { ok: true, name: "incident-log", @@ -486,6 +870,99 @@ try { [httpCreated.projection.message_id], )); + assert((await blockersFor("projector-02", readerTwoKey)).some( + (message) => message.id === routed.message_id, + )); + assert.deepEqual(await renameChannelServer(taskClient, "incident-log", "incident-archive"), { + ok: true, + name: "incident-archive", + }); + assert((await blockersFor("projector-02", readerTwoKey)).some( + (message) => message.id === routed.message_id, + )); + assert.deepEqual( + await taskClient.many<{ scope: string }>( + `SELECT scope FROM incident_projection_scopes + WHERE projection_id=$1 AND scope_type='blocked' ORDER BY scope`, + [routed.id], + ), + canonicalChannelScopesBeforeRename, + ); + assert.deepEqual( + await taskClient.many<{ old_channel: string; current_channel: string }>( + `SELECT old_channel, current_channel FROM channel_rename_aliases + WHERE old_channel IN ('incidents','incident-log') ORDER BY old_channel`, + ), + [ + { old_channel: "incident-log", current_channel: "incident-archive" }, + { old_channel: "incidents", current_channel: "incident-archive" }, + ], + ); + + const postRenameEvent = eventVersion( + fixture, + "12121212-1212-4212-8212-121212121212", + 1, + "2026-07-18T20:12:00.000Z", + ); + postRenameEvent.incident.created_at = postRenameEvent.occurred_at; + const postRenameProjection = await sdk.appendIncidentProjection(postRenameEvent); + assert.equal(postRenameProjection.projection.message.channel, "incident-archive"); + assert.equal(postRenameProjection.projection.message.session_id, "channel:incident-archive"); + assert.equal(postRenameProjection.projection.message.to_agent, "incident-archive"); + const blockersAfterChain = await blockersFor("projector-02", readerTwoKey); + assert(blockersAfterChain.some((message) => message.id === routed.message_id)); + assert(blockersAfterChain.some((message) => message.id === postRenameProjection.projection.message_id)); + assert.deepEqual(await blockersFor("outsider", outsiderKey), []); + + const channelReceipt = await fetch(`${apiBase}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": readerTwoKey, "content-type": "application/json" }, + body: JSON.stringify({ ids: [routed.message_id] }), + }); + assert.equal(channelReceipt.status, 200); + assert.equal((await channelReceipt.json() as { marked: number }).marked, 1); + const repeatedChannelReceipt = await fetch(`${apiBase}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": readerTwoKey, "content-type": "application/json" }, + body: JSON.stringify({ ids: [routed.message_id] }), + }); + assert.equal(repeatedChannelReceipt.status, 200); + assert.equal((await repeatedChannelReceipt.json() as { marked: number }).marked, 0); + const blockersAfterReceipt = await blockersFor("projector-02", readerTwoKey); + assert(!blockersAfterReceipt.some((message) => message.id === routed.message_id)); + assert(blockersAfterReceipt.some((message) => message.id === postRenameProjection.projection.message_id)); + + const recreateHistoricalAlias = await fetch(`${apiBase}/v1/channels`, { + method: "POST", + headers: { "x-api-key": readerTwoKey, "content-type": "application/json" }, + body: JSON.stringify({ name: "incidents", created_by: "projector-02" }), + }); + assert.equal(recreateHistoricalAlias.status, 409); + await taskClient.query(`INSERT INTO channels (name, created_by) VALUES ('unrelated-channel', 'verifier')`); + assert.deepEqual(await renameChannelServer(taskClient, "unrelated-channel", "incidents"), { + ok: false, + error: "Channel #incidents is a reserved historical alias for #incident-archive.", + status: 409, + }); + assert.deepEqual(await renameChannelServer(taskClient, "incident-archive", "incidents"), { + ok: true, + name: "incidents", + }); + assert((await blockersFor("projector-02", readerTwoKey)).some( + (message) => message.id === postRenameProjection.projection.message_id, + )); + assert.deepEqual( + await taskClient.many<{ old_channel: string; current_channel: string }>( + `SELECT old_channel, current_channel FROM channel_rename_aliases + WHERE old_channel IN ('incidents','incident-log','incident-archive') ORDER BY old_channel`, + ), + [ + { old_channel: "incident-archive", current_channel: "incidents" }, + { old_channel: "incident-log", current_channel: "incidents" }, + ], + ); + console.log(`ok incident projection PG integration database=${database} cleanup=pending`); } finally { if (apiServer) apiServer.stop(true); diff --git a/src/lib/channels.test.ts b/src/lib/channels.test.ts index d784acc..dca1257 100644 --- a/src/lib/channels.test.ts +++ b/src/lib/channels.test.ts @@ -305,6 +305,7 @@ describe("renameChannel", () => { expect(getChannel("old-name")?.name).toBe("old-name"); expect(getChannel("failed-rename")).toBeNull(); expect(db.prepare("SELECT COUNT(*) AS n FROM message_scope_rewrite_guard").get()).toEqual({ n: 0 }); + expect(db.prepare("SELECT COUNT(*) AS n FROM channel_rename_aliases").get()).toEqual({ n: 0 }); expect(() => db.prepare("UPDATE messages SET project_id = 'other' WHERE id = ?").run(parent.id)).toThrow( "reply parent scope is immutable", ); diff --git a/src/lib/channels.ts b/src/lib/channels.ts index 090ffa5..be401e0 100644 --- a/src/lib/channels.ts +++ b/src/lib/channels.ts @@ -52,6 +52,15 @@ export function createChannel( const db = getDb(); const channelName = normalizeChannelName(name); + const historicalAlias = db.prepare( + "SELECT current_channel FROM channel_rename_aliases WHERE old_channel = ?", + ).get(channelName) as { current_channel: string } | null; + if (historicalAlias) { + throw new Error( + `Channel #${channelName} is a reserved historical alias for #${historicalAlias.current_channel}.`, + ); + } + if (options?.project_id) { const projectExists = db.prepare("SELECT id FROM projects WHERE id = ?").get(options.project_id); if (!projectExists) { @@ -288,6 +297,14 @@ export function renameChannel(oldName: string, newName: string): Channel { if (conflict) { throw new Error(`Channel #${to} already exists.`); } + const targetAlias = db.prepare( + "SELECT current_channel FROM channel_rename_aliases WHERE old_channel = ?", + ).get(to) as { current_channel: string } | null; + if (targetAlias && targetAlias.current_channel !== from) { + throw new Error( + `Channel #${to} is a reserved historical alias for #${targetAlias.current_channel}.`, + ); + } db.exec("BEGIN"); try { @@ -303,6 +320,24 @@ export function renameChannel(oldName: string, newName: string): Channel { // Channel row itself (PK is the name column). db.prepare("UPDATE channels SET name = ? WHERE name = ?").run(to, from); + // Preserve the name-based canonical scope identity without rewriting the + // append-only incident ledger. Existing aliases are flattened so every + // historical name resolves directly to the current channel after chains. + db.prepare("DELETE FROM channel_rename_aliases WHERE old_channel = ?").run(to); + db.prepare( + `UPDATE channel_rename_aliases + SET current_channel = ?, renamed_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') + WHERE current_channel = ?`, + ).run(to, from); + db.prepare( + `INSERT INTO channel_rename_aliases (old_channel, current_channel) + VALUES (?, ?) + ON CONFLICT(old_channel) DO UPDATE SET + current_channel = excluded.current_channel, + renamed_at = strftime('%Y-%m-%dT%H:%M:%f', 'now')`, + ).run(from, to); + db.prepare("DELETE FROM channel_rename_aliases WHERE old_channel = current_channel").run(); + // Messages: channel field, the channel's session id, and the to_agent // field (channel messages address the channel name as recipient). if (localHasColumn(db, "messages", "channel")) { diff --git a/src/lib/db.test.ts b/src/lib/db.test.ts index 6d73de6..77a107c 100644 --- a/src/lib/db.test.ts +++ b/src/lib/db.test.ts @@ -43,6 +43,7 @@ describe("db", () => { expect(tableNames).toContain("messages"); expect(tableNames).toContain("channels"); expect(tableNames).toContain("channel_members"); + expect(tableNames).toContain("channel_rename_aliases"); expect(tableNames).toContain("channel_subscriptions"); expect(tableNames).toContain("channel_notification_reads"); expect(tableNames).toContain("projects"); @@ -74,6 +75,7 @@ describe("db", () => { expect(names).toContain("idx_projects_status"); expect(names).toContain("idx_channels_project"); expect(names).toContain("idx_channel_subscriptions_agent"); + expect(names).toContain("idx_channel_rename_aliases_current"); expect(names).toContain("idx_channel_notification_reads_agent"); expect(names).toContain("idx_incident_projections_active_scope"); expect(names).toContain("idx_incident_projections_message"); diff --git a/src/lib/db.ts b/src/lib/db.ts index 4bde38d..09a769a 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -698,6 +698,15 @@ export function getDb(): Database { PRIMARY KEY (channel, agent) ) `); + db.exec(` + CREATE TABLE IF NOT EXISTS channel_rename_aliases ( + old_channel TEXT PRIMARY KEY, + current_channel TEXT NOT NULL, + renamed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')), + CHECK (old_channel <> current_channel) + ) + `); + db.exec("CREATE INDEX IF NOT EXISTS idx_channel_rename_aliases_current ON channel_rename_aliases(current_channel)"); db.exec(` CREATE TABLE IF NOT EXISTS channel_subscriptions ( channel TEXT NOT NULL REFERENCES channels(name), diff --git a/src/lib/incident-projections.test.ts b/src/lib/incident-projections.test.ts index 337bbb4..8471b7a 100644 --- a/src/lib/incident-projections.test.ts +++ b/src/lib/incident-projections.test.ts @@ -169,6 +169,69 @@ describe("append-only incident projections", () => { expect(db.prepare("SELECT COUNT(*) AS n FROM agent_presence WHERE agent = ?").get("direct-agent")).toEqual({ n: 0 }); }); + test("keeps canonical channel blockers visible across reserved rename aliases and stale projector routing", () => { + const routedContext: IncidentProjectorContext = { + ...context, + routing: { channel: "incidents", project_id: "engineering" }, + }; + createChannel("incidents", "channel-reader"); + const projection = appendIncidentProjection(fixture(1, { + id: "77777777-7777-4777-8777-777777777777", + blocked: ["channel:incidents"], + }), routedContext); + const db = getDb(); + const canonicalScope = db.prepare( + "SELECT scope FROM incident_projection_scopes WHERE projection_id = ? ORDER BY scope", + ).all(projection.id); + expect(getUnreadBlockers("channel-reader").map((message) => message.id)).toEqual([projection.message.id]); + expect(getUnreadBlockers("outsider")).toEqual([]); + + renameChannel("incidents", "incident-log"); + expect(getUnreadBlockers("channel-reader").map((message) => message.id)).toEqual([projection.message.id]); + renameChannel("incident-log", "incident-archive"); + expect(getUnreadBlockers("channel-reader").map((message) => message.id)).toEqual([projection.message.id]); + expect(getUnreadBlockers("outsider")).toEqual([]); + expect(db.prepare( + "SELECT old_channel, current_channel FROM channel_rename_aliases ORDER BY old_channel", + ).all()).toEqual([ + { old_channel: "incident-log", current_channel: "incident-archive" }, + { old_channel: "incidents", current_channel: "incident-archive" }, + ]); + expect(db.prepare( + "SELECT scope FROM incident_projection_scopes WHERE projection_id = ? ORDER BY scope", + ).all(projection.id)).toEqual(canonicalScope); + + expect(markRead([projection.message.id], "channel-reader")).toBe(1); + expect(markRead([projection.message.id], "channel-reader")).toBe(0); + expect(markRead([projection.message.id], "outsider")).toBe(0); + + const future = appendIncidentProjection(fixture(1, { + id: "88888888-8888-4888-8888-888888888888", + blocked: ["channel:incidents"], + }), routedContext); + expect(future.message.channel).toBe("incident-archive"); + expect(future.message.session_id).toBe("channel:incident-archive"); + expect(future.message.to_agent).toBe("incident-archive"); + expect(getUnreadBlockers("channel-reader").map((message) => message.id)).toEqual([future.message.id]); + expect(getUnreadBlockers("outsider")).toEqual([]); + + expect(() => createChannel("incidents", "attacker")).toThrow("reserved historical alias"); + createChannel("unrelated", "attacker"); + expect(() => renameChannel("unrelated", "incidents")).toThrow("reserved historical alias"); + expect(db.prepare( + "SELECT current_channel FROM channel_rename_aliases WHERE old_channel = 'incidents'", + ).get()).toEqual({ current_channel: "incident-archive" }); + + renameChannel("incident-archive", "incidents"); + expect(getUnreadBlockers("channel-reader").map((message) => message.id)).toEqual([future.message.id]); + expect(db.prepare( + "SELECT old_channel, current_channel FROM channel_rename_aliases ORDER BY old_channel", + ).all()).toEqual([ + { old_channel: "incident-archive", current_channel: "incidents" }, + { old_channel: "incident-log", current_channel: "incidents" }, + ]); + }); + test("missing reciprocal supersession source rolls back message, ledger, and scopes together", () => { const replacementId = "22222222-2222-4222-8222-222222222222"; const missingId = "33333333-3333-4333-8333-333333333333"; diff --git a/src/lib/incident-projections.ts b/src/lib/incident-projections.ts index 01b2506..7b31cf7 100644 --- a/src/lib/incident-projections.ts +++ b/src/lib/incident-projections.ts @@ -153,7 +153,12 @@ export function appendIncidentProjection( // closes the relation by supplying reciprocal supersedes_id. assertSupersededSource(request.incident.supersedes_id); - let channel = display.channel ?? null; + const routedChannel = display.channel + ? db.prepare( + "SELECT current_channel FROM channel_rename_aliases WHERE old_channel = ?", + ).get(display.channel) as { current_channel: string } | null + : null; + let channel = routedChannel?.current_channel ?? display.channel ?? null; let projectId = display.project_id ?? null; let sessionId = channel ? `channel:${channel}` diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 6a4f589..0439fee 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -1148,7 +1148,17 @@ export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset const limitClause = safeLimit > 0 ? `LIMIT ${safeLimit}` : safeOffset > 0 ? "LIMIT -1" : ""; const offsetClause = safeOffset > 0 ? `OFFSET ${safeOffset}` : ""; const rows = db.prepare(` - WITH latest AS ( + WITH member_channel_scopes(scope) AS ( + SELECT 'channel:' || lower(channel) + FROM channel_members + WHERE lower(agent) = lower(?) + UNION + SELECT 'channel:' || lower(alias.old_channel) + FROM channel_rename_aliases alias + JOIN channel_members member ON lower(member.channel) = lower(alias.current_channel) + WHERE lower(member.agent) = lower(?) + ), + latest AS ( SELECT p.* FROM incident_projections p JOIN ( @@ -1195,9 +1205,7 @@ export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset ) AND ( lower(scope.scope) = 'agent:' || lower(?) - OR lower(scope.scope) IN ( - SELECT 'channel:' || lower(channel) FROM channel_members WHERE lower(agent) = lower(?) - ) + OR lower(scope.scope) IN (SELECT scope FROM member_channel_scopes) OR scope.scope IN ( SELECT 'project:' || project_id FROM agent_presence WHERE lower(agent) = lower(?) AND project_id <> '' @@ -1222,7 +1230,7 @@ export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset SELECT m.* FROM messages m JOIN eligible_ids eligible ON eligible.id = m.id ORDER BY m.created_at ASC, m.id ASC ${limitClause} ${offsetClause} - `).all(binding?.tenant_id ?? null, binding?.authority_id ?? null, agent, agent, agent, agent, agent, agent) as Record[]; + `).all(agent, agent, binding?.tenant_id ?? null, binding?.authority_id ?? null, agent, agent, agent, agent, agent) as Record[]; return rows.map(parseMessage); } diff --git a/src/lib/pg-migrations.test.ts b/src/lib/pg-migrations.test.ts index 5fdf66c..3383dc8 100644 --- a/src/lib/pg-migrations.test.ts +++ b/src/lib/pg-migrations.test.ts @@ -25,6 +25,7 @@ describe("PG_MIGRATIONS", () => { expect(sql).toContain("create table if not exists _migrations"); expect(sql).toContain("create table if not exists incident_projections"); expect(sql).toContain("create table if not exists incident_projection_scopes"); + expect(sql).toContain("create table if not exists channel_rename_aliases"); expect(sql).toContain("metadata text"); expect(sql).toContain("tags text"); const channelsDefinition = sql.slice( @@ -41,6 +42,7 @@ describe("PG_MIGRATIONS", () => { expect(sql).toContain("idx_messages_search"); expect(sql).toContain("idx_incident_projections_active_scope"); expect(sql).toContain("idx_incident_projection_scopes_lookup"); + expect(sql).toContain("idx_channel_rename_aliases_current"); expect(sql).toContain("incident_projections_no_update"); expect(sql).toContain("incident_projections_no_delete"); expect(sql).toContain("incident_projection_scopes_no_update"); diff --git a/src/lib/pg-migrations.ts b/src/lib/pg-migrations.ts index cd9bbca..87a73f2 100644 --- a/src/lib/pg-migrations.ts +++ b/src/lib/pg-migrations.ts @@ -51,6 +51,15 @@ export const PG_MIGRATIONS: string[] = [ PRIMARY KEY (channel, agent) ); + CREATE TABLE IF NOT EXISTS channel_rename_aliases ( + old_channel TEXT PRIMARY KEY, + current_channel TEXT NOT NULL, + renamed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (old_channel <> current_channel) + ); + CREATE INDEX IF NOT EXISTS idx_channel_rename_aliases_current + ON channel_rename_aliases(current_channel); + CREATE TABLE IF NOT EXISTS channel_subscriptions ( channel TEXT NOT NULL REFERENCES channels(name), agent TEXT NOT NULL, diff --git a/src/server/api.test.ts b/src/server/api.test.ts index 75b8c1d..4753ddf 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -200,6 +200,40 @@ describe("conversations-serve", () => { } }); + test("incident projector GET awaits and sanitizes rejected storage handlers without leaking logs", async () => { + const deps = makeDeps(); + (deps.client as any).get = async (sql: string) => { + if (/FROM incident_projections WHERE tenant_id/i.test(sql)) { + throw new Error("SENTINEL_DB_PASSWORD_HOST_INTERNAL"); + } + return null; + }; + const logs: string[] = []; + const originalConsoleError = console.error; + console.error = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); }; + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + try { + const response = await fetch( + `http://127.0.0.1:${isolated.port}/v1/incident-projections/iev_0123456789abcdef0123456789abcdef`, + { headers: { "x-api-key": roKey } }, + ); + expect(response.status).toBe(503); + expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); + const rawBody = await response.text(); + expect(JSON.parse(rawBody)).toEqual({ + error: "Incident projection service is temporarily unavailable", + code: "INCIDENT_PROJECTION_UNAVAILABLE", + }); + expect(rawBody).not.toContain("SENTINEL"); + expect(logs.join("\n")).not.toContain("SENTINEL"); + expect(logs.join("\n")).not.toContain("PASSWORD"); + expect(logs.join("\n")).not.toContain("HOST_INTERNAL"); + } finally { + isolated.stop(true); + console.error = originalConsoleError; + } + }); + test("blocker reads and acknowledgements cannot impersonate another agent", async () => { const blockers = await fetch(`${base}/v1/messages/blockers?agent=other`, { headers: { "x-api-key": rwKey }, diff --git a/src/server/api.ts b/src/server/api.ts index e03bc68..f7984fa 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -32,7 +32,11 @@ import { metadataSpoofsIncidentProjection, validateIncidentProjectorBinding, } from "../lib/incident-projection-contract.js"; -import { appendIncidentProjectionPg, getIncidentProjectionPg } from "./incident-projections.js"; +import { + appendIncidentProjectionPg, + CHANNEL_IDENTITY_ADVISORY_LOCK, + getIncidentProjectionPg, +} from "./incident-projections.js"; import type { IncidentProjectionRequestV1, IncidentProjectorContext } from "../types.js"; export const APP = "conversations"; @@ -54,6 +58,20 @@ function json(data: unknown, status = 200, extra?: Record): Resp }); } +class ApiRequestValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ApiRequestValidationError"; + } +} + +function incidentProjectionUnavailable(): Response { + return json({ + error: "Incident projection service is temporarily unavailable", + code: "INCIDENT_PROJECTION_UNAVAILABLE", + }, 503); +} + function signingSecret(): string { const secret = process.env.HASNA_CONVERSATIONS_API_SIGNING_KEY || @@ -163,7 +181,17 @@ async function visibleIncidentBlockerIds( ) )`; const rows = await client.many<{ id: string | number }>( - `WITH latest AS ( + `WITH member_channel_scopes(scope) AS ( + SELECT 'channel:' || lower(channel) + FROM channel_members + WHERE lower(agent) = lower($3) + UNION + SELECT 'channel:' || lower(alias.old_channel) + FROM channel_rename_aliases alias + JOIN channel_members member ON lower(member.channel) = lower(alias.current_channel) + WHERE lower(member.agent) = lower($3) + ), + latest AS ( SELECT p.* FROM incident_projections p JOIN ( @@ -203,9 +231,7 @@ async function visibleIncidentBlockerIds( ${receiptFilter} AND ( lower(scope.scope) = 'agent:' || lower($3) - OR lower(scope.scope) IN ( - SELECT 'channel:' || lower(channel) FROM channel_members WHERE lower(agent) = lower($3) - ) + OR lower(scope.scope) IN (SELECT scope FROM member_channel_scopes) OR scope.scope IN ( SELECT 'project:' || project_id FROM agent_presence WHERE lower(agent) = lower($3) AND project_id <> '' @@ -222,7 +248,7 @@ async function upsertReadReceipts(client: TypedQueryClient, ids: number[], agent const result = await client.query( `INSERT INTO message_read_receipts (message_id, agent, read_at) SELECT message_id, $2, NOW() FROM unnest($1::bigint[]) AS message_id - ON CONFLICT (message_id, agent) DO UPDATE SET read_at = EXCLUDED.read_at`, + ON CONFLICT (message_id, agent) DO NOTHING`, [ids, agent.toLowerCase()], ); return result.rowCount; @@ -257,9 +283,14 @@ async function requireIncidentBlockerContext( async function readJson(req: Request): Promise> { const text = await req.text(); if (!text.trim()) return {}; - const parsed = JSON.parse(text); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new ApiRequestValidationError("Request body must contain valid JSON"); + } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("Request body must be a JSON object"); + throw new ApiRequestValidationError("Request body must be a JSON object"); } return parsed as Record; } @@ -431,11 +462,26 @@ export async function renameChannelServer( const to = normalizeChannelName(newName); try { return await client.transaction(async (tx) => { + await tx.get( + "SELECT pg_advisory_xact_lock($1::bigint) AS channel_identity_locked", + [CHANNEL_IDENTITY_ADVISORY_LOCK], + ); const existing = await tx.get(`SELECT name FROM channels WHERE name = $1 FOR UPDATE`, [from]); if (!existing) return { ok: false as const, error: `Channel not found: ${from}`, status: 404 }; if (from === to) return { ok: true as const, name: from }; const conflict = await tx.get(`SELECT name FROM channels WHERE name = $1 FOR UPDATE`, [to]); if (conflict) return { ok: false as const, error: `Channel #${to} already exists.`, status: 409 }; + const targetAlias = await tx.get<{ current_channel: string }>( + `SELECT current_channel FROM channel_rename_aliases WHERE old_channel = $1 FOR UPDATE`, + [to], + ); + if (targetAlias && targetAlias.current_channel !== from) { + return { + ok: false as const, + error: `Channel #${to} is a reserved historical alias for #${targetAlias.current_channel}.`, + status: 409, + }; + } await tx.get( `SELECT set_config('hasna.conversations.channel_scope_rewrite', $1, TRUE) AS configured`, @@ -454,6 +500,22 @@ export async function renameChannelServer( FROM channels WHERE name = $2`, [to, from], ); + await tx.query(`DELETE FROM channel_rename_aliases WHERE old_channel = $1`, [to]); + await tx.query( + `UPDATE channel_rename_aliases + SET current_channel = $1, renamed_at = NOW() + WHERE current_channel = $2`, + [to, from], + ); + await tx.query( + `INSERT INTO channel_rename_aliases (old_channel, current_channel) + VALUES ($1, $2) + ON CONFLICT (old_channel) DO UPDATE SET + current_channel = EXCLUDED.current_channel, + renamed_at = NOW()`, + [from, to], + ); + await tx.query(`DELETE FROM channel_rename_aliases WHERE old_channel = current_channel`); await tx.query(`UPDATE channel_members SET channel = $1 WHERE channel = $2`, [to, from]); await tx.query(`UPDATE channel_subscriptions SET channel = $1 WHERE channel = $2`, [to, from]); await tx.query( @@ -697,12 +759,20 @@ export function startApiServer(options: StartApiServerOptions = {}) { "WWW-Authenticate": "Bearer", }); } - return handleV1(path, method, req, url, deps, decision.principal.agent); + return await handleV1(path, method, req, url, deps, decision.principal.agent); } return json({ error: "Not found" }, 404); } catch (e) { - return json({ error: (e as Error).message }, 400); + if (e instanceof ApiRequestValidationError) { + return json({ error: e.message }, 400); + } + if (path === "/v1/incident-projections" || path.startsWith("/v1/incident-projections/")) { + console.error("[incident-projector] request failed with an unexpected storage/runtime error"); + return incidentProjectionUnavailable(); + } + console.error(`[api] unexpected ${method} request failure`); + return json({ error: "Service is temporarily unavailable", code: "SERVICE_UNAVAILABLE" }, 503); } }, }); @@ -747,10 +817,7 @@ async function handleV1( return json({ error: error.message, code: error.code }, 400); } console.error("[incident-projector] append failed with an unexpected storage/runtime error"); - return json({ - error: "Incident projection service is temporarily unavailable", - code: "INCIDENT_PROJECTION_UNAVAILABLE", - }, 503); + return incidentProjectionUnavailable(); } } @@ -783,7 +850,17 @@ async function handleV1( throw error; } const rows = await client.many>( - `WITH latest AS ( + `WITH member_channel_scopes(scope) AS ( + SELECT 'channel:' || lower(channel) + FROM channel_members + WHERE lower(agent) = lower($3) + UNION + SELECT 'channel:' || lower(alias.old_channel) + FROM channel_rename_aliases alias + JOIN channel_members member ON lower(member.channel) = lower(alias.current_channel) + WHERE lower(member.agent) = lower($3) + ), + latest AS ( SELECT p.* FROM incident_projections p JOIN ( @@ -831,9 +908,7 @@ async function handleV1( ) AND ( lower(scope.scope) = 'agent:' || lower($3) - OR lower(scope.scope) IN ( - SELECT 'channel:' || lower(channel) FROM channel_members WHERE lower(agent) = lower($3) - ) + OR lower(scope.scope) IN (SELECT scope FROM member_channel_scopes) OR scope.scope IN ( SELECT 'project:' || project_id FROM agent_presence WHERE lower(agent) = lower($3) AND project_id <> '' @@ -969,7 +1044,7 @@ async function handleV1( if ([...projected].some((id) => !visible.has(id))) { return json({ error: "one or more incident blockers are not visible to the authenticated agent" }, 403); } - await upsertReadReceipts(client, ids, reader); + const acknowledged = await upsertReadReceipts(client, ids, reader); const ordinary = ids.filter((id) => !projected.has(id)); const res = await client.query( `UPDATE messages SET read_at = NOW()::text @@ -977,7 +1052,7 @@ async function handleV1( AND NOT EXISTS (SELECT 1 FROM incident_projections p WHERE p.message_id = messages.id)`, [ordinary], ); - marked = projected.size + res.rowCount; + marked = acknowledged + res.rowCount; } else if (all) { const projected = await visibleIncidentBlockerIds(client, deps.incidentProjector, reader); const acknowledged = await upsertReadReceipts(client, projected, reader); @@ -1525,25 +1600,40 @@ async function handleV1( if (!rawName || !createdBy) return json({ error: "name and created_by are required" }, 400); const name = normalizeChannelName(rawName); const projectId = str(body.project_id); - if (projectId) { - const proj = await client.get(`SELECT id FROM projects WHERE id = $1`, [projectId]); - if (!proj) return json({ error: `Project not found: ${projectId}` }, 400); - } - const existing = await client.get(`SELECT name FROM channels WHERE name = $1`, [name]); - if (existing) return json({ error: "Channel already exists" }, 409); const tags = Array.isArray(body.tags) ? JSON.stringify(body.tags) : null; const metadata = body.metadata && typeof body.metadata === "object" ? JSON.stringify(body.metadata) : null; - const row = await client.get>( - `INSERT INTO channels (name, description, topic, project_id, created_by, metadata, tags) - VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, - [name, str(body.description) ?? null, str(body.topic) ?? null, projectId ?? null, createdBy, metadata, tags], - ); - // Creator auto-joins the channel, mirroring the local createChannel. - await client.query( - `INSERT INTO channel_members (channel, agent) VALUES ($1,$2) ON CONFLICT DO NOTHING`, - [name, createdBy], - ); - return json({ channel: row ? parseServerChannel(row) : null }, 201); + return await client.transaction(async (tx) => { + await tx.get( + "SELECT pg_advisory_xact_lock($1::bigint) AS channel_identity_locked", + [CHANNEL_IDENTITY_ADVISORY_LOCK], + ); + if (projectId) { + const proj = await tx.get(`SELECT id FROM projects WHERE id = $1`, [projectId]); + if (!proj) return json({ error: `Project not found: ${projectId}` }, 400); + } + const existing = await tx.get(`SELECT name FROM channels WHERE name = $1`, [name]); + if (existing) return json({ error: "Channel already exists" }, 409); + const historicalAlias = await tx.get<{ current_channel: string }>( + `SELECT current_channel FROM channel_rename_aliases WHERE old_channel = $1`, + [name], + ); + if (historicalAlias) { + return json({ + error: `Channel #${name} is a reserved historical alias for #${historicalAlias.current_channel}.`, + }, 409); + } + const row = await tx.get>( + `INSERT INTO channels (name, description, topic, project_id, created_by, metadata, tags) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [name, str(body.description) ?? null, str(body.topic) ?? null, projectId ?? null, createdBy, metadata, tags], + ); + // Creator auto-joins the channel, mirroring the local createChannel. + await tx.query( + `INSERT INTO channel_members (channel, agent) VALUES ($1,$2) ON CONFLICT DO NOTHING`, + [name, createdBy], + ); + return json({ channel: row ? parseServerChannel(row) : null }, 201); + }); } if (sub === "channels/mine" && method === "GET") { diff --git a/src/server/incident-projections.ts b/src/server/incident-projections.ts index fde6ccc..47c95b2 100644 --- a/src/server/incident-projections.ts +++ b/src/server/incident-projections.ts @@ -15,6 +15,38 @@ import type { type Row = Record; +// One transaction-scoped identity fence covers both existing and not-yet- +// created channel names. Projectors take the shared form; channel create/rename +// take the exclusive form. This deliberately favors a small correctness fence +// over per-name gap-lock complexity for an infrequent control-plane operation. +export const CHANNEL_IDENTITY_ADVISORY_LOCK = 0x434f4e56; + +async function resolveLockedProjectionChannel( + client: TypedQueryClient, + configuredChannel: string, +): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + const before = await client.get<{ current_channel: string }>( + "SELECT current_channel FROM channel_rename_aliases WHERE old_channel = $1", + [configuredChannel], + ); + const candidate = before?.current_channel ?? configuredChannel; + // This row-identity lock serializes projector writes with renameChannelServer. + // If a rename committed while this SELECT waited, the row disappears and + // the alias re-read below moves the projector to the new current channel. + await client.get("SELECT name FROM channels WHERE name = $1 FOR SHARE", [candidate]); + const after = await client.get<{ current_channel: string }>( + "SELECT current_channel FROM channel_rename_aliases WHERE old_channel = $1", + [configuredChannel], + ); + const resolved = after?.current_channel ?? configuredChannel; + if (resolved === candidate) return resolved; + } + throw new IncidentProjectionConflictError( + `Channel routing for #${configuredChannel} changed repeatedly; retry the projection`, + ); +} + async function loadRecord( client: TypedQueryClient, row: Row, @@ -61,6 +93,10 @@ export async function appendIncidentProjectionPg( try { return await client.transaction(async (tx) => { + await tx.get( + "SELECT pg_advisory_xact_lock_shared($1::bigint) AS channel_identity_locked", + [CHANNEL_IDENTITY_ADVISORY_LOCK], + ); const existing = await tx.get( "SELECT * FROM incident_projections WHERE tenant_id = $1 AND event_id = $2", [context.tenant_id, request.event_id], @@ -117,7 +153,9 @@ export async function appendIncidentProjectionPg( }; await assertSupersededSource(request.incident.supersedes_id); - let channel = display.channel ?? null; + let channel = display.channel + ? await resolveLockedProjectionChannel(tx, display.channel) + : null; let projectId = display.project_id ?? null; let sessionId = channel ? `channel:${channel}` diff --git a/src/server/openapi.test.ts b/src/server/openapi.test.ts index 8f71cc4..8e8f7a2 100644 --- a/src/server/openapi.test.ts +++ b/src/server/openapi.test.ts @@ -12,7 +12,9 @@ describe("incident projection public contract", () => { "#/components/schemas/IncidentProjectionEventV1", ); expect(Object.keys(append.responses).sort()).toEqual(["200", "201", "400", "409", "503"]); - expect(spec.paths["/v1/incident-projections/{event_id}"].get.operationId).toBe("getIncidentProjection"); + const get = spec.paths["/v1/incident-projections/{event_id}"].get; + expect(get.operationId).toBe("getIncidentProjection"); + expect(Object.keys(get.responses).sort()).toEqual(["200", "404", "503"]); expect(spec.paths["/v1/messages/blockers"].get.operationId).toBe("listUnreadBlockers"); expect(spec.components.schemas.IncidentProjectionEventV1.additionalProperties).toBe(false); expect(spec.components.schemas.IncidentSnapshotV1.additionalProperties).toBe(false); diff --git a/src/server/openapi.ts b/src/server/openapi.ts index ec25e4d..a753d95 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -225,6 +225,7 @@ export const openapiSpec = { responses: { "200": { description: "projection", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionResponse" } } } }, "404": { description: "not found", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionError" } } } }, + "503": { description: "projector authority or storage temporarily unavailable", content: { "application/json": { schema: { $ref: "#/components/schemas/IncidentProjectionError" } } } }, }, }, }, From 1603d7cae7274b5bedb33b2d4321683a13a25a07 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Sun, 19 Jul 2026 03:33:23 +0300 Subject: [PATCH 3/8] fix: preserve incident projection timestamp precision --- scripts/verify-incident-projection-pg.ts | 150 +++++++++++++++++++--- src/lib/incident-projection-timestamps.ts | 49 +++++++ src/lib/incident-projections.test.ts | 45 ++++++- src/lib/incident-projections.ts | 10 +- src/server/incident-projections.ts | 10 +- 5 files changed, 241 insertions(+), 23 deletions(-) create mode 100644 src/lib/incident-projection-timestamps.ts diff --git a/scripts/verify-incident-projection-pg.ts b/scripts/verify-incident-projection-pg.ts index 0dc5732..0da687f 100644 --- a/scripts/verify-incident-projection-pg.ts +++ b/scripts/verify-incident-projection-pg.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun import assert from "node:assert/strict"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import { Pool } from "pg"; import { ApiKeyStore, mintApiKey, verifyApiKey } from "@hasna/contracts/auth"; @@ -18,19 +18,74 @@ import { renameChannelServer, startApiServer } from "../src/server/api.js"; import { ConversationsClient } from "../src/sdk/index.js"; import type { IncidentProjectionRequestV1, IncidentProjectorContext } from "../src/types.js"; +const APP_TARGET_ENV_NAMES = ["CONVERSATIONS", "TODOS"].flatMap((app) => [ + `HASNA_${app}_DATABASE_URL_OWNER`, + `${app}_DATABASE_URL_OWNER`, + `HASNA_${app}_DATABASE_URL`, + `${app}_DATABASE_URL`, + `HASNA_${app}_API_URL`, + `${app}_API_URL`, + `HASNA_${app}_API_KEY`, + `${app}_API_KEY`, + `HASNA_${app}_API_SIGNING_KEY`, + `${app}_API_SIGNING_KEY`, + `HASNA_${app}_STORAGE_MODE`, + `HASNA_${app}_MODE`, + `${app}_STORAGE_MODE`, + `${app}_MODE`, +]); +const LIBPQ_TARGET_ENV_NAMES = [ + "DATABASE_URL", + "PGHOST", + "PGHOSTADDR", + "PGPORT", + "PGDATABASE", + "PGUSER", + "PGPASSWORD", + "PGPASSFILE", + "PGSERVICE", + "PGSERVICEFILE", + "PGSSLMODE", + "PGREQUIRESSL", + "PGCONNECT_TIMEOUT", + "PGTARGETSESSIONATTRS", + "PGOPTIONS", + "PGAPPNAME", + "HASNA_API_SIGNING_KEY", + "API_KEY_SIGNING_SECRET", +]; +const AMBIENT_TARGET_ENV_NAMES = [...APP_TARGET_ENV_NAMES, ...LIBPQ_TARGET_ENV_NAMES]; + +function assertNoAmbientTargetEnvironment(env: NodeJS.ProcessEnv): void { + const found = AMBIENT_TARGET_ENV_NAMES.find((name) => env[name] !== undefined); + if (found) throw new Error(`refusing ambient target setting: ${found}`); +} + +function unsetAmbientTargetEnvironment(env: NodeJS.ProcessEnv): void { + for (const name of AMBIENT_TARGET_ENV_NAMES) delete env[name]; +} + +const hostileAmbient: NodeJS.ProcessEnv = { + HASNA_CONVERSATIONS_DATABASE_URL: "hostile-test-sentinel", + HASNA_TODOS_API_URL: "hostile-test-sentinel", + PGHOST: "hostile-test-sentinel", +}; +assert.throws( + () => assertNoAmbientTargetEnvironment(hostileAmbient), + /refusing ambient target setting: HASNA_CONVERSATIONS_DATABASE_URL/, +); +unsetAmbientTargetEnvironment(hostileAmbient); +assertNoAmbientTargetEnvironment(hostileAmbient); +unsetAmbientTargetEnvironment(process.env); +assertNoAmbientTargetEnvironment(process.env); + const socket = "/var/run/postgresql"; const port = 5432; -const user = process.env.USER || "hasna"; +const user = "hasna"; const database = `oc_incident_52e65cba_${process.pid}_${randomBytes(4).toString("hex")}`; if (!/^[a-z0-9_]+$/.test(database)) throw new Error("unsafe temporary database name"); const quotedDatabase = `"${database}"`; -function assertPasswordlessLocalPgEnvironment(env: NodeJS.ProcessEnv): void { - for (const name of ["PGPASSWORD", "PGPASSFILE", "PGSERVICE", "PGSERVICEFILE"] as const) { - if (env[name]) throw new Error(`refusing ambient PostgreSQL credential or service setting: ${name}`); - } -} - function localPoolConfig(targetDatabase: string, max: number) { return { host: socket, port, database: targetDatabase, user, max }; } @@ -40,17 +95,11 @@ async function createOwnedDatabase(pool: Pool, quotedName: string): Promise assertPasswordlessLocalPgEnvironment({ PGPASSWORD: "hostile-test-sentinel" }), - /PGPASSWORD/, -); const hostileTargetConfig = localPoolConfig("hostile_target_sentinel", 1); assert.deepEqual( { host: hostileTargetConfig.host, port: hostileTargetConfig.port, database: hostileTargetConfig.database }, { host: socket, port, database: "hostile_target_sentinel" }, ); -assertPasswordlessLocalPgEnvironment(process.env); - const admin = new Pool(localPoolConfig("postgres", 1)); let taskClient: ReturnType | null = null; let guardReuseClient: ReturnType | null = null; @@ -156,11 +205,80 @@ try { baseUrl: `http://127.0.0.1:${apiServer.port}`, apiKey: projectorKey, }); - const httpCreated = await sdk.appendIncidentProjection(fixture); - const httpReplay = await sdk.appendIncidentProjection(fixture); + const occurredAt = "2026-07-18T20:01:51.314Z"; + fixture.occurred_at = occurredAt; + fixture.incident.created_at = occurredAt; + fixture.incident.updated_at = occurredAt; + const projectionUrl = `http://127.0.0.1:${apiServer.port}/v1/incident-projections`; + const projectionHeaders = { "x-api-key": projectorKey, "content-type": "application/json" }; + const createdResponse = await fetch(projectionUrl, { + method: "POST", + headers: projectionHeaders, + body: JSON.stringify(fixture), + }); + assert.equal(createdResponse.status, 201); + const httpCreated = await createdResponse.json() as Awaited>; + const replayResponse = await fetch(projectionUrl, { + method: "POST", + headers: projectionHeaders, + body: JSON.stringify(fixture), + }); + assert.equal(replayResponse.status, 200); + const httpReplay = await replayResponse.json() as Awaited>; + const projectionReadKey = mintApiKey({ + app: "conversations", + agent: "projection-reader", + scopes: ["conversations:read"], + signingSecret, + }).token; + const getResponse = await fetch(`${projectionUrl}/${fixture.event_id}`, { + headers: { "x-api-key": projectionReadKey }, + }); + assert.equal(getResponse.status, 200); + const httpFetched = await getResponse.json() as Awaited>; assert.equal(httpCreated.projection.replayed, false); assert.equal(httpReplay.projection.replayed, true); + assert.equal(httpFetched.projection.replayed, false); assert.equal(httpCreated.projection.message_id, httpReplay.projection.message_id); + const storedTimestamps = await taskClient.one<{ + occurred_at: Date; + projection_created_at: Date; + message_created_at: Date; + edited_at: string | null; + pinned_at: string | null; + read_at: string | null; + }>( + `SELECT p.occurred_at, p.created_at AS projection_created_at, + m.created_at AS message_created_at, m.edited_at, m.pinned_at, m.read_at + FROM incident_projections p JOIN messages m ON m.id = p.message_id + WHERE p.tenant_id = $1 AND p.event_id = $2`, + [context.tenant_id, fixture.event_id], + ); + for (const response of [httpCreated, httpReplay, httpFetched]) { + const projection = response.projection; + const canonical = JSON.parse(projection.canonical_payload); + assert.equal(projection.occurred_at, occurredAt); + assert.equal(canonical.occurred_at, occurredAt); + assert.equal(canonical.incident.created_at, occurredAt); + assert.equal(canonical.incident.updated_at, occurredAt); + assert.equal(canonical.incident.deadline, null); + assert.equal(canonical.incident.resolved_at, null); + assert.equal( + projection.payload_hash, + createHash("sha256").update(projection.canonical_payload).digest("hex"), + ); + assert.equal(projection.created_at, storedTimestamps.projection_created_at.toISOString()); + assert.equal(projection.message.created_at, storedTimestamps.message_created_at.toISOString()); + assert.equal(projection.message.edited_at, storedTimestamps.edited_at); + assert.equal(projection.message.pinned_at, storedTimestamps.pinned_at); + assert.equal(projection.message.read_at, storedTimestamps.read_at); + } + assert.equal(httpReplay.projection.canonical_payload, httpCreated.projection.canonical_payload); + assert.equal(httpReplay.projection.payload_hash, httpCreated.projection.payload_hash); + assert.equal(httpReplay.projection.occurred_at, httpCreated.projection.occurred_at); + assert.equal(httpFetched.projection.canonical_payload, httpCreated.projection.canonical_payload); + assert.equal(httpFetched.projection.payload_hash, httpCreated.projection.payload_hash); + assert.equal(httpFetched.projection.occurred_at, httpCreated.projection.occurred_at); await taskClient.query( `INSERT INTO channels (name, created_by) VALUES ('incidents', 'verifier') ON CONFLICT (name) DO NOTHING`, diff --git a/src/lib/incident-projection-timestamps.ts b/src/lib/incident-projection-timestamps.ts new file mode 100644 index 0000000..7a4ddbb --- /dev/null +++ b/src/lib/incident-projection-timestamps.ts @@ -0,0 +1,49 @@ +import type { Message } from "../types.js"; + +const RFC3339_TIMESTAMP = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; +const SQLITE_UTC_TIMESTAMP = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?$/; +const POSTGRES_TEXT_TIMESTAMP = + /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?)([+-]\d{2})(?::?(\d{2}))?$/; + +/** Normalize only the timestamp representations emitted by supported stores. */ +export function normalizeIncidentProjectionTimestamp(value: unknown, path: string): string { + if (value instanceof Date) { + if (!Number.isFinite(value.getTime())) throw new Error(`${path} is not a valid timestamp`); + return value.toISOString(); + } + if (typeof value !== "string") throw new Error(`${path} is not a timestamp string`); + + let candidate: string; + if (RFC3339_TIMESTAMP.test(value)) { + candidate = value; + } else if (SQLITE_UTC_TIMESTAMP.test(value)) { + candidate = `${value}Z`; + } else { + const postgres = POSTGRES_TEXT_TIMESTAMP.exec(value); + if (!postgres) throw new Error(`${path} is not a supported timestamp`); + candidate = `${postgres[1]}T${postgres[2]}${postgres[3]}:${postgres[4] ?? "00"}`; + } + + const parsed = new Date(candidate); + if (!Number.isFinite(parsed.getTime())) throw new Error(`${path} is not a valid timestamp`); + return parsed.toISOString(); +} + +export function normalizeNullableIncidentProjectionTimestamp( + value: unknown, + path: string, +): string | null { + return value === null ? null : normalizeIncidentProjectionTimestamp(value, path); +} + +export function normalizeIncidentProjectionMessageTimestamps(message: Message): Message { + return { + ...message, + created_at: normalizeIncidentProjectionTimestamp(message.created_at, "message.created_at"), + edited_at: normalizeNullableIncidentProjectionTimestamp(message.edited_at, "message.edited_at"), + pinned_at: normalizeNullableIncidentProjectionTimestamp(message.pinned_at, "message.pinned_at"), + read_at: normalizeNullableIncidentProjectionTimestamp(message.read_at, "message.read_at"), + }; +} diff --git a/src/lib/incident-projections.test.ts b/src/lib/incident-projections.test.ts index 8471b7a..0bb37a7 100644 --- a/src/lib/incident-projections.test.ts +++ b/src/lib/incident-projections.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "crypto"; import { unlinkSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { closeDb, getDb } from "./db"; import { deleteMessage, editMessage, getMessageById, getUnreadBlockers, markRead, recordReadReceipt, sendMessage } from "./messages"; -import { appendIncidentProjection } from "./incident-projections"; +import { appendIncidentProjection, getIncidentProjection } from "./incident-projections"; import { createChannel, renameChannel } from "./channels"; import { computeIncidentProjectionIds } from "./incident-projection-contract"; import type { IncidentProjectionRequestV1, IncidentProjectorContext, IncidentStatus } from "../types"; @@ -81,6 +82,48 @@ afterEach(() => { }); describe("append-only incident projections", () => { + test("preserves exact projection timestamps across append, replay, and GET", () => { + const event = fixture(); + const occurredAt = "2026-07-18T20:01:51.314Z"; + event.occurred_at = occurredAt; + event.incident.created_at = occurredAt; + event.incident.updated_at = occurredAt; + + const created = appendIncidentProjection(event, context); + const replay = appendIncidentProjection(event, context); + const fetched = getIncidentProjection(event.event_id, context); + expect(fetched).not.toBeNull(); + + expect(created.replayed).toBe(false); + expect(replay.replayed).toBe(true); + expect(fetched!.replayed).toBe(false); + for (const record of [created, replay, fetched!]) { + const canonical = JSON.parse(record.canonical_payload); + expect(record.occurred_at).toBe(occurredAt); + expect(canonical.occurred_at).toBe(occurredAt); + expect(canonical.incident.created_at).toBe(occurredAt); + expect(canonical.incident.updated_at).toBe(occurredAt); + expect(canonical.incident.deadline).toBeNull(); + expect(canonical.incident.resolved_at).toBeNull(); + expect(record.payload_hash).toBe( + createHash("sha256").update(record.canonical_payload).digest("hex"), + ); + expect(record.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(new Date(record.created_at).toISOString()).toBe(record.created_at); + expect(record.message.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(new Date(record.message.created_at).toISOString()).toBe(record.message.created_at); + expect(record.message.edited_at).toBeNull(); + expect(record.message.pinned_at).toBeNull(); + expect(record.message.read_at).toBeNull(); + } + expect(replay.canonical_payload).toBe(created.canonical_payload); + expect(replay.payload_hash).toBe(created.payload_hash); + expect(replay.occurred_at).toBe(created.occurred_at); + expect(fetched!.canonical_payload).toBe(created.canonical_payload); + expect(fetched!.payload_hash).toBe(created.payload_hash); + expect(fetched!.occurred_at).toBe(created.occurred_at); + }); + test("identical replay returns the exact existing projection and message", () => { const first = appendIncidentProjection(fixture(), context); const replay = appendIncidentProjection(fixture(), context); diff --git a/src/lib/incident-projections.ts b/src/lib/incident-projections.ts index 7b31cf7..d4e27dd 100644 --- a/src/lib/incident-projections.ts +++ b/src/lib/incident-projections.ts @@ -8,6 +8,10 @@ import { validateIncidentProjectorBinding, validateIncidentProjection, } from "./incident-projection-contract.js"; +import { + normalizeIncidentProjectionMessageTimestamps, + normalizeIncidentProjectionTimestamp, +} from "./incident-projection-timestamps.js"; import type { IncidentProjectionRecord, IncidentProjectionRequestV1, @@ -54,7 +58,7 @@ function projectionRecord(row: ProjectionRow, message: Message, replayed: boolea incident_id: String(row.incident_id), transition_id: String(row.transition_id), incident_version: Number(row.incident_version), - occurred_at: String(row.occurred_at), + occurred_at: normalizeIncidentProjectionTimestamp(row.occurred_at, "projection.occurred_at"), status: row.status as IncidentProjectionRecord["status"], severity: row.severity as IncidentProjectionRecord["severity"], blocking: Boolean(row.blocking), @@ -63,8 +67,8 @@ function projectionRecord(row: ProjectionRow, message: Message, replayed: boolea superseded_by_incident_id: row.superseded_by_incident_id == null ? null : String(row.superseded_by_incident_id), canonical_payload: String(row.canonical_payload), payload_hash: String(row.payload_hash), - created_at: String(row.created_at), - message, + created_at: normalizeIncidentProjectionTimestamp(row.created_at, "projection.created_at"), + message: normalizeIncidentProjectionMessageTimestamps(message), replayed, }; } diff --git a/src/server/incident-projections.ts b/src/server/incident-projections.ts index 47c95b2..f8838e7 100644 --- a/src/server/incident-projections.ts +++ b/src/server/incident-projections.ts @@ -6,6 +6,10 @@ import { IncidentProjectionConflictError, validateIncidentProjection, } from "../lib/incident-projection-contract.js"; +import { + normalizeIncidentProjectionMessageTimestamps, + normalizeIncidentProjectionTimestamp, +} from "../lib/incident-projection-timestamps.js"; import type { IncidentProjectionRecord, IncidentProjectionRequestV1, @@ -66,7 +70,7 @@ async function loadRecord( incident_id: String(row.incident_id), transition_id: String(row.transition_id), incident_version: Number(row.incident_version), - occurred_at: new Date(String(row.occurred_at)).toISOString(), + occurred_at: normalizeIncidentProjectionTimestamp(row.occurred_at, "projection.occurred_at"), status: row.status as IncidentProjectionRecord["status"], severity: row.severity as IncidentProjectionRecord["severity"], blocking: Boolean(row.blocking), @@ -75,8 +79,8 @@ async function loadRecord( superseded_by_incident_id: row.superseded_by_incident_id == null ? null : String(row.superseded_by_incident_id), canonical_payload: String(row.canonical_payload), payload_hash: String(row.payload_hash), - created_at: new Date(String(row.created_at)).toISOString(), - message: parseMessage(messageRow) as Message, + created_at: normalizeIncidentProjectionTimestamp(row.created_at, "projection.created_at"), + message: normalizeIncidentProjectionMessageTimestamps(parseMessage(messageRow) as Message), replayed, }; } From e06073f4e4b6f83be32195e32f2b01884cf0a977 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Sun, 19 Jul 2026 05:49:55 +0300 Subject: [PATCH 4/8] fix: bound and redact message collection reads --- CHANGELOG.md | 8 + README.md | 34 ++- dashboard/src/app.tsx | 2 +- dashboard/src/components/channel-feed.tsx | 2 +- dashboard/src/components/chat-panel.tsx | 2 +- dashboard/src/components/messages-table.tsx | 4 +- dashboard/src/types.ts | 6 +- src/cli/commands/analytics.ts | 8 +- src/cli/commands/channels.ts | 44 +-- src/cli/commands/messaging.ts | 165 +++++----- src/cli/commands/project-panel.test.ts | 18 +- src/cli/compact-output.e2e.test.ts | 41 +-- src/cli/message-output.ts | 16 +- src/cli/receipts-locks.e2e.test.ts | 21 +- src/hooks/blocker-hook.ts | 5 +- src/lib/channel-notifications.test.ts | 34 ++- src/lib/channel-notifications.ts | 54 +++- src/lib/message-previews.test.ts | 119 ++++++++ src/lib/message-previews.ts | 211 +++++++++++++ src/lib/messages.ts | 215 ++++++++++++- src/lib/poll.test.ts | 18 +- src/lib/poll.ts | 12 +- src/lib/project-panel.test.ts | 19 +- src/lib/project-panel.ts | 32 +- src/lib/store/api-store.ts | 197 +++++++++--- src/lib/store/index.ts | 6 + src/lib/summary.ts | 55 ++-- src/lib/topics.test.ts | 22 ++ src/lib/topics.ts | 42 ++- src/mcp/channel.test.ts | 11 + src/mcp/channel.ts | 16 +- src/mcp/compact.ts | 4 +- src/mcp/index.test.ts | 35 ++- src/mcp/tools/advanced.test.ts | 54 +++- src/mcp/tools/advanced.ts | 103 ++++--- src/mcp/tools/agents.test.ts | 21 +- src/mcp/tools/agents.ts | 23 +- src/mcp/tools/channels.test.ts | 49 +-- src/mcp/tools/channels.ts | 51 ++-- src/mcp/tools/messaging.test.ts | 55 ++-- src/mcp/tools/messaging.ts | 90 +++--- src/sdk/index.ts | 17 +- src/sdk/message-preview.test.ts | 79 +++++ src/server/api.test.ts | 113 ++++++- src/server/api.ts | 320 ++++++++++++++------ src/server/openapi.ts | 101 +++++- src/server/serve.test.ts | 35 ++- src/server/serve.ts | 77 +++-- src/test/hermetic.ts | 87 ++++++ src/types.ts | 65 ++++ 50 files changed, 2136 insertions(+), 682 deletions(-) create mode 100644 src/lib/message-previews.test.ts create mode 100644 src/lib/message-previews.ts create mode 100644 src/sdk/message-preview.test.ts create mode 100644 src/test/hermetic.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d506b23..42d6a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Changed +- Message collection reads are now bounded, redacted SQL/server projections. CLI, MCP, Store, HTTP, blocker, pinned, mention, thread, digest, summary, project-panel, watch-startup, and hook collection paths no longer carry source bodies, raw metadata, or raw attachments across collection boundaries. Incident/security collections use a neutral body marker; full content is available only from the exact message-id path. +- Message/channel reads are pure peeks by default. Read-state and receipt mutations now require explicit `--mark-read` / `mark_read: true`; legacy `verbose` collection flags remain accepted but stay preview-only. +- Collection APIs enforce hard result, response-byte, preview-byte, and statement-timeout caps, and expose the preview-page contract in OpenAPI and the generated SDK. + +### Added +- Hermetic safe-read regressions clear ambient cloud/API/database/dotenv routes, block unexpected network access, and verify redaction, restricted-channel suppression, exact-id disclosure, non-mutating peeks, explicit acknowledgements, and cap failures. + ## [0.5.1] - 2026-07-08 ### Fixed diff --git a/README.md b/README.md index dc94a4d..f97e94e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ conversations read --to codex --json conversations channel create engineering --description "Engineering coordination" conversations channel send engineering "Build is green" conversations channel read engineering --json +conversations channel read engineering --from codex --mark-read conversations channel join engineering conversations dashboard conversations storage status @@ -57,17 +58,22 @@ ids, previews, and a hint for the next detail step. ```bash conversations read --to codex # compact previews -conversations read --to codex --verbose # full message bodies conversations show 123 # one full message -conversations read --to codex --json # full machine-readable records +conversations read --to codex --json # bounded machine-readable preview page conversations read --to codex --limit 10 --cursor 10 conversations digest engineering --cursor 123 --max-bytes 8192 --json ``` The same gradual disclosure pattern applies to channel reads, message search, -recent activity, pinned messages, blockers, channel/project/agent/session lists, -and watch output. Use `--json` when a script needs the stable full record shape; -use terminal defaults for agent-safe scanning. +recent activity, pinned messages, blockers, mentions, threads, summaries, and +watch startup output. Collection paths are server/store projections: they never +carry full content, raw metadata, or raw attachments across the API/MCP boundary. +`--json` returns the stable preview-page envelope; `--verbose` remains accepted +for compatibility but does not restore collection bodies. Use `show ` for +one explicit exact message. + +Reads are pure peeks by default. Pass `--mark-read` (and `--from ` when +needed) only when the returned IDs should be acknowledged and receipts updated. For long-running loops and autonomous agents, `conversations digest ` returns a stable compact evidence packet instead of replaying the full channel. @@ -129,11 +135,12 @@ MCP exposes channel-first tools such as `create_channel`, `list_channels`, `send_to_channel`, `read_channel`, `join_channel`, `leave_channel`, `subscribe_channel_notifications`, and `summarize_channel`. -MCP read/list/search tools also default to compact summaries. Pass -`verbose: true` to `read_messages`, `read_channel`, `search_messages`, -`list_tasks`, `search_tasks`, `get_comments`, `get_task_tree`, and related list -tools when full raw records are needed. Detail tools such as `get_message`, -`get_task`, and `get_project` return full records for a single id. +MCP message collection tools (`read_messages`, `read_channel`, +`search_messages`, `get_blockers`, `get_mentions`, thread reads, and pinned +reads) return byte/result/time-capped redacted previews. Their compatibility +`verbose` flags never return source bodies. `get_message` is the explicit exact +full-content path for one id. Reads do not change read state or receipts unless +`mark_read: true` is passed explicitly. Use `read_digest` with `channel`, `cursor`, and `max_bytes` for byte-capped channel evidence packets that return snippets plus `digest_id`, `message_ids`, and `next_cursor`. @@ -162,6 +169,13 @@ engine in the process. Requests to `/v1/*` are authenticated with `@hasna/contracts` API keys (scope grammar `conversations:read` / `conversations:write`). +`GET /v1/messages` and blocker/pinned/thread/mention/summary collection routes +project bounded previews in SQL before serialization. They cap results (100), +response bytes (64 KiB), preview bytes (1 KiB), and query time (5 seconds), with +lower defaults. Incident and security bodies are replaced with a neutral marker +on every collection path. `GET /v1/messages/{id}` is the sole general exact-body +read; `detail=full` collection requests are rejected. + ```bash export HASNA_CONVERSATIONS_STORAGE_MODE=cloud export HASNA_CONVERSATIONS_DATABASE_URL="postgres://…?sslmode=require&uselibpqcompat=true" diff --git a/dashboard/src/app.tsx b/dashboard/src/app.tsx index c8fcbb7..ea06ed4 100644 --- a/dashboard/src/app.tsx +++ b/dashboard/src/app.tsx @@ -71,7 +71,7 @@ export function App() { // Compute unread counts per channel const counts: Record = {}; for (const m of allMsgsRes) { - if (m.channel && !m.read_at) { + if (m.channel && m.unread) { counts[m.channel] = (counts[m.channel] || 0) + 1; } } diff --git a/dashboard/src/components/channel-feed.tsx b/dashboard/src/components/channel-feed.tsx index b832c68..dbae638 100644 --- a/dashboard/src/components/channel-feed.tsx +++ b/dashboard/src/components/channel-feed.tsx @@ -96,7 +96,7 @@ export function ChannelFeed({ channelName, onBack }: ChannelFeedProps) { )}
- {msg.content} + {msg.preview}
))} diff --git a/dashboard/src/components/chat-panel.tsx b/dashboard/src/components/chat-panel.tsx index 6b518cc..fb7c80b 100644 --- a/dashboard/src/components/chat-panel.tsx +++ b/dashboard/src/components/chat-panel.tsx @@ -80,7 +80,7 @@ export function ChatPanel({ open, onClose, sessionId, title }: ChatPanelProps) { {msg.created_at.slice(11, 19)}
- {msg.content} + {msg.preview}
)) diff --git a/dashboard/src/components/messages-table.tsx b/dashboard/src/components/messages-table.tsx index a7f8cca..b71aa9a 100644 --- a/dashboard/src/components/messages-table.tsx +++ b/dashboard/src/components/messages-table.tsx @@ -101,14 +101,14 @@ export function MessagesTable({ messages, onSelectMessage }: MessagesTableProps)
- {msg.content} + {msg.preview}
- {msg.read_at ? ( + {!msg.unread ? ( | null; + has_metadata: boolean; created_at: string; - read_at: string | null; + unread: boolean; } export interface Session { diff --git a/src/cli/commands/analytics.ts b/src/cli/commands/analytics.ts index 9041a26..41ed4cb 100644 --- a/src/cli/commands/analytics.ts +++ b/src/cli/commands/analytics.ts @@ -202,7 +202,7 @@ export function registerAnalyticsCommands(program: Command): void { const onlineAgents = await store.listAgents({ online_only: true }); // Unread DMs - const unreadDMs = await store.readMessages({ to: agent, unread_only: true, limit: 5 }); + const unreadDMs = (await store.readMessagePreviews({ to: agent, unread_only: true, limit: 5 })).messages; // Channels I'm in (with per-channel unread counts) — routed through the Store const myChannels = await store.getMemberChannels(agent); @@ -215,7 +215,7 @@ export function registerAnalyticsCommands(program: Command): void { }); // Recent DMs (last 3 messages to me) - const recentDMs = await store.readMessages({ to: agent, limit: 3 }); + const recentDMs = (await store.readMessagePreviews({ to: agent, limit: 3, order: "desc" })).messages; const context = { agent, @@ -246,7 +246,7 @@ export function registerAnalyticsCommands(program: Command): void { if (unreadDMs.length > 0) { console.log(`${chalk.bold("Unread DMs:")} ${chalk.yellow(unreadDMs.length + " message(s)")}`); for (const msg of unreadDMs.slice(0, 3)) { - console.log(` ${chalk.dim(msg.created_at.slice(11, 16))} ${chalk.cyan(msg.from_agent)}: ${msg.content.slice(0, 80)}`); + console.log(` ${chalk.dim(msg.created_at.slice(11, 16))} ${chalk.cyan(msg.from_agent)}: ${msg.preview.slice(0, 80)}`); } } else { console.log(`${chalk.bold("Unread DMs:")} ${chalk.dim("none")}`); @@ -324,7 +324,7 @@ export function registerAnalyticsCommands(program: Command): void { hasMore: page.hasMore, nextCursor: page.nextCursor, limitCapped: window.limitCapped, - detailHint: "Use conversations read --session --verbose for message bodies.", + detailHint: "Use conversations read --session for previews, then conversations show for one exact full message.", }); } } diff --git a/src/cli/commands/channels.ts b/src/cli/commands/channels.ts index 6514568..3c7a91d 100644 --- a/src/cli/commands/channels.ts +++ b/src/cli/commands/channels.ts @@ -141,7 +141,7 @@ export function registerChannelCommands(program: Command): void { hasMore: page.hasMore, nextCursor: page.nextCursor, limitCapped: window.limitCapped, - detailHint: "Use conversations channel read --verbose for message bodies.", + detailHint: "Use conversations channel read for previews, then conversations show for one exact full message.", }); } } @@ -335,13 +335,16 @@ export function registerChannelCommands(program: Command): void { channel .command("read") - .description("Read messages from a channel") + .description("Peek at bounded, redacted message previews from a channel") .argument("", "Channel name") .option("--from ", "Agent reading the channel") .option("--since ", "Messages after this ISO timestamp") .option("--limit ", "Max messages to return", parseInt) .option("--cursor ", "Skip first N messages for pagination", parseInt) - .option("--verbose", "Show full message bodies") + .option("--mark-read", "Explicitly acknowledge the returned message IDs") + .option("--max-bytes ", "Maximum response-envelope bytes", parseInt) + .option("--timeout-ms ", "Maximum collection query time", parseInt) + .option("--verbose", "Deprecated compatibility flag; collections remain preview-only") .option("-j, --json", "Output as JSON") .action(async (channelName, opts) => { const channelArg = typeof channelName === "string" ? channelName.trim() : ""; @@ -349,40 +352,41 @@ export function registerChannelCommands(program: Command): void { console.error(chalk.red("Channel name cannot be empty.")); process.exit(1); } - const window = getCliWindow({ limit: opts.limit, cursor: opts.cursor }); - const messages = await await getStore().readMessages({ + const page = await getStore().readMessagePreviews({ channel: channelArg, since: opts.since, - limit: opts.json ? opts.limit : queryLimitFor(window), - offset: opts.json ? opts.cursor : window.offset, + limit: opts.limit, + offset: opts.cursor, + max_bytes: opts.maxBytes, + timeout_ms: opts.timeoutMs, }); - const page = opts.json - ? { items: messages, count: messages.length, hasMore: false, nextCursor: null } - : pageFromQuery(messages, window); - if (opts.from && page.items.length > 0) { + if (opts.markRead && page.messages.length > 0) { const agent = resolveIdentity(opts.from).trim(); if (!agent) { console.error(chalk.red("Agent identity is required.")); process.exit(1); } - await await getStore().recordReadReceiptsBatch(page.items.map((m) => m.id), agent); - await getStore().markChannelNotificationsRead(agent, page.items.map((m) => m.id)); + const ids = page.messages.map((message) => message.id); + await getStore().markReadByIds(ids, agent); + if (opts.from) { + await getStore().recordReadReceiptsBatch(ids, agent); + await getStore().markChannelNotificationsRead(agent, ids); + } } if (opts.json) { - console.log(JSON.stringify(messages, null, 2)); + console.log(JSON.stringify(page, null, 2)); } else { - if (messages.length === 0) { + if (page.messages.length === 0) { console.log(chalk.dim(`No messages in #${channelArg}.`)); } else { - for (const msg of page.items) printMessageEntry(msg, { verbose: opts.verbose, destination: chalk.magenta(`#${channelArg}`) }); + for (const msg of page.messages) printMessageEntry(msg, { destination: chalk.magenta(`#${channelArg}`) }); printCompactFooter({ shown: page.count, - hasMore: page.hasMore, - nextCursor: page.nextCursor, - limitCapped: window.limitCapped, - detailHint: opts.verbose ? "Use conversations show for one message." : "Use --verbose for full bodies or conversations show for one message.", + hasMore: page.has_more, + nextCursor: page.next_cursor, + detailHint: "Use conversations show for one exact full message.", }); } } diff --git a/src/cli/commands/messaging.ts b/src/cli/commands/messaging.ts index cb2bc29..0f40b51 100644 --- a/src/cli/commands/messaging.ts +++ b/src/cli/commands/messaging.ts @@ -116,43 +116,44 @@ export function registerMessagingCommands(program: Command): void { .option("--cursor ", "Skip first N messages for pagination", parseInt) .option("--unread", "Only unread messages") .option("--mark-read", "Mark returned messages as read") - .option("--verbose", "Show full message bodies") + .option("--max-bytes ", "Maximum collection response size", parseInt) + .option("--timeout-ms ", "Maximum collection read time", parseInt) + .option("--verbose", "Compatibility flag; collection bodies remain preview-only") .option("-j, --json", "Output as JSON") .action(async (opts) => { const window = getCliWindow({ limit: opts.limit, cursor: opts.cursor }); - const messages = await await getStore().readMessages({ + const page = await getStore().readMessagePreviews({ session_id: opts.session, from: opts.from, to: opts.to, channel: opts.channel, since: opts.since, - limit: opts.json ? opts.limit : queryLimitFor(window), - offset: opts.json ? opts.cursor : window.offset, + limit: opts.limit ?? window.limit, + offset: opts.cursor ?? window.offset, unread_only: opts.unread, + max_bytes: opts.maxBytes, + timeout_ms: opts.timeoutMs, }); - const page = opts.json - ? { items: messages, hasMore: false, nextCursor: null, count: messages.length } - : pageFromQuery(messages, window); if (opts.markRead) { const reader = resolveIdentity(opts.to); - const ids = page.items.filter((m) => !m.read_at).map((m) => m.id); + const ids = page.messages.filter((message) => message.unread).map((message) => message.id); if (ids.length > 0) await await getStore().markReadByIds(ids, reader); } if (opts.json) { - console.log(JSON.stringify(messages, null, 2)); + console.log(JSON.stringify(page, null, 2)); } else { - if (messages.length === 0) { + if (page.messages.length === 0) { console.log(chalk.dim("No messages found.")); } else { - for (const msg of page.items) printMessageEntry(msg, { verbose: opts.verbose }); + for (const msg of page.messages) printMessageEntry(msg); printCompactFooter({ shown: page.count, - hasMore: page.hasMore, - nextCursor: page.nextCursor, + hasMore: page.has_more, + nextCursor: page.next_cursor, limitCapped: window.limitCapped, - detailHint: opts.verbose ? "Use conversations show for one message." : "Use --verbose for full bodies or conversations show for one message.", + detailHint: "Use conversations show for one exact full message.", }); } } @@ -277,7 +278,9 @@ export function registerMessagingCommands(program: Command): void { .option("--to ", "Filter by recipient") .option("--limit ", "Max results to return", parseInt) .option("--cursor ", "Skip first N results for pagination", parseInt) - .option("--verbose", "Show full message bodies") + .option("--max-bytes ", "Maximum collection response size", parseInt) + .option("--timeout-ms ", "Maximum collection read time", parseInt) + .option("--verbose", "Compatibility flag; collection bodies remain preview-only") .option("-j, --json", "Output as JSON") .action(async (query, opts) => { const q = typeof query === "string" ? query.trim() : ""; @@ -287,32 +290,31 @@ export function registerMessagingCommands(program: Command): void { } const window = getCliWindow({ limit: opts.limit, cursor: opts.cursor }); - const messages = await await getStore().searchMessages({ + const page = await getStore().searchMessagePreviews({ query: q, channel: opts.channel, from: opts.from, to: opts.to, - limit: opts.json ? opts.limit : queryLimitFor(window), - offset: opts.json ? opts.cursor : window.offset, + limit: opts.limit ?? window.limit, + offset: opts.cursor ?? window.offset, + max_bytes: opts.maxBytes, + timeout_ms: opts.timeoutMs, }); - const page = opts.json - ? { items: messages, count: messages.length, total: messages.length, hasMore: false, nextCursor: null } - : pageFromQuery(messages, window); if (opts.json) { - console.log(JSON.stringify(messages, null, 2)); + console.log(JSON.stringify(page, null, 2)); } else { - if (messages.length === 0) { + if (page.messages.length === 0) { console.log(chalk.dim("No messages found.")); } else { console.log(chalk.dim(`Search results for "${q}":\n`)); - for (const msg of page.items) printMessageEntry(msg, { verbose: opts.verbose }); + for (const msg of page.messages) printMessageEntry(msg); printCompactFooter({ shown: page.count, - hasMore: page.hasMore, - nextCursor: page.nextCursor, + hasMore: page.has_more, + nextCursor: page.next_cursor, limitCapped: window.limitCapped, - detailHint: opts.verbose ? "Use conversations show for one message." : "Use --verbose for full bodies or conversations show for one message.", + detailHint: "Use conversations show for one exact full message.", }); } } @@ -326,7 +328,9 @@ export function registerMessagingCommands(program: Command): void { .argument("", "Duration: e.g. 30m, 2h, 1d") .option("--limit ", "Max messages to show", parseInt) .option("--cursor ", "Skip first N messages for pagination", parseInt) - .option("--verbose", "Show full message bodies") + .option("--max-bytes ", "Maximum collection response size", parseInt) + .option("--timeout-ms ", "Maximum collection read time", parseInt) + .option("--verbose", "Compatibility flag; collection bodies remain preview-only") .option("-j, --json", "Output as JSON") .action(async (duration, opts) => { // Parse duration string: 30m, 2h, 1d @@ -341,30 +345,29 @@ export function registerMessagingCommands(program: Command): void { const since = new Date(Date.now() - value * msMap[unit]).toISOString().replace("T", "T").slice(0, 23); const window = getCliWindow({ limit: opts.limit, cursor: opts.cursor }); - const messages = await await getStore().readMessages({ + const page = await getStore().readMessagePreviews({ since, order: "asc", - limit: opts.json ? (opts.limit ?? 200) : queryLimitFor(window), - offset: opts.json ? opts.cursor : window.offset, + limit: opts.limit ?? window.limit, + offset: opts.cursor ?? window.offset, + max_bytes: opts.maxBytes, + timeout_ms: opts.timeoutMs, }); - const page = opts.json - ? { items: messages, count: messages.length, hasMore: false, nextCursor: null } - : pageFromQuery(messages, window); if (opts.json) { - console.log(JSON.stringify(messages, null, 2)); + console.log(JSON.stringify(page, null, 2)); } else { - if (messages.length === 0) { + if (page.messages.length === 0) { console.log(chalk.dim(`No activity in the last ${duration}.`)); } else { console.log(chalk.bold(`Activity since ${duration} ago\n`)); - for (const msg of page.items) printMessageEntry(msg, { verbose: opts.verbose }); + for (const msg of page.messages) printMessageEntry(msg); printCompactFooter({ shown: page.count, - hasMore: page.hasMore, - nextCursor: page.nextCursor, + hasMore: page.has_more, + nextCursor: page.next_cursor, limitCapped: window.limitCapped, - detailHint: opts.verbose ? "Use conversations show for one message." : "Use --verbose for full bodies or conversations show for one message.", + detailHint: "Use conversations show for one exact full message.", }); } } @@ -595,38 +598,39 @@ export function registerMessagingCommands(program: Command): void { // ---- pinned ---- program .command("pinned") - .description("List pinned messages") + .description("List bounded, redacted pinned-message previews") .option("--channel ", "Filter by channel") .option("--session ", "Filter by session ID") .option("--limit ", "Max results", parseInt) .option("--cursor ", "Skip first N results for pagination", parseInt) - .option("--verbose", "Show full message bodies") + .option("--max-bytes ", "Maximum response-envelope bytes", parseInt) + .option("--timeout-ms ", "Maximum collection query time", parseInt) + .option("--verbose", "Deprecated compatibility flag; collections remain preview-only") .option("-j, --json", "Output as JSON") .action(async (opts) => { - const window = getCliWindow({ limit: opts.limit, cursor: opts.cursor }); - const messages = await await getStore().getPinnedMessages({ + const page = await getStore().readMessagePreviews({ + pinned_only: true, channel: opts.channel, session_id: opts.session, - limit: opts.json ? opts.limit : queryLimitFor(window), - offset: opts.json ? opts.cursor : window.offset, + limit: opts.limit, + offset: opts.cursor, + order: "desc", + max_bytes: opts.maxBytes, + timeout_ms: opts.timeoutMs, }); - const page = opts.json - ? { items: messages, count: messages.length, total: messages.length, hasMore: false, nextCursor: null } - : pageFromQuery(messages, window); if (opts.json) { - console.log(JSON.stringify(messages, null, 2)); + console.log(JSON.stringify(page, null, 2)); } else { - if (messages.length === 0) { + if (page.messages.length === 0) { console.log(chalk.dim("No pinned messages.")); } else { console.log(chalk.dim("Pinned messages:\n")); - for (const msg of page.items) printMessageEntry(msg, { verbose: opts.verbose }); + for (const msg of page.messages) printMessageEntry(msg); printCompactFooter({ shown: page.count, - hasMore: page.hasMore, - nextCursor: page.nextCursor, - limitCapped: window.limitCapped, - detailHint: opts.verbose ? "Use conversations show for one message." : "Use --verbose for full bodies or conversations show for one message.", + hasMore: page.has_more, + nextCursor: page.next_cursor, + detailHint: "Use conversations show for one exact full message.", }); } } @@ -640,31 +644,35 @@ export function registerMessagingCommands(program: Command): void { .option("--from ", "Agent to check blockers for") .option("--limit ", "Max blockers to show", parseInt) .option("--cursor ", "Skip first N blockers for pagination", parseInt) - .option("--verbose", "Show full message bodies") + .option("--max-bytes ", "Maximum collection response size", parseInt) + .option("--timeout-ms ", "Maximum collection read time", parseInt) + .option("--verbose", "Compatibility flag; blocker bodies remain preview-only") .option("-j, --json", "Output as JSON") .action(async (opts) => { const agent = resolveIdentity(opts.from); const window = getCliWindow({ limit: opts.limit, cursor: opts.cursor }); - const blockers = await getStore().getUnreadBlockers(agent, opts.json ? undefined : { limit: queryLimitFor(window), offset: window.offset }); - const page = opts.json - ? { items: blockers, count: blockers.length, total: blockers.length, hasMore: false, nextCursor: null } - : pageFromQuery(blockers, window); + const page = await getStore().getUnreadBlockerPreviews(agent, { + limit: opts.limit ?? window.limit, + offset: opts.cursor ?? window.offset, + max_bytes: opts.maxBytes, + timeout_ms: opts.timeoutMs, + }); if (opts.json) { - console.log(JSON.stringify(blockers, null, 2)); + console.log(JSON.stringify(page, null, 2)); } else { - if (blockers.length === 0) { + if (page.messages.length === 0) { console.log(chalk.dim("No blocking messages.")); } else { console.log(chalk.red.bold("Blocking messages:\n")); - for (const b of page.items) printMessageEntry(b, { verbose: opts.verbose, destination: b.channel ? chalk.magenta(`#${b.channel}`) : chalk.yellow("DM") }); - console.log(chalk.dim(`Acknowledge shown blockers with: conversations mark-read ${page.items.map(b => b.id).join(" ")}`)); + for (const b of page.messages) printMessageEntry(b, { destination: b.channel ? chalk.magenta(`#${b.channel}`) : chalk.yellow("DM") }); + console.log(chalk.dim(`Acknowledge shown blockers with: conversations mark-read ${page.messages.map(b => b.id).join(" ")}`)); printCompactFooter({ shown: page.count, - hasMore: page.hasMore, - nextCursor: page.nextCursor, + hasMore: page.has_more, + nextCursor: page.next_cursor, limitCapped: window.limitCapped, - detailHint: opts.verbose ? "Use conversations show for one blocker." : "Use --verbose for full bodies or conversations show for one blocker.", + detailHint: "Use conversations show for one exact blocker message.", }); } } @@ -731,7 +739,7 @@ export function registerMessagingCommands(program: Command): void { .option("--channel ", "Watch a specific channel") .option("--all", "Watch DMs and all subscribed channels") .option("--interval ", "Poll interval in milliseconds", parseInt) - .option("--verbose", "Show full message bodies") + .option("--verbose", "Show full bodies for exact-ID messages delivered after polling") .action(async (opts) => { const agent = resolveIdentity(opts.from); await getStore().heartbeat(agent); @@ -769,7 +777,7 @@ export function registerMessagingCommands(program: Command): void { } }; - const renderMessage = (msg: import("../../types.js").Message) => { + const renderMessage = (msg: import("../../types.js").Message | import("../../types.js").MessagePreview) => { const time = chalk.dim(msg.created_at.slice(11, 19)); const where = msg.channel ? chalk.magenta(`#${msg.channel}`) @@ -786,12 +794,15 @@ export function registerMessagingCommands(program: Command): void { console.log(` ${sender} ${where} ${time}${priority}${blocking}`); // Content with indent - const content = opts.verbose - ? renderContentLocal(msg.content) as string - : previewText(msg.content); + const projected = "preview" in msg; + const content = projected + ? msg.preview + : opts.verbose + ? renderContentLocal(msg.content) as string + : previewText(msg.content); const indented = content.split("\n").map((l: string) => " " + l).join("\n"); console.log(indented); - if (!opts.verbose) { + if (projected || !opts.verbose) { console.log(chalk.dim(` Inspect with: conversations show ${msg.id}`)); } @@ -818,7 +829,7 @@ export function registerMessagingCommands(program: Command): void { // Show recent messages first if (opts.all) { - const dmRecent = await await getStore().readMessages({ to: agent, limit: 20, order: "asc" }); + const dmRecent = (await getStore().readMessagePreviews({ to: agent, limit: 20, order: "asc" })).messages; const pendingNotifications = (await getStore().readChannelNotifications({ agent, unread_only: true, @@ -838,12 +849,12 @@ export function registerMessagingCommands(program: Command): void { console.log(chalk.dim(` ── Live ──\n`)); } } else { - const recent = await await getStore().readMessages({ + const recent = (await getStore().readMessagePreviews({ to: opts.channel ? undefined : agent, channel: opts.channel, limit: 20, order: "asc", - }); + })).messages; if (recent.length > 0) { console.log(chalk.dim(` ── Recent messages (${recent.length}) ──\n`)); for (const msg of recent) { renderMessage(msg); } diff --git a/src/cli/commands/project-panel.test.ts b/src/cli/commands/project-panel.test.ts index ac9f77a..eff17f3 100644 --- a/src/cli/commands/project-panel.test.ts +++ b/src/cli/commands/project-panel.test.ts @@ -1,19 +1,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; import { createChannel } from "../../lib/channels.js"; import { closeDb } from "../../lib/db.js"; import { sendMessage } from "../../lib/messages.js"; import { createProject } from "../../lib/projects.js"; +import { createDisposableStore, enterHermeticTestEnv, hermeticSpawnEnv } from "../../test/hermetic.js"; -const TEST_DB = join(tmpdir(), `conversations-test-project-panel-cli-${Date.now()}.db`); +let testStore: ReturnType; +let restoreEnv: () => void; function cleanupDb(): void { closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(`${TEST_DB}-wal`); } catch {} - try { unlinkSync(`${TEST_DB}-shm`); } catch {} } function runCli(args: string[]) { @@ -21,18 +17,20 @@ function runCli(args: string[]) { cmd: ["bun", "run", "src/cli/index.tsx", ...args], stdout: "pipe", stderr: "pipe", - env: { ...process.env, CONVERSATIONS_DB_PATH: TEST_DB }, + env: hermeticSpawnEnv({ CONVERSATIONS_DB_PATH: testStore.dbPath }), }); } beforeEach(() => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; + testStore = createDisposableStore("project-panel-cli"); + restoreEnv = enterHermeticTestEnv({ CONVERSATIONS_DB_PATH: testStore.dbPath }); cleanupDb(); }); afterEach(() => { cleanupDb(); - delete process.env.CONVERSATIONS_DB_PATH; + restoreEnv(); + testStore.cleanup(); }); describe("conversations project-panel CLI", () => { diff --git a/src/cli/compact-output.e2e.test.ts b/src/cli/compact-output.e2e.test.ts index 9128427..1867cd5 100644 --- a/src/cli/compact-output.e2e.test.ts +++ b/src/cli/compact-output.e2e.test.ts @@ -1,21 +1,18 @@ import { afterAll, describe, expect, test } from "bun:test"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; +import { createDisposableStore, hermeticSpawnEnv } from "../test/hermetic"; -const TEST_DB = join(tmpdir(), `conversations-cli-compact-${Date.now()}.db`); +const TEST_STORE = createDisposableStore("cli-compact"); const CLI = ["bun", "run", "./src/cli/index.tsx"]; function runCli(args: string[], agent: string) { const result = Bun.spawnSync({ cmd: [...CLI, ...args], cwd: process.cwd(), - env: { - ...process.env, - CONVERSATIONS_DB_PATH: TEST_DB, + env: hermeticSpawnEnv({ + CONVERSATIONS_DB_PATH: TEST_STORE.dbPath, CONVERSATIONS_AGENT_ID: agent, FORCE_COLOR: "0", - }, + }), stdout: "pipe", stderr: "pipe", }); @@ -28,30 +25,35 @@ function runCli(args: string[], agent: string) { describe("compact CLI output", () => { afterAll(() => { - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(`${TEST_DB}-wal`); } catch {} - try { unlinkSync(`${TEST_DB}-shm`); } catch {} + TEST_STORE.cleanup(); }); - test("read is compact by default but verbose and json keep full content", () => { + test("read and verbose stay preview-only while exact show keeps full content", () => { const content = `Compact output starts here ${"x ".repeat(140)}TAIL_ONLY_IN_VERBOSE`; - const send = runCli(["send", content, "--to", "bob"], "alice"); + const send = runCli(["send", content, "--to", "bob", "--json"], "alice"); expect(send.exitCode).toBe(0); + const sent = JSON.parse(send.stdout); const compact = runCli(["read", "--to", "bob", "--limit", "1"], "bob"); expect(compact.exitCode).toBe(0); expect(compact.stdout).toContain("Compact output starts here"); expect(compact.stdout).not.toContain("TAIL_ONLY_IN_VERBOSE"); - expect(compact.stdout).toContain("Use --verbose"); expect(compact.stdout).toContain("conversations show "); const verbose = runCli(["read", "--to", "bob", "--limit", "1", "--verbose"], "bob"); expect(verbose.exitCode).toBe(0); - expect(verbose.stdout).toContain("TAIL_ONLY_IN_VERBOSE"); + expect(verbose.stdout).not.toContain("TAIL_ONLY_IN_VERBOSE"); + expect(verbose.stdout).toContain("conversations show "); const json = runCli(["read", "--to", "bob", "--limit", "1", "--json"], "bob"); expect(json.exitCode).toBe(0); - expect(JSON.parse(json.stdout)[0].content).toContain("TAIL_ONLY_IN_VERBOSE"); + const page = JSON.parse(json.stdout); + expect(page.messages[0].content).toBeUndefined(); + expect(page.messages[0].preview).not.toContain("TAIL_ONLY_IN_VERBOSE"); + + const exact = runCli(["show", String(sent.id), "--json"], "bob"); + expect(exact.exitCode).toBe(0); + expect(JSON.parse(exact.stdout).content).toContain("TAIL_ONLY_IN_VERBOSE"); }); test("mark-read marks only the displayed compact page", () => { @@ -63,8 +65,9 @@ describe("compact CLI output", () => { const unread = runCli(["read", "--to", "mark-target", "--unread", "--json"], "mark-target"); expect(unread.exitCode).toBe(0); - const messages = JSON.parse(unread.stdout); - expect(messages).toHaveLength(1); - expect(messages[0].content).toBe("second page message"); + const page = JSON.parse(unread.stdout); + expect(page.messages).toHaveLength(1); + expect(page.messages[0].content).toBeUndefined(); + expect(page.messages[0].preview).toBe("second page message"); }); }); diff --git a/src/cli/message-output.ts b/src/cli/message-output.ts index 81eca2e..84c47f6 100644 --- a/src/cli/message-output.ts +++ b/src/cli/message-output.ts @@ -1,20 +1,26 @@ import chalk from "chalk"; import { previewText } from "../lib/compact-output.js"; import { renderContent } from "../lib/terminal-markdown.js"; -import type { Message } from "../types.js"; +import type { Message, MessagePreview } from "../types.js"; -export function printMessageEntry(msg: Message, opts: { verbose?: boolean; destination?: string } = {}): void { +type PrintableMessage = Message | MessagePreview; + +export function printMessageEntry(msg: PrintableMessage, opts: { verbose?: boolean; destination?: string } = {}): void { const time = chalk.dim(msg.created_at.slice(11, 19)); const from = chalk.cyan(msg.from_agent); const to = opts.destination ? opts.destination : msg.channel ? chalk.magenta(`#${msg.channel}`) : chalk.yellow(msg.to_agent); const priority = msg.priority !== "normal" ? chalk.red(` [${msg.priority}]`) : ""; - const unread = !msg.read_at ? chalk.green(" *") : ""; + const unreadState = "unread" in msg ? msg.unread : !msg.read_at; + const unread = unreadState ? chalk.green(" *") : ""; const blocking = msg.blocking ? chalk.red(" [blocking]") : ""; - const attachments = msg.attachments?.length ? chalk.dim(` ${msg.attachments.length} attachment(s)`) : ""; + const attachmentCount = "attachment_count" in msg ? msg.attachment_count : (msg.attachments?.length ?? 0); + const attachments = attachmentCount ? chalk.dim(` ${attachmentCount} attachment(s)`) : ""; console.log(`${time} ${chalk.dim(`[#${msg.id}]`)} ${from} -> ${to}${priority}${blocking}${unread}${attachments}`); - if (opts.verbose) { + if ("preview" in msg) { + console.log(` ${msg.preview}`); + } else if (opts.verbose) { const rendered = renderContent(msg.content); const indented = rendered.split("\n").map((line: string) => " " + line).join("\n"); console.log(indented); diff --git a/src/cli/receipts-locks.e2e.test.ts b/src/cli/receipts-locks.e2e.test.ts index dd8d858..a598ced 100644 --- a/src/cli/receipts-locks.e2e.test.ts +++ b/src/cli/receipts-locks.e2e.test.ts @@ -1,21 +1,18 @@ import { afterAll, describe, expect, test } from "bun:test"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; +import { createDisposableStore, hermeticSpawnEnv } from "../test/hermetic.js"; -const TEST_DB = join(tmpdir(), `conversations-cli-receipts-locks-${Date.now()}.db`); +const TEST_STORE = createDisposableStore("cli-receipts-locks"); const CLI = ["bun", "run", "./src/cli/index.tsx"]; function runCli(args: string[], agent: string) { const result = Bun.spawnSync({ cmd: [...CLI, ...args], cwd: process.cwd(), - env: { - ...process.env, - CONVERSATIONS_DB_PATH: TEST_DB, + env: hermeticSpawnEnv({ + CONVERSATIONS_DB_PATH: TEST_STORE.dbPath, CONVERSATIONS_AGENT_ID: agent, FORCE_COLOR: "0", - }, + }), stdout: "pipe", stderr: "pipe", }); @@ -28,9 +25,7 @@ function runCli(args: string[], agent: string) { describe("receipts + locks CLI (e2e)", () => { afterAll(() => { - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(`${TEST_DB}-wal`); } catch {} - try { unlinkSync(`${TEST_DB}-shm`); } catch {} + TEST_STORE.cleanup(); }); test("receipts shows who has and has not read a channel message", () => { @@ -44,7 +39,7 @@ describe("receipts + locks CLI (e2e)", () => { const messageId = JSON.parse(send.stdout).id as number; // bob reads the channel (records a read receipt); carol does not - const read = runCli(["channel", "read", "receipt-ch", "--from", "bob"], "bob"); + const read = runCli(["channel", "read", "receipt-ch", "--from", "bob", "--mark-read"], "bob"); expect(read.exitCode).toBe(0); const receipts = runCli(["receipts", String(messageId), "--channel", "receipt-ch", "--json"], "alice"); @@ -79,7 +74,7 @@ describe("receipts + locks CLI (e2e)", () => { expect(send.exitCode).toBe(0); const messageId = JSON.parse(send.stdout).id as number; - const read = runCli(["channel", "read", "mixed-receipt-ch", "--from", "Bob"], "Bob"); + const read = runCli(["channel", "read", "mixed-receipt-ch", "--from", "Bob", "--mark-read"], "Bob"); expect(read.exitCode).toBe(0); const receipts = runCli(["receipts", String(messageId), "--channel", "mixed-receipt-ch", "--json"], "Admin"); diff --git a/src/hooks/blocker-hook.ts b/src/hooks/blocker-hook.ts index dbd3e76..10d8d65 100644 --- a/src/hooks/blocker-hook.ts +++ b/src/hooks/blocker-hook.ts @@ -24,7 +24,8 @@ if (process.argv.includes("--help") || process.argv.includes("-h")) { const agent = resolveIdentity(); // Routed through the Store: local sqlite or the self_hosted/cloud API. -const blockers = await getStore().getUnreadBlockers(agent, { limit: 10 }); +const blockerPage = await getStore().getUnreadBlockerPreviews(agent, { limit: 10 }); +const blockers = blockerPage.messages; closeDb(); @@ -37,7 +38,7 @@ if (blockers.length === 0) { const ids = blockers.map((b) => b.id); const details = blockers.map((b) => { const where = b.channel ? `#${b.channel}` : "DM"; - return `[#${b.id}] ${b.from_agent} (${where}): ${b.content}`; + return `[#${b.id}] ${b.from_agent} (${where}): ${b.preview}`; }).join("\n"); console.log(`BLOCKING MESSAGES — You have ${blockers.length} unread blocker(s). You MUST acknowledge them by calling mark_read with IDs [${ids.join(", ")}] BEFORE doing any other work.\n\n${details}`); diff --git a/src/lib/channel-notifications.test.ts b/src/lib/channel-notifications.test.ts index 3a4c890..3ea0e13 100644 --- a/src/lib/channel-notifications.test.ts +++ b/src/lib/channel-notifications.test.ts @@ -1,25 +1,23 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; import { closeDb } from "./db"; import { sendMessage } from "./messages"; import { createChannel } from "./channels"; import { buildMessagePreview, listChannelNotificationSubscriptions, markAllChannelNotificationsRead, markChannelNotificationsRead, readChannelNotifications, subscribeToChannelNotifications, unsubscribeFromChannelNotifications } from "./channel-notifications"; +import { createDisposableStore, enterHermeticTestEnv } from "../test/hermetic"; -const TEST_DB = join(tmpdir(), `conversations-test-channel-notifications-${Date.now()}.db`); +let store: ReturnType; +let restoreEnv: () => void; beforeEach(() => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; + store = createDisposableStore("channel-notifications"); + restoreEnv = enterHermeticTestEnv({ CONVERSATIONS_DB_PATH: store.dbPath }); closeDb(); }); afterEach(() => { closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} - delete process.env.CONVERSATIONS_DB_PATH; + restoreEnv(); + store.cleanup(); }); describe("channel notification subscriptions", () => { @@ -98,6 +96,24 @@ describe("channel notifications", () => { expect(notifications[0].unread).toBe(true); }); + test("redacts sensitive values and never projects restricted channel bodies", () => { + createChannel("ops", "creator"); + createChannel("security-incidents", "creator"); + subscribeToChannelNotifications("ops", "agent-a", { preview_chars: 500 }); + subscribeToChannelNotifications("security-incidents", "agent-a", { preview_chars: 500 }); + const token = ["Bearer", `fixture-${"x".repeat(30)}`].join(" "); + + sendMessage({ from: "alice", to: "ops", channel: "ops", content: `rotate ${token}` }); + sendMessage({ from: "alice", to: "security-incidents", channel: "security-incidents", content: "restricted root cause body" }); + + const notifications = readChannelNotifications({ agent: "agent-a" }); + expect(notifications.find((item) => item.channel === "ops")?.preview).toContain("[REDACTED:BEARER_TOKEN]"); + expect(JSON.stringify(notifications)).not.toContain(token); + const restricted = notifications.find((item) => item.channel === "security-incidents"); + expect(restricted?.preview).toBe("[REDACTED:RESTRICTED_CHANNEL_BODY]"); + expect(JSON.stringify(restricted)).not.toContain("restricted root cause body"); + }); + test("marks notifications read by ids and all", () => { createChannel("ops", "creator"); subscribeToChannelNotifications("ops", "agent-a"); diff --git a/src/lib/channel-notifications.ts b/src/lib/channel-notifications.ts index f645c52..45f50de 100644 --- a/src/lib/channel-notifications.ts +++ b/src/lib/channel-notifications.ts @@ -1,16 +1,32 @@ import { getDb } from "./db.js"; import type { ChannelNotification, ChannelNotificationSubscription } from "../types.js"; import { normalizeChannelName } from "./channel-names.js"; +import { + COLLECTION_MAX_LIMIT, + COLLECTION_MAX_PREVIEW_BYTES, + COLLECTION_PREVIEW_SCAN_CHARS, + RESTRICTED_CHANNEL_PREVIEW, + redactSensitiveText, +} from "./message-previews.js"; const DEFAULT_PREVIEW_CHARS = 140; export function buildMessagePreview(content: string, maxChars = DEFAULT_PREVIEW_CHARS): string { - const normalized = content + if (content === RESTRICTED_CHANNEL_PREVIEW) return content; + const markers: string[] = []; + const protectedContent = redactSensitiveText(content).replace(/\[REDACTED:[A-Z_]+\]/g, (marker) => { + const placeholder = `REDACTIONMARKER${markers.length}TOKEN`; + markers.push(marker); + return placeholder; + }); + const normalized = protectedContent .replace(/[*#`~_>\-]/g, " ") .replace(/\s+/g, " ") - .trim(); - if (normalized.length <= maxChars) return normalized; - return normalized.slice(0, Math.max(1, maxChars)).trimEnd() + "…"; + .trim() + .replace(/REDACTIONMARKER(\d+)TOKEN/g, (_match, index) => markers[Number(index)] ?? "[REDACTED]"); + const boundedMaxChars = Math.min(Math.max(1, maxChars), COLLECTION_MAX_PREVIEW_BYTES); + if (normalized.length <= boundedMaxChars) return normalized; + return normalized.slice(0, boundedMaxChars).trimEnd() + "…"; } export function subscribeToChannelNotifications( @@ -104,6 +120,7 @@ export function readChannelNotifications(opts: ReadChannelNotificationsOptions): ? Math.floor(opts.limit as number) : 20; + const restricted = `(lower(COALESCE(m.channel, '')) LIKE '%incident%' OR lower(COALESCE(m.channel, '')) LIKE '%security%' OR lower(COALESCE(m.to_agent, '')) LIKE '%incident%' OR lower(COALESCE(m.to_agent, '')) LIKE '%security%' OR lower(COALESCE(m.session_id, '')) LIKE '%incident%' OR lower(COALESCE(m.session_id, '')) LIKE '%security%')`; const rows = db.prepare(` SELECT m.id AS message_id, @@ -111,8 +128,8 @@ export function readChannelNotifications(opts: ReadChannelNotificationsOptions): m.from_agent, m.created_at, m.priority, - m.content, - m.attachments, + CASE WHEN ${restricted} THEN '${RESTRICTED_CHANNEL_PREVIEW}' ELSE substr(m.content, 1, ${COLLECTION_PREVIEW_SCAN_CHARS}) END AS preview_source, + CASE WHEN m.attachments IS NULL OR m.attachments = '' THEN 0 ELSE json_array_length(m.attachments) END AS attachment_count, s.preview_chars, snr.message_id AS read_message_id FROM messages m @@ -122,15 +139,15 @@ export function readChannelNotifications(opts: ReadChannelNotificationsOptions): ON snr.message_id = m.id AND snr.agent = s.agent WHERE ${conditions.join(" AND ")} ORDER BY m.created_at DESC, m.id DESC - LIMIT ${Math.max(1, Math.min(limit, 500))} + LIMIT ${Math.max(1, Math.min(limit, COLLECTION_MAX_LIMIT))} `).all(...params) as Array<{ message_id: number; channel: string; from_agent: string; created_at: string; priority: "low" | "normal" | "high" | "urgent"; - content: string; - attachments: string | null; + preview_source: string; + attachment_count: number; preview_chars: number; read_message_id: number | null; }>; @@ -141,9 +158,9 @@ export function readChannelNotifications(opts: ReadChannelNotificationsOptions): from_agent: row.from_agent, created_at: row.created_at, priority: row.priority, - preview: buildMessagePreview(row.content, row.preview_chars), + preview: buildMessagePreview(row.preview_source, row.preview_chars), unread: row.read_message_id == null, - has_attachments: !!row.attachments && row.attachments !== "[]", + has_attachments: row.attachment_count > 0, })) satisfies ChannelNotification[]; if (opts.mark_read && notifications.length > 0) { @@ -171,6 +188,17 @@ export function markChannelNotificationsRead(agent: string, messageIds: number[] } export function markAllChannelNotificationsRead(agent: string, channel?: string): number { - const unread = readChannelNotifications({ agent, channel, unread_only: true, limit: 10000 }); - return markChannelNotificationsRead(agent, unread.map((row) => row.message_id)); + const db = getDb(); + const channelName = channel ? normalizeChannelName(channel) : null; + const result = db.prepare(` + INSERT OR IGNORE INTO channel_notification_reads (agent, message_id) + SELECT ?, m.id + FROM messages m + INNER JOIN channel_subscriptions s ON s.channel = m.channel AND s.agent = ? + WHERE m.channel IS NOT NULL + AND m.from_agent != ? + AND m.id > s.since_message_id + ${channelName ? "AND m.channel = ?" : ""} + `).run(...(channelName ? [agent, agent, agent, channelName] : [agent, agent, agent])); + return result.changes; } diff --git a/src/lib/message-previews.test.ts b/src/lib/message-previews.test.ts new file mode 100644 index 0000000..3866f57 --- /dev/null +++ b/src/lib/message-previews.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + getMessageById, + getReadReceipts, + getUnreadBlockerPreviews, + readMessagePreviews, + searchMessagePreviews, + sendMessage, +} from "./messages"; +import { closeDb } from "./db"; +import { resetStoreForTests } from "./store"; +import { + COLLECTION_MAX_LIMIT, + RESTRICTED_CHANNEL_PREVIEW, + resolveCollectionMaxBytes, + resolveCollectionTimeoutMs, +} from "./message-previews"; +import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../test/hermetic"; + +describe("bounded message collection projections", () => { + let cleanupStore: () => void; + let restoreEnv: () => void; + let restoreNetwork: () => void; + + beforeEach(() => { + const store = createDisposableStore("safe-reads"); + cleanupStore = store.cleanup; + restoreEnv = enterHermeticTestEnv({ + HASNA_CONVERSATIONS_STORAGE_MODE: "local", + HASNA_CONVERSATIONS_MODE: "local", + CONVERSATIONS_STORAGE_MODE: "local", + CONVERSATIONS_MODE: "local", + CONVERSATIONS_DB_PATH: store.dbPath, + HASNA_CONVERSATIONS_API_URL: "https://ambient-route.invalid", + HASNA_CONVERSATIONS_API_KEY: "synthetic-routing-key-not-a-credential", + }); + restoreNetwork = installNetworkGuard(); + closeDb(); + resetStoreForTests(); + }); + + afterEach(() => { + closeDb(); + resetStoreForTests(); + restoreNetwork(); + restoreEnv(); + cleanupStore(); + }); + + test("default list returns a byte-capped redacted projection without full content or read mutations", () => { + const syntheticBearer = ["Bearer", `synthetic_${"a".repeat(48)}`].join(" "); + const fullBody = `coordination note ${syntheticBearer} ${"x".repeat(2_000)}`; + const sent = sendMessage({ from: "alice", to: "bob", content: fullBody, reply_to: undefined }); + + const page = readMessagePreviews({ to: "bob", limit: 100_000, max_bytes: 1_024, timeout_ms: 1_000 }); + + expect(page.limit).toBe(COLLECTION_MAX_LIMIT); + expect(page.byte_length).toBeLessThanOrEqual(1_024); + expect(page.messages).toHaveLength(1); + expect(page.messages[0]).not.toHaveProperty("content"); + expect(page.messages[0].preview).toContain("[REDACTED:BEARER_TOKEN]"); + expect(JSON.stringify(page)).not.toContain(syntheticBearer); + expect(page.messages[0].content_bytes).toBe(Buffer.byteLength(fullBody)); + expect(page.messages[0].reply_to).toBeNull(); + expect(getMessageById(sent.id)?.content).toBe(fullBody); + expect(getMessageById(sent.id)?.read_at).toBeNull(); + expect(getReadReceipts(sent.id)).toEqual([]); + }); + + test("incident and security collection projections never expose a body or body-derived snippet", () => { + const syntheticPat = `ghp_${"z".repeat(24)}`; + sendMessage({ + from: "incident-bot", + to: "security-incidents", + channel: "security-incidents", + content: `restricted coordination ${syntheticPat}`, + blocking: true, + }); + + const page = readMessagePreviews({ channel: "security-incidents" }); + expect(page.messages[0].preview).toBe(RESTRICTED_CHANNEL_PREVIEW); + expect(page.messages[0].redacted).toBe(true); + expect(JSON.stringify(page)).not.toContain("restricted coordination"); + expect(JSON.stringify(page)).not.toContain(syntheticPat); + }); + + test("search and unread blocker collections return projections and remain non-mutating", () => { + const normal = sendMessage({ from: "alice", to: "bob", content: "needle coordination update" }); + const blocker = sendMessage({ from: "alice", to: "bob", content: "needle blocker detail", blocking: true }); + const syntheticBearer = ["Bearer", `synthetic_${"b".repeat(48)}`].join(" "); + sendMessage({ from: "alice", to: "bob", content: `needle secret ${syntheticBearer}` }); + + const search = searchMessagePreviews({ query: "needle", to: "bob", max_bytes: 2_048 }); + expect(search.messages).toHaveLength(3); + expect(search.messages.every((message) => !("content" in message))).toBe(true); + expect(search.byte_length).toBeLessThanOrEqual(2_048); + expect(JSON.stringify(search)).not.toContain(syntheticBearer); + expect(search.messages.some((message) => message.preview.includes("[REDACTED:BEARER_TOKEN]"))).toBe(true); + + const credentialTermSearch = searchMessagePreviews({ query: "synthetic", to: "bob", max_bytes: 2_048 }); + expect(JSON.stringify(credentialTermSearch)).not.toContain(syntheticBearer); + expect(credentialTermSearch.messages[0].preview).toContain("[REDACTED:BEARER_TOKEN]"); + + const blockers = getUnreadBlockerPreviews("bob", { limit: 99_999, max_bytes: 1_024 }); + expect(blockers.messages.map((message) => message.id)).toEqual([blocker.id]); + expect(blockers.messages[0]).not.toHaveProperty("content"); + expect(blockers.byte_length).toBeLessThanOrEqual(1_024); + expect(getMessageById(normal.id)?.read_at).toBeNull(); + expect(getMessageById(blocker.id)?.read_at).toBeNull(); + expect(getReadReceipts(blocker.id)).toEqual([]); + }); + + test("malformed byte and time limits fail closed", () => { + expect(() => resolveCollectionMaxBytes("not-a-number")).toThrow("max_bytes"); + expect(() => resolveCollectionMaxBytes(1)).toThrow("max_bytes"); + expect(() => resolveCollectionTimeoutMs("later")).toThrow("timeout_ms"); + expect(() => resolveCollectionTimeoutMs(0)).toThrow("timeout_ms"); + }); +}); diff --git a/src/lib/message-previews.ts b/src/lib/message-previews.ts new file mode 100644 index 0000000..d629dd8 --- /dev/null +++ b/src/lib/message-previews.ts @@ -0,0 +1,211 @@ +import type { MessagePreview, MessagePreviewPage, Priority } from "../types.js"; + +export const COLLECTION_DEFAULT_LIMIT = 20; +export const COLLECTION_MAX_LIMIT = 100; +export const COLLECTION_DEFAULT_MAX_BYTES = 16_384; +export const COLLECTION_MIN_MAX_BYTES = 512; +export const COLLECTION_MAX_MAX_BYTES = 65_536; +export const COLLECTION_DEFAULT_PREVIEW_BYTES = 320; +export const COLLECTION_MAX_PREVIEW_BYTES = 1_024; +export const COLLECTION_DEFAULT_TIMEOUT_MS = 3_000; +export const COLLECTION_MAX_TIMEOUT_MS = 5_000; +export const COLLECTION_PREVIEW_SCAN_CHARS = 4_096; +export const RESTRICTED_CHANNEL_PREVIEW = "[REDACTED:RESTRICTED_CHANNEL_BODY]"; + +const REDACTION_RULES: Array<[RegExp, string]> = [ + [/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/gi, "[REDACTED:PRIVATE_KEY]"], + [/\bBearer\s+[A-Za-z0-9._~+/@=-]{20,}/gi, "[REDACTED:BEARER_TOKEN]"], + [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b|\bglpat-[A-Za-z0-9_-]{20,}\b/g, "[REDACTED:PAT]"], + [/\b(?:AKIA|ASIA|AGPA|AIDA|AROA)[A-Z0-9]{16}\b/g, "[REDACTED:CLOUD_KEY]"], + [/\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{24,}|xox[baprs]-[A-Za-z0-9-]{20,}|sk_(?:live|test)_[A-Za-z0-9]{16,})\b/g, "[REDACTED:CLOUD_KEY]"], + [/\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|rediss|mssql):\/\/[^\s"'<>`]+/gi, "[REDACTED:DATABASE_URL]"], + [/\b(?:AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|GOOGLE_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|CLOUDFLARE_API_TOKEN|STRIPE_SECRET_KEY|GITHUB_TOKEN|GITLAB_TOKEN|PERSONAL_ACCESS_TOKEN|DATABASE_URL|DB_URL)\s*[:=]\s*["']?[A-Za-z0-9._~+/@=-]{16,}["']?/gi, "[REDACTED:SENSITIVE_VALUE]"], +]; + +function strictPositiveInteger(name: string, value: unknown, fallback: number): number { + if (value === undefined || value === null || value === "") return fallback; + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +export function resolveCollectionLimit(value: unknown): number { + return Math.min(strictPositiveInteger("limit", value, COLLECTION_DEFAULT_LIMIT), COLLECTION_MAX_LIMIT); +} + +export function resolveCollectionOffset(value: unknown): number { + if (value === undefined || value === null || value === "") return 0; + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) { + throw new Error("cursor must be a non-negative integer"); + } + return parsed; +} + +export function resolveCollectionMaxBytes(value: unknown): number { + const parsed = strictPositiveInteger("max_bytes", value, COLLECTION_DEFAULT_MAX_BYTES); + if (parsed < COLLECTION_MIN_MAX_BYTES) { + throw new Error(`max_bytes must be at least ${COLLECTION_MIN_MAX_BYTES}`); + } + return Math.min(parsed, COLLECTION_MAX_MAX_BYTES); +} + +export function resolveCollectionPreviewBytes(value: unknown): number { + const parsed = strictPositiveInteger("preview_bytes", value, COLLECTION_DEFAULT_PREVIEW_BYTES); + return Math.min(parsed, COLLECTION_MAX_PREVIEW_BYTES); +} + +export function resolveCollectionTimeoutMs(value: unknown): number { + const parsed = strictPositiveInteger("timeout_ms", value, COLLECTION_DEFAULT_TIMEOUT_MS); + return Math.min(parsed, COLLECTION_MAX_TIMEOUT_MS); +} + +export function truncateUtf8(value: string, maxBytes: number): { text: string; truncated: boolean } { + if (Buffer.byteLength(value, "utf8") <= maxBytes) return { text: value, truncated: false }; + const suffix = maxBytes >= 3 ? "..." : ""; + const budget = Math.max(0, maxBytes - Buffer.byteLength(suffix)); + let text = ""; + let used = 0; + for (const char of value) { + const bytes = Buffer.byteLength(char); + if (used + bytes > budget) break; + text += char; + used += bytes; + } + return { text: text + suffix, truncated: true }; +} + +export function redactSensitiveText(value: string): string { + let redacted = value; + for (const [pattern, replacement] of REDACTION_RULES) redacted = redacted.replace(pattern, replacement); + return redacted; +} + +function boundedSafeString(value: unknown, maxBytes = 256): string { + return truncateUtf8(redactSensitiveText(String(value ?? "")), maxBytes).text; +} + +function nullableSafeString(value: unknown, maxBytes = 256): string | null { + return value === undefined || value === null || value === "" ? null : boundedSafeString(value, maxBytes); +} + +function isRestrictedName(value: unknown): boolean { + if (typeof value !== "string") return false; + const normalized = value.trim().toLowerCase(); + return normalized.includes("incident") || normalized.includes("security"); +} + +export function isRestrictedCoordinationMessage(row: Record): boolean { + return isRestrictedName(row.channel) || isRestrictedName(row.to_agent) || isRestrictedName(row.session_id); +} + +function attachmentCount(value: unknown): number { + if (Array.isArray(value)) return value.length; + if (typeof value === "string" && value) { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.length : 0; + } catch { + return 0; + } + } + return Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0; +} + +export function buildMessagePreview(row: Record, previewBytes = COLLECTION_DEFAULT_PREVIEW_BYTES): MessagePreview { + const restricted = isRestrictedCoordinationMessage(row); + const source = restricted ? "" : String(row.preview_source ?? "").replace(/\s+/g, " ").trim(); + const redactedSource = restricted ? RESTRICTED_CHANNEL_PREVIEW : redactSensitiveText(source); + const preview = truncateUtf8(redactedSource, resolveCollectionPreviewBytes(previewBytes)); + const contentBytes = Math.max(0, Number(row.content_bytes ?? Buffer.byteLength(source)) || 0); + const attachments = attachmentCount(row.attachment_count ?? row.attachments); + const replyCount = row.reply_count == null ? undefined : Math.max(0, Number(row.reply_count) || 0); + const message: MessagePreview = { + id: Number(row.id), + session_id: boundedSafeString(row.session_id), + from_agent: boundedSafeString(row.from_agent), + to_agent: boundedSafeString(row.to_agent), + channel: nullableSafeString(row.channel), + project_id: nullableSafeString(row.project_id), + priority: (["low", "normal", "high", "urgent"].includes(String(row.priority)) ? String(row.priority) : "normal") as Priority, + working_dir: nullableSafeString(row.working_dir, 512), + repository: nullableSafeString(row.repository, 256), + branch: nullableSafeString(row.branch, 256), + created_at: boundedSafeString(row.created_at, 64), + edited_at: nullableSafeString(row.edited_at, 64), + pinned_at: nullableSafeString(row.pinned_at, 64), + unread: row.unread === true || (row.unread === undefined && (row.read_at === null || row.read_at === undefined)), + blocking: row.blocking === true || row.blocking === 1, + reply_to: row.reply_to == null ? null : Number(row.reply_to), + attachment_count: attachments, + has_attachments: attachments > 0, + has_metadata: row.has_metadata === true || row.has_metadata === 1 || Boolean(row.metadata), + preview: preview.text, + preview_bytes: Buffer.byteLength(preview.text), + content_bytes: contentBytes, + truncated: restricted || preview.truncated || contentBytes > Buffer.byteLength(source), + redacted: restricted || redactedSource !== source, + }; + if (row.uuid != null) message.uuid = boundedSafeString(row.uuid, 128); + if (replyCount !== undefined) message.reply_count = replyCount; + if (row.relevance_score != null) message.relevance_score = Number(row.relevance_score) || 0; + return message; +} + +function finalizePage(page: MessagePreviewPage): MessagePreviewPage { + let finalized = page; + for (let i = 0; i < 3; i++) { + finalized = { ...finalized, byte_length: Buffer.byteLength(JSON.stringify(finalized), "utf8") }; + } + return finalized; +} + +export function packMessagePreviewPage( + candidates: MessagePreview[], + options: { limit?: unknown; cursor?: unknown; max_bytes?: unknown; timeout_ms?: unknown; query?: string } = {}, +): MessagePreviewPage { + const limit = resolveCollectionLimit(options.limit); + const cursor = resolveCollectionOffset(options.cursor); + const maxBytes = resolveCollectionMaxBytes(options.max_bytes); + const timeoutMs = resolveCollectionTimeoutMs(options.timeout_ms); + const windowed = candidates.slice(0, limit + 1); + const available = windowed.slice(0, limit); + let messages: MessagePreview[] = []; + let skippedCount = 0; + + const build = (items: MessagePreview[], hasMore: boolean, skipped: number): MessagePreviewPage => finalizePage({ + messages: items, + count: items.length, + limit, + cursor, + next_cursor: hasMore || skipped > 0 ? cursor + items.length + skipped : null, + has_more: hasMore, + skipped_count: skipped, + byte_length: 0, + max_bytes: maxBytes, + timeout_ms: timeoutMs, + compact: true, + detail_path: "messages/{id}", + ...(options.query ? { query: boundedSafeString(options.query, 256) } : {}), + }); + + for (const candidate of available) { + const next = build([...messages, candidate], windowed.length > messages.length + 1, skippedCount); + if (next.byte_length > maxBytes) { + if (messages.length === 0) skippedCount = 1; + break; + } + messages = [...messages, candidate]; + } + + const consumed = messages.length + skippedCount; + const result = build(messages, windowed.length > consumed, skippedCount); + if (result.byte_length > maxBytes) { + throw new Error(`message preview envelope exceeds max_bytes (${result.byte_length} > ${maxBytes})`); + } + return result; +} + +export const RESTRICTED_COLLECTION_SQL_PREDICATE = `(lower(COALESCE(channel, '')) LIKE '%incident%' OR lower(COALESCE(channel, '')) LIKE '%security%' OR lower(COALESCE(to_agent, '')) LIKE '%incident%' OR lower(COALESCE(to_agent, '')) LIKE '%security%' OR lower(COALESCE(session_id, '')) LIKE '%incident%' OR lower(COALESCE(session_id, '')) LIKE '%security%')`; diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 0439fee..424f3b5 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -1,11 +1,31 @@ import { getDb, getDataDir } from "./db.js"; -import type { Message, Attachment, SendMessageOptions, ReadMessagesOptions, SearchMessagesOptions, SearchResult } from "../types.js"; +import type { + Message, + Attachment, + SendMessageOptions, + ReadMessagesOptions, + ReadMessagePreviewsOptions, + SearchMessagesOptions, + SearchMessagePreviewsOptions, + SearchResult, + MessagePreview, + MessagePreviewPage, +} from "../types.js"; import { createHash, randomUUID } from "crypto"; import { mkdirSync, copyFileSync, statSync, existsSync, realpathSync } from "fs"; import { join, basename, resolve } from "path"; import { fireWebhooks } from "./webhooks.js"; import { normalizeChannelName } from "./channel-names.js"; import { markChannelNotificationsRead } from "./channel-notifications.js"; +import { + COLLECTION_PREVIEW_SCAN_CHARS, + buildMessagePreview, + packMessagePreviewPage, + resolveCollectionLimit, + resolveCollectionOffset, + resolveCollectionPreviewBytes, + resolveCollectionTimeoutMs, +} from "./message-previews.js"; import { IncidentProjectorConfigurationError, metadataSpoofsIncidentProjection, @@ -341,6 +361,76 @@ export function readMessages(opts: ReadMessagesOptions = {}): Message[] { return messages; } +function previewProjectionColumns(alias = ""): string { + const c = alias ? `${alias}.` : ""; + const restricted = `(lower(COALESCE(${c}channel, '')) LIKE '%incident%' OR lower(COALESCE(${c}channel, '')) LIKE '%security%' OR lower(COALESCE(${c}to_agent, '')) LIKE '%incident%' OR lower(COALESCE(${c}to_agent, '')) LIKE '%security%' OR lower(COALESCE(${c}session_id, '')) LIKE '%incident%' OR lower(COALESCE(${c}session_id, '')) LIKE '%security%')`; + return `${c}id, ${c}uuid, ${c}session_id, ${c}from_agent, ${c}to_agent, ${c}channel, ${c}project_id, + ${c}priority, ${c}blocking, ${c}reply_to, ${c}working_dir, ${c}repository, ${c}branch, + ${c}created_at, ${c}read_at, ${c}edited_at, ${c}pinned_at, + CASE WHEN ${c}metadata IS NULL OR ${c}metadata = '' THEN 0 ELSE 1 END AS has_metadata, + CASE WHEN json_valid(${c}attachments) THEN json_array_length(${c}attachments) ELSE 0 END AS attachment_count, + CASE WHEN ${restricted} THEN '' ELSE substr(${c}content, 1, ${COLLECTION_PREVIEW_SCAN_CHARS}) END AS preview_source, + length(CAST(${c}content AS BLOB)) AS content_bytes`; +} + +function assertCollectionDeadline(startedAt: number, timeoutMs: number): void { + if (performance.now() - startedAt > timeoutMs) { + throw new Error(`message collection exceeded timeout_ms (${timeoutMs})`); + } +} + +/** + * Bounded collection read used by CLI/MCP/audit surfaces. The SQL projection + * never selects the full content or raw metadata value into the caller. + */ +export function readMessagePreviews(opts: ReadMessagePreviewsOptions = {}): MessagePreviewPage { + const startedAt = performance.now(); + const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); + const limit = resolveCollectionLimit(opts.latest ?? opts.limit); + const offset = resolveCollectionOffset(opts.offset); + const previewBytes = resolveCollectionPreviewBytes(opts.preview_bytes); + const db = getDb(); + const conditions: string[] = []; + const params: (string | number)[] = []; + + if (opts.id !== undefined) { conditions.push("id = ?"); params.push(opts.id); } + if (opts.session_id) { conditions.push("session_id = ?"); params.push(opts.session_id); } + if (opts.from) { conditions.push("from_agent = ?"); params.push(opts.from); } + if (opts.to) { conditions.push("to_agent = ?"); params.push(opts.to); } + if (opts.channel) { conditions.push("channel = ?"); params.push(normalizeChannelName(opts.channel)); } + if (opts.project_id) { conditions.push("project_id = ?"); params.push(opts.project_id); } + if (opts.since) { conditions.push("created_at > ?"); params.push(opts.since); } + if (opts.since_id !== undefined) { conditions.push("id > ?"); params.push(opts.since_id); } + if (opts.unread_only) conditions.push("read_at IS NULL"); + if (opts.threads_only) conditions.push("reply_to IS NULL"); + if (opts.reply_to !== undefined) { conditions.push("reply_to = ?"); params.push(opts.reply_to); } + if (opts.pinned_only) conditions.push("pinned_at IS NOT NULL"); + if (opts.mentions_only) { + conditions.push("id IN (SELECT message_id FROM message_mentions WHERE mentioned_agent = ?)"); + params.push(opts.mentions_only.toLowerCase()); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const order = opts.latest || opts.order?.toLowerCase() === "desc" ? "DESC" : "ASC"; + const replyCountSelect = opts.include_reply_counts + ? ", (SELECT COUNT(*) FROM messages replies WHERE replies.reply_to = messages.id) AS reply_count" + : ""; + const rows = db.prepare( + `SELECT ${previewProjectionColumns()}${replyCountSelect} + FROM messages ${where} ORDER BY created_at ${order}, id ${order} LIMIT ${limit + 1} OFFSET ${offset}`, + ).all(...params) as Record[]; + assertCollectionDeadline(startedAt, timeoutMs); + const previews = rows.map((row) => buildMessagePreview(row, previewBytes)); + const page = packMessagePreviewPage(previews, { + limit, + cursor: offset, + max_bytes: opts.max_bytes, + timeout_ms: timeoutMs, + }); + assertCollectionDeadline(startedAt, timeoutMs); + return page; +} + export interface CountMessagesOptions { session_id?: string; from?: string; @@ -586,7 +676,7 @@ export const DEFAULT_DIGEST_MAX_BYTES = 8192; export const MIN_DIGEST_MAX_BYTES = 512; export const MAX_DIGEST_MAX_BYTES = 65536; export const DEFAULT_DIGEST_LIMIT = 200; -export const MAX_DIGEST_LIMIT = 1000; +export const MAX_DIGEST_LIMIT = 100; export const DEFAULT_DIGEST_SNIPPET_BYTES = 320; const DIGEST_ID_PLACEHOLDER = "0000000000000000"; @@ -638,24 +728,24 @@ function truncateUtf8(value: string, maxBytes: number): { text: string; truncate return { text: `${text}${suffix}`, truncated: true }; } -function makeDigestMessage(message: Message, snippetBytes: number): DigestMessage { - const normalized = normalizeSnippetText(message.content); +function makeDigestMessage(message: MessagePreview, snippetBytes: number): DigestMessage { + const normalized = normalizeSnippetText(message.preview); const snippet = truncateUtf8(normalized, snippetBytes); - const attachmentCount = message.attachments?.length ?? 0; + const attachmentCount = message.attachment_count; return { id: message.id, from: message.from_agent, created_at: message.created_at, snippet: snippet.text, snippet_bytes: Buffer.byteLength(snippet.text, "utf8"), - truncated: snippet.truncated, + truncated: message.truncated || snippet.truncated, priority: message.priority, has_attachments: attachmentCount > 0, attachment_count: attachmentCount, channel: message.channel, to: message.to_agent, reply_to: message.reply_to, - unread: !message.read_at, + unread: message.unread, }; } @@ -732,7 +822,7 @@ function queryDigestMessages(opts: { project_id?: string; unread_only?: boolean; limit: number; -}): Message[] { +}): MessagePreview[] { const db = getDb(); const conditions: string[] = []; const params: (string | number)[] = []; @@ -748,9 +838,9 @@ function queryDigestMessages(opts: { const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const safeLimit = Math.max(1, Math.min(Math.floor(opts.limit), MAX_DIGEST_LIMIT)); const rows = db.prepare( - `SELECT * FROM messages ${where} ORDER BY id ASC LIMIT ${safeLimit}` + `SELECT ${previewProjectionColumns()} FROM messages ${where} ORDER BY id ASC LIMIT ${safeLimit}` ).all(...params) as Record[]; - return rows.map(parseMessage); + return rows.map((row) => buildMessagePreview(row, DEFAULT_DIGEST_SNIPPET_BYTES)); } function buildDigestResult(opts: { @@ -853,7 +943,7 @@ export interface DigestAssembly { export function assembleDigest( norm: DigestNorm, counts: DigestCounts, - messages: Message[], + messages: MessagePreview[], markReadRequested: boolean, ): DigestAssembly { const build = (entries: DigestMessage[], extra: Partial[0]> = {}): DigestResult => @@ -1118,7 +1208,11 @@ export function getPinnedMessages(opts?: { channel?: string; session_id?: string return rows.map(parseMessage); } -export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset?: number }): Message[] { +function queryUnreadBlockerRows( + agent: string, + opts: { limit?: number; offset?: number } | undefined, + projection: "full" | "preview", +): Record[] { const db = getDb(); const tenantId = process.env.HASNA_CONVERSATIONS_TENANT_ID?.trim(); const authorityId = process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID?.trim(); @@ -1147,7 +1241,8 @@ export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset : 0; const limitClause = safeLimit > 0 ? `LIMIT ${safeLimit}` : safeOffset > 0 ? "LIMIT -1" : ""; const offsetClause = safeOffset > 0 ? `OFFSET ${safeOffset}` : ""; - const rows = db.prepare(` + const select = projection === "preview" ? previewProjectionColumns("m") : "m.*"; + return db.prepare(` WITH member_channel_scopes(scope) AS ( SELECT 'channel:' || lower(channel) FROM channel_members @@ -1227,11 +1322,35 @@ export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset UNION SELECT id FROM legacy_ids ) - SELECT m.* FROM messages m JOIN eligible_ids eligible ON eligible.id = m.id + SELECT ${select} FROM messages m JOIN eligible_ids eligible ON eligible.id = m.id ORDER BY m.created_at ASC, m.id ASC ${limitClause} ${offsetClause} `).all(agent, agent, binding?.tenant_id ?? null, binding?.authority_id ?? null, agent, agent, agent, agent, agent) as Record[]; - return rows.map(parseMessage); +} + +export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset?: number }): Message[] { + return queryUnreadBlockerRows(agent, opts, "full").map(parseMessage); +} + +export function getUnreadBlockerPreviews( + agent: string, + opts: { limit?: number; offset?: number; max_bytes?: number; preview_bytes?: number; timeout_ms?: number } = {}, +): MessagePreviewPage { + const startedAt = performance.now(); + const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); + const limit = resolveCollectionLimit(opts.limit); + const offset = resolveCollectionOffset(opts.offset); + const previewBytes = resolveCollectionPreviewBytes(opts.preview_bytes); + const rows = queryUnreadBlockerRows(agent, { limit: limit + 1, offset }, "preview"); + assertCollectionDeadline(startedAt, timeoutMs); + const page = packMessagePreviewPage(rows.map((row) => buildMessagePreview(row, previewBytes)), { + limit, + cursor: offset, + max_bytes: opts.max_bytes, + timeout_ms: timeoutMs, + }); + assertCollectionDeadline(startedAt, timeoutMs); + return page; } export function getThreadReplies(messageId: number): Message[] { @@ -1331,6 +1450,72 @@ export function searchMessages(opts: SearchMessagesOptions): SearchResult[] { }); } +/** Search equivalent of readMessagePreviews; FTS/LIKE run in SQLite but only a + * bounded, redacted snippet projection leaves the storage boundary. */ +export function searchMessagePreviews(opts: SearchMessagePreviewsOptions): MessagePreviewPage { + const startedAt = performance.now(); + const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); + const limit = resolveCollectionLimit(opts.limit); + const offset = resolveCollectionOffset(opts.offset); + const previewBytes = resolveCollectionPreviewBytes(opts.preview_bytes); + const db = getDb(); + const sortByRelevance = opts.sort !== "recent"; + + try { + const params: (string | number)[] = []; + const query = opts.query.trim(); + const ftsQuery = query.startsWith('"') && query.endsWith('"') + ? query + : query.split(/\s+/).filter(Boolean).map((word) => `"${word.replace(/"/g, '""')}"`).join(" "); + params.push(ftsQuery); + const clauses: string[] = []; + if (opts.channel) { clauses.push("m.channel = ?"); params.push(normalizeChannelName(opts.channel)); } + if (opts.from) { clauses.push("m.from_agent = ?"); params.push(opts.from); } + if (opts.to) { clauses.push("m.to_agent = ?"); params.push(opts.to); } + if (opts.since) { clauses.push("m.created_at >= ?"); params.push(opts.since); } + if (opts.until) { clauses.push("m.created_at <= ?"); params.push(opts.until); } + const extra = clauses.length > 0 ? ` AND ${clauses.join(" AND ")}` : ""; + const order = sortByRelevance ? "ORDER BY rank" : "ORDER BY m.created_at DESC, m.id DESC"; + const rows = db.prepare( + `SELECT ${previewProjectionColumns("m")}, rank AS relevance_score + FROM messages m JOIN messages_fts ON messages_fts.rowid = m.id + WHERE messages_fts MATCH ?${extra} ${order} LIMIT ${limit + 1} OFFSET ${offset}`, + ).all(...params) as Record[]; + assertCollectionDeadline(startedAt, timeoutMs); + const page = packMessagePreviewPage(rows.map((row) => buildMessagePreview(row, previewBytes)), { + limit, + cursor: offset, + max_bytes: opts.max_bytes, + timeout_ms: timeoutMs, + query: opts.query, + }); + assertCollectionDeadline(startedAt, timeoutMs); + return page; + } catch (error) { + if (error instanceof Error && (error.message.includes("max_bytes") || error.message.includes("timeout_ms") || error.message.includes("envelope exceeds"))) throw error; + } + + const conditions: string[] = ["content LIKE ?"]; + const params: (string | number)[] = [`%${opts.query}%`]; + if (opts.channel) { conditions.push("channel = ?"); params.push(normalizeChannelName(opts.channel)); } + if (opts.from) { conditions.push("from_agent = ?"); params.push(opts.from); } + if (opts.to) { conditions.push("to_agent = ?"); params.push(opts.to); } + if (opts.since) { conditions.push("created_at >= ?"); params.push(opts.since); } + if (opts.until) { conditions.push("created_at <= ?"); params.push(opts.until); } + const rows = db.prepare( + `SELECT ${previewProjectionColumns()}, 0 AS relevance_score FROM messages + WHERE ${conditions.join(" AND ")} ORDER BY created_at DESC, id DESC LIMIT ${limit + 1} OFFSET ${offset}`, + ).all(...params) as Record[]; + assertCollectionDeadline(startedAt, timeoutMs); + return packMessagePreviewPage(rows.map((row) => buildMessagePreview(row, previewBytes)), { + limit, + cursor: offset, + max_bytes: opts.max_bytes, + timeout_ms: timeoutMs, + query: opts.query, + }); +} + export interface UnreadCount { channel: string; unread_count: number; diff --git a/src/lib/poll.test.ts b/src/lib/poll.test.ts index 92592e3..eaa00b5 100644 --- a/src/lib/poll.test.ts +++ b/src/lib/poll.test.ts @@ -2,23 +2,25 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { startPolling } from "./poll"; import { sendMessage } from "./messages"; import { closeDb } from "./db"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; import type { Message } from "../types"; +import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../test/hermetic"; -const TEST_DB = join(tmpdir(), `conversations-test-poll-${Date.now()}.db`); +let testStore: ReturnType; +let restoreEnv: () => void; +let restoreNetwork: () => void; beforeEach(() => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; + testStore = createDisposableStore("poll"); + restoreEnv = enterHermeticTestEnv({ CONVERSATIONS_DB_PATH: testStore.dbPath }); + restoreNetwork = installNetworkGuard(); closeDb(); }); afterEach(() => { closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} + restoreNetwork(); + restoreEnv(); + testStore.cleanup(); }); describe("startPolling", () => { diff --git a/src/lib/poll.ts b/src/lib/poll.ts index fbc4795..a885e50 100644 --- a/src/lib/poll.ts +++ b/src/lib/poll.ts @@ -34,7 +34,7 @@ export function startPolling(opts: PollOptions): { stop: () => void } { // poll awaits it before querying, keeping the "only NEW messages" contract in // both modes. const seeded = store - .readMessages({ + .readMessagePreviews({ session_id: opts.session_id, to: opts.to_agent, channel: opts.channel, @@ -42,7 +42,7 @@ export function startPolling(opts: PollOptions): { stop: () => void } { limit: 1, }) .then((latest) => { - if (latest.length > 0 && latest[0].id > lastSeenId) lastSeenId = latest[0].id; + if (latest.messages.length > 0 && latest.messages[0].id > lastSeenId) lastSeenId = latest.messages[0].id; }) .catch(() => { // A failed seed just means the first poll starts from id 0; never fatal. @@ -56,7 +56,7 @@ export function startPolling(opts: PollOptions): { stop: () => void } { await seeded; if (stopped) return; - const messages = await store.readMessages({ + const page = await store.readMessagePreviews({ session_id: opts.session_id, to: opts.to_agent, channel: opts.channel, @@ -64,8 +64,10 @@ export function startPolling(opts: PollOptions): { stop: () => void } { order: "asc", }); - if (messages.length > 0) { - lastSeenId = messages[messages.length - 1].id; + if (page.messages.length > 0) { + lastSeenId = page.messages[page.messages.length - 1].id; + const messages = (await Promise.all(page.messages.map((preview) => store.getMessageById(preview.id)))) + .filter((message): message is Message => message !== null); try { opts.on_messages(messages); } catch (error) { diff --git a/src/lib/project-panel.test.ts b/src/lib/project-panel.test.ts index ce2f326..3a6e9e3 100644 --- a/src/lib/project-panel.test.ts +++ b/src/lib/project-panel.test.ts @@ -1,30 +1,31 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; import { createChannel } from "./channels.js"; import { closeDb } from "./db.js"; import { sendMessage } from "./messages.js"; import { createConversationsProjectPanel } from "./project-panel.js"; import { createProject } from "./projects.js"; +import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../test/hermetic.js"; -const TEST_DB = join(tmpdir(), `conversations-test-project-panel-${Date.now()}.db`); +let testStore: ReturnType; +let restoreEnv: () => void; +let restoreNetwork: () => void; function cleanupDb(): void { closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(`${TEST_DB}-wal`); } catch {} - try { unlinkSync(`${TEST_DB}-shm`); } catch {} } beforeEach(() => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; + testStore = createDisposableStore("project-panel"); + restoreEnv = enterHermeticTestEnv({ CONVERSATIONS_DB_PATH: testStore.dbPath }); + restoreNetwork = installNetworkGuard(); cleanupDb(); }); afterEach(() => { cleanupDb(); - delete process.env.CONVERSATIONS_DB_PATH; + restoreNetwork(); + restoreEnv(); + testStore.cleanup(); }); describe("createConversationsProjectPanel", () => { diff --git a/src/lib/project-panel.ts b/src/lib/project-panel.ts index 549ad50..1456a1e 100644 --- a/src/lib/project-panel.ts +++ b/src/lib/project-panel.ts @@ -6,7 +6,7 @@ import { } from "@hasna/contracts"; import { normalizeChannelName } from "./channel-names.js"; import { getStore, type ConversationsStore } from "./store/index.js"; -import type { ChannelInfo, Message, ProjectInfo } from "../types.js"; +import type { ChannelInfo, MessagePreview, ProjectInfo } from "../types.js"; export interface ConversationsProjectPanelOptions { limit?: number; @@ -68,7 +68,7 @@ function channelResource(channel: ChannelInfo) { }; } -function messageResource(message: Message) { +function messageResource(message: MessagePreview) { return { kind: "comment" as const, id: String(message.id), @@ -94,7 +94,7 @@ function preview(content: string, max = 180): string { return compact.length > max ? `${compact.slice(0, max - 1)}…` : compact; } -function priorityForMessage(message: Message): "low" | "medium" | "high" | "critical" | "unknown" { +function priorityForMessage(message: MessagePreview): "low" | "medium" | "high" | "critical" | "unknown" { if (message.blocking || message.priority === "urgent") return "critical"; if (message.priority === "high") return "high"; if (message.priority === "normal") return "medium"; @@ -102,7 +102,7 @@ function priorityForMessage(message: Message): "low" | "medium" | "high" | "crit return "unknown"; } -function stateForConversation(channels: ChannelInfo[], messages: Message[]): ProjectPanelInput["state"] { +function stateForConversation(channels: ChannelInfo[], messages: MessagePreview[]): ProjectPanelInput["state"] { return channels.length === 0 && messages.length === 0 ? "empty" : "ready"; } @@ -135,27 +135,27 @@ async function countScope( return total; } -async function selectMessages(store: ConversationsStore, projectId: string | null, channels: ChannelInfo[], limit: number): Promise { - const messagesById = new Map(); +async function selectMessages(store: ConversationsStore, projectId: string | null, channels: ChannelInfo[], limit: number): Promise { + const messagesById = new Map(); if (projectId) { - for (const message of await store.readMessages({ + for (const message of (await store.readMessagePreviews({ project_id: projectId, latest: limit, - max_content_length: 200, + preview_bytes: 200, include_reply_counts: true, - })) { + })).messages) { messagesById.set(message.id, message); } } const channelLimit = projectId ? limit : Math.max(1, Math.ceil(limit / Math.max(1, channels.length))); for (const channel of channels) { - for (const message of await store.readMessages({ + for (const message of (await store.readMessagePreviews({ channel: channel.name, latest: channelLimit, - max_content_length: 200, + preview_bytes: 200, include_reply_counts: true, - })) { + })).messages) { messagesById.set(message.id, message); } } @@ -195,7 +195,7 @@ export async function createConversationsProjectPanel(projectRef: string, option const messages = await selectMessages(store, project?.id ?? null, channels, limit); const messageCount = await countScope(store, project?.id ?? null, channelNames, false); const blockingCount = await countScope(store, project?.id ?? null, channelNames, true); - const unreadCount = messages.filter((message) => message.read_at === null).length; + const unreadCount = messages.filter((message) => message.unread).length; const onlineAgents = (await store.listAgents({ online_only: true })).filter((agent) => !project || agent.project_id === project.id || agent.project_id === null); const participants = new Set(messages.flatMap((message) => [message.from_agent, message.to_agent]).filter(Boolean)); for (const channel of channels) { @@ -241,8 +241,8 @@ export async function createConversationsProjectPanel(projectRef: string, option items: messages.map((message) => ({ id: String(message.id), title: message.channel ? `#${message.channel}: ${message.from_agent}` : `${message.from_agent} -> ${message.to_agent}`, - summary: preview(message.content), - status: message.blocking ? "blocking" : message.read_at ? "read" : "unread", + summary: preview(message.preview), + status: message.blocking ? "blocking" : message.unread ? "unread" : "read", priority: priorityForMessage(message), timestamp: toTimestamp(message.created_at), resourceRefs: [ @@ -255,7 +255,7 @@ export async function createConversationsProjectPanel(projectRef: string, option to_agent: message.to_agent, reply_to: message.reply_to, reply_count: message.reply_count ?? 0, - has_attachments: Boolean(message.attachments?.length), + has_attachments: message.has_attachments, }, })), actions: [ diff --git a/src/lib/store/api-store.ts b/src/lib/store/api-store.ts index 53fff5f..35ed4d1 100644 --- a/src/lib/store/api-store.ts +++ b/src/lib/store/api-store.ts @@ -17,13 +17,21 @@ import { normalizeSince } from "../since.js"; import { parseProject } from "../projects.js"; import { parseMessage, - compactMessage, resolveDigestMaxBytes, resolveDigestLimit, resolveDigestCursor, assembleDigest, type DigestNorm, } from "../messages.js"; +import { + COLLECTION_MAX_MAX_BYTES, + resolveCollectionLimit, + resolveCollectionMaxBytes, + resolveCollectionOffset, + resolveCollectionPreviewBytes, + resolveCollectionTimeoutMs, +} from "../message-previews.js"; +import type { Message, MessagePreview, MessagePreviewPage } from "../../types.js"; type Q = Record; @@ -43,6 +51,37 @@ function isHttpStatus(error: unknown, status: number): boolean { ); } +/** + * Compatibility for legacy Store consumers while the remote transport remains + * projection-only. `content` is the already bounded/redacted preview, never the + * source body; exact content is available only through getMessageById. + */ +function previewAsCompatibilityMessage(preview: MessagePreview): Message { + return { + id: preview.id, + session_id: preview.session_id, + from_agent: preview.from_agent, + to_agent: preview.to_agent, + channel: preview.channel, + project_id: preview.project_id, + content: preview.preview, + priority: preview.priority, + working_dir: preview.working_dir, + repository: preview.repository, + branch: preview.branch, + metadata: null, + created_at: preview.created_at, + read_at: preview.unread ? null : preview.created_at, + edited_at: preview.edited_at, + pinned_at: preview.pinned_at, + blocking: preview.blocking, + attachments: null, + reply_to: preview.reply_to, + reply_count: preview.reply_count, + truncated: true, + }; +} + export class ApiStore implements ConversationsStore { readonly transport = "cloud-http" as const; constructor(private readonly client: HasnaStorageClient) {} @@ -54,6 +93,9 @@ export class ApiStore implements ConversationsStore { private async get(path: string, query?: Q): Promise { return this.t.get(path, query ? { query: prune(query) } : undefined); } + private async getBounded(path: string, query: Q, timeoutMs: number): Promise { + return this.t.get(path, { query: prune(query), timeoutMs, retry: false }); + } private async post(path: string, body?: unknown, query?: Q): Promise { return this.t.post(path, body, query ? { query: prune(query) } : undefined); } @@ -172,7 +214,7 @@ export class ApiStore implements ConversationsStore { const body = await this.get<{ notifications?: unknown[] }>("/channel-notifications/inbox", { agent: opts.agent, channel: opts.channel ? normalizeChannelName(opts.channel) : undefined, - unread_only: opts.unread_only ? true : undefined, + unread_only: opts.unread_only, limit: opts.limit, since: normalizeSince(opts.since), }); @@ -530,34 +572,76 @@ export class ApiStore implements ConversationsStore { }; readMessages: ConversationsStore["readMessages"] = async (opts) => { const o = opts ?? {}; - const since = normalizeSince(o.since); - const isLatest = Boolean(o.latest && o.latest > 0); - const limit = isLatest ? Math.floor(o.latest as number) : Number.isFinite(o.limit) && (o.limit as number) > 0 ? Math.floor(o.limit as number) : 20; - const order = isLatest ? "desc" : o.order?.toLowerCase() === "desc" ? "desc" : "asc"; - const res = await this.get<{ messages?: Record[] }>("/messages", { - limit, order, offset: o.offset, session: o.session_id, from: o.from, to: o.to, - channel: o.channel ? normalizeChannelName(o.channel) : undefined, project_id: o.project_id, - since, since_id: o.since_id, unread_only: o.unread_only ? true : undefined, - threads_only: o.threads_only ? true : undefined, - include_reply_counts: o.include_reply_counts ? true : undefined, mentions_only: o.mentions_only, + const page = await this.readMessagePreviews({ + ...o, + max_bytes: COLLECTION_MAX_MAX_BYTES, + preview_bytes: o.max_content_length, + timeout_ms: 5_000, }); - let messages = (res?.messages ?? []).map(parseMessage); - if (o.max_content_length && o.max_content_length > 0) { - const max = o.max_content_length; - messages = messages.map((m) => (m.content.length > max ? { ...m, content: m.content.slice(0, max) + "…", truncated: true } : m)); - } - if (o.compact) return messages.map(compactMessage) as never; - return messages as never; + return page.messages.map(previewAsCompatibilityMessage) as never; + }; + readMessagePreviews: ConversationsStore["readMessagePreviews"] = async (opts) => { + const o = opts ?? {}; + const limit = resolveCollectionLimit(o.latest ?? o.limit); + const offset = resolveCollectionOffset(o.offset); + const maxBytes = resolveCollectionMaxBytes(o.max_bytes); + const previewBytes = resolveCollectionPreviewBytes(o.preview_bytes); + const timeoutMs = resolveCollectionTimeoutMs(o.timeout_ms); + return await this.getBounded("/messages", { + limit, + offset, + max_bytes: maxBytes, + preview_bytes: previewBytes, + timeout_ms: timeoutMs, + order: o.latest || o.order?.toLowerCase() === "desc" ? "desc" : "asc", + id: o.id, + session: o.session_id, + from: o.from, + to: o.to, + channel: o.channel ? normalizeChannelName(o.channel) : undefined, + project_id: o.project_id, + since: normalizeSince(o.since), + since_id: o.since_id, + unread_only: o.unread_only ? true : undefined, + threads_only: o.threads_only ? true : undefined, + reply_to: o.reply_to, + pinned_only: o.pinned_only ? true : undefined, + include_reply_counts: o.include_reply_counts ? true : undefined, + mentions_only: o.mentions_only, + }, timeoutMs) as never; }; searchMessages: ConversationsStore["searchMessages"] = async (opts) => { - const since = normalizeSince(opts.since); - const res = await this.get<{ messages?: Record[] }>("/messages", { - q: opts.query, - limit: Number.isFinite(opts.limit) && (opts.limit as number) > 0 ? Math.floor(opts.limit as number) : 20, - offset: Number.isFinite(opts.offset) && (opts.offset as number) > 0 ? Math.floor(opts.offset as number) : undefined, - order: "desc", channel: opts.channel ? normalizeChannelName(opts.channel) : undefined, from: opts.from, to: opts.to, since, + const page = await this.searchMessagePreviews({ + ...opts, + max_bytes: COLLECTION_MAX_MAX_BYTES, + timeout_ms: 5_000, }); - return (res?.messages ?? []).map((row) => ({ ...parseMessage(row), snippet: null, relevance_score: 0 })) as never; + return page.messages.map((preview) => ({ + ...previewAsCompatibilityMessage(preview), + snippet: preview.preview, + relevance_score: preview.relevance_score ?? 0, + })) as never; + }; + searchMessagePreviews: ConversationsStore["searchMessagePreviews"] = async (opts) => { + const limit = resolveCollectionLimit(opts.limit); + const offset = resolveCollectionOffset(opts.offset); + const maxBytes = resolveCollectionMaxBytes(opts.max_bytes); + const previewBytes = resolveCollectionPreviewBytes(opts.preview_bytes); + const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); + return await this.getBounded("/messages", { + q: opts.query, + limit, + offset, + max_bytes: maxBytes, + preview_bytes: previewBytes, + timeout_ms: timeoutMs, + order: opts.sort === "recent" ? "desc" : "relevance", + channel: opts.channel ? normalizeChannelName(opts.channel) : undefined, + from: opts.from, + to: opts.to, + since: normalizeSince(opts.since), + until: opts.until, + }, timeoutMs) as never; }; readDigest: ConversationsStore["readDigest"] = async (opts) => { const o = opts ?? {}; @@ -571,8 +655,20 @@ export class ApiStore implements ConversationsStore { this.messageCount(o.unread_only ? { ...baseFilter, unread_only: true } : baseFilter), this.messageCount({ ...baseFilter, unread_only: true }), ]); - const listRes = await this.get<{ messages?: Record[] }>("/messages", { ...baseFilter, order: "asc", limit, unread_only: o.unread_only ? true : undefined }); - const messages = (listRes?.messages ?? []).map(parseMessage); + const listRes = await this.readMessagePreviews({ + channel: channel ?? undefined, + session_id: o.session_id, + to: o.to, + since, + since_id: cursor, + project_id: o.project_id, + order: "asc", + limit, + unread_only: o.unread_only, + max_bytes: COLLECTION_MAX_MAX_BYTES, + preview_bytes: 320, + }); + const messages = listRes.messages; const norm: DigestNorm = { channel, session_id: o.session_id, to: o.to, since, cursor, maxBytes, limit }; const assembly = assembleDigest(norm, { total_available: totalAvailable, total_unread: totalUnread }, messages, !!o.mark_read); let markedRead = 0; @@ -604,18 +700,38 @@ export class ApiStore implements ConversationsStore { return String(body?.export ?? "") as never; }; getThreadReplies: ConversationsStore["getThreadReplies"] = async (messageId) => { - const body = await this.get<{ messages?: Record[] }>(`/messages/${encodeURIComponent(String(messageId))}/replies`); - return (body?.messages ?? []).map(parseMessage) as never; + const page = await this.readMessagePreviews({ reply_to: messageId, order: "asc", limit: 100 }); + return page.messages.map(previewAsCompatibilityMessage) as never; }; getUnreadBlockers: ConversationsStore["getUnreadBlockers"] = async (agent, opts) => { - const body = await this.get<{ messages?: Record[] }>("/messages/blockers", { agent, ...(opts as Q) }); - return (body?.messages ?? []).map(parseMessage) as never; + const page = await this.getUnreadBlockerPreviews(agent, { ...opts, max_bytes: COLLECTION_MAX_MAX_BYTES }); + return page.messages.map(previewAsCompatibilityMessage) as never; + }; + getUnreadBlockerPreviews: ConversationsStore["getUnreadBlockerPreviews"] = async (agent, opts) => { + const o = opts ?? {}; + const limit = resolveCollectionLimit(o.limit); + const offset = resolveCollectionOffset(o.offset); + const maxBytes = resolveCollectionMaxBytes(o.max_bytes); + const previewBytes = resolveCollectionPreviewBytes(o.preview_bytes); + const timeoutMs = resolveCollectionTimeoutMs(o.timeout_ms); + return await this.getBounded("/messages/blockers", { + agent, + limit, + offset, + max_bytes: maxBytes, + preview_bytes: previewBytes, + timeout_ms: timeoutMs, + }, timeoutMs) as never; }; getMessagesForAgent: ConversationsStore["getMessagesForAgent"] = async (agent, opts) => { - const body = await this.get<{ items?: Array<{ message: Record; mention_id: number }> }>("/messages/for-agent", { - agent, channel: opts?.channel, unread_only: opts?.unread_only ? true : undefined, limit: opts?.limit, + const page = await this.readMessagePreviews({ + mentions_only: agent, + channel: opts?.channel, + unread_only: opts?.unread_only, + limit: opts?.limit, + order: "desc", }); - return (body?.items ?? []).map((r) => ({ message: parseMessage(r.message), mention_id: r.mention_id })) as never; + return page.messages.map((preview) => ({ message: previewAsCompatibilityMessage(preview), mention_id: preview.id })) as never; }; getMessageReadStatus: ConversationsStore["getMessageReadStatus"] = async (messageId, channel) => { const body = await this.get<{ receipts?: unknown[]; unread_by?: string[] }>(`/messages/${encodeURIComponent(String(messageId))}/read-status`, { channel }); @@ -670,10 +786,15 @@ export class ApiStore implements ConversationsStore { } }; getPinnedMessages: ConversationsStore["getPinnedMessages"] = async (opts) => { - const res = await this.get<{ messages?: Record[] }>("/messages/pinned", { - channel: opts?.channel ? normalizeChannelName(opts.channel) : undefined, session: opts?.session_id, limit: opts?.limit, offset: opts?.offset, + const page = await this.readMessagePreviews({ + pinned_only: true, + channel: opts?.channel, + session_id: opts?.session_id, + limit: opts?.limit, + offset: opts?.offset, + order: "desc", }); - return (res?.messages ?? []).map(parseMessage) as never; + return page.messages.map(previewAsCompatibilityMessage) as never; }; recordReadReceipt: ConversationsStore["recordReadReceipt"] = async (messageId, agent) => { await this.markReadCall({ ids: [messageId], reader: agent }); diff --git a/src/lib/store/index.ts b/src/lib/store/index.ts index ca98ea3..cf52057 100644 --- a/src/lib/store/index.ts +++ b/src/lib/store/index.ts @@ -228,12 +228,15 @@ export interface ConversationsStore { deleteMessage: Async; editMessage: Async; readMessages: Async; + readMessagePreviews: Async; countMessages: Async; searchMessages: Async; + searchMessagePreviews: Async; readDigest: Async; exportMessages: Async; getThreadReplies: Async; getUnreadBlockers: Async; + getUnreadBlockerPreviews: Async; getMessagesForAgent: Async; getMessageReadStatus: Async; markRead: Async; @@ -380,12 +383,15 @@ export class LocalStore implements ConversationsStore { deleteMessage: ConversationsStore["deleteMessage"] = async (...a) => messagesLib.deleteMessage(...a); editMessage: ConversationsStore["editMessage"] = async (...a) => messagesLib.editMessage(...a); readMessages: ConversationsStore["readMessages"] = async (...a) => messagesLib.readMessages(...a); + readMessagePreviews: ConversationsStore["readMessagePreviews"] = async (...a) => messagesLib.readMessagePreviews(...a); countMessages: ConversationsStore["countMessages"] = async (...a) => messagesLib.countMessages(...a); searchMessages: ConversationsStore["searchMessages"] = async (...a) => messagesLib.searchMessages(...a); + searchMessagePreviews: ConversationsStore["searchMessagePreviews"] = async (...a) => messagesLib.searchMessagePreviews(...a); readDigest: ConversationsStore["readDigest"] = async (...a) => messagesLib.readDigest(...a); exportMessages: ConversationsStore["exportMessages"] = async (...a) => messagesLib.exportMessages(...a); getThreadReplies: ConversationsStore["getThreadReplies"] = async (...a) => messagesLib.getThreadReplies(...a); getUnreadBlockers: ConversationsStore["getUnreadBlockers"] = async (...a) => messagesLib.getUnreadBlockers(...a); + getUnreadBlockerPreviews: ConversationsStore["getUnreadBlockerPreviews"] = async (...a) => messagesLib.getUnreadBlockerPreviews(...a); getMessagesForAgent: ConversationsStore["getMessagesForAgent"] = async (...a) => messagesLib.getMessagesForAgent(...a); getMessageReadStatus: ConversationsStore["getMessageReadStatus"] = async (...a) => messagesLib.getMessageReadStatus(...a); markRead: ConversationsStore["markRead"] = async (...a) => messagesLib.markRead(...a); diff --git a/src/lib/summary.ts b/src/lib/summary.ts index dcdaca2..f3f24a3 100644 --- a/src/lib/summary.ts +++ b/src/lib/summary.ts @@ -1,6 +1,6 @@ import { getDb } from "./db.js"; -import type { Message } from "../types.js"; import { extractTopics, type TopicWeight } from "./topics.js"; +import { readMessagePreviews } from "./messages.js"; export interface ConversationSummary { session_id: string; @@ -31,28 +31,29 @@ export function getConversationSummary(sessionOrChannel: string, opts?: SummaryO // Detect if this is a channel or session const isChannel = sessionOrChannel.startsWith("channel:") || db.prepare("SELECT 1 FROM channels WHERE name = ?").get(sessionOrChannel); - const filterCol = isChannel ? "channel" : "session_id"; const filterVal = isChannel && !sessionOrChannel.startsWith("channel:") ? sessionOrChannel : sessionOrChannel; - const messages = db.prepare( - `SELECT * FROM messages WHERE ${filterCol} = ? ORDER BY created_at DESC LIMIT ${limit}` - ).all(filterVal) as Record[]; + const messages = readMessagePreviews({ + ...(isChannel ? { channel: filterVal } : { session_id: filterVal }), + latest: limit, + preview_bytes: 320, + }).messages; if (messages.length === 0) return null; // Participants const agents = new Set(); for (const m of messages) { - agents.add(m.from_agent as string); - if (m.to_agent) agents.add(m.to_agent as string); + agents.add(m.from_agent); + if (m.to_agent) agents.add(m.to_agent); } // Date range - const dates = messages.map((m) => m.created_at as string).sort(); + const dates = messages.map((m) => m.created_at).sort(); const dateRange = { first: dates[0], last: dates[dates.length - 1] }; // Topics - const allContent = messages.map((m) => m.content as string).join("\n"); + const allContent = messages.map((m) => m.preview).join("\n"); const topics = extractTopics(allContent, 10); // Key messages: high priority, pinned, most reactions, most replies @@ -63,17 +64,17 @@ export function getConversationSummary(sessionOrChannel: string, opts?: SummaryO const priority = m.priority as string; if (priority === "high" || priority === "urgent") { keyMessages.push({ - id: m.id as number, - from: m.from_agent as string, - content: (m.content as string).slice(0, 200), + id: m.id, + from: m.from_agent, + content: m.preview.slice(0, 200), reason: `${priority} priority`, }); } if (m.blocking) { keyMessages.push({ - id: m.id as number, - from: m.from_agent as string, - content: (m.content as string).slice(0, 200), + id: m.id, + from: m.from_agent, + content: m.preview.slice(0, 200), reason: "blocking message", }); } @@ -83,16 +84,16 @@ export function getConversationSummary(sessionOrChannel: string, opts?: SummaryO for (const m of messages) { if (m.pinned_at) { keyMessages.push({ - id: m.id as number, - from: m.from_agent as string, - content: (m.content as string).slice(0, 200), + id: m.id, + from: m.from_agent, + content: m.preview.slice(0, 200), reason: "pinned", }); } } // Most reacted (top 3) - const msgIds = messages.map((m) => m.id as number); + const msgIds = messages.map((m) => m.id); if (msgIds.length > 0) { const placeholders = msgIds.map(() => "?").join(","); const reacted = db.prepare( @@ -100,12 +101,12 @@ export function getConversationSummary(sessionOrChannel: string, opts?: SummaryO ).all(...msgIds) as { message_id: number; c: number }[]; for (const r of reacted) { - const m = messages.find((msg) => (msg.id as number) === r.message_id); + const m = messages.find((msg) => msg.id === r.message_id); if (m) { keyMessages.push({ id: r.message_id, - from: m.from_agent as string, - content: (m.content as string).slice(0, 200), + from: m.from_agent, + content: m.preview.slice(0, 200), reason: `${r.c} reaction(s)`, }); } @@ -122,12 +123,12 @@ export function getConversationSummary(sessionOrChannel: string, opts?: SummaryO // Unresolved blockers const blockers = messages - .filter((m) => m.blocking && !m.read_at) + .filter((m) => m.blocking && m.unread) .map((m) => ({ - id: m.id as number, - from: m.from_agent as string, - content: (m.content as string).slice(0, 200), - created_at: m.created_at as string, + id: m.id, + from: m.from_agent, + content: m.preview.slice(0, 200), + created_at: m.created_at, })); // Activity metrics diff --git a/src/lib/topics.test.ts b/src/lib/topics.test.ts index ce49e2e..d36b4dc 100644 --- a/src/lib/topics.test.ts +++ b/src/lib/topics.test.ts @@ -86,6 +86,28 @@ describe("getChannelTopics", () => { createChannel("empty-channel", "tester"); expect(getChannelTopics("empty-channel")).toEqual([]); }); + + test("does not derive topics from secrets, restricted channels, or content beyond the collection scan bound", () => { + createChannel("safe-topics", "tester"); + createChannel("security-incidents", "tester"); + sendMessage({ + from: "a", + to: "safe-topics", + channel: "safe-topics", + content: `deployment Bearer abcdefghijklmnopqrstuvwxyz123456 ${"padding ".repeat(700)} tailtopic ${"tailtopic ".repeat(20)}`, + }); + sendMessage({ + from: "a", + to: "security-incidents", + channel: "security-incidents", + content: "restrictedbody restrictedbody restrictedbody", + }); + + const safeWords = getChannelTopics("safe-topics").map((topic) => topic.topic); + expect(safeWords.join(" ")).not.toContain("abcdefghijklmnopqrstuvwxyz"); + expect(safeWords).not.toContain("tailtopic"); + expect(getChannelTopics("security-incidents")).toEqual([]); + }); }); describe("getSessionTopics", () => { diff --git a/src/lib/topics.ts b/src/lib/topics.ts index 6ba74ef..b15cdf8 100644 --- a/src/lib/topics.ts +++ b/src/lib/topics.ts @@ -1,49 +1,46 @@ import { getDb } from "./db.js"; import { extractTopics, type TopicWeight } from "./topic-extract.js"; +import { + COLLECTION_PREVIEW_SCAN_CHARS, + RESTRICTED_COLLECTION_SQL_PREDICATE, + redactSensitiveText, + resolveCollectionLimit, +} from "./message-previews.js"; // Topic extraction is storage-agnostic and lives in ./topic-extract.js so the // self_hosted/cloud API server can reuse the identical algorithm without a // sqlite import. Re-exported here for existing callers. export { extractTopics, type TopicWeight }; +function projectedTopicText(where: string, params: Array, limit: unknown): string { + const rows = getDb().prepare( + `SELECT CASE WHEN ${RESTRICTED_COLLECTION_SQL_PREDICATE} THEN '' ELSE substr(content, 1, ${COLLECTION_PREVIEW_SCAN_CHARS}) END AS preview_source + FROM messages ${where} ORDER BY created_at DESC LIMIT ?`, + ).all(...params, resolveCollectionLimit(limit)) as Array<{ preview_source: string }>; + return rows.map((row) => redactSensitiveText(row.preview_source)).join("\n"); +} + /** * Get topics for a channel by aggregating recent messages. */ export function getChannelTopics(channelName: string, opts?: { limit?: number; since?: string }): TopicWeight[] { - const db = getDb(); - const limit = opts?.limit ?? 100; const sinceClause = opts?.since ? "AND created_at > ?" : ""; const params: (string | number)[] = [channelName]; if (opts?.since) params.push(opts.since); - - const rows = db.prepare( - `SELECT content FROM messages WHERE channel = ? ${sinceClause} ORDER BY created_at DESC LIMIT ${limit}` - ).all(...params) as { content: string }[]; - - const combined = rows.map((r) => r.content).join("\n"); - return extractTopics(combined, 15); + return extractTopics(projectedTopicText(`WHERE channel = ? ${sinceClause}`, params, opts?.limit ?? 100), 15); } /** * Get topics for a session by aggregating all messages. */ export function getSessionTopics(sessionId: string, opts?: { limit?: number }): TopicWeight[] { - const db = getDb(); - const limit = opts?.limit ?? 100; - - const rows = db.prepare( - `SELECT content FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ${limit}` - ).all(sessionId) as { content: string }[]; - - const combined = rows.map((r) => r.content).join("\n"); - return extractTopics(combined, 15); + return extractTopics(projectedTopicText("WHERE session_id = ?", [sessionId], opts?.limit ?? 100), 15); } /** * Get trending topics across all channels or a specific project. */ export function getTrendingTopics(opts?: { project_id?: string; hours?: number; top_n?: number }): TopicWeight[] { - const db = getDb(); const hours = opts?.hours ?? 24; const topN = opts?.top_n ?? 20; @@ -54,10 +51,5 @@ export function getTrendingTopics(opts?: { project_id?: string; hours?: number; params.push(opts.project_id); } - const rows = db.prepare( - `SELECT content FROM messages ${where} ORDER BY created_at DESC LIMIT 500` - ).all(...params) as { content: string }[]; - - const combined = rows.map((r) => r.content).join("\n"); - return extractTopics(combined, topN); + return extractTopics(projectedTopicText(where, params, 100), Math.min(Math.max(1, topN), 100)); } diff --git a/src/mcp/channel.test.ts b/src/mcp/channel.test.ts index d2d468e..b8b7d5b 100644 --- a/src/mcp/channel.test.ts +++ b/src/mcp/channel.test.ts @@ -9,6 +9,10 @@ import { createChannel } from "../lib/channels.js"; import { readChannelNotifications, subscribeToChannelNotifications } from "../lib/channel-notifications.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerChannelBridge, setSessionAgent, setClaudeSessionId, getSessionAgent, getClaudeSessionId } from "./channel.js"; +import { enterHermeticTestEnv, installNetworkGuard } from "../test/hermetic.js"; + +let restoreEnv: () => void; +let restoreNetwork: () => void; function createTestDbPath(): string { return join(tmpdir(), `conversations-channel-${Date.now()}-${Math.random().toString(16).slice(2)}.db`); @@ -23,6 +27,11 @@ async function waitFor(check: () => boolean, timeoutMs = 1500): Promise { throw new Error("Timed out waiting for channel bridge condition"); } +beforeEach(() => { + restoreEnv = enterHermeticTestEnv(); + restoreNetwork = installNetworkGuard(); +}); + afterEach(() => { closeDb(); const dbPath = process.env.CONVERSATIONS_DB_PATH; @@ -32,6 +41,8 @@ afterEach(() => { try { unlinkSync(dbPath + "-shm"); } catch {} delete process.env.CONVERSATIONS_DB_PATH; } + restoreNetwork(); + restoreEnv(); }); describe("channel bridge delivery", () => { diff --git a/src/mcp/channel.ts b/src/mcp/channel.ts index 1134b5d..fed105a 100644 --- a/src/mcp/channel.ts +++ b/src/mcp/channel.ts @@ -122,9 +122,11 @@ export function registerChannelBridge( // Poll DMs to this agent — skip messages FROM self (no echoes) if (agent) { - const msgs = (await await getStore().readMessages({ to: agent, unread_only: true, order: "asc", limit: 20 })) - .filter(m => m.id > lastAgentMsgId && m.from_agent !== agent); - for (const msg of msgs) { + const previews = (await getStore().readMessagePreviews({ to: agent, unread_only: true, order: "asc", limit: 20 })).messages + .filter(message => message.id > lastAgentMsgId && message.from_agent !== agent); + for (const preview of previews) { + const msg = await getStore().getMessageById(preview.id); + if (!msg) continue; const delivered = await pushNotification(msg, "dm"); if (!delivered) break; lastAgentMsgId = msg.id; @@ -133,9 +135,11 @@ export function registerChannelBridge( // Poll direct session-targeted messages — skip self (no echoes) if (sid) { - const msgs = (await await getStore().readMessages({ to: `session:${sid}`, unread_only: true, order: "asc", limit: 20 })) - .filter(m => m.id > lastSessionMsgId && m.from_agent !== agent); - for (const msg of msgs) { + const previews = (await getStore().readMessagePreviews({ to: `session:${sid}`, unread_only: true, order: "asc", limit: 20 })).messages + .filter(message => message.id > lastSessionMsgId && message.from_agent !== agent); + for (const preview of previews) { + const msg = await getStore().getMessageById(preview.id); + if (!msg) continue; const delivered = await pushNotification(msg, "direct"); if (!delivered) break; lastSessionMsgId = msg.id; diff --git a/src/mcp/compact.ts b/src/mcp/compact.ts index 9adf1a9..f4f97bd 100644 --- a/src/mcp/compact.ts +++ b/src/mcp/compact.ts @@ -45,7 +45,7 @@ export function compactQueriedMessages(messages: Message[], args: Record void; beforeAll(async () => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; - delete process.env.CONVERSATIONS_AGENT_ID; + restoreEnv = enterHermeticTestEnv({ CONVERSATIONS_DB_PATH: TEST_STORE.dbPath }); closeDb(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -29,9 +27,8 @@ beforeAll(async () => { afterAll(async () => { await client.close(); closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} + restoreEnv(); + TEST_STORE.cleanup(); }); function parseResult(result: { content: unknown[] }): unknown { @@ -314,7 +311,7 @@ describe("channel notification subscription tools", () => { expect(list[0].channel).toBe("notify-channel"); }); - test("reads preview-only notifications and clears them after read_channel", async () => { + test("keeps read_channel pure by default and clears only after explicit mark_read", async () => { createChannel("notify-channel-read", "creator"); await client.callTool({ name: "subscribe_channel_notifications", @@ -343,12 +340,28 @@ describe("channel notification subscription tools", () => { arguments: { from: "watcher-agent", channel: "notify-channel-read", limit: 10 }, }); + const afterPeekResult = await client.callTool({ + name: "read_channel_notifications", + arguments: { from: "watcher-agent" }, + }); + const afterPeekPayload = parseResult(afterPeekResult as any) as any; + expect(afterPeekPayload.count).toBe(1); + expect(getMessageById(sent.id)?.read_at).toBeNull(); + expect(getReadReceipts(sent.id)).toEqual([]); + + await client.callTool({ + name: "read_channel", + arguments: { from: "watcher-agent", channel: "notify-channel-read", limit: 10, mark_read: true }, + }); + const afterReadResult = await client.callTool({ name: "read_channel_notifications", arguments: { from: "watcher-agent" }, }); const afterReadPayload = parseResult(afterReadResult as any) as any; expect(afterReadPayload.count).toBe(0); + expect(getMessageById(sent.id)?.read_at).not.toBeNull(); + expect(getReadReceipts(sent.id).map((receipt) => receipt.agent)).toContain("watcher-agent"); }); }); diff --git a/src/mcp/tools/advanced.test.ts b/src/mcp/tools/advanced.test.ts index 31b2844..d69333b 100644 --- a/src/mcp/tools/advanced.test.ts +++ b/src/mcp/tools/advanced.test.ts @@ -4,18 +4,21 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAdvancedTools } from "./advanced"; import { closeDb } from "../../lib/db"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; +import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../../test/hermetic"; -const TEST_DB = join(tmpdir(), `conversations-test-advanced-mcp-${Date.now()}.db`); +const TEST_STORE = createDisposableStore("advanced-mcp"); describe("advanced MCP tools", () => { let client: Client; + let restoreEnv: () => void; + let restoreNetwork: () => void; beforeAll(async () => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; - process.env.CONVERSATIONS_AGENT_ID = "advanced-test-agent"; + restoreEnv = enterHermeticTestEnv({ + CONVERSATIONS_DB_PATH: TEST_STORE.dbPath, + CONVERSATIONS_AGENT_ID: "advanced-test-agent", + }); + restoreNetwork = installNetworkGuard(); closeDb(); const server = new McpServer({ name: "test-advanced-mcp", version: "0.0.1" }); @@ -28,13 +31,11 @@ describe("advanced MCP tools", () => { }); afterAll(async () => { - delete process.env.CONVERSATIONS_DB_PATH; - delete process.env.CONVERSATIONS_AGENT_ID; - closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} await client.close(); + closeDb(); + restoreNetwork(); + restoreEnv(); + TEST_STORE.cleanup(); }); function parseResult(result: { content: unknown[] }): unknown { @@ -157,12 +158,21 @@ describe("advanced MCP tools", () => { }); describe("get_mentions", () => { - test("returns mentions for agent", async () => { + test("returns preview-only mentions even when verbose is requested", async () => { + const { sendMessage } = await import("../../lib/messages"); + sendMessage({ + from: "mention-sender", + to: "mention-safe-channel", + channel: "mention-safe-channel", + content: "@advanced-test-agent bounded mention body", + }); const result = parseResult(await client.callTool({ name: "get_mentions", - arguments: { agent: "advanced-test-agent" }, + arguments: { agent: "advanced-test-agent", unread_only: false, verbose: true }, }) as any) as any; expect(Array.isArray(result.mentions)).toBe(true); + expect(result.mentions[0].message.preview).toContain("bounded mention"); + expect(result.mentions[0].message.content).toBeUndefined(); }); }); @@ -172,7 +182,7 @@ describe("advanced MCP tools", () => { name: "mark_mentions_read", arguments: { agent: "advanced-test-agent" }, }) as any) as any; - expect(result.cleared).toBe(0); + expect(result.cleared).toBeGreaterThanOrEqual(1); }); }); @@ -340,6 +350,20 @@ describe("advanced MCP tools", () => { }) as any) as any; expect(Array.isArray(result.replies)).toBe(true); }); + + test("thread collections never expose full bodies through verbose compatibility", async () => { + const { sendMessage } = await import("../../lib/messages"); + const parent = sendMessage({ from: "thread-a", to: "thread-b", content: "parent exact-only body" }); + sendMessage({ from: "thread-b", to: "thread-a", content: "reply exact-only body", reply_to: parent.id, session_id: parent.session_id }); + const result = parseResult(await client.callTool({ + name: "get_thread_replies", + arguments: { message_id: parent.id, verbose: true }, + }) as any) as any; + expect(result.parent.preview).toContain("parent exact-only"); + expect(result.parent.content).toBeUndefined(); + expect(result.replies[0].preview).toContain("reply exact-only"); + expect(result.replies[0].content).toBeUndefined(); + }); }); describe("search_tools", () => { diff --git a/src/mcp/tools/advanced.ts b/src/mcp/tools/advanced.ts index 7f9f2dc..4ea6370 100644 --- a/src/mcp/tools/advanced.ts +++ b/src/mcp/tools/advanced.ts @@ -129,37 +129,38 @@ export function registerAdvancedTools(server: McpServer, pkgVersion: string): vo }); server.registerTool("get_mentions", { - description: "Get messages that @mention a specific agent. Useful for catching up on missed pings.", + description: "Get a bounded, redacted page of messages that @mention a specific agent. Use get_message for one exact full body.", inputSchema: { agent: z.string().describe("Agent name to find mentions for"), channel: z.string().optional().describe("Filter to a specific channel"), unread_only: z.coerce.boolean().optional().describe("Only unread (not yet notified) mentions (default: true)"), limit: z.coerce.number().optional().describe("Max results (default: 50)"), cursor: z.coerce.number().optional().describe("Skip first N mention results"), - verbose: z.coerce.boolean().optional().describe("Return full raw mention message records"), + max_bytes: z.coerce.number().optional(), + preview_bytes: z.coerce.number().optional(), + timeout_ms: z.coerce.number().optional(), + verbose: z.coerce.boolean().optional().describe("Deprecated compatibility flag; collections remain preview-only"), }, }, async (args: Record) => { - const window = resolveMcpWindow(args); - const verbose = args.verbose === true; - const results = await getStore().getMessagesForAgent(args.agent as string, { + const page = await getStore().readMessagePreviews({ + mentions_only: args.agent as string, channel: args.channel, unread_only: args.unread_only ?? true, - limit: verbose ? args.limit : window.offset + window.limit + 1, + limit: args.limit, + offset: args.cursor, + order: "desc", + max_bytes: args.max_bytes, + preview_bytes: args.preview_bytes, + timeout_ms: args.timeout_ms, }); - if (verbose) return { content: [{ type: "text", text: jsonText({ mentions: results, count: results.length, compact: false }) }] }; - const page = pageQueriedItems(results.slice(window.offset), window); + const { messages, ...metadata } = page; return { content: [{ type: "text", text: jsonText({ - mentions: page.items.map((item) => ({ mention_id: item.mention_id, message: summarizeMessage(item.message) })), - count: page.count, - limit: page.limit, - cursor: page.cursor, - next_cursor: page.next_cursor, - has_more: page.has_more, - compact: true, - hint: "Use verbose:true for full mention messages or get_message with an id.", + ...metadata, + mentions: messages.map((message) => ({ mention_id: message.id, message })), + hint: "Use get_message with an id for one exact full message.", }), }], }; @@ -402,48 +403,56 @@ export function registerAdvancedTools(server: McpServer, pkgVersion: string): vo // ---- Thread Tools ---- server.registerTool("get_thread_replies", { - description: "Get all replies in a thread for a given parent message ID. Also accessible as read_thread.", + description: "Get a bounded, redacted preview page for a thread. Also accessible as read_thread; use get_message for one exact full body.", inputSchema: { message_id: z.coerce.number(), limit: z.coerce.number().optional(), - verbose: z.coerce.boolean().optional().describe("Return full raw parent/reply message records"), + max_bytes: z.coerce.number().optional(), + preview_bytes: z.coerce.number().optional(), + timeout_ms: z.coerce.number().optional(), + verbose: z.coerce.boolean().optional().describe("Deprecated compatibility flag; collections remain preview-only"), }, }, async (args: Record) => { - let replies = await getStore().getThreadReplies(args.message_id); - if (args.limit) replies = replies.slice(0, args.limit); - const parent = await getStore().getMessageById(args.message_id); - const payload = args.verbose - ? { parent, replies, reply_count: replies.length, compact: false } - : { - parent: parent ? summarizeMessage(parent) : null, - replies: replies.map((reply) => summarizeMessage(reply)), - reply_count: replies.length, - compact: true, - hint: "Use verbose:true for full thread messages or get_message with an id.", - }; + const [parents, replies] = await Promise.all([ + getStore().readMessagePreviews({ id: args.message_id, limit: 1, preview_bytes: args.preview_bytes, timeout_ms: args.timeout_ms }), + getStore().readMessagePreviews({ reply_to: args.message_id, limit: args.limit, order: "asc", max_bytes: args.max_bytes, preview_bytes: args.preview_bytes, timeout_ms: args.timeout_ms }), + ]); + const payload = { + parent: parents.messages[0] ?? null, + replies: replies.messages, + reply_count: replies.count, + next_cursor: replies.next_cursor, + has_more: replies.has_more, + compact: true, + hint: "Use get_message with an id for one exact full message.", + }; return { content: [{ type: "text", text: jsonText(payload) }] }; }); server.registerTool("read_thread", { - description: "Alias for get_thread_replies. Read all replies to a specific message, forming a thread view.", + description: "Alias for get_thread_replies. Read bounded, redacted previews for one thread.", inputSchema: { message_id: z.coerce.number(), limit: z.coerce.number().optional(), - verbose: z.coerce.boolean().optional().describe("Return full raw parent/reply message records"), + max_bytes: z.coerce.number().optional(), + preview_bytes: z.coerce.number().optional(), + timeout_ms: z.coerce.number().optional(), + verbose: z.coerce.boolean().optional().describe("Deprecated compatibility flag; collections remain preview-only"), }, }, async (args: Record) => { - let replies = await getStore().getThreadReplies(args.message_id); - if (args.limit) replies = replies.slice(0, args.limit); - const parent = await getStore().getMessageById(args.message_id); - const payload = args.verbose - ? { parent, replies, reply_count: replies.length, compact: false } - : { - parent: parent ? summarizeMessage(parent) : null, - replies: replies.map((reply) => summarizeMessage(reply)), - reply_count: replies.length, - compact: true, - hint: "Use verbose:true for full thread messages or get_message with an id.", - }; + const [parents, replies] = await Promise.all([ + getStore().readMessagePreviews({ id: args.message_id, limit: 1, preview_bytes: args.preview_bytes, timeout_ms: args.timeout_ms }), + getStore().readMessagePreviews({ reply_to: args.message_id, limit: args.limit, order: "asc", max_bytes: args.max_bytes, preview_bytes: args.preview_bytes, timeout_ms: args.timeout_ms }), + ]); + const payload = { + parent: parents.messages[0] ?? null, + replies: replies.messages, + reply_count: replies.count, + next_cursor: replies.next_cursor, + has_more: replies.has_more, + compact: true, + hint: "Use get_message with an id for one exact full message.", + }; return { content: [{ type: "text", text: jsonText(payload) }] }; }); @@ -495,21 +504,21 @@ export function registerAdvancedTools(server: McpServer, pkgVersion: string): vo const descriptions: Record = { // DM tools send_message: "Send DM to agent. Required: to, content. Optional: from?, priority?(low|normal|high|urgent), blocking?", - read_messages: "Read messages with filters. Optional: session_id?, from?, to?, channel?, since?(ISO), limit?, unread_only?, mark_read?(default true \u2014 auto-marks returned messages as read, pass false to peek without consuming)", + read_messages: "Read a bounded, redacted preview page with filters. Pure peek by default; mark_read:true explicitly acknowledges returned IDs. Optional: session_id?, from?, to?, channel?, since?(ISO), limit?, cursor?, unread_only?, max_bytes?, timeout_ms?", get_message: "Get the full content of a specific message by id. Required: id", read_digest: "Cursored byte-capped digest — preview snippets only, no full bodies, non-destructive unless mark_read:true. Returns { digest_id, message_ids, next_cursor, messages, byte_length }. Optional: channel?, session_id?, to?, since?(ISO), cursor?(message id), max_bytes?, limit?, unread_only?, mark_read?, project_id?", list_sessions: "List all DM sessions. Optional: agent?(filter by participant)", reply: "Reply to a specific message, creating a thread (sets reply_to). Use read_thread to retrieve. Required: message_id, content. Optional: from?", mark_read: "Mark messages as read. Optional: from?, ids?(array), all?(bool \u2014 mark all unread)", mark_channel_read: "Mark ALL messages in a channel as read without fetching. Required: channel. Optional: from?", - search_messages: "Full-text search messages. Required: query. Optional: channel?, from?, to?, limit?", + search_messages: "Search messages and return bounded, redacted previews. Required: query. Optional: channel?, from?, to?, limit?, cursor?, max_bytes?, timeout_ms?", export_messages: "Export messages as JSON or CSV. Optional: channel?, session_id?, from?, since?, until?, format?(json|csv)", // Channel tools create_channel: "Create channel and auto-join. Required: name. Optional: from?, description?, topic?, project_id?", list_unread_counts: "Get unread message counts per channel (no content). Ideal for session start triage. Optional: agent?(filter to agent's channels)", list_channels: "List channels with member/message counts. Optional: project_id?, include_archived?", send_to_channel: "Post message to channel. Required: channel, content. Optional: from?, priority?(low|normal|high|urgent), blocking?", - read_channel: "Read messages in a channel. Required: channel. Optional: since?(ISO), limit?, mark_read?(default true \u2014 auto-marks returned messages as read)", + read_channel: "Peek at a bounded, redacted channel preview page. Pure by default; mark_read:true explicitly records receipts for returned IDs. Required: channel. Optional: since?(ISO), limit?, cursor?, max_bytes?, timeout_ms?", join_channel: "Join a channel. Required: channel. Optional: from?", leave_channel: "Leave a channel. Required: channel. Optional: from?", update_channel: "Update channel fields. Required: name. Optional: description?, topic?(use 'null' to remove), project_id?(use 'null' to remove)", diff --git a/src/mcp/tools/agents.test.ts b/src/mcp/tools/agents.test.ts index cc5aa09..09140a9 100644 --- a/src/mcp/tools/agents.test.ts +++ b/src/mcp/tools/agents.test.ts @@ -5,20 +5,20 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAgentTools } from "./agents"; import { closeDb } from "../../lib/db"; import { resolveIdentity, _resetAutoName } from "../../lib/identity"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; +import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../../test/hermetic"; -const TEST_DB = join(tmpdir(), `conversations-test-agents-mcp-${Date.now()}.db`); +const TEST_STORE = createDisposableStore("agents-mcp"); describe("agent MCP tools", () => { let client: Client; + let restoreEnv: () => void; + let restoreNetwork: () => void; let agentFocus: Map; const getAgentFocus = async (agentId: string) => agentFocus.get(agentId)?.project_id ?? null; beforeAll(async () => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; - delete process.env.CONVERSATIONS_AGENT_ID; + restoreEnv = enterHermeticTestEnv({ CONVERSATIONS_DB_PATH: TEST_STORE.dbPath }); + restoreNetwork = installNetworkGuard(); closeDb(); const server = new McpServer({ name: "test-agents-mcp", version: "0.0.1" }); @@ -32,12 +32,11 @@ describe("agent MCP tools", () => { }); afterAll(async () => { - delete process.env.CONVERSATIONS_DB_PATH; - closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} await client.close(); + closeDb(); + restoreNetwork(); + restoreEnv(); + TEST_STORE.cleanup(); }); function parseResult(result: { content: unknown[] }): unknown { diff --git a/src/mcp/tools/agents.ts b/src/mcp/tools/agents.ts index 3381612..4c91307 100644 --- a/src/mcp/tools/agents.ts +++ b/src/mcp/tools/agents.ts @@ -8,7 +8,7 @@ import { z } from "zod"; import { getStore } from "../../lib/store/index.js"; import { resolveIdentity, updateCachedAutoName } from "../../lib/identity.js"; import { setSessionAgent, setClaudeSessionId } from "../channel.js"; -import { compactQueriedMessages, compactWindowedAgents, jsonText, resolveMcpWindow } from "../compact.js"; +import { compactWindowedAgents, jsonText } from "../compact.js"; export function registerAgentTools( server: McpServer, @@ -228,24 +228,29 @@ export function registerAgentTools( }); server.registerTool("get_blockers", { - description: "Check for unread blocking messages.", + description: "Peek at a bounded, redacted page of unread blocking-message previews. Incident and security bodies never cross this collection path.", inputSchema: { from: z.string().optional(), limit: z.coerce.number().optional(), cursor: z.coerce.number().optional(), - verbose: z.coerce.boolean().optional().describe("Return full raw blocker messages instead of previews"), + preview_bytes: z.coerce.number().optional().describe("Maximum bytes per redacted preview (hard-capped by the server)"), + max_bytes: z.coerce.number().optional().describe("Maximum bytes for the entire response envelope"), + timeout_ms: z.coerce.number().optional().describe("Maximum collection-query time in milliseconds (hard-capped by the server)"), + verbose: z.coerce.boolean().optional().describe("Deprecated compatibility flag; blocker collections remain preview-only"), }, }, async (args: Record) => { const { from: fromParam } = args; const agent = resolveIdentity(fromParam); - const window = resolveMcpWindow(args); - const blockers = await getStore().getUnreadBlockers( - agent, - args.verbose ? undefined : { limit: window.limit + 1, offset: window.offset }, - ); + const page = await getStore().getUnreadBlockerPreviews(agent, { + limit: args.limit, + offset: args.cursor, + preview_bytes: args.preview_bytes, + max_bytes: args.max_bytes, + timeout_ms: args.timeout_ms, + }); return { - content: [{ type: "text", text: jsonText(args.verbose ? blockers : compactQueriedMessages(blockers, args)) }], + content: [{ type: "text", text: jsonText(page) }], }; }); } diff --git a/src/mcp/tools/channels.test.ts b/src/mcp/tools/channels.test.ts index 06048cb..0349d85 100644 --- a/src/mcp/tools/channels.test.ts +++ b/src/mcp/tools/channels.test.ts @@ -4,18 +4,22 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerChannelTools } from "./channels"; import { closeDb } from "../../lib/db"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; +import { getMessageById, getReadReceipts } from "../../lib/messages"; +import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../../test/hermetic"; -const TEST_DB = join(tmpdir(), `conversations-test-channels-mcp-${Date.now()}.db`); +const TEST_STORE = createDisposableStore("channels-mcp"); describe("channels MCP tools", () => { let client: Client; + let restoreEnv: () => void; + let restoreNetwork: () => void; beforeAll(async () => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; - process.env.CONVERSATIONS_AGENT_ID = "channels-test-agent"; + restoreEnv = enterHermeticTestEnv({ + CONVERSATIONS_DB_PATH: TEST_STORE.dbPath, + CONVERSATIONS_AGENT_ID: "channels-test-agent", + }); + restoreNetwork = installNetworkGuard(); closeDb(); const server = new McpServer({ name: "test-channels-mcp", version: "0.0.1" }); @@ -28,13 +32,11 @@ describe("channels MCP tools", () => { }); afterAll(async () => { - delete process.env.CONVERSATIONS_DB_PATH; - delete process.env.CONVERSATIONS_AGENT_ID; - closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} await client.close(); + closeDb(); + restoreNetwork(); + restoreEnv(); + TEST_STORE.cleanup(); }); function parseResult(result: { content: unknown[] }): unknown { @@ -152,20 +154,25 @@ describe("channels MCP tools", () => { expect(result.count).toBeLessThanOrEqual(1); }); - test("reads with mark_read=false", async () => { - const result = parseResult(await client.callTool({ + test("defaults to a pure peek and records receipts only with mark_read:true", async () => { + const sent = parseResult(await client.callTool({ + name: "send_to_channel", + arguments: { channel: "test-channel-1", content: "explicit acknowledgement fixture", from: "ack-sender" }, + }) as any) as any; + const peek = parseResult(await client.callTool({ name: "read_channel", - arguments: { channel: "test-channel-1", mark_read: false }, + arguments: { channel: "test-channel-1", from: "reader-agent" }, }) as any) as any; - expect(Array.isArray(result.messages)).toBe(true); - }); + expect(Array.isArray(peek.messages)).toBe(true); + expect(getMessageById(sent.id)?.read_at).toBeNull(); + expect(getReadReceipts(sent.id)).toEqual([]); - test("records per-agent read receipts", async () => { - const result = parseResult(await client.callTool({ + await client.callTool({ name: "read_channel", arguments: { channel: "test-channel-1", from: "reader-agent", mark_read: true }, - }) as any) as any; - expect(Array.isArray(result.messages)).toBe(true); + }); + expect(getMessageById(sent.id)?.read_at).not.toBeNull(); + expect(getReadReceipts(sent.id).map((receipt) => receipt.agent)).toContain("reader-agent"); }); test("supports threads_only and include_reply_counts", async () => { diff --git a/src/mcp/tools/channels.ts b/src/mcp/tools/channels.ts index 183adaf..f8ececc 100644 --- a/src/mcp/tools/channels.ts +++ b/src/mcp/tools/channels.ts @@ -13,7 +13,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { getStore } from "../../lib/store/index.js"; import { resolveIdentity } from "../../lib/identity.js"; -import { compactQueriedMessages, compactWindowedChannels, jsonText, resolveMcpWindow } from "../compact.js"; +import { compactWindowedChannels, jsonText } from "../compact.js"; export function registerChannelTools(server: McpServer): void { @@ -119,50 +119,51 @@ export function registerChannelTools(server: McpServer): void { }); server.registerTool("read_channel", { - description: "Read messages from a channel.", + description: "Peek at a bounded, redacted page of channel message previews. Non-mutating unless mark_read:true is explicit; use get_message for one exact full body.", inputSchema: { channel: z.string(), from: z.string().optional().describe("Agent reading the channel — used for per-agent read receipts"), since: z.string().optional(), limit: z.coerce.number().optional(), mark_read: z.coerce.boolean().optional(), - max_content_length: z.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)"), + max_content_length: z.coerce.number().optional().describe("Deprecated compatibility alias; channel collections are always preview-only"), + preview_bytes: z.coerce.number().optional().describe("Maximum bytes per redacted preview (hard-capped by the server)"), + max_bytes: z.coerce.number().optional().describe("Maximum bytes for the entire response envelope"), + timeout_ms: z.coerce.number().optional().describe("Maximum collection-query time in milliseconds (hard-capped by the server)"), threads_only: z.coerce.boolean().optional().describe("Only return root messages (hides thread replies)"), include_reply_counts: z.coerce.boolean().optional().describe("Include reply_count on each message"), latest: z.coerce.number().optional().describe("Return the N most recent messages, newest first"), cursor: z.coerce.number().optional().describe("Alias for offset pagination"), - verbose: z.coerce.boolean().optional().describe("Return full raw message records instead of compact previews"), + verbose: z.coerce.boolean().optional().describe("Deprecated compatibility flag; channel collections remain preview-only"), }, }, async (args: Record) => { const store = getStore(); const { channel, from: fromParam, since, limit, mark_read, max_content_length, threads_only, include_reply_counts, latest } = args; - const window = resolveMcpWindow(args); - const verbose = args.verbose === true; - const messages = await store.readMessages({ + const page = await store.readMessagePreviews({ channel, since, - limit: verbose ? limit : window.limit + 1, - offset: verbose ? args.cursor : window.offset, - max_content_length: verbose ? max_content_length : undefined, + limit, + offset: args.cursor, + preview_bytes: args.preview_bytes ?? max_content_length, + max_bytes: args.max_bytes, + timeout_ms: args.timeout_ms, threads_only, include_reply_counts, latest, }); - const visible = verbose ? messages : messages.slice(0, window.limit); - if (mark_read !== false && visible.length > 0) { - await store.markReadByIds(visible.map((m) => m.id)); - } - - // Record per-agent read receipts for all channel messages - if (fromParam && visible.length > 0) { + if (mark_read === true && page.messages.length > 0) { const agent = resolveIdentity(fromParam); - await store.recordReadReceiptsBatch(visible.map((m) => m.id), agent); - await store.markChannelNotificationsRead(agent, visible.map((m) => m.id)); + const ids = page.messages.map((message) => message.id); + await store.markReadByIds(ids, agent); + if (fromParam) { + await store.recordReadReceiptsBatch(ids, agent); + await store.markChannelNotificationsRead(agent, ids); + } } return { - content: [{ type: "text", text: jsonText(verbose ? messages : compactQueriedMessages(messages, args)) }], + content: [{ type: "text", text: jsonText(page) }], }; }); @@ -429,8 +430,8 @@ export function registerChannelTools(server: McpServer): void { const { channel, since, limit } = args; const sinceTs = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - // Newest-first window through the Store (no direct sqlite). - const rows = await store.readMessages({ channel, since: sinceTs, latest: limit ?? 100 }); + // Newest-first bounded projection through the Store (no full bodies). + const rows = (await store.readMessagePreviews({ channel, since: sinceTs, latest: limit ?? 100 })).messages; const total = rows.length; if (total === 0) { @@ -448,10 +449,10 @@ export function registerChannelTools(server: McpServer): void { agents.add(from); agentCounts[from] = (agentCounts[from] ?? 0) + 1; if (m.blocking) { - blockers.push({ id: m.id, from, content: m.content.slice(0, 150), created_at: m.created_at }); + blockers.push({ id: m.id, from, content: m.preview.slice(0, 150), created_at: m.created_at }); } // Count @mentions - const mentionedAgents = m.content.match(/@([a-zA-Z0-9_-]+)/g) ?? []; + const mentionedAgents = m.preview.match(/@([a-zA-Z0-9_-]+)/g) ?? []; for (const mention of mentionedAgents) { const a = mention.slice(1).toLowerCase(); mentions[a] = (mentions[a] ?? 0) + 1; @@ -480,7 +481,7 @@ export function registerChannelTools(server: McpServer): void { if (highPri.length > 0) { parts.push(`\n🔴 High priority (${highPri.length}):`); for (const m of highPri) { - parts.push(` • [${m.priority}] ${m.from_agent}: ${m.content.slice(0, 100)}`); + parts.push(` • [${m.priority}] ${m.from_agent}: ${m.preview.slice(0, 100)}`); } } diff --git a/src/mcp/tools/messaging.test.ts b/src/mcp/tools/messaging.test.ts index 328cedf..da199b8 100644 --- a/src/mcp/tools/messaging.test.ts +++ b/src/mcp/tools/messaging.test.ts @@ -4,13 +4,11 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerMessagingTools } from "./messaging"; import { createChannel } from "../../lib/channels"; -import { sendMessage } from "../../lib/messages"; +import { getMessageById, getReadReceipts, sendMessage } from "../../lib/messages"; import { closeDb } from "../../lib/db"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; +import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../../test/hermetic"; -const TEST_DB = join(tmpdir(), `conversations-test-messaging-mcp-${Date.now()}.db`); +const TEST_STORE = createDisposableStore("messaging-mcp"); async function resolveProjectId(explicit: string | undefined, _agent: string): Promise { return explicit; @@ -18,10 +16,15 @@ async function resolveProjectId(explicit: string | undefined, _agent: string): P describe("messaging MCP tools", () => { let client: Client; + let restoreEnv: () => void; + let restoreNetwork: () => void; beforeAll(async () => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; - process.env.CONVERSATIONS_AGENT_ID = "messaging-test-agent"; + restoreEnv = enterHermeticTestEnv({ + CONVERSATIONS_DB_PATH: TEST_STORE.dbPath, + CONVERSATIONS_AGENT_ID: "messaging-test-agent", + }); + restoreNetwork = installNetworkGuard(); closeDb(); const server = new McpServer({ name: "test-messaging-mcp", version: "0.0.1" }); @@ -34,13 +37,11 @@ describe("messaging MCP tools", () => { }); afterAll(async () => { - delete process.env.CONVERSATIONS_DB_PATH; - delete process.env.CONVERSATIONS_AGENT_ID; - closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} await client.close(); + closeDb(); + restoreNetwork(); + restoreEnv(); + TEST_STORE.cleanup(); }); function parseResult(result: { content: unknown[] }): unknown { @@ -111,27 +112,41 @@ describe("messaging MCP tools", () => { expect(result.count).toBeLessThanOrEqual(5); }); - test("returns compact previews by default and full content with verbose", async () => { + test("keeps collections preview-only and uses get_message for one exact full body", async () => { const long = `compact preview ${"x".repeat(220)} tail-visible-only-in-verbose`; - await client.callTool({ + const sent = parseResult(await client.callTool({ name: "send_message", arguments: { to: "compact-reader", content: long }, - }); + }) as any) as any; const compact = parseResult(await client.callTool({ name: "read_messages", - arguments: { to: "compact-reader", mark_read: false }, + arguments: { to: "compact-reader" }, }) as any) as any; expect(compact.compact).toBe(true); expect(compact.messages[0].preview).toContain("compact preview"); expect(compact.messages[0].content).toBeUndefined(); + expect(getMessageById(sent.id)?.read_at).toBeNull(); + expect(getReadReceipts(sent.id)).toEqual([]); const verbose = parseResult(await client.callTool({ name: "read_messages", - arguments: { to: "compact-reader", verbose: true, mark_read: false }, + arguments: { to: "compact-reader", verbose: true }, }) as any) as any; - expect(verbose.compact).toBe(false); - expect(verbose.messages.some((m: any) => m.content.includes("tail-visible-only-in-verbose"))).toBe(true); + expect(verbose.compact).toBe(true); + expect(verbose.messages.every((message: any) => message.content === undefined)).toBe(true); + + const exact = parseResult(await client.callTool({ + name: "get_message", + arguments: { id: sent.id }, + }) as any) as any; + expect(exact.content).toBe(long); + + await client.callTool({ + name: "read_messages", + arguments: { to: "compact-reader", mark_read: true }, + }); + expect(getMessageById(sent.id)?.read_at).not.toBeNull(); }); test("supports latest param", async () => { diff --git a/src/mcp/tools/messaging.ts b/src/mcp/tools/messaging.ts index bcbc61f..be0b14a 100644 --- a/src/mcp/tools/messaging.ts +++ b/src/mcp/tools/messaging.ts @@ -11,7 +11,7 @@ import { getStore } from "../../lib/store/index.js"; // Reads/writes route through getStore(): ApiStore when HASNA_CONVERSATIONS_API_URL // + _API_KEY are set (self_hosted/cloud), else LocalStore. import { resolveIdentity } from "../../lib/identity.js"; -import { compactQueriedMessages, compactQueriedSearchMessages, compactWindowedSessions, jsonText, resolveMcpWindow } from "../compact.js"; +import { compactWindowedSessions, jsonText } from "../compact.js"; export function registerMessagingTools( server: McpServer, @@ -98,7 +98,7 @@ export function registerMessagingTools( }); server.registerTool("read_messages", { - description: "Read DMs with optional filters.", + description: "Peek at a bounded, redacted page of message previews. This is non-mutating unless mark_read:true is explicit; use get_message for one exact full body.", inputSchema: { session_id: z.string().optional(), from: z.string().optional(), @@ -109,36 +109,45 @@ export function registerMessagingTools( limit: z.coerce.number().optional(), unread_only: z.coerce.boolean().optional(), mark_read: z.coerce.boolean().optional(), - max_content_length: z.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)"), + max_content_length: z.coerce.number().optional().describe("Deprecated compatibility alias; collection bodies are always preview-only"), + preview_bytes: z.coerce.number().optional().describe("Maximum bytes per redacted preview (hard-capped by the server)"), + max_bytes: z.coerce.number().optional().describe("Maximum bytes for the entire response envelope"), + timeout_ms: z.coerce.number().optional().describe("Maximum collection-query time in milliseconds (hard-capped by the server)"), threads_only: z.coerce.boolean().optional().describe("Only return root messages (reply_to IS NULL) — hides thread replies"), include_reply_counts: z.coerce.boolean().optional().describe("Include reply_count on each message (adds one extra query)"), mentions_only: z.string().optional().describe("Only return messages that @mention this agent"), latest: z.coerce.number().optional().describe("Return the N most recent unread messages, newest first. Shorthand for order:desc + limit:N."), offset: z.coerce.number().optional().describe("Skip first N messages for pagination (use with limit)"), cursor: z.coerce.number().optional().describe("Alias for offset"), - verbose: z.coerce.boolean().optional().describe("Return full raw message records instead of compact previews"), + verbose: z.coerce.boolean().optional().describe("Deprecated compatibility flag; collection reads remain preview-only"), }, }, async (args: Record) => { const agent = resolveIdentity(args.from); - const window = resolveMcpWindow(args); - const verbose = args.verbose === true; - const messages = await await getStore().readMessages({ - ...args, - limit: verbose ? args.limit : window.limit + 1, - offset: verbose ? (args.offset ?? args.cursor) : window.offset, + const page = await getStore().readMessagePreviews({ + session_id: args.session_id, + from: args.from, + to: args.to, + channel: args.channel, project_id: args.project_id ?? (await resolveProjectId(undefined, agent)), + since: args.since, + limit: args.limit, + unread_only: args.unread_only, + threads_only: args.threads_only, + include_reply_counts: args.include_reply_counts, + mentions_only: args.mentions_only, + latest: args.latest, + offset: args.offset ?? args.cursor, + preview_bytes: args.preview_bytes ?? args.max_content_length, + max_bytes: args.max_bytes, + timeout_ms: args.timeout_ms, }); - if (args.mark_read !== false && messages.length > 0) { - const visible = verbose ? messages : messages.slice(0, window.limit); - await await getStore().markReadByIds(visible.map((m) => m.id), agent); + if (args.mark_read === true && page.messages.length > 0) { + await getStore().markReadByIds(page.messages.map((message) => message.id), agent); } - const payload = verbose - ? { messages, count: messages.length, offset: args.offset ?? args.cursor ?? 0, compact: false } - : compactQueriedMessages(messages, args); return { - content: [{ type: "text", text: jsonText(payload) }], + content: [{ type: "text", text: jsonText(page) }], }; }); @@ -273,7 +282,7 @@ export function registerMessagingTools( }); server.registerTool("search_messages", { - description: "Full-text search across messages. Uses FTS5 with BM25 ranking if available, falls back to LIKE. Returns messages with snippet and relevance_score.", + description: "Search messages and return a bounded, redacted preview page. Full bodies never cross this collection path; use get_message for one exact result.", inputSchema: { query: z.string().describe("Search query. Wrap in quotes for exact phrase: '\"BUG-005\"'"), channel: z.string().optional().describe("Limit to a specific channel"), @@ -284,13 +293,14 @@ export function registerMessagingTools( sort: z.enum(["relevance", "recent"]).optional().describe("Sort order (default: relevance)"), limit: z.coerce.number().optional().describe("Max results (default: 20)"), cursor: z.coerce.number().optional().describe("Skip first N results for pagination"), - verbose: z.coerce.boolean().optional().describe("Return full raw message records instead of compact previews"), + preview_bytes: z.coerce.number().optional().describe("Maximum bytes per redacted preview (hard-capped by the server)"), + max_bytes: z.coerce.number().optional().describe("Maximum bytes for the entire response envelope"), + timeout_ms: z.coerce.number().optional().describe("Maximum collection-query time in milliseconds (hard-capped by the server)"), + verbose: z.coerce.boolean().optional().describe("Deprecated compatibility flag; collection searches remain preview-only"), }, }, async (args: Record) => { const { query, channel, from, to, since, until, sort } = args; - const window = resolveMcpWindow(args); - const verbose = args.verbose === true; - const results = await await getStore().searchMessages({ + const page = await getStore().searchMessagePreviews({ query, channel, from, @@ -298,15 +308,16 @@ export function registerMessagingTools( since, until, sort, - limit: verbose ? args.limit : window.limit + 1, - offset: verbose ? args.cursor : window.offset, + limit: args.limit, + offset: args.cursor, + preview_bytes: args.preview_bytes, + max_bytes: args.max_bytes, + timeout_ms: args.timeout_ms, }); + const { messages, ...pageMetadata } = page; - const payload = verbose - ? { results, count: results.length, query, compact: false } - : compactQueriedSearchMessages(results, args); return { - content: [{ type: "text", text: jsonText(payload) }], + content: [{ type: "text", text: jsonText({ ...pageMetadata, results: messages }) }], }; }); @@ -467,28 +478,33 @@ export function registerMessagingTools( }); server.registerTool("get_pinned_messages", { - description: "Get pinned messages by channel or session.", + description: "Get a bounded, redacted page of pinned-message previews by channel or session.", inputSchema: { channel: z.string().optional(), session_id: z.string().optional(), limit: z.coerce.number().optional(), cursor: z.coerce.number().optional(), - verbose: z.coerce.boolean().optional().describe("Return full raw message records instead of compact previews"), + preview_bytes: z.coerce.number().optional(), + max_bytes: z.coerce.number().optional(), + timeout_ms: z.coerce.number().optional(), + verbose: z.coerce.boolean().optional().describe("Deprecated compatibility flag; collections remain preview-only"), }, }, async (args: Record) => { const { channel, session_id } = args; - const window = resolveMcpWindow(args); - const verbose = args.verbose === true; - const messages = await await getStore().getPinnedMessages({ + const page = await getStore().readMessagePreviews({ + pinned_only: true, channel, session_id, - limit: verbose ? args.limit : window.limit + 1, - offset: verbose ? args.cursor : window.offset, + limit: args.limit, + offset: args.cursor, + order: "desc", + preview_bytes: args.preview_bytes, + max_bytes: args.max_bytes, + timeout_ms: args.timeout_ms, }); - const payload = verbose ? messages : compactQueriedMessages(messages, args); return { - content: [{ type: "text", text: jsonText(payload) }], + content: [{ type: "text", text: jsonText(page) }], }; }); diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 565890f..553886c 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -6,6 +6,12 @@ export interface Message { "id"?: number; "uuid"?: string; "session_id"?: string; "from_agent"?: string; "to_agent"?: string; "channel"?: string | null; "project_id"?: string | null; "content"?: string; "priority"?: string; "blocking"?: boolean; "reply_to"?: number | null; "working_dir"?: string | null; "repository"?: string | null; "branch"?: string | null; "metadata"?: Record | null; "attachments"?: Array> | null; "created_at"?: string } +export interface MessagePreview { "id": number; "uuid"?: string; "session_id": string; "from_agent": string; "to_agent": string; "channel": string | null; "project_id": string | null; "priority": "low" | "normal" | "high" | "urgent"; "working_dir": string | null; "repository": string | null; "branch": string | null; "created_at": string; "edited_at": string | null; "pinned_at": string | null; "unread": boolean; "blocking": boolean; "reply_to": number | null; "reply_count"?: number; "attachment_count": number; "has_attachments": boolean; "has_metadata": boolean; "preview": string; "preview_bytes": number; "content_bytes": number; "truncated": boolean; "redacted": boolean; "relevance_score"?: number } + +export interface MessagePreviewPage { "messages": Array; "count": number; "limit": number; "cursor": number; "next_cursor": number | null; "has_more": boolean; "skipped_count": number; "byte_length": number; "max_bytes": number; "timeout_ms": number; "compact": true; "detail_path": "messages/{id}"; "query"?: string } + +export interface MessageResponse { "message": Message } + export interface Channel { "name"?: string; "description"?: string | null; "topic"?: string | null; "project_id"?: string | null; "created_by"?: string; "created_at"?: string; "archived_at"?: string | null } export interface Project { "id"?: string; "name"?: string; "description"?: string | null; "path"?: string | null; "repository"?: string | null; "created_by"?: string; "status"?: string; "created_at"?: string } @@ -169,8 +175,8 @@ export class ConversationsClient { }); } - /** List messages */ - async listMessages(query?: { "to"?: string; "from"?: string; "channel"?: string; "session"?: string; "limit"?: number; "count"?: boolean }, init?: RequestInit): Promise> { + /** List bounded, redacted message previews */ + async listMessages(query?: { "to"?: string; "from"?: string; "channel"?: string; "session"?: string; "limit"?: number; "offset"?: number; "order"?: "asc" | "desc"; "q"?: string; "unread_only"?: boolean; "threads_only"?: boolean; "pinned_only"?: boolean; "reply_to"?: number; "max_bytes"?: number; "preview_bytes"?: number; "timeout_ms"?: number }, init?: RequestInit): Promise { return this.request("GET", `/v1/messages`, { body: undefined, query, @@ -187,8 +193,8 @@ export class ConversationsClient { }); } - /** List canonical current blockers visible to one agent */ - async listUnreadBlockers(query?: { "agent": string; "limit"?: number; "offset"?: number }, init?: RequestInit): Promise> { + /** List bounded, redacted current-blocker previews visible to one agent */ + async listUnreadBlockers(query?: { "agent": string; "limit"?: number; "offset"?: number; "max_bytes"?: number; "preview_bytes"?: number; "timeout_ms"?: number }, init?: RequestInit): Promise { return this.request("GET", `/v1/messages/blockers`, { body: undefined, query, @@ -205,7 +211,8 @@ export class ConversationsClient { }); } - async getMessage(id: number, init?: RequestInit): Promise> { + /** Get one exact full message */ + async getMessage(id: number, init?: RequestInit): Promise { return this.request("GET", `/v1/messages/${encodeURIComponent(String(id))}`, { body: undefined, query: undefined, diff --git a/src/sdk/message-preview.test.ts b/src/sdk/message-preview.test.ts new file mode 100644 index 0000000..2583ca4 --- /dev/null +++ b/src/sdk/message-preview.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { + ConversationsClient, + type MessagePreviewPage, + type MessageResponse, +} from "./index"; + +const previewPage = { + messages: [{ + id: 41, + session_id: "channel:engineering", + from_agent: "alice", + to_agent: "engineering", + channel: "engineering", + project_id: null, + priority: "normal", + working_dir: null, + repository: null, + branch: null, + created_at: "2026-07-19T00:00:00.000Z", + edited_at: null, + pinned_at: null, + unread: true, + blocking: false, + reply_to: null, + attachment_count: 0, + has_attachments: false, + has_metadata: false, + preview: "bounded coordination update", + preview_bytes: 27, + content_bytes: 27, + truncated: false, + redacted: false, + }], + count: 1, + limit: 20, + cursor: 0, + next_cursor: null, + has_more: false, + skipped_count: 0, + byte_length: 512, + max_bytes: 4096, + timeout_ms: 1000, + compact: true, + detail_path: "messages/{id}", +} satisfies MessagePreviewPage; + +describe("generated safe message-read client", () => { + test("types list/blocker reads as preview pages and keeps exact get typed separately", async () => { + const requests: string[] = []; + const exact: MessageResponse = { message: { id: 41, content: "exact coordination update" } }; + const client = new ConversationsClient({ + baseUrl: "https://conversations.invalid", + fetch: (async (input: string | URL | Request) => { + const url = String(input); + requests.push(url); + const body = url.endsWith("/v1/messages/41") ? exact : previewPage; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch, + }); + + const listed: MessagePreviewPage = await client.listMessages({ + limit: 10, + max_bytes: 4096, + preview_bytes: 128, + timeout_ms: 1000, + }); + const blockers: MessagePreviewPage = await client.listUnreadBlockers({ agent: "alice", limit: 5 }); + const detail: MessageResponse = await client.getMessage(41); + + expect(listed.messages[0].preview).toContain("bounded"); + expect("content" in listed.messages[0]).toBe(false); + expect(blockers.compact).toBe(true); + expect(detail.message.content).toBe("exact coordination update"); + expect(requests[0]).toContain("max_bytes=4096"); + expect(requests[0]).toContain("preview_bytes=128"); + expect(requests[2]).toEndWith("/v1/messages/41"); + }); +}); diff --git a/src/server/api.test.ts b/src/server/api.test.ts index 4753ddf..c51a2ed 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -3,21 +3,57 @@ import { startApiServer, type ApiServerDeps } from "./api.js"; import { mintApiKey } from "@hasna/contracts/auth"; import { verifyApiKey, ApiKeyStore } from "@hasna/contracts/auth"; import { readFileSync } from "node:fs"; +import { enterHermeticTestEnv, installNetworkGuard } from "../test/hermetic.js"; // In-memory query shim standing in for the vendored kit's TypedQueryClient. // Exercises the router + auth without a live Postgres. function makeFakeClient(incidentProjectionCount = 0) { const channels: Record = {}; const messages: any[] = []; + const queries: string[] = []; let nextId = 1; const client = { async many(sql: string, _p: readonly unknown[] = []): Promise { + queries.push(sql); if (/FROM channels/i.test(sql)) return Object.values(channels); - if (/FROM messages/i.test(sql)) return messages.slice().reverse(); + if (/FROM messages/i.test(sql)) { + const rows = messages.slice().reverse(); + if (/preview_source/i.test(sql)) { + return rows.map((message) => { + const scope = [message.channel, message.to_agent, message.session_id].join(" ").toLowerCase(); + const restricted = scope.includes("incident") || scope.includes("security"); + return { + id: message.id, + uuid: message.uuid, + session_id: message.session_id, + from_agent: message.from_agent, + to_agent: message.to_agent, + channel: message.channel, + project_id: message.project_id, + priority: message.priority, + blocking: message.blocking, + reply_to: message.reply_to, + working_dir: message.working_dir, + repository: message.repository, + branch: message.branch, + created_at: message.created_at, + read_at: message.read_at ?? null, + edited_at: message.edited_at ?? null, + pinned_at: message.pinned_at ?? null, + has_metadata: Boolean(message.metadata), + attachment_count: Array.isArray(message.attachments) ? message.attachments.length : 0, + preview_source: restricted ? "" : String(message.content ?? "").slice(0, 4096), + content_bytes: Buffer.byteLength(String(message.content ?? "")), + }; + }); + } + return rows; + } if (/revoked_at IS NOT NULL/i.test(sql)) return []; return []; }, async query(sql: string, p: readonly unknown[] = []): Promise<{ rows: any[]; rowCount: number }> { + queries.push(sql); if (/INSERT INTO messages/i.test(sql) && /ON CONFLICT/i.test(sql)) { // One COALESCE(...) is emitted per row (for created_at) → row count. const numRows = (sql.match(/COALESCE\(/g) || []).length || 1; @@ -38,6 +74,7 @@ function makeFakeClient(incidentProjectionCount = 0) { return { rows: [], rowCount: 0 }; }, async get(sql: string, p: readonly unknown[] = []): Promise { + queries.push(sql); if (/SELECT 1 AS ok/i.test(sql)) return { ok: 1 }; if (/count\(\*\).*incident_projections/is.test(sql)) return { n: incidentProjectionCount }; if (/count\(\*\)/i.test(sql)) return { n: messages.length }; @@ -56,6 +93,9 @@ function makeFakeClient(incidentProjectionCount = 0) { if (/SELECT id, session_id, channel, project_id FROM messages WHERE id/i.test(sql)) { return messages.find((m) => m.id === Number((p as any[])[0])) ?? null; } + if (/SELECT \* FROM messages WHERE id/i.test(sql)) { + return messages.find((m) => m.id === Number((p as any[])[0])) ?? null; + } if (/INSERT INTO messages/i.test(sql)) { const [ session_id, from_agent, to_agent, channel, project_id, content, priority, blocking, @@ -65,16 +105,18 @@ function makeFakeClient(incidentProjectionCount = 0) { id: nextId++, uuid: `u${nextId}`, session_id, from_agent, to_agent, channel, project_id, content, priority, blocking, reply_to, metadata, working_dir, repository, branch, attachments, created_at: new Date().toISOString(), + read_at: null, }; messages.push(row); return row; } return null; }, - async execute(_sql: string, _p: readonly unknown[] = []): Promise {}, + async execute(sql: string, _p: readonly unknown[] = []): Promise { queries.push(sql); }, async transaction(fn: (tx: any) => Promise): Promise { return fn(client); }, + queries, }; return client; } @@ -102,8 +144,12 @@ let base: string; let rwKey: string; let roKey: string; let projectorKey: string; +let restoreEnv: () => void; +let restoreNetwork: () => void; beforeAll(() => { + restoreEnv = enterHermeticTestEnv(); + restoreNetwork = installNetworkGuard({ allowLoopback: true }); server = startApiServer({ port: 0, host: "127.0.0.1", deps: makeDeps() }); base = `http://127.0.0.1:${server.port}`; rwKey = mintApiKey({ app: "conversations", agent: "test", scopes: ["conversations:read", "conversations:write"], signingSecret: SIGNING }).token; @@ -111,7 +157,11 @@ beforeAll(() => { projectorKey = mintApiKey({ app: "conversations", agent: "todos-projector", scopes: ["conversations:incident-project"], signingSecret: SIGNING }).token; }); -afterAll(() => { server.stop(true); }); +afterAll(() => { + server.stop(true); + restoreNetwork(); + restoreEnv(); +}); describe("conversations-serve", () => { test("GET /health is unauthenticated and returns status+version+mode", async () => { @@ -316,6 +366,63 @@ describe("conversations-serve", () => { const list = await (await fetch(`${base}/v1/messages?channel=deploys`, { headers: { "x-api-key": rwKey } })).json(); expect(list.messages.length).toBeGreaterThan(0); + expect(list.messages[0].content).toBeUndefined(); + }); + + test("collection reads project, redact, cap, and reserve full content for exact IDs", async () => { + const deps = makeDeps(); + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + const isolatedBase = `http://127.0.0.1:${isolated.port}`; + try { + const token = ["Bearer", `fixture-${"x".repeat(30)}`].join(" "); + const normalBody = `coordination update ${token}`; + const restrictedBody = "incident detail must remain exact-only"; + const normalResponse = await fetch(`${isolatedBase}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ from: "a", to: "audit", channel: "audit", content: normalBody, metadata: { internal: "hidden" } }), + }); + const restrictedResponse = await fetch(`${isolatedBase}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ from: "a", to: "incidents", channel: "incidents", content: restrictedBody }), + }); + const normal = (await normalResponse.json()).message; + expect(restrictedResponse.status).toBe(201); + + const listResponse = await fetch(`${isolatedBase}/v1/messages?limit=9999&max_bytes=4096`, { + headers: { "x-api-key": rwKey }, + }); + expect(listResponse.status).toBe(200); + const page = await listResponse.json(); + expect(page.compact).toBe(true); + expect(page.limit).toBe(100); + expect(page.byte_length).toBeLessThanOrEqual(4096); + expect(page.messages.every((message: any) => message.content === undefined && message.metadata === undefined && message.attachments === undefined)).toBe(true); + const normalPreview = page.messages.find((message: any) => message.id === normal.id); + expect(normalPreview.preview).toContain("[REDACTED:BEARER_TOKEN]"); + expect(normalPreview.preview).not.toContain(token); + const restrictedPreview = page.messages.find((message: any) => message.channel === "incidents"); + expect(restrictedPreview.preview).toBe("[REDACTED:RESTRICTED_CHANNEL_BODY]"); + expect(JSON.stringify(restrictedPreview)).not.toContain(restrictedBody); + + const projectedQuery = (deps.client as any).queries.find((sql: string) => /preview_source/i.test(sql) && /ORDER BY created_at/i.test(sql)); + expect(projectedQuery).toBeTruthy(); + expect(projectedQuery).not.toMatch(/SELECT\s+(?:m\.)?\*/i); + + const broadFull = await fetch(`${isolatedBase}/v1/messages?detail=full`, { headers: { "x-api-key": rwKey } }); + expect(broadFull.status).toBe(400); + expect((await broadFull.json()).error).toContain("exact message"); + + const exact = await fetch(`${isolatedBase}/v1/messages/${normal.id}`, { headers: { "x-api-key": rwKey } }); + expect(exact.status).toBe(200); + expect((await exact.json()).message.content).toBe(normalBody); + + const malformed = await fetch(`${isolatedBase}/v1/messages?max_bytes=not-a-number`, { headers: { "x-api-key": rwKey } }); + expect(malformed.status).toBe(400); + } finally { + isolated.stop(true); + } }); test("POST /v1/messages validates required fields", async () => { diff --git a/src/server/api.ts b/src/server/api.ts index f7984fa..fc21ad1 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -25,6 +25,7 @@ import { version as pkgVersion } from "../../package.json"; import { openapiSpec } from "./openapi.js"; import { normalizeChannelName } from "../lib/channel-names.js"; import { extractTopics } from "../lib/topic-extract.js"; +import { buildMessagePreview as buildChannelNotificationPreview } from "../lib/channel-notifications.js"; import { IncidentProjectionConflictError, IncidentProjectionValidationError, @@ -38,6 +39,18 @@ import { getIncidentProjectionPg, } from "./incident-projections.js"; import type { IncidentProjectionRequestV1, IncidentProjectorContext } from "../types.js"; +import { + COLLECTION_PREVIEW_SCAN_CHARS, + RESTRICTED_CHANNEL_PREVIEW, + buildMessagePreview as buildCollectionMessagePreview, + packMessagePreviewPage, + redactSensitiveText, + resolveCollectionLimit, + resolveCollectionMaxBytes, + resolveCollectionOffset, + resolveCollectionPreviewBytes, + resolveCollectionTimeoutMs, +} from "../lib/message-previews.js"; export const APP = "conversations"; const SCOPE_READ = `${APP}:read`; @@ -305,6 +318,65 @@ function clampLimit(raw: string | null, def = 50, max = 500): number { return Math.min(n, max); } +function restrictedMessagePredicatePg(alias = ""): string { + const c = alias ? `${alias}.` : ""; + return `(lower(COALESCE(${c}channel, '')) LIKE '%incident%' OR lower(COALESCE(${c}channel, '')) LIKE '%security%' OR lower(COALESCE(${c}to_agent, '')) LIKE '%incident%' OR lower(COALESCE(${c}to_agent, '')) LIKE '%security%' OR lower(COALESCE(${c}session_id, '')) LIKE '%incident%' OR lower(COALESCE(${c}session_id, '')) LIKE '%security%')`; +} + +function messagePreviewProjectionPg(alias = ""): string { + const c = alias ? `${alias}.` : ""; + const restricted = restrictedMessagePredicatePg(alias); + return `${c}id, ${c}uuid, ${c}session_id, ${c}from_agent, ${c}to_agent, ${c}channel, ${c}project_id, + ${c}priority, ${c}blocking, ${c}reply_to, ${c}working_dir, ${c}repository, ${c}branch, + ${c}created_at, ${c}read_at, ${c}edited_at, ${c}pinned_at, + CASE WHEN ${c}metadata IS NULL OR ${c}metadata = '' THEN FALSE ELSE TRUE END AS has_metadata, + CASE WHEN ${c}attachments IS NULL OR ${c}attachments = '' THEN 0 ELSE jsonb_array_length(${c}attachments::jsonb) END AS attachment_count, + CASE WHEN ${restricted} THEN '' ELSE left(${c}content, ${COLLECTION_PREVIEW_SCAN_CHARS}) END AS preview_source, + octet_length(${c}content) AS content_bytes`; +} + +function collectionReadOptions(url: URL): { + limit: number; + offset: number; + maxBytes: number; + previewBytes: number; + timeoutMs: number; +} { + try { + return { + limit: resolveCollectionLimit(url.searchParams.get("limit")), + offset: resolveCollectionOffset(url.searchParams.get("offset") ?? url.searchParams.get("cursor")), + maxBytes: resolveCollectionMaxBytes(url.searchParams.get("max_bytes")), + previewBytes: resolveCollectionPreviewBytes(url.searchParams.get("preview_bytes")), + timeoutMs: resolveCollectionTimeoutMs(url.searchParams.get("timeout_ms")), + }; + } catch (error) { + throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); + } +} + +function analyticsReadOptions(url: URL): { limit: number; timeoutMs: number } { + try { + return { + limit: resolveCollectionLimit(url.searchParams.get("limit") ?? 100), + timeoutMs: resolveCollectionTimeoutMs(url.searchParams.get("timeout_ms")), + }; + } catch (error) { + throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); + } +} + +async function boundedCollectionQuery( + client: PoolQueryClient, + timeoutMs: number, + query: (tx: TypedQueryClient) => Promise, +): Promise { + return client.transaction(async (tx) => { + await tx.execute(`SET LOCAL statement_timeout = ${Math.max(1, Math.floor(timeoutMs))}`); + return query(tx); + }); +} + /** Truthy query-param check: "true", "1", "yes" all count. */ function isTrue(raw: string | null): boolean { if (!raw) return false; @@ -837,9 +909,7 @@ async function handleV1( return json({ error: "blocker agent must match the authenticated agent" }, 403); } const who = agent; - const limit = clampLimit(url.searchParams.get("limit"), 50, 500); - const offsetRaw = Number(url.searchParams.get("offset") ?? 0); - const offset = Number.isSafeInteger(offsetRaw) && offsetRaw > 0 ? offsetRaw : 0; + const collection = collectionReadOptions(url); let projector: IncidentProjectorContext | null; try { projector = await requireIncidentBlockerContext(client, deps.incidentProjector); @@ -849,7 +919,7 @@ async function handleV1( } throw error; } - const rows = await client.many>( + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many>( `WITH member_channel_scopes(scope) AS ( SELECT 'channel:' || lower(channel) FROM channel_members @@ -928,11 +998,16 @@ async function handleV1( eligible_ids AS ( SELECT id FROM projected_ids UNION SELECT id FROM legacy_ids ) - SELECT m.* FROM messages m JOIN eligible_ids eligible ON eligible.id = m.id + SELECT ${messagePreviewProjectionPg("m")} FROM messages m JOIN eligible_ids eligible ON eligible.id = m.id ORDER BY m.created_at ASC, m.id ASC LIMIT $4 OFFSET $5`, - [projector?.tenant_id ?? null, projector?.authority_id ?? null, who, limit, offset], - ); - return json({ messages: rows.map(parseServerMessage) }); + [projector?.tenant_id ?? null, projector?.authority_id ?? null, who, collection.limit + 1, collection.offset], + )); + return json(packMessagePreviewPage(rows.map((row) => buildCollectionMessagePreview(row, collection.previewBytes)), { + limit: collection.limit, + cursor: collection.offset, + max_bytes: collection.maxBytes, + timeout_ms: collection.timeoutMs, + })); } if (sub === "messages" && method === "GET") { @@ -942,21 +1017,23 @@ async function handleV1( const session = str(url.searchParams.get("session")) ?? str(url.searchParams.get("session_id")); const projectId = str(url.searchParams.get("project_id")); const since = str(url.searchParams.get("since")); + const idRaw = str(url.searchParams.get("id")); + const replyToRaw = str(url.searchParams.get("reply_to")); const sinceIdRaw = str(url.searchParams.get("since_id")); const q = str(url.searchParams.get("q")); const uuid = str(url.searchParams.get("uuid")); const mentionsOnly = str(url.searchParams.get("mentions_only")); const unreadOnly = isTrue(url.searchParams.get("unread_only")); const threadsOnly = isTrue(url.searchParams.get("threads_only")); + const pinnedOnly = isTrue(url.searchParams.get("pinned_only")); const includeReplyCounts = isTrue(url.searchParams.get("include_reply_counts")); + const detail = str(url.searchParams.get("detail")); // Default DESC (newest first) preserves the original behaviour; ?order=asc // gives chronological order for read_channel-style paging. const order = str(url.searchParams.get("order"))?.toLowerCase() === "asc" ? "ASC" : "DESC"; - const limit = clampLimit(url.searchParams.get("limit")); - const offsetRaw = parseInt(url.searchParams.get("offset") || url.searchParams.get("cursor") || "0", 10); - const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? offsetRaw : 0; const clauses: string[] = []; const params: unknown[] = []; + if (idRaw && Number.isSafeInteger(Number(idRaw)) && Number(idRaw) > 0) { params.push(Number(idRaw)); clauses.push(`id = $${params.length}`); } if (to) { params.push(to); clauses.push(`to_agent = $${params.length}`); } if (from) { params.push(from); clauses.push(`from_agent = $${params.length}`); } if (channel) { params.push(channel); clauses.push(`channel = $${params.length}`); } @@ -972,32 +1049,48 @@ async function handleV1( } if (unreadOnly) clauses.push(`read_at IS NULL`); if (threadsOnly) clauses.push(`reply_to IS NULL`); + if (replyToRaw && Number.isSafeInteger(Number(replyToRaw)) && Number(replyToRaw) > 0) { params.push(Number(replyToRaw)); clauses.push(`reply_to = $${params.length}`); } + if (pinnedOnly) clauses.push(`pinned_at IS NOT NULL`); if (isTrue(url.searchParams.get("blocking_only"))) clauses.push(`blocking = true`); const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; // count=1 → authoritative total (honours the same filters). Lets callers // verify backfill parity from the API without paging through every row. if (str(url.searchParams.get("count"))) { - const row = await client.get<{ n: string | number }>( + let timeoutMs: number; + try { + timeoutMs = resolveCollectionTimeoutMs(url.searchParams.get("timeout_ms")); + } catch (error) { + throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); + } + const row = await boundedCollectionQuery(client, timeoutMs, (tx) => tx.get<{ n: string | number }>( `SELECT count(*)::bigint AS n FROM messages ${where}`, params, - ); + )); return json({ count: Number(row?.n ?? 0) }); } + const collection = collectionReadOptions(url); const replyCountSelect = includeReplyCounts ? `, (SELECT count(*) FROM messages r WHERE r.reply_to = messages.id)::int AS reply_count` : ""; - params.push(limit); + + if (detail === "full") return json({ error: "Full collection reads are disabled; use GET /v1/messages/{id} for one exact message" }, 400); + + params.push(collection.limit + 1); const limitIdx = params.length; - params.push(offset); + params.push(collection.offset); const offsetIdx = params.length; - const rows = await client.many( - `SELECT id, uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, - blocking, reply_to, working_dir, repository, branch, metadata, edited_at, pinned_at, - attachments, created_at, read_at${replyCountSelect} + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many>( + `SELECT ${messagePreviewProjectionPg()}${replyCountSelect} FROM messages ${where} ORDER BY created_at ${order}, id ${order} LIMIT $${limitIdx} OFFSET $${offsetIdx}`, params, - ); - return json({ messages: rows }); + )); + return json(packMessagePreviewPage(rows.map((row) => buildCollectionMessagePreview(row, collection.previewBytes)), { + limit: collection.limit, + cursor: collection.offset, + max_bytes: collection.maxBytes, + timeout_ms: collection.timeoutMs, + query: q, + })); } // ---- mark messages read (per-agent receipts + global read_at) ---- @@ -1152,27 +1245,28 @@ async function handleV1( // ---- pinned messages ---- if (sub === "messages/pinned" && method === "GET") { + const collection = collectionReadOptions(url); const channel = str(url.searchParams.get("channel")); const session = str(url.searchParams.get("session")) ?? str(url.searchParams.get("session_id")); - const limit = clampLimit(url.searchParams.get("limit")); - const offsetRaw = parseInt(url.searchParams.get("offset") || "0", 10); - const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? offsetRaw : 0; const clauses = ["pinned_at IS NOT NULL"]; const params: unknown[] = []; if (channel) { params.push(channel); clauses.push(`channel = $${params.length}`); } if (session) { params.push(session); clauses.push(`session_id = $${params.length}`); } - params.push(limit); + params.push(collection.limit + 1); const limitIdx = params.length; - params.push(offset); + params.push(collection.offset); const offsetIdx = params.length; - const rows = await client.many( - `SELECT id, uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, - blocking, reply_to, working_dir, repository, branch, metadata, edited_at, pinned_at, - attachments, created_at, read_at - FROM messages WHERE ${clauses.join(" AND ")} ORDER BY pinned_at DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`, + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many>( + `SELECT ${messagePreviewProjectionPg()} + FROM messages WHERE ${clauses.join(" AND ")} ORDER BY pinned_at DESC, id DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`, params, - ); - return json({ messages: rows }); + )); + return json(packMessagePreviewPage(rows.map((row) => buildCollectionMessagePreview(row, collection.previewBytes)), { + limit: collection.limit, + cursor: collection.offset, + max_bytes: collection.maxBytes, + timeout_ms: collection.timeoutMs, + })); } // ---- export messages (json|csv) ---- @@ -1212,6 +1306,7 @@ async function handleV1( // ---- messages that @mention an agent ---- if (sub === "messages/for-agent" && method === "GET") { + const collection = collectionReadOptions(url); const who = str(url.searchParams.get("agent")); if (!who) return json({ error: "agent is required" }, 400); const clauses = ["mm.mentioned_agent = $1"]; @@ -1219,20 +1314,23 @@ async function handleV1( const channel = str(url.searchParams.get("channel")); if (channel) { params.push(normalizeChannelName(channel)); clauses.push(`m.channel = $${params.length}`); } if (isTrue(url.searchParams.get("unread_only"))) clauses.push(`mm.notified_at IS NULL`); - const limit = clampLimit(url.searchParams.get("limit"), 50, 1000); - params.push(limit); - const rows = await client.many>( - `SELECT m.*, mm.id AS mention_id FROM messages m + params.push(collection.limit + 1); + const limitIdx = params.length; + params.push(collection.offset); + const offsetIdx = params.length; + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many>( + `SELECT ${messagePreviewProjectionPg("m")}, mm.id AS mention_id FROM messages m JOIN message_mentions mm ON mm.message_id = m.id WHERE ${clauses.join(" AND ")} - ORDER BY m.created_at DESC LIMIT $${params.length}`, + ORDER BY m.created_at DESC, m.id DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`, params, - ); - const items = rows.map((r) => { - const { mention_id, ...rest } = r as Record & { mention_id: number }; - return { message: parseServerMessage(rest), mention_id: Number(mention_id) }; - }); - return json({ items }); + )); + return json(packMessagePreviewPage(rows.map((row) => buildCollectionMessagePreview(row, collection.previewBytes)), { + limit: collection.limit, + cursor: collection.offset, + max_bytes: collection.maxBytes, + timeout_ms: collection.timeoutMs, + })); } if (sub === "messages" && method === "POST") { @@ -1493,11 +1591,18 @@ async function handleV1( const replyMatch = sub.match(/^messages\/(\d+)\/replies$/); if (replyMatch && method === "GET") { const id = Number(replyMatch[1]); - const rows = await client.many( - `SELECT * FROM messages WHERE reply_to = $1 ORDER BY created_at ASC, id ASC`, - [id], - ); - return json({ messages: rows }); + const collection = collectionReadOptions(url); + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many>( + `SELECT ${messagePreviewProjectionPg()} FROM messages WHERE reply_to = $1 + ORDER BY created_at ASC, id ASC LIMIT $2 OFFSET $3`, + [id, collection.limit + 1, collection.offset], + )); + return json(packMessagePreviewPage(rows.map((row) => buildCollectionMessagePreview(row, collection.previewBytes)), { + limit: collection.limit, + cursor: collection.offset, + max_bytes: collection.maxBytes, + timeout_ms: collection.timeoutMs, + })); } // ---- per-message read status (channel members who have/haven't read) ---- @@ -1961,18 +2066,12 @@ async function handleV1( // ---- channel notifications router ------------------------------------------- -function buildMessagePreview(content: string, maxChars = 140): string { - const normalized = content.replace(/[*#`~_>\-]/g, " ").replace(/\s+/g, " ").trim(); - if (normalized.length <= maxChars) return normalized; - return normalized.slice(0, Math.max(1, maxChars)).trimEnd() + "…"; -} - async function handleChannelNotifications( sub: string, method: string, req: Request, url: URL, - client: TypedQueryClient, + client: PoolQueryClient, agent: string | null, ): Promise { if (sub !== "channel-notifications" && !sub.startsWith("channel-notifications/")) return null; @@ -2033,31 +2132,55 @@ async function handleChannelNotifications( if (since) { params.push(since); clauses.push(`m.created_at > $${params.length}`); } // Default filters to unread unless explicitly unread_only=false (matches local). if (url.searchParams.get("unread_only") !== "false") clauses.push("snr.message_id IS NULL"); - const limit = clampLimit(url.searchParams.get("limit"), 20, 500); - const rows = await client.many<{ + const collection = collectionReadOptions(url); + const restricted = restrictedMessagePredicatePg("m"); + params.push(collection.limit + 1); + const limitIdx = params.length; + params.push(collection.offset); + const offsetIdx = params.length; + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many<{ message_id: number; channel: string; from_agent: string; created_at: string; - priority: string; content: string; attachments: string | null; preview_chars: number; read_message_id: number | null; + priority: string; preview_source: string; attachment_count: number; preview_chars: number; read_message_id: number | null; }>( - `SELECT m.id AS message_id, m.channel, m.from_agent, m.created_at, m.priority, m.content, m.attachments, + `SELECT m.id AS message_id, m.channel, m.from_agent, m.created_at, m.priority, + CASE WHEN ${restricted} THEN '${RESTRICTED_CHANNEL_PREVIEW}' ELSE left(m.content, ${COLLECTION_PREVIEW_SCAN_CHARS}) END AS preview_source, + CASE WHEN m.attachments IS NULL OR m.attachments = '' THEN 0 ELSE jsonb_array_length(m.attachments::jsonb) END AS attachment_count, s.preview_chars, snr.message_id AS read_message_id FROM messages m INNER JOIN channel_subscriptions s ON s.channel = m.channel LEFT JOIN channel_notification_reads snr ON snr.message_id = m.id AND snr.agent = s.agent WHERE ${clauses.join(" AND ")} - ORDER BY m.created_at DESC, m.id DESC LIMIT ${limit}`, + ORDER BY m.created_at DESC, m.id DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`, params, - ); - const notifications = rows.map((r) => ({ + )); + const candidates = rows.slice(0, collection.limit).map((r) => ({ message_id: Number(r.message_id), channel: r.channel, from_agent: r.from_agent, created_at: r.created_at, priority: r.priority, - preview: buildMessagePreview(r.content, Number(r.preview_chars ?? 140)), + preview: buildChannelNotificationPreview(r.preview_source, Math.min(Number(r.preview_chars ?? 140), collection.previewBytes)), unread: r.read_message_id == null, - has_attachments: !!r.attachments && r.attachments !== "[]", + has_attachments: Number(r.attachment_count) > 0, })); - return json({ notifications }); + const notifications: typeof candidates = []; + for (const candidate of candidates) { + const envelope = { notifications: [...notifications, candidate] }; + if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > collection.maxBytes) break; + notifications.push(candidate); + } + const skipped = notifications.length === 0 && candidates.length > 0 ? 1 : 0; + const consumed = notifications.length + skipped; + const hasMore = rows.length > consumed; + return json({ + notifications, + count: notifications.length, + cursor: collection.offset, + next_cursor: hasMore || skipped > 0 ? collection.offset + consumed : null, + has_more: hasMore, + skipped_count: skipped, + max_bytes: collection.maxBytes, + }); } if (sub === "channel-notifications/read" && method === "POST") { @@ -2733,7 +2856,7 @@ async function handleAnalytics( method: string, req: Request, url: URL, - client: TypedQueryClient, + client: PoolQueryClient, ): Promise { // ---- sessions ---- if (sub === "sessions" && method === "GET") { @@ -2818,39 +2941,45 @@ async function handleAnalytics( const topicChannelMatch = sub.match(/^topics\/channel\/([^/]+)$/); if (topicChannelMatch && method === "GET") { const channel = normalizeChannelName(decodeURIComponent(topicChannelMatch[1])); - const limit = clampLimit(url.searchParams.get("limit"), 100, 1000); + const collection = analyticsReadOptions(url); const since = str(url.searchParams.get("since")); const params: unknown[] = [channel]; let sinceClause = ""; if (since) { params.push(since); sinceClause = `AND created_at > $${params.length}`; } - const rows = await client.many<{ content: string }>( - `SELECT content FROM messages WHERE channel = $1 ${sinceClause} ORDER BY created_at DESC LIMIT ${limit}`, + params.push(collection.limit); + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many<{ preview_source: string }>( + `SELECT CASE WHEN ${restrictedMessagePredicatePg()} THEN '' ELSE left(content, ${COLLECTION_PREVIEW_SCAN_CHARS}) END AS preview_source + FROM messages WHERE channel = $1 ${sinceClause} ORDER BY created_at DESC LIMIT $${params.length}`, params, - ); - return json({ topics: extractTopics(rows.map((r) => r.content).join("\n"), 15) }); + )); + return json({ topics: extractTopics(rows.map((r) => redactSensitiveText(r.preview_source)).join("\n"), 15) }); } const topicSessionMatch = sub.match(/^topics\/session\/([^/]+)$/); if (topicSessionMatch && method === "GET") { const sid = decodeURIComponent(topicSessionMatch[1]); - const limit = clampLimit(url.searchParams.get("limit"), 100, 1000); - const rows = await client.many<{ content: string }>( - `SELECT content FROM messages WHERE session_id = $1 ORDER BY created_at DESC LIMIT ${limit}`, - [sid], - ); - return json({ topics: extractTopics(rows.map((r) => r.content).join("\n"), 15) }); + const collection = analyticsReadOptions(url); + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many<{ preview_source: string }>( + `SELECT CASE WHEN ${restrictedMessagePredicatePg()} THEN '' ELSE left(content, ${COLLECTION_PREVIEW_SCAN_CHARS}) END AS preview_source + FROM messages WHERE session_id = $1 ORDER BY created_at DESC LIMIT $2`, + [sid, collection.limit], + )); + return json({ topics: extractTopics(rows.map((r) => redactSensitiveText(r.preview_source)).join("\n"), 15) }); } if (sub === "topics/trending" && method === "GET") { const hours = Number(str(url.searchParams.get("hours")) ?? "24") || 24; - const topN = Number(str(url.searchParams.get("top_n")) ?? "20") || 20; + const topN = Math.min(Math.max(1, Number(str(url.searchParams.get("top_n")) ?? "20") || 20), 100); + const collection = analyticsReadOptions(url); const projectId = str(url.searchParams.get("project_id")); const params: unknown[] = []; let where = `WHERE created_at > NOW() - interval '${Math.floor(hours)} hours'`; if (projectId) { params.push(projectId); where += ` AND project_id = $${params.length}`; } - const rows = await client.many<{ content: string }>( - `SELECT content FROM messages ${where} ORDER BY created_at DESC LIMIT 500`, + params.push(collection.limit); + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many<{ preview_source: string }>( + `SELECT CASE WHEN ${restrictedMessagePredicatePg()} THEN '' ELSE left(content, ${COLLECTION_PREVIEW_SCAN_CHARS}) END AS preview_source + FROM messages ${where} ORDER BY created_at DESC LIMIT $${params.length}`, params, - ); - return json({ topics: extractTopics(rows.map((r) => r.content).join("\n"), topN) }); + )); + return json({ topics: extractTopics(rows.map((r) => redactSensitiveText(r.preview_source)).join("\n"), topN) }); } // ---- graph ---- @@ -2932,26 +3061,27 @@ async function handleAnalytics( const summaryMatch = sub.match(/^summary\/([^/]+)$/); if (summaryMatch && method === "GET") { const key = decodeURIComponent(summaryMatch[1]); - const limit = clampLimit(url.searchParams.get("limit"), 50, 1000); + const collection = collectionReadOptions(url); const isChannelRow = key.startsWith("channel:") ? true : Boolean(await client.get(`SELECT 1 FROM channels WHERE name = $1`, [key])); const filterCol = isChannelRow ? "channel" : "session_id"; - const rows = await client.many>( - `SELECT * FROM messages WHERE ${filterCol} = $1 ORDER BY created_at DESC LIMIT ${limit}`, - [key], - ); + const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many>( + `SELECT ${messagePreviewProjectionPg()} FROM messages WHERE ${filterCol} = $1 + ORDER BY created_at DESC, id DESC LIMIT $2`, + [key, collection.limit], + )); if (rows.length === 0) return json({ summary: null }); - const msgs = rows.map(parseServerMessage); + const msgs = rows.map((row) => buildCollectionMessagePreview(row, collection.previewBytes)); const agents = new Set(); for (const m of msgs) { agents.add(String(m.from_agent)); if (m.to_agent) agents.add(String(m.to_agent)); } const dates = msgs.map((m) => String(m.created_at)).sort(); - const topics = extractTopics(msgs.map((m) => String(m.content)).join("\n"), 10); + const topics = extractTopics(msgs.map((m) => m.preview).join("\n"), 10); const keyMessages: Array<{ id: number; from: string; content: string; reason: string }> = []; for (const m of msgs) { const p = String(m.priority); - if (p === "high" || p === "urgent") keyMessages.push({ id: Number(m.id), from: String(m.from_agent), content: String(m.content).slice(0, 200), reason: `${p} priority` }); - if (m.blocking) keyMessages.push({ id: Number(m.id), from: String(m.from_agent), content: String(m.content).slice(0, 200), reason: "blocking message" }); + if (p === "high" || p === "urgent") keyMessages.push({ id: Number(m.id), from: String(m.from_agent), content: m.preview.slice(0, 200), reason: `${p} priority` }); + if (m.blocking) keyMessages.push({ id: Number(m.id), from: String(m.from_agent), content: m.preview.slice(0, 200), reason: "blocking message" }); } - for (const m of msgs) if (m.pinned_at) keyMessages.push({ id: Number(m.id), from: String(m.from_agent), content: String(m.content).slice(0, 200), reason: "pinned" }); + for (const m of msgs) if (m.pinned_at) keyMessages.push({ id: Number(m.id), from: String(m.from_agent), content: m.preview.slice(0, 200), reason: "pinned" }); const msgIds = msgs.map((m) => Number(m.id)); if (msgIds.length > 0) { const reacted = await client.many<{ message_id: number; c: number }>( @@ -2960,12 +3090,12 @@ async function handleAnalytics( ); for (const r of reacted) { const m = msgs.find((x) => Number(x.id) === Number(r.message_id)); - if (m) keyMessages.push({ id: Number(r.message_id), from: String(m.from_agent), content: String(m.content).slice(0, 200), reason: `${r.c} reaction(s)` }); + if (m) keyMessages.push({ id: Number(r.message_id), from: String(m.from_agent), content: m.preview.slice(0, 200), reason: `${r.c} reaction(s)` }); } } const seen = new Set(); const uniqueKey = keyMessages.filter((k) => (seen.has(k.id) ? false : (seen.add(k.id), true))).slice(0, 10); - const blockers = msgs.filter((m) => m.blocking && !m.read_at).map((m) => ({ id: Number(m.id), from: String(m.from_agent), content: String(m.content).slice(0, 200), created_at: m.created_at })); + const blockers = msgs.filter((m) => m.blocking && m.unread).map((m) => ({ id: Number(m.id), from: String(m.from_agent), content: m.preview.slice(0, 200), created_at: m.created_at })); const replyCount = msgs.filter((m) => m.reply_to).length; let reactionCount = 0; if (msgIds.length > 0) { diff --git a/src/server/openapi.ts b/src/server/openapi.ts index a753d95..3c64c0d 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -43,6 +43,73 @@ export const openapiSpec = { created_at: { type: "string" }, }, }, + MessagePreview: { + type: "object", + additionalProperties: false, + required: [ + "id", "session_id", "from_agent", "to_agent", "channel", "project_id", "priority", + "working_dir", "repository", "branch", "created_at", "edited_at", "pinned_at", "unread", + "blocking", "reply_to", "attachment_count", "has_attachments", "has_metadata", "preview", + "preview_bytes", "content_bytes", "truncated", "redacted", + ], + properties: { + id: { type: "integer" }, + uuid: { type: "string" }, + session_id: { type: "string" }, + from_agent: { type: "string" }, + to_agent: { type: "string" }, + channel: { type: "string", nullable: true }, + project_id: { type: "string", nullable: true }, + priority: { type: "string", enum: ["low", "normal", "high", "urgent"] }, + working_dir: { type: "string", nullable: true }, + repository: { type: "string", nullable: true }, + branch: { type: "string", nullable: true }, + created_at: { type: "string" }, + edited_at: { type: "string", nullable: true }, + pinned_at: { type: "string", nullable: true }, + unread: { type: "boolean" }, + blocking: { type: "boolean" }, + reply_to: { type: "integer", nullable: true }, + reply_count: { type: "integer" }, + attachment_count: { type: "integer" }, + has_attachments: { type: "boolean" }, + has_metadata: { type: "boolean" }, + preview: { type: "string" }, + preview_bytes: { type: "integer" }, + content_bytes: { type: "integer" }, + truncated: { type: "boolean" }, + redacted: { type: "boolean" }, + relevance_score: { type: "number" }, + }, + }, + MessagePreviewPage: { + type: "object", + additionalProperties: false, + required: [ + "messages", "count", "limit", "cursor", "next_cursor", "has_more", "skipped_count", + "byte_length", "max_bytes", "timeout_ms", "compact", "detail_path", + ], + properties: { + messages: { type: "array", items: { $ref: "#/components/schemas/MessagePreview" } }, + count: { type: "integer" }, + limit: { type: "integer", maximum: 100 }, + cursor: { type: "integer" }, + next_cursor: { type: "integer", nullable: true }, + has_more: { type: "boolean" }, + skipped_count: { type: "integer" }, + byte_length: { type: "integer" }, + max_bytes: { type: "integer", maximum: 65536 }, + timeout_ms: { type: "integer", maximum: 5000 }, + compact: { type: "boolean", enum: [true] }, + detail_path: { type: "string", enum: ["messages/{id}"] }, + query: { type: "string" }, + }, + }, + MessageResponse: { + type: "object", + required: ["message"], + properties: { message: { $ref: "#/components/schemas/Message" } }, + }, Channel: { type: "object", properties: { @@ -232,28 +299,42 @@ export const openapiSpec = { "/v1/messages/blockers": { get: { operationId: "listUnreadBlockers", - summary: "List canonical current blockers visible to one agent", + summary: "List bounded, redacted current-blocker previews visible to one agent", + description: "Pure peek. Incident and security bodies never enter this collection response; fetch one exact body by id when authorized.", parameters: [ { name: "agent", in: "query", required: true, schema: { type: "string" } }, - { name: "limit", in: "query", schema: { type: "integer" } }, - { name: "offset", in: "query", schema: { type: "integer" } }, + { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100 } }, + { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }, + { name: "max_bytes", in: "query", schema: { type: "integer", minimum: 512, maximum: 65536 } }, + { name: "preview_bytes", in: "query", schema: { type: "integer", minimum: 1, maximum: 1024 } }, + { name: "timeout_ms", in: "query", schema: { type: "integer", minimum: 1, maximum: 5000 } }, ], - responses: { "200": { description: "blockers", content: { "application/json": { schema: okObject } } } }, + responses: { "200": { description: "bounded blocker previews", content: { "application/json": { schema: { $ref: "#/components/schemas/MessagePreviewPage" } } } } }, }, }, "/v1/messages": { get: { operationId: "listMessages", - summary: "List messages", + summary: "List bounded, redacted message previews", + description: "Pure collection peek. Full content, raw metadata, and raw attachments never enter this response; use GET /v1/messages/{id} for one exact message.", parameters: [ { name: "to", in: "query", schema: { type: "string" } }, { name: "from", in: "query", schema: { type: "string" } }, { name: "channel", in: "query", schema: { type: "string" } }, { name: "session", in: "query", schema: { type: "string" } }, - { name: "limit", in: "query", schema: { type: "integer" } }, - { name: "count", in: "query", description: "When set, return { count } (honours the same filters) instead of rows.", schema: { type: "boolean" } }, + { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100 } }, + { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }, + { name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"] } }, + { name: "q", in: "query", schema: { type: "string" } }, + { name: "unread_only", in: "query", schema: { type: "boolean" } }, + { name: "threads_only", in: "query", schema: { type: "boolean" } }, + { name: "pinned_only", in: "query", schema: { type: "boolean" } }, + { name: "reply_to", in: "query", schema: { type: "integer" } }, + { name: "max_bytes", in: "query", schema: { type: "integer", minimum: 512, maximum: 65536 } }, + { name: "preview_bytes", in: "query", schema: { type: "integer", minimum: 1, maximum: 1024 } }, + { name: "timeout_ms", in: "query", schema: { type: "integer", minimum: 1, maximum: 5000 } }, ], - responses: { "200": { description: "messages", content: { "application/json": { schema: okObject } } } }, + responses: { "200": { description: "bounded message previews", content: { "application/json": { schema: { $ref: "#/components/schemas/MessagePreviewPage" } } } } }, }, post: { operationId: "sendMessage", @@ -329,8 +410,10 @@ export const openapiSpec = { "/v1/messages/{id}": { get: { operationId: "getMessage", + summary: "Get one exact full message", + description: "The explicit full-content path. Collection endpoints never return source bodies.", parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], - responses: { "200": { description: "message", content: { "application/json": { schema: okObject } } } }, + responses: { "200": { description: "exact message", content: { "application/json": { schema: { $ref: "#/components/schemas/MessageResponse" } } } } }, }, delete: { operationId: "deleteMessage", diff --git a/src/server/serve.test.ts b/src/server/serve.test.ts index d4a4e73..db4f250 100644 --- a/src/server/serve.test.ts +++ b/src/server/serve.test.ts @@ -4,17 +4,27 @@ import { sendMessage } from "../lib/messages"; import { createChannel, joinChannel } from "../lib/channels"; import { createProject } from "../lib/projects"; import { closeDb } from "../lib/db"; -import { mkdirSync, rmSync, unlinkSync, writeFileSync } from "fs"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { + createDisposableStore, + enterHermeticTestEnv, + installNetworkGuard, +} from "../test/hermetic"; -const TEST_DB = join(tmpdir(), `conversations-test-server-${Date.now()}.db`); +const TEST_STORE = createDisposableStore("dashboard-server"); const TEST_DASHBOARD_DIST = join(tmpdir(), `conversations-test-dashboard-dist-${Date.now()}`); let server: ReturnType; +let restoreEnv: () => void; +let restoreNetwork: () => void; beforeAll(() => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; - process.env.CONVERSATIONS_DASHBOARD_DIST = TEST_DASHBOARD_DIST; + restoreEnv = enterHermeticTestEnv({ + CONVERSATIONS_DB_PATH: TEST_STORE.dbPath, + CONVERSATIONS_DASHBOARD_DIST: TEST_DASHBOARD_DIST, + }); + restoreNetwork = installNetworkGuard({ allowLoopback: true }); mkdirSync(TEST_DASHBOARD_DIST, { recursive: true }); writeFileSync( join(TEST_DASHBOARD_DIST, "index.html"), @@ -27,11 +37,10 @@ beforeAll(() => { afterAll(() => { server?.stop(); - delete process.env.CONVERSATIONS_DASHBOARD_DIST; closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} + restoreNetwork?.(); + restoreEnv?.(); + TEST_STORE.cleanup(); rmSync(TEST_DASHBOARD_DIST, { recursive: true, force: true }); }); @@ -58,7 +67,12 @@ describe("API /api/messages", () => { expect(res.status).toBe(200); const data = await res.json() as any[]; expect(data.length).toBeGreaterThanOrEqual(1); - expect(data[0].content).toBe("test-msg"); // reversed order: newest first + expect(data[0].preview).toBe("test-msg"); // reversed order: newest first + expect(data[0].content).toBeUndefined(); + + const exact = await fetch(`${base()}/api/messages/${data[0].id}`); + expect(exact.status).toBe(200); + expect((await exact.json() as any).content).toBe("test-msg"); }); test("GET respects limit param", async () => { @@ -178,7 +192,8 @@ describe("API /api/messages/search", () => { expect(res.status).toBe(200); const data = await res.json() as any[]; expect(data).toHaveLength(1); - expect(data[0].content).toBe("unique-search-term-xyz"); + expect(data[0].preview).toContain("unique-search-term-xyz"); + expect(data[0].content).toBeUndefined(); }); test("returns 400 when query is missing", async () => { diff --git a/src/server/serve.ts b/src/server/serve.ts index 9d8f6be..8594223 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -7,7 +7,7 @@ * conversations dashboard # Start dashboard server */ -import { readMessages, sendMessage, markRead, searchMessages, exportMessages, deleteMessage, editMessage, pinMessage, unpinMessage, getPinnedMessages } from "../lib/messages.js"; +import { readMessagePreviews, sendMessage, markRead, searchMessagePreviews, exportMessages, deleteMessage, editMessage, pinMessage, unpinMessage, getMessageById } from "../lib/messages.js"; import { listSessions, getSession } from "../lib/sessions.js"; import { listChannels, getChannel, createChannel, updateChannel, archiveChannel, unarchiveChannel, joinChannel, leaveChannel, getChannelMembers } from "../lib/channels.js"; import { listProjects, getProject, getProjectByName, createProject, updateProject, deleteProject } from "../lib/projects.js"; @@ -212,17 +212,27 @@ export function startDashboardServer(port = 0, host?: string) { } if (path === "/api/messages" && req.method === "GET") { - const rawLimit = url.searchParams.get("limit"); - let limit = parseInt(rawLimit || "50", 10); - if (!Number.isFinite(limit) || limit <= 0) limit = 50; - if (limit > 500) limit = 500; const session = url.searchParams.get("session") || undefined; const channel = url.searchParams.get("channel") || undefined; const from = url.searchParams.get("from") || undefined; const to = url.searchParams.get("to") || undefined; - const compact = url.searchParams.get("compact") === "true"; - const messages = readMessages({ session_id: session, channel, from, to, limit, order: "desc", compact }); - return jsonResponse(applyFields(messages, url.searchParams.get("fields"))); + try { + const page = readMessagePreviews({ + session_id: session, + channel, + from, + to, + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined, + offset: url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : undefined, + max_bytes: url.searchParams.get("max_bytes") ? Number(url.searchParams.get("max_bytes")) : undefined, + preview_bytes: url.searchParams.get("preview_bytes") ? Number(url.searchParams.get("preview_bytes")) : undefined, + timeout_ms: url.searchParams.get("timeout_ms") ? Number(url.searchParams.get("timeout_ms")) : undefined, + order: "desc", + }); + return jsonResponse(applyFields(page.messages, url.searchParams.get("fields"))); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } } if (path === "/api/messages" && req.method === "POST") { @@ -262,15 +272,25 @@ export function startDashboardServer(port = 0, host?: string) { if (!q.trim()) { return jsonResponse({ error: "Query parameter 'q' is required" }, 400); } - const rawLimit = url.searchParams.get("limit"); - let limit = parseInt(rawLimit || "50", 10); - if (!Number.isFinite(limit) || limit <= 0) limit = 50; - if (limit > 500) limit = 500; const channel = url.searchParams.get("channel") || undefined; const from = url.searchParams.get("from") || undefined; const to = url.searchParams.get("to") || undefined; - const messages = searchMessages({ query: q.trim(), channel, from, to, limit }); - return jsonResponse(messages); + try { + const page = searchMessagePreviews({ + query: q.trim(), + channel, + from, + to, + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined, + offset: url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : undefined, + max_bytes: url.searchParams.get("max_bytes") ? Number(url.searchParams.get("max_bytes")) : undefined, + preview_bytes: url.searchParams.get("preview_bytes") ? Number(url.searchParams.get("preview_bytes")) : undefined, + timeout_ms: url.searchParams.get("timeout_ms") ? Number(url.searchParams.get("timeout_ms")) : undefined, + }); + return jsonResponse(page.messages); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } } if (path === "/api/export" && req.method === "GET") { @@ -298,15 +318,22 @@ export function startDashboardServer(port = 0, host?: string) { if (path === "/api/messages/pinned" && req.method === "GET") { const channel = url.searchParams.get("channel") || undefined; const session_id = url.searchParams.get("session_id") || undefined; - const rawLimit = url.searchParams.get("limit"); - let limit: number | undefined; - if (rawLimit) { - limit = parseInt(rawLimit, 10); - if (!Number.isFinite(limit) || limit <= 0) limit = 50; - if (limit > 500) limit = 500; - } - const messages = getPinnedMessages({ channel, session_id, limit }); - return jsonResponse(messages); + try { + const page = readMessagePreviews({ + pinned_only: true, + channel, + session_id, + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined, + offset: url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : undefined, + max_bytes: url.searchParams.get("max_bytes") ? Number(url.searchParams.get("max_bytes")) : undefined, + preview_bytes: url.searchParams.get("preview_bytes") ? Number(url.searchParams.get("preview_bytes")) : undefined, + timeout_ms: url.searchParams.get("timeout_ms") ? Number(url.searchParams.get("timeout_ms")) : undefined, + order: "desc", + }); + return jsonResponse(page.messages); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } } // Message pin/unpin by ID: /api/messages/:id/pin @@ -335,6 +362,10 @@ export function startDashboardServer(port = 0, host?: string) { const messageMatch = path.match(/^\/api\/messages\/(\d+)$/); if (messageMatch) { const messageId = parseInt(messageMatch[1], 10); + if (req.method === "GET") { + const message = getMessageById(messageId); + return message ? jsonResponse(message) : jsonResponse({ error: "Message not found" }, 404); + } if (req.method === "DELETE") { if (!isSameOrigin(req)) { return jsonResponse({ error: "Invalid origin" }, 403); diff --git a/src/test/hermetic.ts b/src/test/hermetic.ts new file mode 100644 index 0000000..e6756ea --- /dev/null +++ b/src/test/hermetic.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export const AMBIENT_TEST_ENV_KEYS = [ + "HASNA_CONVERSATIONS_STORAGE_MODE", + "HASNA_CONVERSATIONS_MODE", + "CONVERSATIONS_STORAGE_MODE", + "CONVERSATIONS_MODE", + "HASNA_CONVERSATIONS_API_URL", + "CONVERSATIONS_API_URL", + "HASNA_CONVERSATIONS_API_KEY", + "CONVERSATIONS_API_KEY", + "HASNA_CONVERSATIONS_DB_PATH", + "CONVERSATIONS_DB_PATH", + "HASNA_CONVERSATIONS_AGENT_ID", + "CONVERSATIONS_AGENT_ID", + "HASNA_CONVERSATIONS_PROJECT_ID", + "CONVERSATIONS_PROJECT_ID", + "CONVERSATIONS_SESSION_ID", + "HASNA_CONVERSATIONS_API_SIGNING_KEY", + "HASNA_API_SIGNING_KEY", + "API_KEY_SIGNING_SECRET", + "PGHOST", + "PGPORT", + "PGDATABASE", + "PGUSER", + "PGPASSWORD", + "DATABASE_URL", + "BASH_ENV", + "ENV", + "NODE_OPTIONS", + "BUN_OPTIONS", + "BUN_CONFIG_DOTENV", + "DOTENV_CONFIG_PATH", + "DOTENV_CONFIG_ENCODING", + "DOTENV_CONFIG_QUIET", + "DOTENV_KEY", + "CONVERSATIONS_DASHBOARD_DIST", + "CONVERSATIONS_DASHBOARD_HOST", + "CONVERSATIONS_DASHBOARD_PORT", + "CONVERSATIONS_REGISTRY_TIMEOUT_MS", +] as const; + +export function enterHermeticTestEnv(overrides: Record = {}): () => void { + const snapshot = new Map(AMBIENT_TEST_ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of AMBIENT_TEST_ENV_KEYS) delete process.env[key]; + Object.assign(process.env, overrides); + return () => { + for (const key of AMBIENT_TEST_ENV_KEYS) { + const value = snapshot.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; +} + +export function createDisposableStore(label: string): { dbPath: string; cleanup: () => void } { + const directory = mkdtempSync(join(tmpdir(), `conversations-${label}-`)); + return { + dbPath: join(directory, "store.db"), + cleanup: () => rmSync(directory, { recursive: true, force: true }), + }; +} + +export function installNetworkGuard(options: { allowLoopback?: boolean } = {}): () => void { + const original = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const raw = input instanceof Request ? input.url : String(input); + const url = new URL(raw); + const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"; + if (!options.allowLoopback || !loopback) { + throw new Error(`Hermetic test blocked non-loopback network request to ${url.origin}`); + } + return original(input as RequestInfo | URL, init); + }) as typeof fetch; + return () => { globalThis.fetch = original; }; +} + +export function hermeticSpawnEnv(overrides: Record = {}): Record { + const env: Record = {}; + for (const key of ["PATH", "TMPDIR", "LANG", "LC_ALL"] as const) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + return { ...env, ...overrides }; +} diff --git a/src/types.ts b/src/types.ts index caa5278..a3952a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,56 @@ export interface Message { truncated?: boolean; } +/** + * Safe collection-read projection. It intentionally has no `content` or raw + * metadata field; full bodies are available only from the exact-message path. + */ +export interface MessagePreview { + id: number; + uuid?: string; + session_id: string; + from_agent: string; + to_agent: string; + channel: string | null; + project_id: string | null; + priority: Priority; + working_dir: string | null; + repository: string | null; + branch: string | null; + created_at: string; + edited_at: string | null; + pinned_at: string | null; + unread: boolean; + blocking: boolean; + reply_to: number | null; + reply_count?: number; + attachment_count: number; + has_attachments: boolean; + has_metadata: boolean; + preview: string; + preview_bytes: number; + content_bytes: number; + truncated: boolean; + redacted: boolean; + relevance_score?: number; +} + +export interface MessagePreviewPage { + messages: MessagePreview[]; + count: number; + limit: number; + cursor: number; + next_cursor: number | null; + has_more: boolean; + skipped_count: number; + byte_length: number; + max_bytes: number; + timeout_ms: number; + compact: true; + detail_path: "messages/{id}"; + query?: string; +} + export interface Reaction { id: number; message_id: number; @@ -222,6 +272,7 @@ export interface IncidentProjectionRecord { } export interface ReadMessagesOptions { + id?: number; session_id?: string; from?: string; to?: string; @@ -237,10 +288,18 @@ export interface ReadMessagesOptions { threads_only?: boolean; include_reply_counts?: boolean; mentions_only?: string; + reply_to?: number; + pinned_only?: boolean; latest?: number; offset?: number; } +export interface ReadMessagePreviewsOptions extends ReadMessagesOptions { + max_bytes?: number; + preview_bytes?: number; + timeout_ms?: number; +} + export interface SearchMessagesOptions { query: string; channel?: string; @@ -254,6 +313,12 @@ export interface SearchMessagesOptions { offset?: number; } +export interface SearchMessagePreviewsOptions extends SearchMessagesOptions { + max_bytes?: number; + preview_bytes?: number; + timeout_ms?: number; +} + export interface SearchResult extends Message { snippet: string | null; relevance_score: number; From 4394743bdaa329c930e472a164fc31f22bd2b5c0 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Sun, 19 Jul 2026 06:59:56 +0300 Subject: [PATCH 5/8] fix: harden conversation read boundaries --- CHANGELOG.md | 6 + README.md | 18 ++ package.json | 2 +- src/cli/commands/analytics.ts | 13 +- src/cli/commands/messaging.ts | 47 ++- src/cli/components/ChatView.tsx | 205 +++++++------ src/cli/components/MessageBubble.tsx | 11 +- src/hooks/blocker-hook.test.ts | 41 ++- src/index.test.ts | 13 +- src/lib/channel-notifications.test.ts | 32 +- src/lib/channel-notifications.ts | 122 +++++++- src/lib/local-read-runner.ts | 104 +++++++ src/lib/local-read-worker.ts | 109 +++++++ src/lib/message-exports.ts | 223 ++++++++++++++ src/lib/message-previews.ts | 34 ++- src/lib/messages.test.ts | 86 ++++-- src/lib/messages.ts | 411 ++++++++++---------------- src/lib/poll.test.ts | 10 +- src/lib/poll.ts | 36 ++- src/lib/safe-read-remediation.test.ts | 190 ++++++++++++ src/lib/store/api-store.test.ts | 150 +++++++++- src/lib/store/api-store.ts | 92 +++--- src/lib/store/index.ts | 97 +++++- src/mcp/channel.test.ts | 4 +- src/mcp/channel.ts | 28 +- src/mcp/tools/channels.ts | 12 +- src/mcp/tools/messaging.test.ts | 29 +- src/mcp/tools/messaging.ts | 26 +- src/mcp/tools/projects.test.ts | 25 +- src/sdk/index.ts | 43 ++- src/sdk/message-preview.test.ts | 79 +++++ src/server/api.test.ts | 168 ++++++++++- src/server/api.ts | 385 +++++++++++++++++------- src/server/openapi.ts | 159 +++++++++- src/server/serve.test.ts | 30 +- src/server/serve.ts | 36 +-- src/test/hermetic.ts | 3 + src/types.ts | 68 +++++ 38 files changed, 2469 insertions(+), 678 deletions(-) create mode 100644 src/lib/local-read-runner.ts create mode 100644 src/lib/local-read-worker.ts create mode 100644 src/lib/message-exports.ts create mode 100644 src/lib/safe-read-remediation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d6a3d..71c12b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,15 @@ All notable changes to this project will be documented in this file. - Message collection reads are now bounded, redacted SQL/server projections. CLI, MCP, Store, HTTP, blocker, pinned, mention, thread, digest, summary, project-panel, watch-startup, and hook collection paths no longer carry source bodies, raw metadata, or raw attachments across collection boundaries. Incident/security collections use a neutral body marker; full content is available only from the exact message-id path. - Message/channel reads are pure peeks by default. Read-state and receipt mutations now require explicit `--mark-read` / `mark_read: true`; legacy `verbose` collection flags remain accepted but stay preview-only. - Collection APIs enforce hard result, response-byte, preview-byte, and statement-timeout caps, and expose the preview-page contract in OpenAPI and the generated SDK. +- The interactive TUI now loads preview pages only. `Tab` enters browse mode, `v` fetches the selected exact message, and `m` acknowledges only that selected id; opening a conversation no longer marks a whole channel or session read. +- Message exports now create capped file artifacts instead of returning an unbounded inline body. Preview detail is the default; full detail requires an explicit reason and principal-bound acknowledgement. The cloud API returns an authenticated download path and never exposes its filesystem path. +- Channel notification reads now share a cursored, byte-capped, timeout-capped page contract across local Store, cloud Store, CLI, MCP, OpenAPI, and generated SDK. Cloud targets are bound to the authenticated API-key principal, and `mark_read` acknowledges returned ids only. +- Local Store collection reads execute in terminable SQLite workers, so `timeout_ms` cancels active work rather than checking elapsed time only after a query has finished. +- Malformed typed message filters (`id`, `reply_to`, `since_id`, dates, booleans, and order) now return `400` instead of silently dropping the filter and widening the read. ### Added - Hermetic safe-read regressions clear ambient cloud/API/database/dotenv routes, block unexpected network access, and verify redaction, restricted-channel suppression, exact-id disclosure, non-mutating peeks, explicit acknowledgements, and cap failures. +- Export-artifact, notification-page, principal-binding, strict-filter, and worker-cancellation regressions, including proof that a timed-out worker cannot perform a late mutation or retain a SQLite lock. ## [0.5.1] - 2026-07-08 diff --git a/README.md b/README.md index f97e94e..260f86e 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,17 @@ Use `read_digest` with `channel`, `cursor`, and `max_bytes` for byte-capped channel evidence packets that return snippets plus `digest_id`, `message_ids`, and `next_cursor`. +`export_messages` creates a capped preview artifact and returns only its path or +authenticated download metadata. It never returns message bodies inline. The +CLI equivalent is `conversations export --max-bytes 65536`; full detail is an +explicit local operator action requiring both `--as ` and +`--authorize-full `. + +Notification inbox reads return `{ notifications, next_cursor, has_more, +byte_length, marked_read, ... }`. Supply `cursor`, `max_bytes`, `preview_bytes`, +and `timeout_ms` when paging. In cloud mode the requested agent must match the +API-key principal, and `mark_read` affects only ids returned in that page. + ## HTTP mode Long-lived Streamable HTTP transport (stateless, bind `127.0.0.1` only): @@ -211,6 +222,13 @@ const client = new ConversationsClient({ apiKey: process.env.CONVERSATIONS_API_KEY!, }); await client.sendMessage({ from: "me", to: "you", content: "hi", channel: "deploys" }); +const notifications = await client.readChannelNotifications({ + limit: 20, cursor: 0, max_bytes: 16_384, timeout_ms: 3_000, +}); +const { artifact } = await client.createMessageExport({ + detail: "preview", limit: 100, max_bytes: 65_536, timeout_ms: 3_000, +}); +// Fetch artifact.download_path with the same authenticated principal when needed. ``` ## Channels diff --git a/package.json b/package.json index 4f1ad97..551c924 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "types": "./dist/index.d.ts", "scripts": { "clean": "rm -rf dist bin", - "build": "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/serve-entry.ts --outfile ./bin/serve.js --target bun && bun build ./src/hooks/blocker-hook.ts --outfile ./bin/hook.js --target bun && bun build ./src/index.ts ./src/sdk/index.ts --outdir ./dist --target bun && (tsc --emitDeclarationOnly --declaration --outDir dist || true)", + "build": "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/serve-entry.ts --outfile ./bin/serve.js --target bun && bun build ./src/hooks/blocker-hook.ts --outfile ./bin/hook.js --target bun && bun build ./src/lib/local-read-worker.ts --outfile ./bin/local-read-worker.js --target bun && bun build ./src/index.ts ./src/sdk/index.ts --outdir ./dist --target bun && (tsc --emitDeclarationOnly --declaration --outDir dist || true)", "build:dashboard": "cd dashboard && bun install && bun run build", "test": "bun test", "test:incident-pg": "bun run ./scripts/verify-incident-projection-pg.ts", diff --git a/src/cli/commands/analytics.ts b/src/cli/commands/analytics.ts index 41ed4cb..2ab4b46 100644 --- a/src/cli/commands/analytics.ts +++ b/src/cli/commands/analytics.ts @@ -208,7 +208,7 @@ export function registerAnalyticsCommands(program: Command): void { const myChannels = await store.getMemberChannels(agent); const subscriptions = await store.listChannelNotificationSubscriptions(agent); - const channelNotifications = await store.readChannelNotifications({ + const channelNotificationPage = await store.readChannelNotifications({ agent, unread_only: true, limit: 5, @@ -223,7 +223,12 @@ export function registerAnalyticsCommands(program: Command): void { unread_dms: unreadDMs, channels: myChannels, channel_subscriptions: subscriptions, - channel_notifications: channelNotifications, + channel_notifications: channelNotificationPage.notifications, + channel_notification_page: { + next_cursor: channelNotificationPage.next_cursor, + has_more: channelNotificationPage.has_more, + byte_length: channelNotificationPage.byte_length, + }, recent_dms: recentDMs, }; @@ -276,9 +281,9 @@ export function registerAnalyticsCommands(program: Command): void { console.log(`${chalk.bold("Subscribed channels:")} ${chalk.dim("none")}`); } - if (channelNotifications.length > 0) { + if (channelNotificationPage.notifications.length > 0) { console.log(`${chalk.bold("Channel notifications:")}`); - for (const notification of channelNotifications) { + for (const notification of channelNotificationPage.notifications) { console.log( ` ${chalk.dim(notification.created_at.slice(11, 16))} ${chalk.cyan(notification.from_agent)} ${chalk.magenta("#" + notification.channel)} ${chalk.dim(`msg #${notification.message_id}`)}` ); diff --git a/src/cli/commands/messaging.ts b/src/cli/commands/messaging.ts index 0f40b51..2a09f06 100644 --- a/src/cli/commands/messaging.ts +++ b/src/cli/commands/messaging.ts @@ -466,15 +466,26 @@ export function registerMessagingCommands(program: Command): void { // ---- export ---- program .command("export") - .description("Export messages as JSON or CSV") + .description("Create a bounded message export artifact (preview-only by default)") .option("--channel ", "Filter by channel") .option("--session ", "Filter by session ID") .option("--from ", "Filter by sender") .option("--since ", "Messages after this ISO date") .option("--until ", "Messages before this ISO date") .option("--format ", "Output format: json or csv", "json") + .option("--limit ", "Maximum records (hard-capped at 100)", parseInt) + .option("--max-bytes ", "Maximum artifact bytes (hard-capped at 65536)", parseInt) + .option("--preview-bytes ", "Maximum preview bytes per record", parseInt) + .option("--timeout-ms ", "Maximum export query time", parseInt) + .option("--full", "Explicitly request full message bodies") + .option("--authorize-full ", "Reason for the principal-bound full export") + .option("--as ", "Authenticated local principal for a full export") .action(async (opts) => { const format = opts.format === "csv" ? "csv" : "json"; + const principal = opts.full ? resolveIdentity(opts.as).trim() : undefined; + if (opts.full && (!principal || !opts.authorizeFull?.trim())) { + throw new Error("Full export requires --as and --authorize-full "); + } const result = await getStore().exportMessages({ channel: opts.channel, session_id: opts.session, @@ -482,8 +493,18 @@ export function registerMessagingCommands(program: Command): void { since: normalizeSince(opts.since), until: opts.until, format, + detail: opts.full ? "full" : "preview", + limit: opts.limit, + max_bytes: opts.maxBytes, + preview_bytes: opts.previewBytes, + timeout_ms: opts.timeoutMs, + authorization: opts.full ? { + principal: principal!, + reason: opts.authorizeFull.trim(), + acknowledged: true, + } : undefined, }); - console.log(result); + console.log(JSON.stringify(result, null, 2)); closeDb(); }); @@ -687,6 +708,10 @@ export function registerMessagingCommands(program: Command): void { .option("--channel ", "Filter to a single channel") .option("--since ", "Notifications after this ISO timestamp") .option("--limit ", "Max notifications to return", parseInt) + .option("--cursor ", "Notification page cursor", parseInt) + .option("--max-bytes ", "Maximum notification envelope bytes", parseInt) + .option("--preview-bytes ", "Maximum bytes per notification preview", parseInt) + .option("--timeout-ms ", "Maximum notification query time", parseInt) .option("--all", "Include already-read notifications") .option("--mark-read", "Mark returned notifications as read") .option("--clear", "Mark all matching unread notifications as read without listing") @@ -706,17 +731,22 @@ export function registerMessagingCommands(program: Command): void { return; } - const notifications = await getStore().readChannelNotifications({ + const page = await getStore().readChannelNotifications({ agent, channel: opts.channel, since: normalizeSince(opts.since), unread_only: !opts.all, limit: opts.limit, + cursor: opts.cursor, + max_bytes: opts.maxBytes, + preview_bytes: opts.previewBytes, + timeout_ms: opts.timeoutMs, mark_read: opts.markRead, }); + const notifications = page.notifications; if (opts.json) { - console.log(JSON.stringify(notifications, null, 2)); + console.log(JSON.stringify(page, null, 2)); } else if (notifications.length === 0) { console.log(chalk.dim("No channel notifications.")); } else { @@ -835,7 +865,7 @@ export function registerMessagingCommands(program: Command): void { unread_only: true, limit: 20, mark_read: true, - })).sort((left, right) => left.created_at.localeCompare(right.created_at) || left.message_id - right.message_id); + })).notifications.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.message_id - right.message_id); if (dmRecent.length > 0) { console.log(chalk.dim(` ── Recent DMs (${dmRecent.length}) ──\n`)); @@ -862,15 +892,14 @@ export function registerMessagingCommands(program: Command): void { } } - const onNewMessages = (messages: import("../../types.js").Message[]) => { + const onNewMessages = (messages: import("../../types.js").MessagePreview[]) => { for (const msg of messages) { if (msg.from_agent === agent) continue; renderMessage(msg); // Desktop notification (short preview) const where = msg.channel ? `#${msg.channel}` : "DM"; - const preview = buildMessagePreview(msg.content, 150); - desktopNotify(`${msg.from_agent} (${where})`, preview); + desktopNotify(`${msg.from_agent} (${where})`, msg.preview); } }; @@ -896,7 +925,7 @@ export function registerMessagingCommands(program: Command): void { unread_only: true, limit: 200, mark_read: true, - })).sort((left, right) => left.created_at.localeCompare(right.created_at) || left.message_id - right.message_id); + })).notifications.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.message_id - right.message_id); if (notifications.length > 0) { onNewNotifications(notifications); diff --git a/src/cli/components/ChatView.tsx b/src/cli/components/ChatView.tsx index 4bc2a06..fb23160 100644 --- a/src/cli/components/ChatView.tsx +++ b/src/cli/components/ChatView.tsx @@ -1,156 +1,177 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import TextInput from "ink-text-input"; -import { readMessages, sendMessage, markSessionRead, markChannelRead } from "../../lib/messages.js"; +import { getStore } from "../../lib/store/index.js"; import { startPolling } from "../../lib/poll.js"; import { MessageBubble } from "./MessageBubble.js"; -import type { Message } from "../../types.js"; +import type { Message, MessagePreview } from "../../types.js"; interface ChatViewProps { agent: string; onBack: () => void; - // DM mode sessionId?: string; recipient?: string; - // Channel mode channelName?: string; } export function ChatView({ agent, onBack, sessionId: initialSessionId, recipient, channelName }: ChatViewProps) { - const [messages, setMessages] = useState([]); + const store = useMemo(() => getStore(), []); + const [messages, setMessages] = useState([]); + const [detail, setDetail] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); const [input, setInput] = useState(""); + const [inputFocused, setInputFocused] = useState(true); const [sessionId, setSessionId] = useState(initialSessionId); const isChannel = !!channelName; const seenIds = useRef>(new Set()); - // Load existing messages + poll for new ones + // Broad history is always a bounded preview page. No read state changes here. useEffect(() => { + let cancelled = false; seenIds.current = new Set(); + setDetail(null); const opts = isChannel ? { channel: channelName } : sessionId ? { session_id: sessionId } - : {}; + : null; - // Only load if we have something to query - if (isChannel || sessionId) { - const existing = readMessages(opts); - for (const msg of existing) { - seenIds.current.add(msg.id); - } - setMessages(existing); - } else { + if (!opts) { setMessages([]); + return; } - const pollOpts = isChannel - ? { channel: channelName } - : sessionId - ? { session_id: sessionId } - : null; - - if (!pollOpts) return; + void store.readMessagePreviews({ ...opts, order: "asc", limit: 100 }) + .then((page) => { + if (cancelled) return; + for (const message of page.messages) seenIds.current.add(message.id); + setMessages(page.messages); + setSelectedIndex(Math.max(0, page.messages.length - 1)); + }); const { stop } = startPolling({ - ...pollOpts, + ...opts, interval_ms: 200, - on_messages: (newMsgs) => { - const unseen = newMsgs.filter((msg) => !seenIds.current.has(msg.id)); + on_messages: (newPreviews) => { + if (cancelled) return; + const unseen = newPreviews.filter((message) => !seenIds.current.has(message.id)); if (unseen.length === 0) return; - for (const msg of unseen) { - seenIds.current.add(msg.id); - } - setMessages((prev) => [...prev, ...unseen]); + for (const message of unseen) seenIds.current.add(message.id); + setMessages((previous) => [...previous, ...unseen]); + setSelectedIndex((previous) => previous + unseen.length); }, }); - return stop; - }, [sessionId, channelName]); - - // Mark as read - useEffect(() => { - if (messages.length === 0) return; - if (isChannel && channelName) { - markChannelRead(channelName, agent); - } else if (sessionId) { - markSessionRead(sessionId, agent); - } - }, [messages.length, isChannel, channelName, sessionId, agent]); + return () => { + cancelled = true; + stop(); + }; + }, [store, sessionId, channelName, isChannel]); - useInput((_, key) => { - if (key.escape) onBack(); - }); + const selected = messages[selectedIndex] ?? null; - const handleSubmit = (value: string) => { - if (!value.trim()) return; - - if (isChannel && channelName) { - const msg = sendMessage({ - from: agent, - to: channelName, - content: value.trim(), - channel: channelName, - session_id: `channel:${channelName}`, + // Full content and acknowledgement are explicit, exact-id actions only. + useInput((keyInput, key) => { + if (key.escape) { + if (detail) setDetail(null); + else onBack(); + return; + } + if (key.tab && !detail) { + setInputFocused((focused) => !focused); + return; + } + if (inputFocused || detail) return; + if (key.upArrow) setSelectedIndex((index) => Math.max(0, index - 1)); + if (key.downArrow) setSelectedIndex((index) => Math.min(Math.max(0, messages.length - 1), index + 1)); + if (keyInput === "v" && selected) { + void store.getMessageById(selected.id).then((message) => { + if (message) setDetail(message); }); - seenIds.current.add(msg.id); - setMessages((prev) => [...prev, msg]); - } else { - const to = recipient || agent; - const msg = sendMessage({ - from: agent, - to, - content: value.trim(), - session_id: sessionId, + } + if (keyInput === "m" && selected) { + void store.markReadByIds([selected.id], agent).then(() => { + setMessages((current) => current.map((message) => ( + message.id === selected.id ? { ...message, unread: false } : message + ))); }); - seenIds.current.add(msg.id); - setMessages((prev) => [...prev, msg]); - // For new conversations, capture the real session ID from the first message - if (!sessionId) { - setSessionId(msg.session_id); - } } + }); + const handleSubmit = (value: string) => { + const content = value.trim(); + if (!content) return; + + void (async () => { + const sent = await store.sendMessage(isChannel && channelName + ? { + from: agent, + to: channelName, + content, + channel: channelName, + session_id: `channel:${channelName}`, + } + : { + from: agent, + to: recipient || agent, + content, + session_id: sessionId, + }); + const page = await store.readMessagePreviews({ id: sent.id, limit: 1 }); + const preview = page.messages[0]; + if (preview && !seenIds.current.has(preview.id)) { + seenIds.current.add(preview.id); + setMessages((previous) => [...previous, preview]); + setSelectedIndex((index) => index + 1); + } + if (!sessionId) setSessionId(sent.session_id); + })(); setInput(""); }; - const title = isChannel - ? `#${channelName}` - : recipient || "self"; - - const prompt = isChannel - ? `${agent} → #${channelName}` - : `${agent} → ${recipient || "self"}`; + const title = isChannel ? `#${channelName}` : recipient || "self"; + const prompt = isChannel ? `${agent} → #${channelName}` : `${agent} → ${recipient || "self"}`; return ( {title} - (Esc: back) + (Tab: type/browse, ↑/↓: select, v: exact detail, m: mark selected, Esc: back) - {messages.length === 0 ? ( + {detail ? ( + + Exact message #{detail.id} + {detail.content} + Esc returns to preview history. + + ) : messages.length === 0 ? ( No messages yet. Type below and press Enter. ) : ( - messages.map((msg) => ( + messages.map((message, index) => ( )) )} - - {prompt}: - - + {!detail && ( + + {prompt}: + + + )} ); } diff --git a/src/cli/components/MessageBubble.tsx b/src/cli/components/MessageBubble.tsx index 356465a..41e29c3 100644 --- a/src/cli/components/MessageBubble.tsx +++ b/src/cli/components/MessageBubble.tsx @@ -1,17 +1,19 @@ import React from "react"; import { Box, Text } from "ink"; -import type { Message } from "../../types.js"; +import type { MessagePreview } from "../../types.js"; interface MessageBubbleProps { - message: Message; + message: MessagePreview; isOwn: boolean; + selected?: boolean; } -export function MessageBubble({ message, isOwn }: MessageBubbleProps) { +export function MessageBubble({ message, isOwn, selected = false }: MessageBubbleProps) { const time = message.created_at.slice(11, 19); return ( + {selected ? "› " : " "} {time} {message.from_agent} @@ -19,7 +21,8 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) { {message.priority !== "normal" && ( [{message.priority}] )} - : {message.content} + : {message.preview} + {message.unread && [unread]} ); } diff --git a/src/hooks/blocker-hook.test.ts b/src/hooks/blocker-hook.test.ts index 54caf00..951d749 100644 --- a/src/hooks/blocker-hook.test.ts +++ b/src/hooks/blocker-hook.test.ts @@ -1,32 +1,41 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { sendMessage } from "../lib/messages"; import { closeDb } from "../lib/db"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; import { execSync } from "child_process"; - -const TEST_DB = join(tmpdir(), `conversations-test-blocker-hook-${Date.now()}.db`); +import { createDisposableStore, enterHermeticTestEnv, hermeticSpawnEnv } from "../test/hermetic"; describe("blocker-hook", () => { + let testStore: ReturnType; + let restoreEnv: () => void; + beforeEach(() => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; + testStore = createDisposableStore("blocker-hook"); + restoreEnv = enterHermeticTestEnv({ + HASNA_CONVERSATIONS_STORAGE_MODE: "local", + CONVERSATIONS_DB_PATH: testStore.dbPath, + }); closeDb(); }); afterEach(() => { - delete process.env.CONVERSATIONS_DB_PATH; - delete process.env.CONVERSATIONS_AGENT_ID; closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} + restoreEnv(); + testStore.cleanup(); }); + function spawnEnv(): Record { + const agent = process.env.CONVERSATIONS_AGENT_ID; + return hermeticSpawnEnv({ + HASNA_CONVERSATIONS_STORAGE_MODE: "local", + CONVERSATIONS_DB_PATH: testStore.dbPath, + ...(agent ? { CONVERSATIONS_AGENT_ID: agent } : {}), + }); + } + test("exits 0 with no blockers", () => { process.env.CONVERSATIONS_AGENT_ID = "hook-test-no-blockers"; const output = execSync(`bun run src/hooks/blocker-hook.ts`, { - env: { ...process.env }, + env: spawnEnv(), encoding: "utf-8", }); expect(output).toBe(""); @@ -43,7 +52,7 @@ describe("blocker-hook", () => { }); const output = execSync(`bun run src/hooks/blocker-hook.ts`, { - env: { ...process.env }, + env: spawnEnv(), encoding: "utf-8", }); expect(output).toContain("BLOCKING MESSAGES"); @@ -63,7 +72,7 @@ describe("blocker-hook", () => { // Should exit 0, not 2 try { execSync(`bun run src/hooks/blocker-hook.ts`, { - env: { ...process.env }, + env: spawnEnv(), encoding: "utf-8", }); // exit 0 is expected @@ -87,7 +96,7 @@ describe("blocker-hook", () => { markRead([msg.id], "hook-test-read"); const output = execSync(`bun run src/hooks/blocker-hook.ts`, { - env: { ...process.env }, + env: spawnEnv(), encoding: "utf-8", }); expect(output).toBe(""); @@ -95,7 +104,7 @@ describe("blocker-hook", () => { test("shows --help output", () => { const output = execSync(`bun run src/hooks/blocker-hook.ts --help`, { - env: { ...process.env }, + env: spawnEnv(), encoding: "utf-8", }); expect(output).toContain("PreToolUse hook"); diff --git a/src/index.test.ts b/src/index.test.ts index a4ac0e1..ba88ae7 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,11 +1,22 @@ -import { describe, test, expect } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import * as index from "./index"; +import { enterHermeticTestEnv } from "./test/hermetic"; // The public SDK surface is the Store abstraction + domain types (see the module // header in index.ts). The raw sqlite-bound helpers and getDb handle are NOT // exported — that was the split-brain bug. These tests pin the new surface. describe("public API exports", () => { + let restoreEnv: () => void; + + beforeAll(() => { + restoreEnv = enterHermeticTestEnv({ HASNA_CONVERSATIONS_STORAGE_MODE: "local" }); + }); + + afterAll(() => { + restoreEnv(); + }); + test("exports the Store resolver and implementations", () => { expect(typeof index.getStore).toBe("function"); expect(typeof index.LocalStore).toBe("function"); diff --git a/src/lib/channel-notifications.test.ts b/src/lib/channel-notifications.test.ts index 3ea0e13..118dbcb 100644 --- a/src/lib/channel-notifications.test.ts +++ b/src/lib/channel-notifications.test.ts @@ -63,7 +63,7 @@ describe("channel notifications", () => { const subscription = subscribeToChannelNotifications("ops", "agent-a"); expect(subscription.since_message_id).toBe(historical.id); - expect(readChannelNotifications({ agent: "agent-a" })).toHaveLength(0); + expect(readChannelNotifications({ agent: "agent-a" }).notifications).toHaveLength(0); const fresh = sendMessage({ from: "alice", @@ -73,7 +73,7 @@ describe("channel notifications", () => { content: "sent after subscribing", }); - const notifications = readChannelNotifications({ agent: "agent-a" }); + const notifications = readChannelNotifications({ agent: "agent-a" }).notifications; expect(notifications).toHaveLength(1); expect(notifications[0].message_id).toBe(fresh.id); }); @@ -87,7 +87,7 @@ describe("channel notifications", () => { sendMessage({ from: "alice", to: "other", channel: "other", session_id: "channel:other", content: "should not show" }); sendMessage({ from: "agent-a", to: "ops", channel: "ops", session_id: "channel:ops", content: "my own message" }); - const notifications = readChannelNotifications({ agent: "agent-a" }); + const notifications = readChannelNotifications({ agent: "agent-a" }).notifications; expect(notifications).toHaveLength(1); expect(notifications[0].channel).toBe("ops"); expect(notifications[0].preview).toBe("deploy finished succ…"); @@ -106,7 +106,7 @@ describe("channel notifications", () => { sendMessage({ from: "alice", to: "ops", channel: "ops", content: `rotate ${token}` }); sendMessage({ from: "alice", to: "security-incidents", channel: "security-incidents", content: "restricted root cause body" }); - const notifications = readChannelNotifications({ agent: "agent-a" }); + const notifications = readChannelNotifications({ agent: "agent-a" }).notifications; expect(notifications.find((item) => item.channel === "ops")?.preview).toContain("[REDACTED:BEARER_TOKEN]"); expect(JSON.stringify(notifications)).not.toContain(token); const restricted = notifications.find((item) => item.channel === "security-incidents"); @@ -123,12 +123,12 @@ describe("channel notifications", () => { expect(markChannelNotificationsRead("agent-a", [one.id])).toBe(1); - let unread = readChannelNotifications({ agent: "agent-a" }); + let unread = readChannelNotifications({ agent: "agent-a" }).notifications; expect(unread).toHaveLength(1); expect(unread[0].message_id).toBe(two.id); expect(markAllChannelNotificationsRead("agent-a", "ops")).toBe(1); - unread = readChannelNotifications({ agent: "agent-a" }); + unread = readChannelNotifications({ agent: "agent-a" }).notifications; expect(unread).toHaveLength(0); }); @@ -137,10 +137,26 @@ describe("channel notifications", () => { subscribeToChannelNotifications("ops", "agent-a"); sendMessage({ from: "alice", to: "ops", channel: "ops", session_id: "channel:ops", content: "preview me" }); - const notifications = readChannelNotifications({ agent: "agent-a", mark_read: true }); + const page = readChannelNotifications({ agent: "agent-a", mark_read: true }); + const notifications = page.notifications; expect(notifications).toHaveLength(1); expect(notifications[0].unread).toBe(false); - expect(readChannelNotifications({ agent: "agent-a" })).toHaveLength(0); + expect(page.marked_read).toBe(1); + expect(readChannelNotifications({ agent: "agent-a" }).notifications).toHaveLength(0); + }); + + test("preview_bytes caps UTF-8 bytes rather than characters", () => { + createChannel("unicode", "creator"); + subscribeToChannelNotifications("unicode", "agent-a", { preview_chars: 500 }); + sendMessage({ + from: "alice", + to: "unicode", + channel: "unicode", + content: "🙂".repeat(100), + }); + + const [notification] = readChannelNotifications({ agent: "agent-a", preview_bytes: 12 }).notifications; + expect(Buffer.byteLength(notification.preview, "utf8")).toBeLessThanOrEqual(12); }); }); diff --git a/src/lib/channel-notifications.ts b/src/lib/channel-notifications.ts index 45f50de..bb3732a 100644 --- a/src/lib/channel-notifications.ts +++ b/src/lib/channel-notifications.ts @@ -1,5 +1,5 @@ import { getDb } from "./db.js"; -import type { ChannelNotification, ChannelNotificationSubscription } from "../types.js"; +import type { ChannelNotification, ChannelNotificationPage, ChannelNotificationSubscription } from "../types.js"; import { normalizeChannelName } from "./channel-names.js"; import { COLLECTION_MAX_LIMIT, @@ -7,6 +7,12 @@ import { COLLECTION_PREVIEW_SCAN_CHARS, RESTRICTED_CHANNEL_PREVIEW, redactSensitiveText, + resolveCollectionLimit, + resolveCollectionMaxBytes, + resolveCollectionOffset, + resolveCollectionPreviewBytes, + resolveCollectionTimeoutMs, + truncateUtf8, } from "./message-previews.js"; const DEFAULT_PREVIEW_CHARS = 140; @@ -29,6 +35,14 @@ export function buildMessagePreview(content: string, maxChars = DEFAULT_PREVIEW_ return normalized.slice(0, boundedMaxChars).trimEnd() + "…"; } +/** Apply the subscription's character cap and the caller's UTF-8 byte cap. */ +export function buildByteBoundedMessagePreview(content: string, maxChars: number, maxBytes: number): string { + const preview = buildMessagePreview(content, maxChars); + // Keep the explicit restricted-channel marker intact; it contains no body-derived data. + if (preview === RESTRICTED_CHANNEL_PREVIEW) return preview; + return truncateUtf8(preview, resolveCollectionPreviewBytes(maxBytes)).text; +} + export function subscribeToChannelNotifications( channel: string, agent: string, @@ -92,9 +106,75 @@ export interface ReadChannelNotificationsOptions { limit?: number; since?: string; mark_read?: boolean; + cursor?: number; + max_bytes?: number; + preview_bytes?: number; + timeout_ms?: number; +} + +export function finalizeChannelNotificationPage(page: ChannelNotificationPage): ChannelNotificationPage { + let finalized = page; + for (let index = 0; index < 3; index++) { + finalized = { ...finalized, byte_length: Buffer.byteLength(JSON.stringify(finalized), "utf8") }; + } + return finalized; +} + +export function packChannelNotificationPage( + candidates: ChannelNotification[], + options: { limit?: unknown; cursor?: unknown; max_bytes?: unknown; timeout_ms?: unknown; marked_read?: number } = {}, +): ChannelNotificationPage { + const limit = resolveCollectionLimit(options.limit); + const cursor = resolveCollectionOffset(options.cursor); + const maxBytes = resolveCollectionMaxBytes(options.max_bytes); + const timeoutMs = resolveCollectionTimeoutMs(options.timeout_ms); + const windowed = candidates.slice(0, limit + 1); + const available = windowed.slice(0, limit); + + const buildPage = (notifications: ChannelNotification[], skippedCount: number): ChannelNotificationPage => { + const consumed = notifications.length + skippedCount; + const hasMore = windowed.length > consumed; + return finalizeChannelNotificationPage({ + notifications, + count: notifications.length, + limit, + cursor, + next_cursor: hasMore || skippedCount > 0 ? cursor + consumed : null, + has_more: hasMore, + skipped_count: skippedCount, + byte_length: 0, + max_bytes: maxBytes, + timeout_ms: timeoutMs, + marked_read: Math.max(0, Math.floor(options.marked_read ?? 0)), + compact: true, + detail_path: "messages/{id}", + }); + }; + + let notifications: ChannelNotification[] = []; + let skippedCount = 0; + for (const candidate of available) { + const next = buildPage([...notifications, candidate], skippedCount); + if (next.byte_length > maxBytes) { + if (notifications.length === 0) skippedCount = 1; + break; + } + notifications = [...notifications, candidate]; + } + + const page = buildPage(notifications, skippedCount); + if (page.byte_length > maxBytes) { + throw new Error(`channel notification envelope exceeds max_bytes (${page.byte_length} > ${maxBytes})`); + } + return page; } -export function readChannelNotifications(opts: ReadChannelNotificationsOptions): ChannelNotification[] { +export function readChannelNotifications(opts: ReadChannelNotificationsOptions): ChannelNotificationPage { + if (!opts.agent?.trim()) throw new Error("agent must be a non-empty string"); + if (opts.channel !== undefined && !opts.channel.trim()) throw new Error("channel must be a non-empty string"); + if (opts.since !== undefined && !Number.isFinite(Date.parse(opts.since))) { + throw new Error("since must be a valid ISO 8601 date"); + } const db = getDb(); const conditions: string[] = [ "s.agent = ?", @@ -116,9 +196,11 @@ export function readChannelNotifications(opts: ReadChannelNotificationsOptions): conditions.push("snr.message_id IS NULL"); } - const limit = Number.isFinite(opts.limit) && (opts.limit as number) > 0 - ? Math.floor(opts.limit as number) - : 20; + const limit = resolveCollectionLimit(opts.limit); + const cursor = resolveCollectionOffset(opts.cursor); + const maxBytes = resolveCollectionMaxBytes(opts.max_bytes); + const previewBytes = resolveCollectionPreviewBytes(opts.preview_bytes); + const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); const restricted = `(lower(COALESCE(m.channel, '')) LIKE '%incident%' OR lower(COALESCE(m.channel, '')) LIKE '%security%' OR lower(COALESCE(m.to_agent, '')) LIKE '%incident%' OR lower(COALESCE(m.to_agent, '')) LIKE '%security%' OR lower(COALESCE(m.session_id, '')) LIKE '%incident%' OR lower(COALESCE(m.session_id, '')) LIKE '%security%')`; const rows = db.prepare(` @@ -139,7 +221,8 @@ export function readChannelNotifications(opts: ReadChannelNotificationsOptions): ON snr.message_id = m.id AND snr.agent = s.agent WHERE ${conditions.join(" AND ")} ORDER BY m.created_at DESC, m.id DESC - LIMIT ${Math.max(1, Math.min(limit, COLLECTION_MAX_LIMIT))} + LIMIT ${Math.max(1, Math.min(limit + 1, COLLECTION_MAX_LIMIT + 1))} + OFFSET ${cursor} `).all(...params) as Array<{ message_id: number; channel: string; @@ -152,23 +235,38 @@ export function readChannelNotifications(opts: ReadChannelNotificationsOptions): read_message_id: number | null; }>; - const notifications = rows.map((row) => ({ + const candidates = rows.map((row) => ({ message_id: row.message_id, channel: row.channel, from_agent: row.from_agent, created_at: row.created_at, priority: row.priority, - preview: buildMessagePreview(row.preview_source, row.preview_chars), + preview: buildByteBoundedMessagePreview(row.preview_source, row.preview_chars, previewBytes), unread: row.read_message_id == null, has_attachments: row.attachment_count > 0, })) satisfies ChannelNotification[]; - if (opts.mark_read && notifications.length > 0) { - markChannelNotificationsRead(opts.agent, notifications.map((row) => row.message_id)); - for (const row of notifications) row.unread = false; + let page = packChannelNotificationPage(candidates, { + limit, + cursor, + max_bytes: maxBytes, + timeout_ms: timeoutMs, + }); + + let markedRead = 0; + if (opts.mark_read && page.notifications.length > 0) { + markedRead = markChannelNotificationsRead(opts.agent, page.notifications.map((row) => row.message_id)); + page = finalizeChannelNotificationPage({ + ...page, + notifications: page.notifications.map((row) => ({ ...row, unread: false })), + marked_read: markedRead, + }); } - return notifications; + if (page.byte_length > maxBytes) { + throw new Error(`channel notification envelope exceeds max_bytes (${page.byte_length} > ${maxBytes})`); + } + return page; } export function markChannelNotificationsRead(agent: string, messageIds: number[]): number { diff --git a/src/lib/local-read-runner.ts b/src/lib/local-read-runner.ts new file mode 100644 index 0000000..46584e0 --- /dev/null +++ b/src/lib/local-read-runner.ts @@ -0,0 +1,104 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { getDbPath } from "./db.js"; +import { getMessageExportDir } from "./message-exports.js"; +import { resolveCollectionTimeoutMs } from "./message-previews.js"; + +export type LocalReadOperation = + | "readMessagePreviews" + | "searchMessagePreviews" + | "getUnreadBlockerPreviews" + | "getMessagesForAgent" + | "getPinnedMessages" + | "readChannelNotifications" + | "exportMessages"; + +export class LocalCollectionTimeoutError extends Error { + readonly code = "LOCAL_COLLECTION_TIMEOUT"; + constructor(readonly timeoutMs: number, readonly queryStarted: boolean) { + super(`local collection exceeded timeout_ms (${timeoutMs})`); + this.name = "LocalCollectionTimeoutError"; + } +} + +let activeWorkers = 0; + +function workerUrl(): URL { + const configured = process.env.CONVERSATIONS_LOCAL_READ_WORKER?.trim(); + if (configured) return new URL(configured, import.meta.url); + const candidates = [ + new URL("./local-read-worker.ts", import.meta.url), + new URL("./local-read-worker.js", import.meta.url), + new URL("../bin/local-read-worker.js", import.meta.url), + ]; + const found = candidates.find((candidate) => existsSync(fileURLToPath(candidate))); + if (!found) throw new Error("local collection worker is missing from this installation"); + return found; +} + +interface WorkerEnvelope { + type: "started" | "result" | "error"; + result?: unknown; + error?: string; + name?: string; +} + +async function executeWorker(operation: LocalReadOperation | "__cancellationProbe", args: unknown[], timeoutValue: unknown): Promise { + const timeoutMs = resolveCollectionTimeoutMs(timeoutValue); + const worker = new Worker(workerUrl(), { type: "module" }); + activeWorkers += 1; + let settled = false; + let queryStarted = false; + + return await new Promise((resolve, reject) => { + const finish = (outcome: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + worker.terminate(); + activeWorkers -= 1; + outcome(); + }; + const timer = setTimeout(() => { + finish(() => reject(new LocalCollectionTimeoutError(timeoutMs, queryStarted))); + }, timeoutMs); + worker.onmessage = (event: MessageEvent) => { + const envelope = event.data; + if (envelope.type === "started") { + queryStarted = true; + return; + } + if (envelope.type === "error") { + const error = new Error(envelope.error || "local collection worker failed"); + error.name = envelope.name || "Error"; + finish(() => reject(error)); + return; + } + finish(() => resolve(envelope.result as T)); + }; + worker.onerror = (event) => { + finish(() => reject(new Error(event.message || "local collection worker failed"))); + }; + worker.postMessage({ + operation, + args, + dbPath: getDbPath(), + exportDir: operation === "exportMessages" ? getMessageExportDir() : undefined, + tenantId: process.env.HASNA_CONVERSATIONS_TENANT_ID, + authorityId: process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID, + }); + }); +} + +export function runLocalReadWorker(operation: LocalReadOperation, args: unknown[], timeoutValue: unknown): Promise { + return executeWorker(operation, args, timeoutValue); +} + +/** Test-only deterministic probe for worker termination and no-late-mutation. */ +export function runLocalCancellationProbeForTests(timeoutMs: number): Promise { + return executeWorker("__cancellationProbe", [], timeoutMs); +} + +export function activeLocalReadWorkerCountForTests(): number { + return activeWorkers; +} diff --git a/src/lib/local-read-worker.ts b/src/lib/local-read-worker.ts new file mode 100644 index 0000000..2f25055 --- /dev/null +++ b/src/lib/local-read-worker.ts @@ -0,0 +1,109 @@ +import { Database } from "bun:sqlite"; +import { closeDb } from "./db.js"; +import { + exportMessages, + getMessagesForAgent, + getPinnedMessages, + getUnreadBlockerPreviews, + readMessagePreviews, + searchMessagePreviews, +} from "./messages.js"; +import { readChannelNotifications } from "./channel-notifications.js"; + +type LocalReadOperation = + | "readMessagePreviews" + | "searchMessagePreviews" + | "getUnreadBlockerPreviews" + | "getMessagesForAgent" + | "getPinnedMessages" + | "readChannelNotifications" + | "exportMessages" + | "__cancellationProbe"; + +interface WorkerRequest { + operation: LocalReadOperation; + args: unknown[]; + dbPath: string; + exportDir?: string; + tenantId?: string; + authorityId?: string; +} + +function configure(request: WorkerRequest): void { + process.env.CONVERSATIONS_DB_PATH = request.dbPath; + delete process.env.HASNA_CONVERSATIONS_DB_PATH; + if (request.exportDir) process.env.CONVERSATIONS_EXPORT_DIR = request.exportDir; + else delete process.env.CONVERSATIONS_EXPORT_DIR; + if (request.tenantId) process.env.HASNA_CONVERSATIONS_TENANT_ID = request.tenantId; + else delete process.env.HASNA_CONVERSATIONS_TENANT_ID; + if (request.authorityId) process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID = request.authorityId; + else delete process.env.HASNA_CONVERSATIONS_INCIDENT_AUTHORITY_ID; +} + +function cancellationProbe(dbPath: string): never { + // Read-only work intentionally large enough to remain active until the parent + // terminates this worker. The marker write is proof that killed workers cannot + // continue into a later mutation. + const db = new Database(dbPath); + try { + db.query(` + WITH RECURSIVE counter(value) AS ( + VALUES(0) + UNION ALL + SELECT value + 1 FROM counter WHERE value < 1000000000 + ) + SELECT sum(value) FROM counter + `).get(); + db.exec("CREATE TABLE IF NOT EXISTS local_read_cancellation_probe (value INTEGER NOT NULL)"); + db.exec("INSERT INTO local_read_cancellation_probe (value) VALUES (1)"); + throw new Error("cancellation probe unexpectedly completed"); + } finally { + db.close(); + } +} + +self.onmessage = (event: MessageEvent) => { + const request = event.data; + configure(request); + self.postMessage({ type: "started" }); + try { + let result: unknown; + switch (request.operation) { + case "readMessagePreviews": + result = readMessagePreviews(request.args[0] as never); + break; + case "searchMessagePreviews": + result = searchMessagePreviews(request.args[0] as never); + break; + case "getUnreadBlockerPreviews": + result = getUnreadBlockerPreviews(request.args[0] as string, request.args[1] as never); + break; + case "getMessagesForAgent": + result = getMessagesForAgent(request.args[0] as string, request.args[1] as never); + break; + case "getPinnedMessages": + result = getPinnedMessages(request.args[0] as never); + break; + case "readChannelNotifications": + result = readChannelNotifications(request.args[0] as never); + break; + case "exportMessages": + result = exportMessages(request.args[0] as never); + break; + case "__cancellationProbe": + result = cancellationProbe(request.dbPath); + break; + default: + throw new Error("unknown local collection operation"); + } + self.postMessage({ type: "result", result }); + } catch (error) { + self.postMessage({ + type: "error", + error: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : "Error", + }); + } finally { + closeDb(); + } +}; diff --git a/src/lib/message-exports.ts b/src/lib/message-exports.ts new file mode 100644 index 0000000..74b847b --- /dev/null +++ b/src/lib/message-exports.ts @@ -0,0 +1,223 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import type { + ExportDetail, + ExportFormat, + ExportMessagesOptions, + MessageExportArtifact, +} from "../types.js"; +import { getDataDir } from "./db.js"; +import { + resolveCollectionLimit, + resolveCollectionMaxBytes, + resolveCollectionPreviewBytes, + resolveCollectionTimeoutMs, +} from "./message-previews.js"; + +export interface ResolvedExportOptions { + format: ExportFormat; + detail: ExportDetail; + limit: number; + maxBytes: number; + previewBytes: number; + timeoutMs: number; +} + +export interface SerializedMessageExport { + payload: string; + count: number; + hasMore: boolean; + skippedCount: number; +} + +interface StoredExportMetadata { + owner_principal: string; + artifact: MessageExportArtifact; +} + +export interface LoadedMessageExportArtifact { + artifact: MessageExportArtifact; + payload: Uint8Array; + contentType: string; +} + +function validateDate(value: string | undefined, name: string): void { + if (value !== undefined && !Number.isFinite(Date.parse(value))) { + throw new Error(`${name} must be a valid ISO 8601 date`); + } +} + +export function resolveMessageExportOptions(opts: ExportMessagesOptions = {}): ResolvedExportOptions { + const format = opts.format ?? "json"; + if (format !== "json" && format !== "csv") throw new Error("format must be json or csv"); + const detail = opts.detail ?? "preview"; + if (detail !== "preview" && detail !== "full") throw new Error("detail must be preview or full"); + validateDate(opts.since, "since"); + validateDate(opts.until, "until"); + if (opts.since && opts.until && Date.parse(opts.since) > Date.parse(opts.until)) { + throw new Error("since must not be later than until"); + } + if (detail === "full") { + const authorization = opts.authorization; + if ( + authorization?.acknowledged !== true + || !authorization.principal?.trim() + || !authorization.reason?.trim() + ) { + throw new Error("full export requires principal-bound authorization, a reason, and acknowledged=true"); + } + } + return { + format, + detail, + limit: resolveCollectionLimit(opts.limit), + maxBytes: resolveCollectionMaxBytes(opts.max_bytes), + previewBytes: resolveCollectionPreviewBytes(opts.preview_bytes), + timeoutMs: resolveCollectionTimeoutMs(opts.timeout_ms), + }; +} + +function csv(value: unknown): string { + if (value === null || value === undefined) return ""; + const raw = typeof value === "object" ? JSON.stringify(value) : String(value); + return /[",\n\r]/.test(raw) ? `"${raw.replace(/"/g, '""')}"` : raw; +} + +function csvColumns(detail: ExportDetail): string[] { + return detail === "preview" + ? [ + "id", "session_id", "from_agent", "to_agent", "channel", "project_id", "preview", + "priority", "created_at", "unread", "blocking", "truncated", "redacted", + ] + : [ + "id", "uuid", "session_id", "from_agent", "to_agent", "channel", "project_id", "content", + "priority", "created_at", "read_at", "blocking", "reply_to", "working_dir", "repository", + "branch", "metadata", "attachments", "edited_at", "pinned_at", + ]; +} + +function serializeRecords(records: Array>, format: ExportFormat, detail: ExportDetail): string { + if (format === "json") return JSON.stringify(records, null, 2); + const columns = csvColumns(detail); + return [columns.join(","), ...records.map((row) => columns.map((column) => csv(row[column])).join(","))].join("\n"); +} + +/** + * Serialize only complete records that fit the byte budget. The export never + * truncates a full body in place: if the next record does not fit, it is left + * for a narrower filter or an exact-id read. + */ +export function serializeMessageExport( + records: Array>, + options: { format: ExportFormat; detail: ExportDetail; maxBytes: number; hasMore?: boolean }, +): SerializedMessageExport { + const included: Array> = []; + let skippedCount = 0; + for (const record of records) { + const candidate = serializeRecords([...included, record], options.format, options.detail); + if (Buffer.byteLength(candidate, "utf8") > options.maxBytes) { + skippedCount += 1; + break; + } + included.push(record); + } + const payload = serializeRecords(included, options.format, options.detail); + if (Buffer.byteLength(payload, "utf8") > options.maxBytes) { + throw new Error(`export artifact exceeds max_bytes (${Buffer.byteLength(payload, "utf8")} > ${options.maxBytes})`); + } + return { + payload, + count: included.length, + hasMore: options.hasMore === true || skippedCount > 0, + skippedCount, + }; +} + +export function getMessageExportDir(): string { + const configured = process.env.HASNA_CONVERSATIONS_EXPORT_DIR ?? process.env.CONVERSATIONS_EXPORT_DIR; + return resolve(configured?.trim() || join(getDataDir(), "exports")); +} + +export function writeMessageExportArtifact( + serialized: SerializedMessageExport, + options: ResolvedExportOptions, + ownerPrincipal: string, + exposure: "local" | "remote", +): MessageExportArtifact { + const owner = ownerPrincipal.trim(); + if (!owner) throw new Error("export artifact owner principal is required"); + const directory = getMessageExportDir(); + const directoryExisted = existsSync(directory); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (!directoryExisted) chmodSync(directory, 0o700); + + const artifactId = randomUUID(); + const filename = `message-export-${artifactId}.${options.format}`; + const filePath = join(directory, filename); + const payloadBytes = Buffer.from(serialized.payload, "utf8"); + writeFileSync(filePath, payloadBytes, { flag: "wx", mode: 0o600 }); + chmodSync(filePath, 0o600); + const sha256 = createHash("sha256").update(payloadBytes).digest("hex"); + const artifact: MessageExportArtifact = { + artifact_id: artifactId, + filename, + path: exposure === "local" ? filePath : null, + download_path: exposure === "remote" ? `/v1/messages/exports/${artifactId}` : null, + sha256, + format: options.format, + detail: options.detail, + count: serialized.count, + has_more: serialized.hasMore, + skipped_count: serialized.skippedCount, + byte_length: payloadBytes.byteLength, + max_bytes: options.maxBytes, + timeout_ms: options.timeoutMs, + created_at: new Date().toISOString(), + }; + const stored: StoredExportMetadata = { + owner_principal: owner, + artifact: { ...artifact, path: filePath }, + }; + const metadataPath = join(directory, `${artifactId}.meta.json`); + writeFileSync(metadataPath, JSON.stringify(stored), { flag: "wx", mode: 0o600 }); + chmodSync(metadataPath, 0o600); + return artifact; +} + +export function loadMessageExportArtifact(artifactId: string, principal: string): LoadedMessageExportArtifact | null { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(artifactId)) { + return null; + } + const metadataPath = join(getMessageExportDir(), `${artifactId}.meta.json`); + let stored: StoredExportMetadata; + try { + stored = JSON.parse(readFileSync(metadataPath, "utf8")) as StoredExportMetadata; + } catch { + return null; + } + if (stored.owner_principal.toLowerCase() !== principal.trim().toLowerCase()) return null; + const filePath = stored.artifact.path; + if (!filePath) return null; + const expectedFilename = `message-export-${artifactId}.${stored.artifact.format}`; + const expectedPath = resolve(join(getMessageExportDir(), expectedFilename)); + if (stored.artifact.filename !== expectedFilename || resolve(filePath) !== expectedPath) return null; + const payload = readFileSync(filePath); + const stat = statSync(filePath); + const sha256 = createHash("sha256").update(payload).digest("hex"); + if (stat.size !== stored.artifact.byte_length || sha256 !== stored.artifact.sha256) { + throw new Error("export artifact integrity check failed"); + } + return { + artifact: { ...stored.artifact, path: null, download_path: `/v1/messages/exports/${artifactId}` }, + payload, + contentType: stored.artifact.format === "csv" ? "text/csv; charset=utf-8" : "application/json; charset=utf-8", + }; +} diff --git a/src/lib/message-previews.ts b/src/lib/message-previews.ts index d629dd8..3f063eb 100644 --- a/src/lib/message-previews.ts +++ b/src/lib/message-previews.ts @@ -1,4 +1,4 @@ -import type { MessagePreview, MessagePreviewPage, Priority } from "../types.js"; +import type { Message, MessagePreview, MessagePreviewPage, Priority } from "../types.js"; export const COLLECTION_DEFAULT_LIMIT = 20; export const COLLECTION_MAX_LIMIT = 100; @@ -148,12 +148,44 @@ export function buildMessagePreview(row: Record, previewBytes = truncated: restricted || preview.truncated || contentBytes > Buffer.byteLength(source), redacted: restricted || redactedSource !== source, }; + if (row.mention_id != null) message.mention_id = Number(row.mention_id); if (row.uuid != null) message.uuid = boundedSafeString(row.uuid, 128); if (replyCount !== undefined) message.reply_count = replyCount; if (row.relevance_score != null) message.relevance_score = Number(row.relevance_score) || 0; return message; } +/** + * Temporary compatibility shape for older Store callers. `content` is the + * already bounded/redacted preview and raw metadata/attachments are withheld. + * New collection consumers should use MessagePreviewPage directly. + */ +export function previewAsCompatibilityMessage(preview: MessagePreview): Message { + return { + id: preview.id, + session_id: preview.session_id, + from_agent: preview.from_agent, + to_agent: preview.to_agent, + channel: preview.channel, + project_id: preview.project_id, + content: preview.preview, + priority: preview.priority, + working_dir: preview.working_dir, + repository: preview.repository, + branch: preview.branch, + metadata: null, + created_at: preview.created_at, + read_at: preview.unread ? null : preview.created_at, + edited_at: preview.edited_at, + pinned_at: preview.pinned_at, + blocking: preview.blocking, + attachments: null, + reply_to: preview.reply_to, + reply_count: preview.reply_count, + truncated: true, + }; +} + function finalizePage(page: MessagePreviewPage): MessagePreviewPage { let finalized = page; for (let i = 0; i < 3; i++) { diff --git a/src/lib/messages.test.ts b/src/lib/messages.test.ts index 83aae1e..8835de9 100644 --- a/src/lib/messages.test.ts +++ b/src/lib/messages.test.ts @@ -3,14 +3,21 @@ import { sendMessage, readMessages, readDigest, markRead, markReadByIds, markSes import { createChannel, joinChannel } from "./channels"; import { readChannelNotifications, subscribeToChannelNotifications } from "./channel-notifications"; import { closeDb, getDb } from "./db"; -import { unlinkSync } from "fs"; +import { readFileSync, rmSync, unlinkSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; const TEST_DB = join(tmpdir(), `conversations-test-msg-${Date.now()}.db`); +const TEST_EXPORT_DIR = join(tmpdir(), `conversations-test-msg-exports-${Date.now()}`); + +function readExportArtifact(result: ReturnType): string { + expect(result.path).toBeTruthy(); + return readFileSync(result.path!, "utf8"); +} beforeEach(() => { process.env.CONVERSATIONS_DB_PATH = TEST_DB; + process.env.CONVERSATIONS_EXPORT_DIR = TEST_EXPORT_DIR; closeDb(); }); @@ -19,6 +26,8 @@ afterEach(() => { try { unlinkSync(TEST_DB); } catch {} try { unlinkSync(TEST_DB + "-wal"); } catch {} try { unlinkSync(TEST_DB + "-shm"); } catch {} + rmSync(TEST_EXPORT_DIR, { recursive: true, force: true }); + delete process.env.CONVERSATIONS_EXPORT_DIR; }); describe("sendMessage", () => { @@ -298,22 +307,25 @@ describe("markAllRead", () => { }); describe("exportMessages", () => { - test("returns JSON by default", () => { + test("writes a preview JSON artifact by default", () => { sendMessage({ from: "alice", to: "bob", content: "hello" }); sendMessage({ from: "bob", to: "alice", content: "world" }); const result = exportMessages(); - const parsed = JSON.parse(result); + const parsed = JSON.parse(readExportArtifact(result)); expect(Array.isArray(parsed)).toBe(true); expect(parsed).toHaveLength(2); - expect(parsed[0].content).toBe("hello"); - expect(parsed[1].content).toBe("world"); + expect(parsed[0].preview).toBe("hello"); + expect(parsed[1].preview).toBe("world"); + expect(parsed[0].content).toBeUndefined(); + expect(result.byte_length).toBeLessThanOrEqual(result.max_bytes); }); test("returns CSV with headers", () => { sendMessage({ from: "alice", to: "bob", content: "hello" }); const result = exportMessages({ format: "csv" }); - const lines = result.split("\n"); - expect(lines[0]).toBe("id,session_id,from_agent,to_agent,channel,content,priority,created_at,read_at"); + const lines = readExportArtifact(result).split("\n"); + expect(lines[0]).toContain("preview"); + expect(lines[0]).not.toContain("content"); expect(lines).toHaveLength(2); expect(lines[1]).toContain("alice"); expect(lines[1]).toContain("bob"); @@ -324,9 +336,9 @@ describe("exportMessages", () => { sendMessage({ from: "a", to: "general", content: "in-channel", channel: "general" }); sendMessage({ from: "a", to: "b", content: "no-channel" }); const result = exportMessages({ channel: "general" }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(readExportArtifact(result)); expect(parsed).toHaveLength(1); - expect(parsed[0].content).toBe("in-channel"); + expect(parsed[0].preview).toBe("in-channel"); }); test("filters by date range (since/until)", () => { @@ -335,7 +347,7 @@ describe("exportMessages", () => { sendMessage({ from: "a", to: "b", content: "new" }); const until = new Date(Date.now() + 60000).toISOString(); const result = exportMessages({ since, until }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(readExportArtifact(result)); // The "new" message should be included (created_at >= since) // The "old" message may or may not be included depending on timing for (const msg of parsed) { @@ -346,7 +358,7 @@ describe("exportMessages", () => { test("escapes CSV fields with commas", () => { sendMessage({ from: "alice", to: "bob", content: "hello, world" }); const result = exportMessages({ format: "csv" }); - const lines = result.split("\n"); + const lines = readExportArtifact(result).split("\n"); expect(lines[1]).toContain('"hello, world"'); }); @@ -354,19 +366,32 @@ describe("exportMessages", () => { sendMessage({ from: "a", to: "b", content: "1", session_id: "s1" }); sendMessage({ from: "a", to: "b", content: "2", session_id: "s2" }); const result = exportMessages({ session_id: "s1" }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(readExportArtifact(result)); expect(parsed).toHaveLength(1); - expect(parsed[0].content).toBe("1"); + expect(parsed[0].preview).toBe("1"); }); test("filters by from", () => { sendMessage({ from: "alice", to: "bob", content: "1" }); sendMessage({ from: "charlie", to: "bob", content: "2" }); const result = exportMessages({ from: "alice" }); - const parsed = JSON.parse(result); + const parsed = JSON.parse(readExportArtifact(result)); expect(parsed).toHaveLength(1); expect(parsed[0].from_agent).toBe("alice"); }); + + test("requires explicit principal-bound authorization for full artifacts", () => { + sendMessage({ from: "alice", to: "bob", content: "exact body" }); + expect(() => exportMessages({ detail: "full" })).toThrow("principal-bound authorization"); + + const result = exportMessages({ + detail: "full", + authorization: { principal: "alice", reason: "audited incident handoff", acknowledged: true }, + }); + const parsed = JSON.parse(readExportArtifact(result)); + expect(parsed[0].content).toBe("exact body"); + expect(result.detail).toBe("full"); + }); }); describe("deleteMessage", () => { @@ -500,6 +525,17 @@ describe("getPinnedMessages", () => { expect(pinned).toHaveLength(2); }); + test("orders by pin time rather than message creation time", () => { + const older = sendMessage({ from: "a", to: "b", content: "older message, newer pin" }); + const newer = sendMessage({ from: "a", to: "b", content: "newer message, older pin" }); + pinMessage(older.id); + pinMessage(newer.id); + getDb().prepare("UPDATE messages SET pinned_at = ? WHERE id = ?").run("2026-07-19T00:00:02.000Z", older.id); + getDb().prepare("UPDATE messages SET pinned_at = ? WHERE id = ?").run("2026-07-19T00:00:01.000Z", newer.id); + + expect(getPinnedMessages().map((message) => message.id)).toEqual([older.id, newer.id]); + }); + test("returns empty array when no pinned messages", () => { sendMessage({ from: "a", to: "b", content: "not pinned" }); const pinned = getPinnedMessages(); @@ -851,11 +887,11 @@ describe("readDigest", () => { subscribeToChannelNotifications("digest-notify", "reader"); const msg = sendMessage({ from: "alice", to: "digest-notify", channel: "digest-notify", content: "notify me" }); - expect(readChannelNotifications({ agent: "reader", channel: "digest-notify", unread_only: true })).toHaveLength(1); + expect(readChannelNotifications({ agent: "reader", channel: "digest-notify", unread_only: true }).notifications).toHaveLength(1); const result = readDigest({ channel: "digest-notify", mark_read: true, reader: "reader" }); expect(result.message_ids).toEqual([msg.id]); - expect(readChannelNotifications({ agent: "reader", channel: "digest-notify", unread_only: true })).toHaveLength(0); + expect(readChannelNotifications({ agent: "reader", channel: "digest-notify", unread_only: true }).notifications).toHaveLength(0); }); test("supports unread-only mode explicitly", () => { @@ -1024,12 +1060,26 @@ describe("listUnreadCountsWithMentions", () => { describe("getMessagesForAgent", () => { test("returns messages mentioning agent", async () => { createChannel("team", "admin"); - sendMessage({ from: "a", to: "team", channel: "team", content: "ping @dave" }); + sendMessage({ from: "a", to: "team", channel: "team", content: "no mention" }); + const mentioned = sendMessage({ from: "a", to: "team", channel: "team", content: "ping @dave" }); // processMentions is async — give it time await new Promise((r) => setTimeout(r, 100)); const result = getMessagesForAgent("dave"); expect(result.length).toBeGreaterThanOrEqual(1); - expect(result[0].mention_id).toBeDefined(); + const mention = getDb().prepare("SELECT id FROM message_mentions WHERE message_id = ?").get(mentioned.id) as { id: number }; + expect(result[0].message.id).toBe(mentioned.id); + expect(result[0].mention_id).toBe(mention.id); + expect(result[0].mention_id).not.toBe(result[0].message.id); + }); + + test("unread_only follows mention notification state, not message read_at", async () => { + createChannel("mention-state", "admin"); + sendMessage({ from: "a", to: "mention-state", channel: "mention-state", content: "ping @dave" }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(getMessagesForAgent("dave", { unread_only: true })).toHaveLength(1); + markMentionsRead("dave"); + expect(getMessagesForAgent("dave", { unread_only: true })).toHaveLength(0); }); test("filters by channel", async () => { diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 424f3b5..d2101f0 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -10,6 +10,8 @@ import type { SearchResult, MessagePreview, MessagePreviewPage, + ExportMessagesOptions, + MessageExportArtifact, } from "../types.js"; import { createHash, randomUUID } from "crypto"; import { mkdirSync, copyFileSync, statSync, existsSync, realpathSync } from "fs"; @@ -19,8 +21,10 @@ import { normalizeChannelName } from "./channel-names.js"; import { markChannelNotificationsRead } from "./channel-notifications.js"; import { COLLECTION_PREVIEW_SCAN_CHARS, + COLLECTION_MAX_MAX_BYTES, buildMessagePreview, packMessagePreviewPage, + previewAsCompatibilityMessage, resolveCollectionLimit, resolveCollectionOffset, resolveCollectionPreviewBytes, @@ -31,6 +35,11 @@ import { metadataSpoofsIncidentProjection, validateIncidentProjectorBinding, } from "./incident-projection-contract.js"; +import { + resolveMessageExportOptions, + serializeMessageExport, + writeMessageExportArtifact, +} from "./message-exports.js"; /** Strip null/undefined fields from a message for compact output. */ export function compactMessage(msg: Message): Partial { @@ -273,94 +282,6 @@ export function sendMessage(opts: SendMessageOptions): Message { return message; } -export function readMessages(opts: ReadMessagesOptions = {}): Message[] { - const db = getDb(); - const conditions: string[] = []; - const params: (string | number)[] = []; - - if (opts.session_id) { - conditions.push("session_id = ?"); - params.push(opts.session_id); - } - if (opts.from) { - conditions.push("from_agent = ?"); - params.push(opts.from); - } - if (opts.to) { - conditions.push("to_agent = ?"); - params.push(opts.to); - } - if (opts.channel) { - conditions.push("channel = ?"); - params.push(normalizeChannelName(opts.channel)); - } - if (opts.project_id) { - conditions.push("project_id = ?"); - params.push(opts.project_id); - } - if (opts.since) { - conditions.push("created_at > ?"); - params.push(opts.since); - } - if (opts.since_id !== undefined) { - conditions.push("id > ?"); - params.push(opts.since_id); - } - if (opts.unread_only) { - conditions.push("read_at IS NULL"); - } - if (opts.threads_only) { - conditions.push("reply_to IS NULL"); - } - if (opts.mentions_only) { - conditions.push(`id IN (SELECT message_id FROM message_mentions WHERE mentioned_agent = ?)`); - params.push(opts.mentions_only.toLowerCase()); - } - - // latest: N — return the N most recent messages (newest first), overrides limit + order - const isLatest = opts.latest && opts.latest > 0; - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - const resolvedLimit = isLatest - ? Math.floor(opts.latest as number) - : Number.isFinite(opts.limit) && (opts.limit as number) > 0 - ? Math.floor(opts.limit as number) - : 20; - const order = isLatest ? "DESC" : (opts.order?.toLowerCase() === "desc" ? "DESC" : "ASC"); - - // SQLite LIMIT/OFFSET require literal integers — validated and bounded here - const resolvedOffset = Number.isFinite(opts.offset) ? Math.floor(opts.offset as number) : 0; - const safeLimit = Math.max(1, Math.min(resolvedLimit, 10000)); - const safeOffset = Math.max(0, Math.floor(resolvedOffset)); - const rows = db.prepare( - `SELECT * FROM messages ${where} ORDER BY created_at ${order}, id ${order} LIMIT ${safeLimit} OFFSET ${safeOffset}` - ).all(...params) as Record[]; - - let messages = rows.map(parseMessage); - - // Attach reply_count if requested - if (opts.include_reply_counts && messages.length > 0) { - const db2 = getDb(); - const counts = db2.prepare( - `SELECT reply_to, COUNT(*) as c FROM messages WHERE reply_to IN (${messages.map(() => "?").join(",")}) GROUP BY reply_to` - ).all(...messages.map((m) => m.id)) as Array<{ reply_to: number; c: number }>; - const countMap = new Map(counts.map((r) => [r.reply_to, r.c])); - messages = messages.map((m) => ({ ...m, reply_count: countMap.get(m.id) ?? 0 })); - } - - // Truncate content if max_content_length is set - if (opts.max_content_length && opts.max_content_length > 0) { - messages = messages.map((m) => { - if (m.content.length > opts.max_content_length!) { - return { ...m, content: m.content.slice(0, opts.max_content_length) + "…", truncated: true }; - } - return m; - }); - } - - if (opts.compact) return messages.map(compactMessage) as Message[]; - return messages; -} - function previewProjectionColumns(alias = ""): string { const c = alias ? `${alias}.` : ""; const restricted = `(lower(COALESCE(${c}channel, '')) LIKE '%incident%' OR lower(COALESCE(${c}channel, '')) LIKE '%security%' OR lower(COALESCE(${c}to_agent, '')) LIKE '%incident%' OR lower(COALESCE(${c}to_agent, '')) LIKE '%security%' OR lower(COALESCE(${c}session_id, '')) LIKE '%incident%' OR lower(COALESCE(${c}session_id, '')) LIKE '%security%')`; @@ -379,11 +300,46 @@ function assertCollectionDeadline(startedAt: number, timeoutMs: number): void { } } +function assertOptionalPositiveId(name: string, value: unknown, allowZero = false): void { + if (value === undefined || value === null) return; + if (!Number.isSafeInteger(value) || (allowZero ? Number(value) < 0 : Number(value) <= 0)) { + throw new Error(`${name} must be ${allowZero ? "a non-negative" : "a positive"} integer`); + } +} + +function assertOptionalFilter(name: string, value: unknown): void { + if (value !== undefined && value !== null && (typeof value !== "string" || !value.trim())) { + throw new Error(`${name} must be a non-empty string`); + } +} + +function assertOptionalDate(name: string, value: unknown): void { + assertOptionalFilter(name, value); + if (typeof value === "string" && !Number.isFinite(Date.parse(value))) { + throw new Error(`${name} must be a valid ISO 8601 date`); + } +} + +function validateReadPreviewFilters(opts: ReadMessagePreviewsOptions): void { + assertOptionalPositiveId("id", opts.id); + assertOptionalPositiveId("reply_to", opts.reply_to); + assertOptionalPositiveId("since_id", opts.since_id, true); + for (const [name, value] of [ + ["session_id", opts.session_id], ["from", opts.from], ["to", opts.to], ["channel", opts.channel], + ["project_id", opts.project_id], ["mentions_only", opts.mentions_only], + ] as const) assertOptionalFilter(name, value); + assertOptionalDate("since", opts.since); + if (opts.order !== undefined && opts.order !== "asc" && opts.order !== "desc") { + throw new Error("order must be asc or desc"); + } +} + /** * Bounded collection read used by CLI/MCP/audit surfaces. The SQL projection * never selects the full content or raw metadata value into the caller. */ export function readMessagePreviews(opts: ReadMessagePreviewsOptions = {}): MessagePreviewPage { + validateReadPreviewFilters(opts); const startedAt = performance.now(); const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); const limit = resolveCollectionLimit(opts.latest ?? opts.limit); @@ -431,6 +387,20 @@ export function readMessagePreviews(opts: ReadMessagePreviewsOptions = {}): Mess return page; } +/** + * Public compatibility collection read. It returns bounded/redacted preview + * text in the legacy `content` slot and never returns raw metadata or + * attachments. Use getMessageById for one explicit full message. + */ +export function readMessages(opts: ReadMessagesOptions = {}): Message[] { + const page = readMessagePreviews({ + ...opts, + preview_bytes: opts.max_content_length, + max_bytes: COLLECTION_MAX_MAX_BYTES, + }); + return page.messages.map(previewAsCompatibilityMessage); +} + export interface CountMessagesOptions { session_id?: string; from?: string; @@ -1066,25 +1036,13 @@ export function readDigest(opts: ReadDigestOptions = {}): DigestResult { return assembly.rebuild(markedRead); } -export interface ExportMessagesOptions { - channel?: string; - session_id?: string; - from?: string; - since?: string; - until?: string; - format?: "json" | "csv"; -} - -function escapeCsvField(value: string | null | undefined): string { - if (value === null || value === undefined) return ""; - const str = String(value); - if (str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r")) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; -} - -export function exportMessages(opts?: ExportMessagesOptions): string { +/** + * Write a bounded export artifact. Preview projection is the default; raw + * bodies are selected only after explicit full-export authorization passes. + */ +export function exportMessages(opts: ExportMessagesOptions = {}): MessageExportArtifact { + const resolved = resolveMessageExportOptions(opts); + const startedAt = performance.now(); const db = getDb(); const conditions: string[] = []; const params: (string | number)[] = []; @@ -1112,32 +1070,27 @@ export function exportMessages(opts?: ExportMessagesOptions): string { const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const projection = resolved.detail === "preview" ? previewProjectionColumns() : "*"; const rows = db.prepare( - `SELECT * FROM messages ${where} ORDER BY created_at ASC, id ASC` + `SELECT ${projection} FROM messages ${where} ORDER BY created_at ASC, id ASC LIMIT ${resolved.limit + 1}`, ).all(...params) as Record[]; - - const messages = rows.map(parseMessage); - const format = opts?.format ?? "json"; - - if (format === "csv") { - const headers = "id,session_id,from_agent,to_agent,channel,content,priority,created_at,read_at"; - const lines = messages.map((m) => - [ - String(m.id), - escapeCsvField(m.session_id), - escapeCsvField(m.from_agent), - escapeCsvField(m.to_agent), - escapeCsvField(m.channel), - escapeCsvField(m.content), - escapeCsvField(m.priority), - escapeCsvField(m.created_at), - escapeCsvField(m.read_at), - ].join(",") - ); - return [headers, ...lines].join("\n"); - } - - return JSON.stringify(messages, null, 2); + assertCollectionDeadline(startedAt, resolved.timeoutMs); + const records = rows.slice(0, resolved.limit).map((row) => resolved.detail === "preview" + ? buildMessagePreview(row, resolved.previewBytes) as unknown as Record + : parseMessage(row) as unknown as Record); + const serialized = serializeMessageExport(records, { + format: resolved.format, + detail: resolved.detail, + maxBytes: resolved.maxBytes, + hasMore: rows.length > resolved.limit, + }); + assertCollectionDeadline(startedAt, resolved.timeoutMs); + return writeMessageExportArtifact( + serialized, + resolved, + opts.authorization?.principal ?? "local", + "local", + ); } export function deleteMessage(id: number, agent: string): boolean { @@ -1176,11 +1129,13 @@ export function unpinMessage(id: number): Message | null { return row ? parseMessage(row) : null; } +/** Bounded preview compatibility reader for pinned collections. */ export function getPinnedMessages(opts?: { channel?: string; session_id?: string; limit?: number; offset?: number }): Message[] { + assertOptionalFilter("channel", opts?.channel); + assertOptionalFilter("session_id", opts?.session_id); const db = getDb(); - const conditions: string[] = ["pinned_at IS NOT NULL"]; + const conditions = ["pinned_at IS NOT NULL"]; const params: (string | number)[] = []; - if (opts?.channel) { conditions.push("channel = ?"); params.push(normalizeChannelName(opts.channel)); @@ -1189,29 +1144,23 @@ export function getPinnedMessages(opts?: { channel?: string; session_id?: string conditions.push("session_id = ?"); params.push(opts.session_id); } - - const where = `WHERE ${conditions.join(" AND ")}`; - // LIMIT must be a literal integer — validated and capped - const safeLimit = Number.isFinite(opts?.limit) && (opts!.limit as number) > 0 - ? Math.floor(opts!.limit as number) - : 0; - const safeOffset = Number.isFinite(opts?.offset) && (opts!.offset as number) > 0 - ? Math.floor(opts!.offset as number) - : 0; - const limitClause = safeLimit > 0 ? `LIMIT ${safeLimit}` : safeOffset > 0 ? "LIMIT -1" : ""; - const offsetClause = safeOffset > 0 ? `OFFSET ${safeOffset}` : ""; - + const limit = resolveCollectionLimit(opts?.limit); + const offset = resolveCollectionOffset(opts?.offset); const rows = db.prepare( - `SELECT * FROM messages ${where} ORDER BY pinned_at DESC, id DESC ${limitClause} ${offsetClause}` + `SELECT ${previewProjectionColumns()} FROM messages + WHERE ${conditions.join(" AND ")} + ORDER BY pinned_at DESC, id DESC LIMIT ${limit + 1} OFFSET ${offset}`, ).all(...params) as Record[]; - - return rows.map(parseMessage); + return packMessagePreviewPage(rows.map((row) => buildMessagePreview(row)), { + limit, + cursor: offset, + max_bytes: COLLECTION_MAX_MAX_BYTES, + }).messages.map(previewAsCompatibilityMessage); } function queryUnreadBlockerRows( agent: string, opts: { limit?: number; offset?: number } | undefined, - projection: "full" | "preview", ): Record[] { const db = getDb(); const tenantId = process.env.HASNA_CONVERSATIONS_TENANT_ID?.trim(); @@ -1241,7 +1190,6 @@ function queryUnreadBlockerRows( : 0; const limitClause = safeLimit > 0 ? `LIMIT ${safeLimit}` : safeOffset > 0 ? "LIMIT -1" : ""; const offsetClause = safeOffset > 0 ? `OFFSET ${safeOffset}` : ""; - const select = projection === "preview" ? previewProjectionColumns("m") : "m.*"; return db.prepare(` WITH member_channel_scopes(scope) AS ( SELECT 'channel:' || lower(channel) @@ -1322,26 +1270,30 @@ function queryUnreadBlockerRows( UNION SELECT id FROM legacy_ids ) - SELECT ${select} FROM messages m JOIN eligible_ids eligible ON eligible.id = m.id + SELECT ${previewProjectionColumns("m")} FROM messages m JOIN eligible_ids eligible ON eligible.id = m.id ORDER BY m.created_at ASC, m.id ASC ${limitClause} ${offsetClause} `).all(agent, agent, binding?.tenant_id ?? null, binding?.authority_id ?? null, agent, agent, agent, agent, agent) as Record[]; } export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset?: number }): Message[] { - return queryUnreadBlockerRows(agent, opts, "full").map(parseMessage); + return getUnreadBlockerPreviews(agent, { + ...opts, + max_bytes: COLLECTION_MAX_MAX_BYTES, + }).messages.map(previewAsCompatibilityMessage); } export function getUnreadBlockerPreviews( agent: string, opts: { limit?: number; offset?: number; max_bytes?: number; preview_bytes?: number; timeout_ms?: number } = {}, ): MessagePreviewPage { + assertOptionalFilter("agent", agent); const startedAt = performance.now(); const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); const limit = resolveCollectionLimit(opts.limit); const offset = resolveCollectionOffset(opts.offset); const previewBytes = resolveCollectionPreviewBytes(opts.preview_bytes); - const rows = queryUnreadBlockerRows(agent, { limit: limit + 1, offset }, "preview"); + const rows = queryUnreadBlockerRows(agent, { limit: limit + 1, offset }); assertCollectionDeadline(startedAt, timeoutMs); const page = packMessagePreviewPage(rows.map((row) => buildMessagePreview(row, previewBytes)), { limit, @@ -1354,105 +1306,26 @@ export function getUnreadBlockerPreviews( } export function getThreadReplies(messageId: number): Message[] { - const db = getDb(); - const rows = db.prepare( - "SELECT * FROM messages WHERE reply_to = ? ORDER BY created_at ASC, id ASC" - ).all(messageId) as Record[]; - return rows.map(parseMessage); -} - -export function searchMessages(opts: SearchMessagesOptions): SearchResult[] { - const db = getDb(); - - const limit = Number.isFinite(opts.limit) && (opts.limit as number) > 0 - ? Math.floor(opts.limit as number) - : 20; - const offset = Number.isFinite(opts.offset) && (opts.offset as number) > 0 - ? Math.floor(opts.offset as number) - : 0; - const sortByRelevance = opts.sort !== "recent"; - - // Priority weight map for scoring boost - const priorityWeights: Record = { urgent: 10, high: 5, normal: 1, low: 0.5 }; - - // Try FTS5 first for proper full-text search with BM25 ranking - try { - const ftsParams: (string | number)[] = []; - - // Build FTS match expression — support phrase queries and prefix matching - const query = opts.query.trim(); - let ftsQuery: string; - if (query.startsWith('"') && query.endsWith('"')) { - // Exact phrase query — pass through - ftsQuery = query; - } else { - // Quote each word for prefix matching - const words = query.split(/\s+/).filter(Boolean); - ftsQuery = words.map((w) => `"${w.replace(/"/g, '""')}"`).join(" "); - } - - ftsParams.push(ftsQuery); - - let extraWhere = ""; - if (opts.channel) { extraWhere += " AND m.channel = ?"; ftsParams.push(normalizeChannelName(opts.channel)); } - if (opts.from) { extraWhere += " AND m.from_agent = ?"; ftsParams.push(opts.from); } - if (opts.to) { extraWhere += " AND m.to_agent = ?"; ftsParams.push(opts.to); } - if (opts.since) { extraWhere += " AND m.created_at >= ?"; ftsParams.push(opts.since); } - if (opts.until) { extraWhere += " AND m.created_at <= ?"; ftsParams.push(opts.until); } - - const orderClause = sortByRelevance ? "ORDER BY rank" : "ORDER BY m.created_at DESC, m.id DESC"; - - const rows = db.prepare( - `SELECT m.*, rank, - snippet(messages_fts, 0, '**', '**', '...', 20) as snippet - FROM messages m - JOIN messages_fts ON messages_fts.rowid = m.id - WHERE messages_fts MATCH ?${extraWhere} - ${orderClause} LIMIT ${limit} OFFSET ${offset}` - ).all(...ftsParams) as Record[]; - - // Normalize: FTS5 rank is negative (closer to 0 = better). Convert to positive scale. - const maxRank = rows.reduce((max, r) => Math.max(max, Math.abs(r.rank as number || 0)), 0) || 1; - - return rows.map((row) => { - const msg = parseMessage(row); - // Normalize FTS rank to 0-100 scale (higher = more relevant) - const ftsScore = maxRank > 0 ? (Math.abs(row.rank as number || 0) / maxRank) * 100 : 50; - const priorityBoost = priorityWeights[msg.priority] || 1; - const pinnedBoost = msg.pinned_at ? 20 : 0; - const blockingBoost = msg.blocking ? 15 : 0; - const relevance_score = Math.round((ftsScore * priorityBoost + pinnedBoost + blockingBoost) * 100) / 100; - return { ...msg, snippet: (row.snippet as string) || null, relevance_score }; - }); - } catch { - // Fallback to LIKE if FTS not available - } - - // LIKE fallback - const conditions: string[] = ["content LIKE ?"]; - const params: (string | number)[] = [`%${opts.query}%`]; - - if (opts.channel) { conditions.push("channel = ?"); params.push(normalizeChannelName(opts.channel)); } - if (opts.from) { conditions.push("from_agent = ?"); params.push(opts.from); } - if (opts.to) { conditions.push("to_agent = ?"); params.push(opts.to); } - if (opts.since) { conditions.push("created_at >= ?"); params.push(opts.since); } - if (opts.until) { conditions.push("created_at <= ?"); params.push(opts.until); } - - const where = `WHERE ${conditions.join(" AND ")}`; - - const rows = db.prepare( - `SELECT * FROM messages ${where} ORDER BY created_at DESC, id DESC LIMIT ${limit} OFFSET ${offset}` - ).all(...params) as Record[]; - - return rows.map((row) => { - const msg = parseMessage(row); - return { ...msg, snippet: null, relevance_score: 0 }; - }); + return readMessagePreviews({ + reply_to: messageId, + order: "asc", + limit: 100, + max_bytes: COLLECTION_MAX_MAX_BYTES, + }).messages.map(previewAsCompatibilityMessage); } /** Search equivalent of readMessagePreviews; FTS/LIKE run in SQLite but only a * bounded, redacted snippet projection leaves the storage boundary. */ export function searchMessagePreviews(opts: SearchMessagePreviewsOptions): MessagePreviewPage { + assertOptionalFilter("query", opts.query); + assertOptionalFilter("channel", opts.channel); + assertOptionalFilter("from", opts.from); + assertOptionalFilter("to", opts.to); + assertOptionalDate("since", opts.since); + assertOptionalDate("until", opts.until); + if (opts.since && opts.until && Date.parse(opts.since) > Date.parse(opts.until)) { + throw new Error("since must not be later than until"); + } const startedAt = performance.now(); const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); const limit = resolveCollectionLimit(opts.limit); @@ -1477,7 +1350,7 @@ export function searchMessagePreviews(opts: SearchMessagePreviewsOptions): Messa const extra = clauses.length > 0 ? ` AND ${clauses.join(" AND ")}` : ""; const order = sortByRelevance ? "ORDER BY rank" : "ORDER BY m.created_at DESC, m.id DESC"; const rows = db.prepare( - `SELECT ${previewProjectionColumns("m")}, rank AS relevance_score + `SELECT ${previewProjectionColumns("m")}, ABS(rank) AS relevance_score FROM messages m JOIN messages_fts ON messages_fts.rowid = m.id WHERE messages_fts MATCH ?${extra} ${order} LIMIT ${limit + 1} OFFSET ${offset}`, ).all(...params) as Record[]; @@ -1516,6 +1389,19 @@ export function searchMessagePreviews(opts: SearchMessagePreviewsOptions): Messa }); } +/** Public compatibility search: preview text only, never raw rows. */ +export function searchMessages(opts: SearchMessagesOptions): SearchResult[] { + return searchMessagePreviews({ + ...opts, + preview_bytes: opts.snippet_length, + max_bytes: COLLECTION_MAX_MAX_BYTES, + }).messages.map((preview) => ({ + ...previewAsCompatibilityMessage(preview), + snippet: preview.preview, + relevance_score: preview.relevance_score ?? 0, + })); +} + export interface UnreadCount { channel: string; unread_count: number; @@ -1627,22 +1513,33 @@ export function listUnreadCountsWithMentions(agent: string): MentionCount[] { return rows; } -/** Get messages that mention a specific agent. */ +/** Bounded preview compatibility reader for mention collections. */ export function getMessagesForAgent(agent: string, opts?: { channel?: string; unread_only?: boolean; limit?: number }): Array<{ message: Message; mention_id: number }> { + assertOptionalFilter("agent", agent); + assertOptionalFilter("channel", opts?.channel); const db = getDb(); const conditions = ["mm.mentioned_agent = ?"]; const params: (string | number)[] = [agent.toLowerCase()]; - if (opts?.channel) { conditions.push("m.channel = ?"); params.push(normalizeChannelName(opts.channel)); } - if (opts?.unread_only) { conditions.push("mm.notified_at IS NULL"); } - // LIMIT must be a literal integer — validated and capped - const safeLimit = Math.max(1, Math.min(Math.floor(opts?.limit ?? 50), 1000)); + if (opts?.channel) { + conditions.push("m.channel = ?"); + params.push(normalizeChannelName(opts.channel)); + } + if (opts?.unread_only) conditions.push("mm.notified_at IS NULL"); + const limit = resolveCollectionLimit(opts?.limit ?? 50); const rows = db.prepare( - `SELECT m.*, mm.id AS mention_id FROM messages m + `SELECT ${previewProjectionColumns("m")}, mm.id AS mention_id FROM messages m JOIN message_mentions mm ON mm.message_id = m.id WHERE ${conditions.join(" AND ")} - ORDER BY m.created_at DESC LIMIT ${safeLimit}` - ).all(...params) as Array & { mention_id: number }>; - return rows.map(({ mention_id, ...row }) => ({ message: parseMessage(row), mention_id })); + ORDER BY m.created_at DESC, m.id DESC LIMIT ${limit + 1}`, + ).all(...params) as Record[]; + const page = packMessagePreviewPage(rows.map((row) => buildMessagePreview(row)), { + limit, + max_bytes: COLLECTION_MAX_MAX_BYTES, + }); + return page.messages.map((preview) => ({ + message: previewAsCompatibilityMessage(preview), + mention_id: preview.mention_id ?? preview.id, + })); } /** Mark mentions as notified (agent has seen them). */ diff --git a/src/lib/poll.test.ts b/src/lib/poll.test.ts index eaa00b5..6da3630 100644 --- a/src/lib/poll.test.ts +++ b/src/lib/poll.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { startPolling } from "./poll"; import { sendMessage } from "./messages"; import { closeDb } from "./db"; -import type { Message } from "../types"; +import type { MessagePreview } from "../types"; import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../test/hermetic"; let testStore: ReturnType; @@ -34,7 +34,7 @@ describe("startPolling", () => { }); test("detects new messages", async () => { - const received: Message[] = []; + const received: MessagePreview[] = []; const { stop } = startPolling({ to_agent: "bob", @@ -50,11 +50,11 @@ describe("startPolling", () => { stop(); expect(received.length).toBeGreaterThanOrEqual(1); - expect(received[0].content).toBe("hello"); + expect(received[0].preview).toBe("hello"); }); test("filters by session_id", async () => { - const received: Message[] = []; + const received: MessagePreview[] = []; const { stop } = startPolling({ session_id: "target-session", @@ -72,7 +72,7 @@ describe("startPolling", () => { }); test("filters by channel", async () => { - const received: Message[] = []; + const received: MessagePreview[] = []; const { stop } = startPolling({ channel: "general", diff --git a/src/lib/poll.ts b/src/lib/poll.ts index a885e50..156d877 100644 --- a/src/lib/poll.ts +++ b/src/lib/poll.ts @@ -6,14 +6,14 @@ // split-brain bug this eliminates. import { getStore } from "./store/index.js"; -import type { Message } from "../types.js"; +import type { MessagePreview } from "../types.js"; export interface PollOptions { session_id?: string; to_agent?: string; channel?: string; interval_ms?: number; - on_messages: (messages: Message[]) => void; + on_messages: (messages: MessagePreview[]) => void; } /** @@ -27,22 +27,34 @@ export function startPolling(opts: PollOptions): { stop: () => void } { let stopped = false; let inFlight = false; let lastSeenId = 0; + const startedAt = Date.now(); - // Seed lastSeenId at call time so we never replay messages that already - // existed when watching began. The read is issued synchronously (the local - // transport resolves inline; the cloud transport on the next tick) and every - // poll awaits it before querying, keeping the "only NEW messages" contract in - // both modes. + const createdAtMillis = (value: string): number => { + // SQLite timestamps are UTC but omit the trailing Z; cloud timestamps are + // already ISO strings. Normalize both before comparing to the call-time + // boundary so an asynchronous worker seed cannot swallow a newly sent row. + const normalized = /(?:Z|[+-]\d\d:\d\d)$/i.test(value) ? value : `${value}Z`; + return Date.parse(normalized); + }; + + // Seed from preview rows that predate this function call. Local reads now run + // in a real worker, so a message can be written while that worker starts; the + // call-time timestamp prevents such a new row from being swallowed into the + // high-water mark. Equal-millisecond rows are conservatively treated as new. const seeded = store .readMessagePreviews({ session_id: opts.session_id, to: opts.to_agent, channel: opts.channel, order: "desc", - limit: 1, + limit: 100, }) .then((latest) => { - if (latest.messages.length > 0 && latest.messages[0].id > lastSeenId) lastSeenId = latest.messages[0].id; + for (const message of latest.messages) { + if (createdAtMillis(message.created_at) < startedAt && message.id > lastSeenId) { + lastSeenId = message.id; + } + } }) .catch(() => { // A failed seed just means the first poll starts from id 0; never fatal. @@ -66,10 +78,10 @@ export function startPolling(opts: PollOptions): { stop: () => void } { if (page.messages.length > 0) { lastSeenId = page.messages[page.messages.length - 1].id; - const messages = (await Promise.all(page.messages.map((preview) => store.getMessageById(preview.id)))) - .filter((message): message is Message => message !== null); try { - opts.on_messages(messages); + // Polling is a broad collection read, so it never upgrades previews + // into full message bodies. Callers may fetch one exact id explicitly. + opts.on_messages(page.messages); } catch (error) { console.error("Polling callback error:", error); } diff --git a/src/lib/safe-read-remediation.test.ts b/src/lib/safe-read-remediation.test.ts new file mode 100644 index 0000000..1b859fd --- /dev/null +++ b/src/lib/safe-read-remediation.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { createChannel } from "./channels.js"; +import { + getMessageById, + pinMessage, + sendMessage, +} from "./messages.js"; +import { + readChannelNotifications, + subscribeToChannelNotifications, +} from "./channel-notifications.js"; +import { closeDb, getDb } from "./db.js"; +import { LocalStore } from "./store/index.js"; +import { + activeLocalReadWorkerCountForTests, + LocalCollectionTimeoutError, + runLocalCancellationProbeForTests, +} from "./local-read-runner.js"; +import { + createDisposableStore, + enterHermeticTestEnv, +} from "../test/hermetic.js"; + +let disposable: ReturnType; +let restoreEnv: () => void; + +beforeEach(() => { + disposable = createDisposableStore("safe-read-remediation"); + restoreEnv = enterHermeticTestEnv({ + CONVERSATIONS_DB_PATH: disposable.dbPath, + CONVERSATIONS_EXPORT_DIR: `${disposable.dbPath}.exports`, + }); + closeDb(); +}); + +afterEach(() => { + closeDb(); + restoreEnv(); + disposable.cleanup(); +}); + +describe("E-00051 safe public read boundaries", () => { + test("the default TUI uses preview pages, exact-id detail, and id-scoped acknowledgement", () => { + const source = readFileSync( + new URL("../cli/components/ChatView.tsx", import.meta.url), + "utf8", + ); + + expect(source).toContain("readMessagePreviews"); + expect(source).toContain("getMessageById"); + expect(source).toContain("markReadByIds"); + expect(source).not.toMatch(/\breadMessages\b/); + expect(source).not.toMatch(/\bmarkChannelRead\b/); + expect(source).not.toMatch(/\bmarkSessionRead\b/); + }); + + test("LocalStore broad collection methods never return restricted bodies or raw fields", async () => { + const channel = "security-incidents"; + const rawBody = "needle restricted root cause body"; + createChannel(channel, "creator"); + const root = sendMessage({ + from: "alice", + to: channel, + channel, + session_id: `channel:${channel}`, + content: rawBody, + blocking: true, + metadata: { internal_note: "raw-metadata-must-not-escape" }, + }); + getDb().prepare("UPDATE messages SET attachments = ? WHERE id = ?").run( + JSON.stringify([{ name: "evidence.txt", path: "/private/evidence.txt", size: 42, mime_type: "text/plain" }]), + root.id, + ); + pinMessage(root.id); + sendMessage({ + from: "bob", + to: channel, + channel, + session_id: `channel:${channel}`, + content: `@reader ${rawBody} reply`, + reply_to: root.id, + }); + + const store = new LocalStore(); + const results = { + read: await store.readMessages({ channel }), + search: await store.searchMessages({ query: "needle", channel }), + thread: await store.getThreadReplies(root.id), + blockers: await store.getUnreadBlockers(channel, { limit: 20 }), + mentions: await store.getMessagesForAgent("reader", { channel, limit: 20 }), + pinned: await store.getPinnedMessages({ channel, limit: 20 }), + }; + const serialized = JSON.stringify(results); + + expect(serialized).not.toContain(rawBody); + expect(serialized).not.toContain("raw-metadata-must-not-escape"); + expect(serialized).not.toContain("/private/evidence.txt"); + expect(serialized).toContain("[REDACTED:RESTRICTED_CHANNEL_BODY]"); + + // Exact-id disclosure remains the one explicit full-body path. + expect(getMessageById(root.id)?.content).toBe(rawBody); + }); + + test("default export is a bounded preview projection without bodies, metadata, or attachments", async () => { + const channel = "security-export"; + const rawBody = "restricted export body must not be serialized"; + createChannel(channel, "creator"); + const message = sendMessage({ + from: "alice", + to: channel, + channel, + content: rawBody, + metadata: { raw: "metadata" }, + }); + getDb().prepare("UPDATE messages SET attachments = ? WHERE id = ?").run( + JSON.stringify([{ name: "private.txt", path: "/private/private.txt", size: 1, mime_type: "text/plain" }]), + message.id, + ); + + const artifact = await new LocalStore().exportMessages({ channel, format: "json" }); + expect(artifact.path).toBeTruthy(); + const payload = readFileSync(artifact.path!, "utf8"); + expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(64 * 1024); + expect(artifact.byte_length).toBe(Buffer.byteLength(payload, "utf8")); + expect(artifact.download_path).toBeNull(); + expect(payload).not.toContain(rawBody); + expect(payload).not.toContain("/private/private.txt"); + expect(payload).toContain("[REDACTED:RESTRICTED_CHANNEL_BODY]"); + const exported = JSON.parse(payload) as Array>; + expect(exported[0].content).toBeUndefined(); + expect(exported[0].metadata).toBeUndefined(); + expect(exported[0].attachments).toBeUndefined(); + }); + + test("notification reads are cursor pages and explicit mark_read acknowledges only returned ids", () => { + createChannel("ops", "creator"); + subscribeToChannelNotifications("ops", "reader"); + const first = sendMessage({ from: "alice", to: "ops", channel: "ops", content: "first" }); + const second = sendMessage({ from: "alice", to: "ops", channel: "ops", content: "second" }); + const third = sendMessage({ from: "alice", to: "ops", channel: "ops", content: "third" }); + + const firstPage = readChannelNotifications({ + agent: "reader", + limit: 2, + max_bytes: 8 * 1024, + timeout_ms: 1_000, + mark_read: true, + }); + + expect(Array.isArray(firstPage)).toBe(false); + expect(firstPage.notifications.map((item) => item.message_id)).toEqual([third.id, second.id]); + expect(firstPage.notifications.every((item) => item.unread === false)).toBe(true); + expect(firstPage.marked_read).toBe(2); + expect(firstPage.has_more).toBe(true); + expect(firstPage.next_cursor).toBe(second.id); + expect(firstPage.byte_length).toBeLessThanOrEqual(firstPage.max_bytes); + + const remaining = readChannelNotifications({ agent: "reader", limit: 20 }); + expect(remaining.notifications.map((item) => item.message_id)).toEqual([first.id]); + }); + + test("local collection deadlines terminate the SQLite worker with no late mutation or worker leak", async () => { + // Initialize the disposable schema before the worker starts its deliberately + // long read. The only write in the worker is sequenced after that read. + getDb(); + const startedAt = performance.now(); + let caught: unknown; + try { + await runLocalCancellationProbeForTests(500); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(LocalCollectionTimeoutError); + expect((caught as LocalCollectionTimeoutError).queryStarted).toBe(true); + expect(performance.now() - startedAt).toBeLessThan(1_500); + expect(activeLocalReadWorkerCountForTests()).toBe(0); + + // Acquiring an exclusive transaction proves the killed worker no longer has + // a live SQLite statement/connection; the marker proves it never ran late. + const db = getDb(); + db.exec("BEGIN EXCLUSIVE"); + db.exec("ROLLBACK"); + const marker = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'local_read_cancellation_probe'", + ).get(); + expect(marker).toBeNull(); + }); +}); diff --git a/src/lib/store/api-store.test.ts b/src/lib/store/api-store.test.ts index d471243..506020a 100644 --- a/src/lib/store/api-store.test.ts +++ b/src/lib/store/api-store.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"; import { readFileSync } from "fs"; import { ApiStore } from "./api-store.js"; import type { HasnaStorageClient } from "../contracts-client/storage.js"; +import type { ChannelNotificationPage, MessageExportArtifact, MessagePreview, MessagePreviewPage } from "../../types.js"; // A minimal fake HasnaStorageClient whose transport returns whatever the test // queues, so we can assert ApiStore normalizes raw API rows into the client @@ -29,7 +30,10 @@ function capturingClient(response: unknown): { const calls: Array<{ resource: string; body: unknown; options: unknown }> = []; const transport = { baseUrl: "https://conversations.hasna.xyz/v1", - get: async () => response, + get: async (resource: string, options: unknown) => { + calls.push({ resource, body: undefined, options }); + return response; + }, post: async (resource: string, body: unknown, options: unknown) => { calls.push({ resource, body, options }); return response; @@ -49,6 +53,50 @@ function capturingClient(response: unknown): { return { client, calls }; } +function previewPage(preview: Partial = {}): MessagePreviewPage { + const message: MessagePreview = { + id: 7, + session_id: "session-1", + from_agent: "alice", + to_agent: "bob", + channel: null, + project_id: null, + priority: "normal", + working_dir: null, + repository: null, + branch: null, + created_at: "2026-07-19T00:00:00.000Z", + edited_at: null, + pinned_at: null, + unread: true, + blocking: false, + reply_to: null, + attachment_count: 0, + has_attachments: false, + has_metadata: false, + preview: "bounded preview", + preview_bytes: 15, + content_bytes: 15, + truncated: false, + redacted: false, + ...preview, + }; + return { + messages: [message], + count: 1, + limit: 20, + cursor: 0, + next_cursor: null, + has_more: false, + skipped_count: 0, + byte_length: 512, + max_bytes: 65_536, + timeout_ms: 3_000, + compact: true, + detail_path: "messages/{id}", + }; +} + /** A client whose transport rejects every read with a 404 HasnaHttpError. */ function throwing404Client(): HasnaStorageClient { const err = Object.assign(new Error("Not Found"), { name: "HasnaHttpError", status: 404 }); @@ -122,6 +170,106 @@ describe("ApiStore project normalization", () => { }); describe("ApiStore message transport", () => { + test("uses dedicated preview routes for mention identity and pin ordering", async () => { + const mentions = capturingClient(previewPage({ mention_id: 41 })); + const mentionRows = await new ApiStore(mentions.client).getMessagesForAgent("alice", { + unread_only: true, + limit: 5, + }); + expect(mentions.calls[0].resource).toBe("/messages/for-agent"); + expect(mentions.calls[0].options).toMatchObject({ + query: { agent: "alice", unread_only: true, limit: 5 }, + retry: false, + }); + expect(mentionRows[0].mention_id).toBe(41); + + const pinned = capturingClient(previewPage({ pinned_at: "2026-07-19T00:00:01.000Z" })); + await new ApiStore(pinned.client).getPinnedMessages({ session_id: "session-1", offset: 2, limit: 3 }); + expect(pinned.calls[0].resource).toBe("/messages/pinned"); + expect(pinned.calls[0].options).toMatchObject({ + query: { session_id: "session-1", offset: 2, limit: 3 }, + retry: false, + }); + }); + + test("posts bounded artifact export options without requesting inline bodies", async () => { + const artifact = { + artifact_id: "00000000-0000-4000-8000-000000000001", + filename: "message-export.json", + path: null, + download_path: "/v1/messages/exports/00000000-0000-4000-8000-000000000001", + sha256: "a".repeat(64), + format: "json", + detail: "preview", + count: 1, + has_more: false, + skipped_count: 0, + byte_length: 512, + max_bytes: 4096, + timeout_ms: 1000, + created_at: "2026-07-19T00:00:00.000Z", + } satisfies MessageExportArtifact; + const { client, calls } = capturingClient({ artifact }); + const result = await new ApiStore(client).exportMessages({ + channel: "engineering", + detail: "preview", + limit: 10, + max_bytes: 4096, + preview_bytes: 128, + timeout_ms: 1000, + }); + + expect(calls).toEqual([{ + resource: "/messages/exports", + body: { + channel: "engineering", + detail: "preview", + limit: 10, + max_bytes: 4096, + preview_bytes: 128, + timeout_ms: 1000, + }, + options: undefined, + }]); + expect(result).toEqual(artifact); + }); + + test("forwards notification cursor, byte, preview, timeout, and mark options", async () => { + const page = { + notifications: [], count: 0, limit: 10, cursor: 20, next_cursor: null, has_more: false, + skipped_count: 0, byte_length: 256, max_bytes: 4096, timeout_ms: 1000, marked_read: 0, + compact: true, detail_path: "messages/{id}", + } satisfies ChannelNotificationPage; + const { client, calls } = capturingClient(page); + const result = await new ApiStore(client).readChannelNotifications({ + agent: "alice", + channel: "engineering", + limit: 10, + cursor: 20, + max_bytes: 4096, + preview_bytes: 128, + timeout_ms: 1000, + mark_read: true, + }); + + expect(result).toEqual(page); + expect(calls[0].resource).toBe("/channel-notifications/inbox"); + expect(calls[0].options).toEqual({ + query: { + agent: "alice", + channel: "engineering", + limit: 10, + cursor: 20, + max_bytes: 4096, + preview_bytes: 128, + timeout_ms: 1000, + mark_read: true, + }, + timeoutMs: 1000, + retry: false, + }); + }); + test("forwards reply correlation, metadata, and source context to cloud create", async () => { const { client, calls } = capturingClient({ message: { diff --git a/src/lib/store/api-store.ts b/src/lib/store/api-store.ts index 35ed4d1..98542dc 100644 --- a/src/lib/store/api-store.ts +++ b/src/lib/store/api-store.ts @@ -30,8 +30,9 @@ import { resolveCollectionOffset, resolveCollectionPreviewBytes, resolveCollectionTimeoutMs, + previewAsCompatibilityMessage, } from "../message-previews.js"; -import type { Message, MessagePreview, MessagePreviewPage } from "../../types.js"; +import type { ChannelNotificationPage, MessagePreviewPage } from "../../types.js"; type Q = Record; @@ -51,37 +52,6 @@ function isHttpStatus(error: unknown, status: number): boolean { ); } -/** - * Compatibility for legacy Store consumers while the remote transport remains - * projection-only. `content` is the already bounded/redacted preview, never the - * source body; exact content is available only through getMessageById. - */ -function previewAsCompatibilityMessage(preview: MessagePreview): Message { - return { - id: preview.id, - session_id: preview.session_id, - from_agent: preview.from_agent, - to_agent: preview.to_agent, - channel: preview.channel, - project_id: preview.project_id, - content: preview.preview, - priority: preview.priority, - working_dir: preview.working_dir, - repository: preview.repository, - branch: preview.branch, - metadata: null, - created_at: preview.created_at, - read_at: preview.unread ? null : preview.created_at, - edited_at: preview.edited_at, - pinned_at: preview.pinned_at, - blocking: preview.blocking, - attachments: null, - reply_to: preview.reply_to, - reply_count: preview.reply_count, - truncated: true, - }; -} - export class ApiStore implements ConversationsStore { readonly transport = "cloud-http" as const; constructor(private readonly client: HasnaStorageClient) {} @@ -211,14 +181,23 @@ export class ApiStore implements ConversationsStore { return (body.channels ?? []) as never; }; readChannelNotifications: ConversationsStore["readChannelNotifications"] = async (opts) => { - const body = await this.get<{ notifications?: unknown[] }>("/channel-notifications/inbox", { + const limit = resolveCollectionLimit(opts.limit); + const cursor = resolveCollectionOffset(opts.cursor); + const maxBytes = resolveCollectionMaxBytes(opts.max_bytes); + const previewBytes = resolveCollectionPreviewBytes(opts.preview_bytes); + const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); + return await this.getBounded("/channel-notifications/inbox", { agent: opts.agent, channel: opts.channel ? normalizeChannelName(opts.channel) : undefined, unread_only: opts.unread_only, - limit: opts.limit, + limit, + cursor, + max_bytes: maxBytes, + preview_bytes: previewBytes, + timeout_ms: timeoutMs, since: normalizeSince(opts.since), - }); - return (body.notifications ?? []) as never; + mark_read: opts.mark_read ? true : undefined, + }, timeoutMs) as never; }; markChannelNotificationsRead: ConversationsStore["markChannelNotificationsRead"] = async (agent, messageIds) => { const body = await this.post<{ marked?: number }>("/channel-notifications/read", { agent, message_ids: messageIds }); @@ -696,8 +675,8 @@ export class ApiStore implements ConversationsStore { })) as never; }; exportMessages: ConversationsStore["exportMessages"] = async (opts) => { - const body = await this.get<{ export?: string }>("/messages/export", opts as Q); - return String(body?.export ?? "") as never; + const body = await this.post<{ artifact: unknown }>("/messages/exports", opts ?? {}); + return body.artifact as never; }; getThreadReplies: ConversationsStore["getThreadReplies"] = async (messageId) => { const page = await this.readMessagePreviews({ reply_to: messageId, order: "asc", limit: 100 }); @@ -724,14 +703,21 @@ export class ApiStore implements ConversationsStore { }, timeoutMs) as never; }; getMessagesForAgent: ConversationsStore["getMessagesForAgent"] = async (agent, opts) => { - const page = await this.readMessagePreviews({ - mentions_only: agent, + const limit = resolveCollectionLimit(opts?.limit ?? 50); + const maxBytes = COLLECTION_MAX_MAX_BYTES; + const timeoutMs = resolveCollectionTimeoutMs(undefined); + const page = await this.getBounded("/messages/for-agent", { + agent, channel: opts?.channel, - unread_only: opts?.unread_only, - limit: opts?.limit, - order: "desc", - }); - return page.messages.map((preview) => ({ message: previewAsCompatibilityMessage(preview), mention_id: preview.id })) as never; + unread_only: opts?.unread_only ? true : undefined, + limit, + max_bytes: maxBytes, + timeout_ms: timeoutMs, + }, timeoutMs); + return page.messages.map((preview) => ({ + message: previewAsCompatibilityMessage(preview), + mention_id: preview.mention_id ?? preview.id, + })) as never; }; getMessageReadStatus: ConversationsStore["getMessageReadStatus"] = async (messageId, channel) => { const body = await this.get<{ receipts?: unknown[]; unread_by?: string[] }>(`/messages/${encodeURIComponent(String(messageId))}/read-status`, { channel }); @@ -786,14 +772,18 @@ export class ApiStore implements ConversationsStore { } }; getPinnedMessages: ConversationsStore["getPinnedMessages"] = async (opts) => { - const page = await this.readMessagePreviews({ - pinned_only: true, + const limit = resolveCollectionLimit(opts?.limit); + const offset = resolveCollectionOffset(opts?.offset); + const maxBytes = COLLECTION_MAX_MAX_BYTES; + const timeoutMs = resolveCollectionTimeoutMs(undefined); + const page = await this.getBounded("/messages/pinned", { channel: opts?.channel, session_id: opts?.session_id, - limit: opts?.limit, - offset: opts?.offset, - order: "desc", - }); + limit, + offset, + max_bytes: maxBytes, + timeout_ms: timeoutMs, + }, timeoutMs); return page.messages.map(previewAsCompatibilityMessage) as never; }; recordReadReceipt: ConversationsStore["recordReadReceipt"] = async (messageId, agent) => { diff --git a/src/lib/store/index.ts b/src/lib/store/index.ts index cf52057..a744401 100644 --- a/src/lib/store/index.ts +++ b/src/lib/store/index.ts @@ -44,6 +44,8 @@ import * as hotLib from "../hot.js"; import * as messagesLib from "../messages.js"; import * as incidentProjectionsLib from "../incident-projections.js"; import type { IncidentProjectionRecord, IncidentProjectionRequestV1 } from "../../types.js"; +import { previewAsCompatibilityMessage, COLLECTION_MAX_MAX_BYTES } from "../message-previews.js"; +import { runLocalReadWorker } from "../local-read-runner.js"; const APP = "conversations"; @@ -290,7 +292,12 @@ export class LocalStore implements ConversationsStore { unsubscribeFromChannelNotifications: ConversationsStore["unsubscribeFromChannelNotifications"] = async (...a) => notificationsLib.unsubscribeFromChannelNotifications(...a); listChannelNotificationSubscriptions: ConversationsStore["listChannelNotificationSubscriptions"] = async (...a) => notificationsLib.listChannelNotificationSubscriptions(...a); getSubscribedChannels: ConversationsStore["getSubscribedChannels"] = async (...a) => notificationsLib.getSubscribedChannels(...a); - readChannelNotifications: ConversationsStore["readChannelNotifications"] = async (...a) => notificationsLib.readChannelNotifications(...a); + readChannelNotifications: ConversationsStore["readChannelNotifications"] = async (opts) => + runLocalReadWorker>( + "readChannelNotifications", + [opts], + opts.timeout_ms, + ); markChannelNotificationsRead: ConversationsStore["markChannelNotificationsRead"] = async (...a) => notificationsLib.markChannelNotificationsRead(...a); markAllChannelNotificationsRead: ConversationsStore["markAllChannelNotificationsRead"] = async (...a) => notificationsLib.markAllChannelNotificationsRead(...a); @@ -382,17 +389,79 @@ export class LocalStore implements ConversationsStore { getMessageById: ConversationsStore["getMessageById"] = async (...a) => messagesLib.getMessageById(...a); deleteMessage: ConversationsStore["deleteMessage"] = async (...a) => messagesLib.deleteMessage(...a); editMessage: ConversationsStore["editMessage"] = async (...a) => messagesLib.editMessage(...a); - readMessages: ConversationsStore["readMessages"] = async (...a) => messagesLib.readMessages(...a); - readMessagePreviews: ConversationsStore["readMessagePreviews"] = async (...a) => messagesLib.readMessagePreviews(...a); + readMessages: ConversationsStore["readMessages"] = async (opts = {}) => { + const page = await this.readMessagePreviews({ + ...opts, + preview_bytes: opts.max_content_length, + max_bytes: COLLECTION_MAX_MAX_BYTES, + timeout_ms: 5_000, + }); + return page.messages.map(previewAsCompatibilityMessage); + }; + readMessagePreviews: ConversationsStore["readMessagePreviews"] = async (opts = {}) => + runLocalReadWorker>( + "readMessagePreviews", + [opts], + opts.timeout_ms, + ); countMessages: ConversationsStore["countMessages"] = async (...a) => messagesLib.countMessages(...a); - searchMessages: ConversationsStore["searchMessages"] = async (...a) => messagesLib.searchMessages(...a); - searchMessagePreviews: ConversationsStore["searchMessagePreviews"] = async (...a) => messagesLib.searchMessagePreviews(...a); + searchMessages: ConversationsStore["searchMessages"] = async (opts) => { + const page = await this.searchMessagePreviews({ + ...opts, + preview_bytes: opts.snippet_length, + max_bytes: COLLECTION_MAX_MAX_BYTES, + timeout_ms: 5_000, + }); + return page.messages.map((preview) => ({ + ...previewAsCompatibilityMessage(preview), + snippet: preview.preview, + relevance_score: preview.relevance_score ?? 0, + })); + }; + searchMessagePreviews: ConversationsStore["searchMessagePreviews"] = async (opts) => + runLocalReadWorker>( + "searchMessagePreviews", + [opts], + opts.timeout_ms, + ); readDigest: ConversationsStore["readDigest"] = async (...a) => messagesLib.readDigest(...a); - exportMessages: ConversationsStore["exportMessages"] = async (...a) => messagesLib.exportMessages(...a); - getThreadReplies: ConversationsStore["getThreadReplies"] = async (...a) => messagesLib.getThreadReplies(...a); - getUnreadBlockers: ConversationsStore["getUnreadBlockers"] = async (...a) => messagesLib.getUnreadBlockers(...a); - getUnreadBlockerPreviews: ConversationsStore["getUnreadBlockerPreviews"] = async (...a) => messagesLib.getUnreadBlockerPreviews(...a); - getMessagesForAgent: ConversationsStore["getMessagesForAgent"] = async (...a) => messagesLib.getMessagesForAgent(...a); + exportMessages: ConversationsStore["exportMessages"] = async (opts = {}) => + runLocalReadWorker>( + "exportMessages", + [opts], + opts.timeout_ms, + ); + getThreadReplies: ConversationsStore["getThreadReplies"] = async (messageId) => { + const page = await this.readMessagePreviews({ + reply_to: messageId, + order: "asc", + limit: 100, + max_bytes: COLLECTION_MAX_MAX_BYTES, + timeout_ms: 5_000, + }); + return page.messages.map(previewAsCompatibilityMessage); + }; + getUnreadBlockers: ConversationsStore["getUnreadBlockers"] = async (agent, opts = {}) => { + const page = await this.getUnreadBlockerPreviews(agent, { + ...opts, + max_bytes: COLLECTION_MAX_MAX_BYTES, + timeout_ms: 5_000, + }); + return page.messages.map(previewAsCompatibilityMessage); + }; + getUnreadBlockerPreviews: ConversationsStore["getUnreadBlockerPreviews"] = async (agent, opts = {}) => + runLocalReadWorker>( + "getUnreadBlockerPreviews", + [agent, opts], + opts.timeout_ms, + ); + getMessagesForAgent: ConversationsStore["getMessagesForAgent"] = async (agent, opts = {}) => { + return runLocalReadWorker>( + "getMessagesForAgent", + [agent, opts], + undefined, + ); + }; getMessageReadStatus: ConversationsStore["getMessageReadStatus"] = async (...a) => messagesLib.getMessageReadStatus(...a); markRead: ConversationsStore["markRead"] = async (...a) => messagesLib.markRead(...a); markReadByIds: ConversationsStore["markReadByIds"] = async (...a) => messagesLib.markReadByIds(...a); @@ -406,7 +475,13 @@ export class LocalStore implements ConversationsStore { listUnreadCountsWithMentions: ConversationsStore["listUnreadCountsWithMentions"] = async (...a) => messagesLib.listUnreadCountsWithMentions(...a); pinMessage: ConversationsStore["pinMessage"] = async (...a) => messagesLib.pinMessage(...a); unpinMessage: ConversationsStore["unpinMessage"] = async (...a) => messagesLib.unpinMessage(...a); - getPinnedMessages: ConversationsStore["getPinnedMessages"] = async (...a) => messagesLib.getPinnedMessages(...a); + getPinnedMessages: ConversationsStore["getPinnedMessages"] = async (opts = {}) => { + return runLocalReadWorker>( + "getPinnedMessages", + [opts], + undefined, + ); + }; recordReadReceipt: ConversationsStore["recordReadReceipt"] = async (...a) => messagesLib.recordReadReceipt(...a); recordReadReceiptsBatch: ConversationsStore["recordReadReceiptsBatch"] = async (...a) => messagesLib.recordReadReceiptsBatch(...a); getReadReceipts: ConversationsStore["getReadReceipts"] = async (...a) => messagesLib.getReadReceipts(...a); diff --git a/src/mcp/channel.test.ts b/src/mcp/channel.test.ts index b8b7d5b..55b7025 100644 --- a/src/mcp/channel.test.ts +++ b/src/mcp/channel.test.ts @@ -81,7 +81,7 @@ describe("channel bridge delivery", () => { }); await waitFor(() => attempts >= 1); - expect(readChannelNotifications({ agent: "watcher", unread_only: true })).toHaveLength(1); + expect(readChannelNotifications({ agent: "watcher", unread_only: true }).notifications).toHaveLength(1); expect(delivered).toHaveLength(0); allowDelivery = true; @@ -92,7 +92,7 @@ describe("channel bridge delivery", () => { expect(delivered[0].params.meta.channel).toBe("notify-bridge"); expect(delivered[0].params.content).toContain("alice posted in #notify-bridge"); expect(delivered[0].params.content).toContain("Preview only for channel message"); - expect(readChannelNotifications({ agent: "watcher", unread_only: true })).toHaveLength(0); + expect(readChannelNotifications({ agent: "watcher", unread_only: true }).notifications).toHaveLength(0); } finally { stop(); } diff --git a/src/mcp/channel.ts b/src/mcp/channel.ts index fed105a..e000906 100644 --- a/src/mcp/channel.ts +++ b/src/mcp/channel.ts @@ -125,11 +125,16 @@ export function registerChannelBridge( const previews = (await getStore().readMessagePreviews({ to: agent, unread_only: true, order: "asc", limit: 20 })).messages .filter(message => message.id > lastAgentMsgId && message.from_agent !== agent); for (const preview of previews) { - const msg = await getStore().getMessageById(preview.id); - if (!msg) continue; - const delivered = await pushNotification(msg, "dm"); + const delivered = await pushNotification({ + id: preview.id, + content: preview.preview, + from_agent: preview.from_agent, + session_id: preview.session_id, + channel: preview.channel, + priority: preview.priority, + }, "dm"); if (!delivered) break; - lastAgentMsgId = msg.id; + lastAgentMsgId = preview.id; } } @@ -138,11 +143,16 @@ export function registerChannelBridge( const previews = (await getStore().readMessagePreviews({ to: `session:${sid}`, unread_only: true, order: "asc", limit: 20 })).messages .filter(message => message.id > lastSessionMsgId && message.from_agent !== agent); for (const preview of previews) { - const msg = await getStore().getMessageById(preview.id); - if (!msg) continue; - const delivered = await pushNotification(msg, "direct"); + const delivered = await pushNotification({ + id: preview.id, + content: preview.preview, + from_agent: preview.from_agent, + session_id: preview.session_id, + channel: preview.channel, + priority: preview.priority, + }, "direct"); if (!delivered) break; - lastSessionMsgId = msg.id; + lastSessionMsgId = preview.id; } } @@ -152,7 +162,7 @@ export function registerChannelBridge( unread_only: true, limit: 20, mark_read: false, - })).sort((left, right) => left.created_at.localeCompare(right.created_at) || left.message_id - right.message_id); + })).notifications.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.message_id - right.message_id); for (const notification of notifications) { const delivered = await pushNotification({ diff --git a/src/mcp/tools/channels.ts b/src/mcp/tools/channels.ts index f8ececc..534e8e2 100644 --- a/src/mcp/tools/channels.ts +++ b/src/mcp/tools/channels.ts @@ -260,21 +260,29 @@ export function registerChannelTools(server: McpServer): void { channel: z.string().optional(), unread_only: z.coerce.boolean().optional(), limit: z.coerce.number().optional(), + cursor: z.coerce.number().optional(), + max_bytes: z.coerce.number().optional(), + preview_bytes: z.coerce.number().optional(), + timeout_ms: z.coerce.number().optional(), since: z.string().optional(), mark_read: z.coerce.boolean().optional(), }, }, async (args: Record) => { const store = getStore(); const agent = resolveIdentity(args.from); - const notifications = await store.readChannelNotifications({ + const page = await store.readChannelNotifications({ agent, channel: args.channel, unread_only: args.unread_only, limit: args.limit, + cursor: args.cursor, + max_bytes: args.max_bytes, + preview_bytes: args.preview_bytes, + timeout_ms: args.timeout_ms, since: args.since, mark_read: args.mark_read, }); - return { content: [{ type: "text", text: JSON.stringify({ notifications, count: notifications.length }) }] }; + return { content: [{ type: "text", text: JSON.stringify(page) }] }; }); server.registerTool("mark_channel_notifications_read", { diff --git a/src/mcp/tools/messaging.test.ts b/src/mcp/tools/messaging.test.ts index da199b8..78bcbbe 100644 --- a/src/mcp/tools/messaging.test.ts +++ b/src/mcp/tools/messaging.test.ts @@ -7,6 +7,8 @@ import { createChannel } from "../../lib/channels"; import { getMessageById, getReadReceipts, sendMessage } from "../../lib/messages"; import { closeDb } from "../../lib/db"; import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../../test/hermetic"; +import { createHash } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; const TEST_STORE = createDisposableStore("messaging-mcp"); @@ -240,12 +242,35 @@ describe("messaging MCP tools", () => { }); describe("export_messages", () => { - test("exports as JSON by default", async () => { + test("returns a bounded preview artifact without inline bodies", async () => { + const privateBody = "MCP_EXPORT_BODY_MUST_NOT_BE_INLINE"; + sendMessage({ from: "export-sender", to: "export-reader", content: privateBody }); const result = parseResult(await client.callTool({ name: "export_messages", arguments: {}, }) as any) as any; - expect(Array.isArray(result)).toBe(true); + + expect(result.artifact_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(typeof result.path).toBe("string"); + expect(result.download_path).toBeNull(); + expect(result.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(result.format).toBe("json"); + expect(result.detail).toBe("preview"); + expect(typeof result.count).toBe("number"); + expect(result.byte_length).toBeLessThanOrEqual(result.max_bytes); + expect(typeof result.created_at).toBe("string"); + expect(JSON.stringify(result)).not.toContain(privateBody); + expect(result.messages).toBeUndefined(); + expect(result.content).toBeUndefined(); + + const artifact = readFileSync(result.path); + expect(statSync(result.path).mode & 0o777).toBe(0o600); + expect(createHash("sha256").update(artifact).digest("hex")).toBe(result.sha256); + const records = JSON.parse(artifact.toString("utf8")) as Array>; + expect(records.length).toBe(result.count); + expect(records.every((record) => !("content" in record))).toBe(true); + expect(records.every((record) => !("metadata" in record))).toBe(true); + expect(records.every((record) => !("attachments" in record))).toBe(true); }); }); diff --git a/src/mcp/tools/messaging.ts b/src/mcp/tools/messaging.ts index be0b14a..8040ce2 100644 --- a/src/mcp/tools/messaging.ts +++ b/src/mcp/tools/messaging.ts @@ -322,21 +322,37 @@ export function registerMessagingTools( }); server.registerTool("export_messages", { - description: "Export messages as JSON or CSV.", + description: "Create a bounded preview-only message export artifact. Returns artifact metadata, never inline message bodies.", inputSchema: { channel: z.string().optional(), session_id: z.string().optional(), from: z.string().optional(), since: z.string().optional(), until: z.string().optional(), - format: z.string().optional(), + format: z.enum(["json", "csv"]).optional(), + limit: z.coerce.number().optional(), + max_bytes: z.coerce.number().optional(), + preview_bytes: z.coerce.number().optional(), + timeout_ms: z.coerce.number().optional(), }, }, async (args: Record) => { - const { channel, session_id, from, since, until, format } = args; - const result = await getStore().exportMessages({ channel, session_id, from, since, until, format }); + const { channel, session_id, from, since, until, format, limit, max_bytes, preview_bytes, timeout_ms } = args; + const result = await getStore().exportMessages({ + channel, + session_id, + from, + since, + until, + format, + detail: "preview", + limit, + max_bytes, + preview_bytes, + timeout_ms, + }); return { - content: [{ type: "text", text: result }], + content: [{ type: "text", text: JSON.stringify(result) }], }; }); diff --git a/src/mcp/tools/projects.test.ts b/src/mcp/tools/projects.test.ts index b564e79..1f8b586 100644 --- a/src/mcp/tools/projects.test.ts +++ b/src/mcp/tools/projects.test.ts @@ -4,18 +4,21 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerProjectTools } from "./projects"; import { closeDb } from "../../lib/db"; -import { unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; +import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../../test/hermetic"; -const TEST_DB = join(tmpdir(), `conversations-test-projects-mcp-${Date.now()}.db`); +const TEST_STORE = createDisposableStore("projects-mcp"); describe("projects MCP tools", () => { let client: Client; + let restoreEnv: () => void; + let restoreNetwork: () => void; beforeAll(async () => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; - process.env.CONVERSATIONS_AGENT_ID = "projects-test-agent"; + restoreEnv = enterHermeticTestEnv({ + CONVERSATIONS_DB_PATH: TEST_STORE.dbPath, + CONVERSATIONS_AGENT_ID: "projects-test-agent", + }); + restoreNetwork = installNetworkGuard(); closeDb(); const server = new McpServer({ name: "test-projects-mcp", version: "0.0.1" }); @@ -28,13 +31,11 @@ describe("projects MCP tools", () => { }); afterAll(async () => { - delete process.env.CONVERSATIONS_DB_PATH; - delete process.env.CONVERSATIONS_AGENT_ID; - closeDb(); - try { unlinkSync(TEST_DB); } catch {} - try { unlinkSync(TEST_DB + "-wal"); } catch {} - try { unlinkSync(TEST_DB + "-shm"); } catch {} await client.close(); + closeDb(); + restoreNetwork(); + restoreEnv(); + TEST_STORE.cleanup(); }); function parseResult(result: { content: unknown[] }): unknown { diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 553886c..257f9a7 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -4,12 +4,24 @@ // @generated from OpenAPI by @hasna/contracts SDK generator — DO NOT EDIT. // Source: ConversationsClient 0.5.6 -export interface Message { "id"?: number; "uuid"?: string; "session_id"?: string; "from_agent"?: string; "to_agent"?: string; "channel"?: string | null; "project_id"?: string | null; "content"?: string; "priority"?: string; "blocking"?: boolean; "reply_to"?: number | null; "working_dir"?: string | null; "repository"?: string | null; "branch"?: string | null; "metadata"?: Record | null; "attachments"?: Array> | null; "created_at"?: string } +export interface Message { "id"?: number; "mention_id"?: number; "uuid"?: string; "session_id"?: string; "from_agent"?: string; "to_agent"?: string; "channel"?: string | null; "project_id"?: string | null; "content"?: string; "priority"?: string; "blocking"?: boolean; "reply_to"?: number | null; "working_dir"?: string | null; "repository"?: string | null; "branch"?: string | null; "metadata"?: Record | null; "attachments"?: Array> | null; "created_at"?: string } export interface MessagePreview { "id": number; "uuid"?: string; "session_id": string; "from_agent": string; "to_agent": string; "channel": string | null; "project_id": string | null; "priority": "low" | "normal" | "high" | "urgent"; "working_dir": string | null; "repository": string | null; "branch": string | null; "created_at": string; "edited_at": string | null; "pinned_at": string | null; "unread": boolean; "blocking": boolean; "reply_to": number | null; "reply_count"?: number; "attachment_count": number; "has_attachments": boolean; "has_metadata": boolean; "preview": string; "preview_bytes": number; "content_bytes": number; "truncated": boolean; "redacted": boolean; "relevance_score"?: number } export interface MessagePreviewPage { "messages": Array; "count": number; "limit": number; "cursor": number; "next_cursor": number | null; "has_more": boolean; "skipped_count": number; "byte_length": number; "max_bytes": number; "timeout_ms": number; "compact": true; "detail_path": "messages/{id}"; "query"?: string } +export interface ChannelNotification { "message_id": number; "channel": string; "from_agent": string; "created_at": string; "priority": "low" | "normal" | "high" | "urgent"; "preview": string; "unread": boolean; "has_attachments": boolean } + +export interface ChannelNotificationPage { "notifications": Array; "count": number; "limit": number; "cursor": number; "next_cursor": number | null; "has_more": boolean; "skipped_count": number; "byte_length": number; "max_bytes": number; "timeout_ms": number; "marked_read": number; "compact": true; "detail_path": "messages/{id}" } + +export interface FullExportAuthorization { "principal": string; "reason": string; "acknowledged": true } + +export interface MessageExportRequest { "channel"?: string; "session_id"?: string; "from"?: string; "since"?: string; "until"?: string; "format"?: "json" | "csv"; "detail"?: "preview" | "full"; "limit"?: number; "max_bytes"?: number; "preview_bytes"?: number; "timeout_ms"?: number; "authorization"?: FullExportAuthorization } + +export interface MessageExportArtifact { "artifact_id": string; "filename": string; "path": string | null; "download_path": string | null; "sha256": string; "format": "json" | "csv"; "detail": "preview" | "full"; "count": number; "has_more": boolean; "skipped_count": number; "byte_length": number; "max_bytes": number; "timeout_ms": number; "created_at": string } + +export interface MessageExportArtifactResponse { "artifact": MessageExportArtifact } + export interface MessageResponse { "message": Message } export interface Channel { "name"?: string; "description"?: string | null; "topic"?: string | null; "project_id"?: string | null; "created_by"?: string; "created_at"?: string; "archived_at"?: string | null } @@ -117,6 +129,15 @@ export class ConversationsClient { }); } + /** Read a bounded, cursored page of notifications for the authenticated principal */ + async readChannelNotifications(query?: { "agent"?: string; "channel"?: string; "since"?: string; "unread_only"?: boolean; "mark_read"?: boolean; "limit"?: number; "cursor"?: number; "max_bytes"?: number; "preview_bytes"?: number; "timeout_ms"?: number }, init?: RequestInit): Promise { + return this.request("GET", `/v1/channel-notifications/inbox`, { + body: undefined, + query, + init, + }); + } + async listChannels(query?: { "include_archived"?: boolean }, init?: RequestInit): Promise> { return this.request("GET", `/v1/channels`, { body: undefined, @@ -176,7 +197,7 @@ export class ConversationsClient { } /** List bounded, redacted message previews */ - async listMessages(query?: { "to"?: string; "from"?: string; "channel"?: string; "session"?: string; "limit"?: number; "offset"?: number; "order"?: "asc" | "desc"; "q"?: string; "unread_only"?: boolean; "threads_only"?: boolean; "pinned_only"?: boolean; "reply_to"?: number; "max_bytes"?: number; "preview_bytes"?: number; "timeout_ms"?: number }, init?: RequestInit): Promise { + async listMessages(query?: { "to"?: string; "from"?: string; "channel"?: string; "session"?: string; "id"?: number; "since_id"?: number; "limit"?: number; "offset"?: number; "order"?: "asc" | "desc"; "q"?: string; "unread_only"?: boolean; "threads_only"?: boolean; "pinned_only"?: boolean; "reply_to"?: number; "detail"?: "preview"; "max_bytes"?: number; "preview_bytes"?: number; "timeout_ms"?: number }, init?: RequestInit): Promise { return this.request("GET", `/v1/messages`, { body: undefined, query, @@ -211,6 +232,24 @@ export class ConversationsClient { }); } + /** Create a bounded message export artifact */ + async createMessageExport(body?: MessageExportRequest, init?: RequestInit): Promise { + return this.request("POST", `/v1/messages/exports`, { + body, + query: undefined, + init, + }); + } + + /** Download one bounded export artifact owned by the authenticated principal */ + async downloadMessageExport(artifactId: string, init?: RequestInit): Promise { + return this.request("GET", `/v1/messages/exports/${encodeURIComponent(String(artifactId))}`, { + body: undefined, + query: undefined, + init, + }); + } + /** Get one exact full message */ async getMessage(id: number, init?: RequestInit): Promise { return this.request("GET", `/v1/messages/${encodeURIComponent(String(id))}`, { diff --git a/src/sdk/message-preview.test.ts b/src/sdk/message-preview.test.ts index 2583ca4..8c9c4d0 100644 --- a/src/sdk/message-preview.test.ts +++ b/src/sdk/message-preview.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { ConversationsClient, + type ChannelNotificationPage, + type MessageExportArtifactResponse, type MessagePreviewPage, type MessageResponse, } from "./index"; @@ -76,4 +78,81 @@ describe("generated safe message-read client", () => { expect(requests[0]).toContain("preview_bytes=128"); expect(requests[2]).toEndWith("/v1/messages/41"); }); + + test("types notification pages and artifact-only exports with caps", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const notifications: ChannelNotificationPage = { + notifications: [{ + message_id: 41, + channel: "engineering", + from_agent: "alice", + created_at: "2026-07-19T00:00:00.000Z", + priority: "normal", + preview: "bounded coordination update", + unread: false, + has_attachments: false, + }], + count: 1, + limit: 10, + cursor: 0, + next_cursor: null, + has_more: false, + skipped_count: 0, + byte_length: 512, + max_bytes: 4096, + timeout_ms: 1000, + marked_read: 1, + compact: true, + detail_path: "messages/{id}", + }; + const exported: MessageExportArtifactResponse = { + artifact: { + artifact_id: "00000000-0000-4000-8000-000000000001", + filename: "message-export.json", + path: null, + download_path: "/v1/messages/exports/00000000-0000-4000-8000-000000000001", + sha256: "a".repeat(64), + format: "json", + detail: "preview", + count: 1, + has_more: false, + skipped_count: 0, + byte_length: 512, + max_bytes: 4096, + timeout_ms: 1000, + created_at: "2026-07-19T00:00:00.000Z", + }, + }; + const client = new ConversationsClient({ + baseUrl: "https://conversations.invalid", + fetch: (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + requests.push({ url, init }); + const body = url.includes("channel-notifications") ? notifications : exported; + return new Response(JSON.stringify(body), { status: url.includes("exports") ? 201 : 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch, + }); + + const page: ChannelNotificationPage = await client.readChannelNotifications({ + limit: 10, + cursor: 0, + max_bytes: 4096, + preview_bytes: 128, + timeout_ms: 1000, + mark_read: true, + }); + const artifact: MessageExportArtifactResponse = await client.createMessageExport({ + detail: "preview", + limit: 10, + max_bytes: 4096, + preview_bytes: 128, + timeout_ms: 1000, + }); + + expect(page.marked_read).toBe(1); + expect(requests[0].url).toContain("cursor=0"); + expect(artifact.artifact.path).toBeNull(); + expect(requests[1].init?.method).toBe("POST"); + expect(String(requests[1].init?.body)).toContain('"detail":"preview"'); + }); }); diff --git a/src/server/api.test.ts b/src/server/api.test.ts index c51a2ed..0f58d8f 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -3,7 +3,7 @@ import { startApiServer, type ApiServerDeps } from "./api.js"; import { mintApiKey } from "@hasna/contracts/auth"; import { verifyApiKey, ApiKeyStore } from "@hasna/contracts/auth"; import { readFileSync } from "node:fs"; -import { enterHermeticTestEnv, installNetworkGuard } from "../test/hermetic.js"; +import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../test/hermetic.js"; // In-memory query shim standing in for the vendored kit's TypedQueryClient. // Exercises the router + auth without a live Postgres. @@ -11,13 +11,28 @@ function makeFakeClient(incidentProjectionCount = 0) { const channels: Record = {}; const messages: any[] = []; const queries: string[] = []; + const queryCalls: Array<{ sql: string; params: readonly unknown[] }> = []; let nextId = 1; const client = { async many(sql: string, _p: readonly unknown[] = []): Promise { queries.push(sql); + queryCalls.push({ sql, params: _p }); if (/FROM channels/i.test(sql)) return Object.values(channels); if (/FROM messages/i.test(sql)) { const rows = messages.slice().reverse(); + if (/channel_subscriptions s/i.test(sql) && /read_message_id/i.test(sql)) { + return rows.map((message) => ({ + message_id: message.id, + channel: message.channel, + from_agent: message.from_agent, + created_at: message.created_at, + priority: message.priority, + preview_source: String(message.content ?? "").slice(0, 4096), + attachment_count: Array.isArray(message.attachments) ? message.attachments.length : 0, + preview_chars: 140, + read_message_id: null, + })); + } if (/preview_source/i.test(sql)) { return rows.map((message) => { const scope = [message.channel, message.to_agent, message.session_id].join(" ").toLowerCase(); @@ -54,6 +69,11 @@ function makeFakeClient(incidentProjectionCount = 0) { }, async query(sql: string, p: readonly unknown[] = []): Promise<{ rows: any[]; rowCount: number }> { queries.push(sql); + queryCalls.push({ sql, params: p }); + if (/INSERT INTO channel_notification_reads/i.test(sql)) { + const ids = Array.isArray((p as any[])[1]) ? (p as any[])[1] : []; + return { rows: [], rowCount: ids.length }; + } if (/INSERT INTO messages/i.test(sql) && /ON CONFLICT/i.test(sql)) { // One COALESCE(...) is emitted per row (for created_at) → row count. const numRows = (sql.match(/COALESCE\(/g) || []).length || 1; @@ -75,6 +95,7 @@ function makeFakeClient(incidentProjectionCount = 0) { }, async get(sql: string, p: readonly unknown[] = []): Promise { queries.push(sql); + queryCalls.push({ sql, params: p }); if (/SELECT 1 AS ok/i.test(sql)) return { ok: 1 }; if (/count\(\*\).*incident_projections/is.test(sql)) return { n: incidentProjectionCount }; if (/count\(\*\)/i.test(sql)) return { n: messages.length }; @@ -112,11 +133,15 @@ function makeFakeClient(incidentProjectionCount = 0) { } return null; }, - async execute(sql: string, _p: readonly unknown[] = []): Promise { queries.push(sql); }, + async execute(sql: string, _p: readonly unknown[] = []): Promise { + queries.push(sql); + queryCalls.push({ sql, params: _p }); + }, async transaction(fn: (tx: any) => Promise): Promise { return fn(client); }, queries, + queryCalls, }; return client; } @@ -146,9 +171,10 @@ let roKey: string; let projectorKey: string; let restoreEnv: () => void; let restoreNetwork: () => void; +const API_EXPORT_STORE = createDisposableStore("cloud-api-exports"); beforeAll(() => { - restoreEnv = enterHermeticTestEnv(); + restoreEnv = enterHermeticTestEnv({ CONVERSATIONS_EXPORT_DIR: `${API_EXPORT_STORE.dbPath}.artifacts` }); restoreNetwork = installNetworkGuard({ allowLoopback: true }); server = startApiServer({ port: 0, host: "127.0.0.1", deps: makeDeps() }); base = `http://127.0.0.1:${server.port}`; @@ -161,6 +187,7 @@ afterAll(() => { server.stop(true); restoreNetwork(); restoreEnv(); + API_EXPORT_STORE.cleanup(); }); describe("conversations-serve", () => { @@ -425,6 +452,141 @@ describe("conversations-serve", () => { } }); + test("malformed typed collection filters return 400 before any widened query", async () => { + const deps = makeDeps(); + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + const isolatedBase = `http://127.0.0.1:${isolated.port}`; + try { + for (const query of [ + "id=not-an-id", + "id=0", + "reply_to=-1", + "reply_to=1.5", + "since_id=-1", + "since_id=1.5", + "since=not-a-date", + "unread_only=perhaps", + "order=relevance", + "q=%20%20", + ]) { + const before = (deps.client as any).queryCalls.length; + const response = await fetch(`${isolatedBase}/v1/messages?${query}`, { headers: { "x-api-key": rwKey } }); + expect(response.status).toBe(400); + expect((deps.client as any).queryCalls.length).toBe(before); + } + const validZeroCursor = await fetch(`${isolatedBase}/v1/messages?since_id=0`, { headers: { "x-api-key": rwKey } }); + expect(validZeroCursor.status).toBe(200); + } finally { + isolated.stop(true); + } + }); + + test("notification pages bind to the authenticated principal and mark only returned ids", async () => { + const deps = makeDeps(); + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + const isolatedBase = `http://127.0.0.1:${isolated.port}`; + try { + for (const content of ["first", "second", "third"]) { + const sent = await fetch(`${isolatedBase}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ from: "alice", to: "ops", channel: "ops", content }), + }); + expect(sent.status).toBe(201); + } + + const spoofed = await fetch(`${isolatedBase}/v1/channel-notifications/inbox?agent=other`, { + headers: { "x-api-key": rwKey }, + }); + expect(spoofed.status).toBe(403); + + const pageResponse = await fetch( + `${isolatedBase}/v1/channel-notifications/inbox?agent=test&limit=2&cursor=0&max_bytes=4096&preview_bytes=80&timeout_ms=1000&mark_read=true`, + { headers: { "x-api-key": rwKey } }, + ); + expect(pageResponse.status).toBe(200); + const page = await pageResponse.json(); + expect(page.notifications).toHaveLength(2); + expect(page.notifications.every((notification: any) => notification.unread === false)).toBe(true); + expect(page.marked_read).toBe(2); + expect(page.has_more).toBe(true); + expect(page.next_cursor).toBe(2); + expect(page.byte_length).toBeLessThanOrEqual(page.max_bytes); + const markCall = (deps.client as any).queryCalls.findLast((call: any) => /INSERT INTO channel_notification_reads/i.test(call.sql)); + expect(markCall.params[1]).toEqual(page.notifications.map((notification: any) => notification.message_id)); + } finally { + isolated.stop(true); + } + }); + + test("exports are bounded artifacts and full detail is principal-bound", async () => { + const deps = makeDeps(); + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + const isolatedBase = `http://127.0.0.1:${isolated.port}`; + const rawBody = "principal-bound full export body"; + try { + await fetch(`${isolatedBase}/v1/messages`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ from: "test", to: "security-audit", channel: "security-audit", content: rawBody, metadata: { raw: "hidden" } }), + }); + + const previewResponse = await fetch(`${isolatedBase}/v1/messages/exports`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ channel: "security-audit", max_bytes: 4096, limit: 10 }), + }); + expect(previewResponse.status).toBe(201); + const previewArtifact = (await previewResponse.json()).artifact; + expect(previewArtifact.path).toBeNull(); + expect(previewArtifact.detail).toBe("preview"); + expect(previewArtifact.byte_length).toBeLessThanOrEqual(4096); + const previewDownload = await fetch(`${isolatedBase}${previewArtifact.download_path}`, { headers: { "x-api-key": rwKey } }); + expect(previewDownload.status).toBe(200); + const previewPayload = await previewDownload.text(); + expect(previewPayload).not.toContain(rawBody); + expect(previewPayload).not.toContain('"content"'); + expect(previewDownload.headers.get("x-content-sha256")).toBe(previewArtifact.sha256); + + const otherPrincipalDownload = await fetch(`${isolatedBase}${previewArtifact.download_path}`, { headers: { "x-api-key": roKey } }); + expect(otherPrincipalDownload.status).toBe(404); + + const missingAuthorization = await fetch(`${isolatedBase}/v1/messages/exports`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ detail: "full" }), + }); + expect(missingAuthorization.status).toBe(400); + const spoofedAuthorization = await fetch(`${isolatedBase}/v1/messages/exports`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ + detail: "full", + authorization: { principal: "other", reason: "not authorized", acknowledged: true }, + }), + }); + expect(spoofedAuthorization.status).toBe(403); + + const fullResponse = await fetch(`${isolatedBase}/v1/messages/exports`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ + channel: "security-audit", + detail: "full", + max_bytes: 4096, + authorization: { principal: "test", reason: "audited incident handoff", acknowledged: true }, + }), + }); + expect(fullResponse.status).toBe(201); + const fullArtifact = (await fullResponse.json()).artifact; + const fullDownload = await fetch(`${isolatedBase}${fullArtifact.download_path}`, { headers: { "x-api-key": rwKey } }); + expect(fullDownload.status).toBe(200); + expect(await fullDownload.text()).toContain(rawBody); + } finally { + isolated.stop(true); + } + }); + test("POST /v1/messages validates required fields", async () => { const r = await fetch(`${base}/v1/messages`, { method: "POST", diff --git a/src/server/api.ts b/src/server/api.ts index fc21ad1..c890660 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -25,7 +25,11 @@ import { version as pkgVersion } from "../../package.json"; import { openapiSpec } from "./openapi.js"; import { normalizeChannelName } from "../lib/channel-names.js"; import { extractTopics } from "../lib/topic-extract.js"; -import { buildMessagePreview as buildChannelNotificationPreview } from "../lib/channel-notifications.js"; +import { + buildByteBoundedMessagePreview as buildChannelNotificationPreview, + finalizeChannelNotificationPage, + packChannelNotificationPage, +} from "../lib/channel-notifications.js"; import { IncidentProjectionConflictError, IncidentProjectionValidationError, @@ -38,7 +42,12 @@ import { CHANNEL_IDENTITY_ADVISORY_LOCK, getIncidentProjectionPg, } from "./incident-projections.js"; -import type { IncidentProjectionRequestV1, IncidentProjectorContext } from "../types.js"; +import type { + ChannelNotification, + ExportMessagesOptions, + IncidentProjectionRequestV1, + IncidentProjectorContext, +} from "../types.js"; import { COLLECTION_PREVIEW_SCAN_CHARS, RESTRICTED_CHANNEL_PREVIEW, @@ -51,6 +60,12 @@ import { resolveCollectionPreviewBytes, resolveCollectionTimeoutMs, } from "../lib/message-previews.js"; +import { + loadMessageExportArtifact, + resolveMessageExportOptions, + serializeMessageExport, + writeMessageExportArtifact, +} from "../lib/message-exports.js"; export const APP = "conversations"; const SCOPE_READ = `${APP}:read`; @@ -312,6 +327,42 @@ function str(v: unknown): string | undefined { return typeof v === "string" && v.trim() ? v.trim() : undefined; } +function strictIntegerParam(raw: string | null, name: string, allowZero = false): number | undefined { + if (raw === null) return undefined; + const normalized = raw.trim(); + const pattern = allowZero ? /^(?:0|[1-9]\d*)$/ : /^[1-9]\d*$/; + if (!pattern.test(normalized)) { + throw new ApiRequestValidationError(`${name} must be ${allowZero ? "a non-negative" : "a positive"} integer`); + } + const parsed = Number(normalized); + if (!Number.isSafeInteger(parsed)) throw new ApiRequestValidationError(`${name} is outside the safe integer range`); + return parsed; +} + +function strictBooleanParam(raw: string | null, name: string): boolean { + if (raw === null) return false; + const normalized = raw.trim().toLowerCase(); + if (["true", "1", "yes"].includes(normalized)) return true; + if (["false", "0", "no"].includes(normalized)) return false; + throw new ApiRequestValidationError(`${name} must be true or false`); +} + +function strictStringParam(raw: string | null, name: string): string | undefined { + if (raw === null) return undefined; + const normalized = raw.trim(); + if (!normalized) throw new ApiRequestValidationError(`${name} must not be empty`); + return normalized; +} + +function strictDateParam(raw: string | null, name: string): string | undefined { + const normalized = strictStringParam(raw, name); + if (normalized === undefined) return undefined; + if (!Number.isFinite(Date.parse(normalized))) { + throw new ApiRequestValidationError(`${name} must be a valid ISO 8601 date`); + } + return normalized; +} + function clampLimit(raw: string | null, def = 50, max = 500): number { let n = parseInt(raw || String(def), 10); if (!Number.isFinite(n) || n <= 0) n = def; @@ -470,13 +521,6 @@ function parseServerProject(row: Record): Record 0) { params.push(Number(idRaw)); clauses.push(`id = $${params.length}`); } + if (id !== undefined) { params.push(id); clauses.push(`id = $${params.length}`); } if (to) { params.push(to); clauses.push(`to_agent = $${params.length}`); } if (from) { params.push(from); clauses.push(`from_agent = $${params.length}`); } if (channel) { params.push(channel); clauses.push(`channel = $${params.length}`); } @@ -1041,7 +1095,7 @@ async function handleV1( if (projectId) { params.push(projectId); clauses.push(`project_id = $${params.length}`); } if (uuid) { params.push(uuid); clauses.push(`uuid = $${params.length}`); } if (since) { params.push(since); clauses.push(`created_at > $${params.length}`); } - if (sinceIdRaw && Number.isFinite(Number(sinceIdRaw))) { params.push(Number(sinceIdRaw)); clauses.push(`id > $${params.length}`); } + if (sinceId !== undefined) { params.push(sinceId); clauses.push(`id > $${params.length}`); } if (q) { params.push(`%${q}%`); clauses.push(`content ILIKE $${params.length}`); } if (mentionsOnly) { params.push(mentionsOnly.toLowerCase()); @@ -1049,13 +1103,13 @@ async function handleV1( } if (unreadOnly) clauses.push(`read_at IS NULL`); if (threadsOnly) clauses.push(`reply_to IS NULL`); - if (replyToRaw && Number.isSafeInteger(Number(replyToRaw)) && Number(replyToRaw) > 0) { params.push(Number(replyToRaw)); clauses.push(`reply_to = $${params.length}`); } + if (replyTo !== undefined) { params.push(replyTo); clauses.push(`reply_to = $${params.length}`); } if (pinnedOnly) clauses.push(`pinned_at IS NOT NULL`); - if (isTrue(url.searchParams.get("blocking_only"))) clauses.push(`blocking = true`); + if (blockingOnly) clauses.push(`blocking = true`); const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; // count=1 → authoritative total (honours the same filters). Lets callers // verify backfill parity from the API without paging through every row. - if (str(url.searchParams.get("count"))) { + if (countOnly) { let timeoutMs: number; try { timeoutMs = resolveCollectionTimeoutMs(url.searchParams.get("timeout_ms")); @@ -1073,8 +1127,6 @@ async function handleV1( ? `, (SELECT count(*) FROM messages r WHERE r.reply_to = messages.id)::int AS reply_count` : ""; - if (detail === "full") return json({ error: "Full collection reads are disabled; use GET /v1/messages/{id} for one exact message" }, 400); - params.push(collection.limit + 1); const limitIdx = params.length; params.push(collection.offset); @@ -1269,39 +1321,105 @@ async function handleV1( })); } - // ---- export messages (json|csv) ---- - if (sub === "messages/export" && method === "GET") { + const exportArtifactMatch = sub.match(/^messages\/exports\/([0-9a-f-]+)$/i); + if (exportArtifactMatch && method === "GET") { + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const loaded = loadMessageExportArtifact(exportArtifactMatch[1], agent); + if (!loaded) return json({ error: "Export artifact not found" }, 404); + const responseBytes = new Uint8Array(loaded.payload).buffer; + return new Response(responseBytes, { + status: 200, + headers: { + ...SECURITY_HEADERS, + "Content-Type": loaded.contentType, + "Content-Length": String(loaded.artifact.byte_length), + "Content-Disposition": `attachment; filename="${loaded.artifact.filename}"`, + "X-Content-SHA256": loaded.artifact.sha256, + }, + }); + } + + // ---- bounded export artifacts (preview by default; full is explicit) ---- + if ((sub === "messages/exports" && method === "POST") || (sub === "messages/export" && method === "GET")) { + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const body = method === "POST" ? await readJson(req) : {}; + const queryValue = (name: string): unknown => method === "POST" ? body[name] : url.searchParams.get(name) ?? undefined; + const optionalString = (name: string): string | undefined => { + const value = queryValue(name); + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || !value.trim()) throw new ApiRequestValidationError(`${name} must be a non-empty string`); + return value.trim(); + }; + const optionalNumber = (name: string): number | undefined => { + const value = queryValue(name); + if (value === undefined || value === null || value === "") return undefined; + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new ApiRequestValidationError(`${name} must be a positive integer`); + return parsed; + }; + const authorization = method === "POST" && body.authorization && typeof body.authorization === "object" && !Array.isArray(body.authorization) + ? body.authorization as Record + : undefined; + if (authorization && authorization.acknowledged !== true) { + throw new ApiRequestValidationError("authorization.acknowledged must be true"); + } + const opts: ExportMessagesOptions = { + channel: optionalString("channel"), + session_id: optionalString("session_id") ?? optionalString("session"), + from: optionalString("from"), + since: optionalString("since"), + until: optionalString("until"), + format: optionalString("format") as ExportMessagesOptions["format"], + detail: (method === "GET" ? "preview" : optionalString("detail")) as ExportMessagesOptions["detail"], + limit: optionalNumber("limit"), + max_bytes: optionalNumber("max_bytes"), + preview_bytes: optionalNumber("preview_bytes"), + timeout_ms: optionalNumber("timeout_ms"), + authorization: authorization ? { + principal: typeof authorization.principal === "string" ? authorization.principal : "", + reason: typeof authorization.reason === "string" ? authorization.reason : "", + acknowledged: true, + } : undefined, + }; + if (opts.detail === "full" && opts.authorization && opts.authorization.principal.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "full export authorization principal must match the authenticated agent" }, 403); + } + let resolved: ReturnType; + try { + resolved = resolveMessageExportOptions(opts); + } catch (error) { + throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); + } const clauses: string[] = []; const params: unknown[] = []; - const channel = str(url.searchParams.get("channel")); - const session = str(url.searchParams.get("session_id")) ?? str(url.searchParams.get("session")); - const from = str(url.searchParams.get("from")); - const since = str(url.searchParams.get("since")); - const until = str(url.searchParams.get("until")); - if (channel) { params.push(normalizeChannelName(channel)); clauses.push(`channel = $${params.length}`); } - if (session) { params.push(session); clauses.push(`session_id = $${params.length}`); } - if (from) { params.push(from); clauses.push(`from_agent = $${params.length}`); } - if (since) { params.push(since); clauses.push(`created_at >= $${params.length}`); } - if (until) { params.push(until); clauses.push(`created_at <= $${params.length}`); } + if (opts.channel) { params.push(normalizeChannelName(opts.channel)); clauses.push(`channel = $${params.length}`); } + if (opts.session_id) { params.push(opts.session_id); clauses.push(`session_id = $${params.length}`); } + if (opts.from) { params.push(opts.from); clauses.push(`from_agent = $${params.length}`); } + if (opts.since) { params.push(opts.since); clauses.push(`created_at >= $${params.length}`); } + if (opts.until) { params.push(opts.until); clauses.push(`created_at <= $${params.length}`); } const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; - const rows = await client.many>( - `SELECT id, uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, - blocking, reply_to, working_dir, repository, branch, metadata, edited_at, pinned_at, - attachments, created_at, read_at - FROM messages ${where} ORDER BY created_at ASC, id ASC`, + params.push(resolved.limit + 1); + const limitIdx = params.length; + const projection = resolved.detail === "preview" + ? messagePreviewProjectionPg() + : `id, uuid, session_id, from_agent, to_agent, channel, project_id, content, priority, + blocking, reply_to, working_dir, repository, branch, metadata, edited_at, pinned_at, + attachments, created_at, read_at`; + const rows = await boundedCollectionQuery(client, resolved.timeoutMs, (tx) => tx.many>( + `SELECT ${projection} FROM messages ${where} ORDER BY created_at ASC, id ASC LIMIT $${limitIdx}`, params, - ); - const messages = rows.map(parseServerMessage); - const format = str(url.searchParams.get("format")) === "csv" ? "csv" : "json"; - if (format === "csv") { - const headers = "id,session_id,from_agent,to_agent,channel,content,priority,created_at,read_at"; - const lines = messages.map((m) => [ - String(m.id), csv(m.session_id), csv(m.from_agent), csv(m.to_agent), csv(m.channel), - csv(m.content), csv(m.priority), csv(m.created_at), csv(m.read_at), - ].join(",")); - return json({ export: [headers, ...lines].join("\n") }); - } - return json({ export: JSON.stringify(messages, null, 2) }); + )); + const records = rows.slice(0, resolved.limit).map((row) => resolved.detail === "preview" + ? buildCollectionMessagePreview(row, resolved.previewBytes) as unknown as Record + : parseServerMessage(row)); + const serialized = serializeMessageExport(records, { + format: resolved.format, + detail: resolved.detail, + maxBytes: resolved.maxBytes, + hasMore: rows.length > resolved.limit, + }); + const artifact = writeMessageExportArtifact(serialized, resolved, agent, "remote"); + return json({ artifact }, 201); } // ---- messages that @mention an agent ---- @@ -2078,13 +2196,23 @@ async function handleChannelNotifications( if (sub === "channel-notifications" && method === "POST") { const body = await readJson(req); - const channel = str(body.channel); - const who = str(body.agent) ?? agent ?? undefined; - if (!channel || !who) return json({ error: "channel and agent are required" }, 400); + const channel = typeof body.channel === "string" ? body.channel.trim() : undefined; + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const requestedAgent = body.agent === undefined ? undefined : typeof body.agent === "string" && body.agent.trim() + ? body.agent.trim() + : (() => { throw new ApiRequestValidationError("agent must be a non-empty string"); })(); + if (requestedAgent && requestedAgent.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "notification agent must match the authenticated agent" }, 403); + } + const who = agent; + if (!channel) return json({ error: "channel is required" }, 400); const channelName = normalizeChannelName(channel); const exists = await client.get(`SELECT name FROM channels WHERE name = $1`, [channelName]); if (!exists) return json({ error: `Channel not found: ${channel}` }, 404); - const previewChars = Number.isFinite(Number(body.preview_chars)) && Number(body.preview_chars) > 0 ? Math.floor(Number(body.preview_chars)) : 140; + const previewChars = body.preview_chars === undefined ? 140 : Number(body.preview_chars); + if (!Number.isSafeInteger(previewChars) || previewChars <= 0 || previewChars > 1024) { + throw new ApiRequestValidationError("preview_chars must be a positive integer no greater than 1024"); + } const maxRow = await client.get<{ max_id: number }>(`SELECT COALESCE(MAX(id), 0)::int AS max_id FROM messages WHERE channel = $1`, [channelName]); await client.query( `INSERT INTO channel_subscriptions (channel, agent, preview_chars, since_message_id) VALUES ($1,$2,$3,$4) @@ -2099,21 +2227,25 @@ async function handleChannelNotifications( } if (sub === "channel-notifications" && method === "GET") { - const who = str(url.searchParams.get("agent")); - const rows = who - ? await client.many( - `SELECT channel, agent, created_at, preview_chars, since_message_id FROM channel_subscriptions WHERE agent = $1 ORDER BY created_at ASC, channel ASC`, - [who], - ) - : await client.many( - `SELECT channel, agent, created_at, preview_chars, since_message_id FROM channel_subscriptions ORDER BY agent ASC, channel ASC`, - ); + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const requestedAgent = strictStringParam(url.searchParams.get("agent"), "agent"); + if (requestedAgent && requestedAgent.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "notification agent must match the authenticated agent" }, 403); + } + const rows = await client.many( + `SELECT channel, agent, created_at, preview_chars, since_message_id FROM channel_subscriptions WHERE agent = $1 ORDER BY created_at ASC, channel ASC`, + [agent], + ); return json({ subscriptions: rows }); } if (sub === "channel-notifications/subscribed" && method === "GET") { - const who = str(url.searchParams.get("agent")); - if (!who) return json({ error: "agent is required" }, 400); + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const requestedAgent = strictStringParam(url.searchParams.get("agent"), "agent"); + if (requestedAgent && requestedAgent.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "notification agent must match the authenticated agent" }, 403); + } + const who = agent; const rows = await client.many<{ channel: string }>( `SELECT channel FROM channel_subscriptions WHERE agent = $1 ORDER BY created_at ASC, channel ASC`, [who], @@ -2122,16 +2254,23 @@ async function handleChannelNotifications( } if (sub === "channel-notifications/inbox" && method === "GET") { - const who = str(url.searchParams.get("agent")); - if (!who) return json({ error: "agent is required" }, 400); + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const requestedAgent = strictStringParam(url.searchParams.get("agent"), "agent"); + if (requestedAgent && requestedAgent.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "notification agent must match the authenticated agent" }, 403); + } + const who = agent; const clauses = ["s.agent = $1", "m.channel IS NOT NULL", "m.from_agent <> $1", "m.id > s.since_message_id"]; const params: unknown[] = [who]; - const channel = str(url.searchParams.get("channel")); + const channel = strictStringParam(url.searchParams.get("channel"), "channel"); if (channel) { params.push(normalizeChannelName(channel)); clauses.push(`m.channel = $${params.length}`); } - const since = str(url.searchParams.get("since")); + const since = strictDateParam(url.searchParams.get("since"), "since"); if (since) { params.push(since); clauses.push(`m.created_at > $${params.length}`); } // Default filters to unread unless explicitly unread_only=false (matches local). - if (url.searchParams.get("unread_only") !== "false") clauses.push("snr.message_id IS NULL"); + const unreadOnly = url.searchParams.get("unread_only") === null + ? true + : strictBooleanParam(url.searchParams.get("unread_only"), "unread_only"); + if (unreadOnly) clauses.push("snr.message_id IS NULL"); const collection = collectionReadOptions(url); const restricted = restrictedMessagePredicatePg("m"); params.push(collection.limit + 1); @@ -2153,40 +2292,59 @@ async function handleChannelNotifications( ORDER BY m.created_at DESC, m.id DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`, params, )); - const candidates = rows.slice(0, collection.limit).map((r) => ({ + const markRequested = strictBooleanParam(url.searchParams.get("mark_read"), "mark_read"); + const candidates = rows.map((r) => ({ message_id: Number(r.message_id), channel: r.channel, from_agent: r.from_agent, created_at: r.created_at, - priority: r.priority, - preview: buildChannelNotificationPreview(r.preview_source, Math.min(Number(r.preview_chars ?? 140), collection.previewBytes)), - unread: r.read_message_id == null, + priority: r.priority as ChannelNotification["priority"], + preview: buildChannelNotificationPreview(r.preview_source, Number(r.preview_chars ?? 140), collection.previewBytes), + unread: markRequested ? false : r.read_message_id == null, has_attachments: Number(r.attachment_count) > 0, - })); - const notifications: typeof candidates = []; - for (const candidate of candidates) { - const envelope = { notifications: [...notifications, candidate] }; - if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > collection.maxBytes) break; - notifications.push(candidate); - } - const skipped = notifications.length === 0 && candidates.length > 0 ? 1 : 0; - const consumed = notifications.length + skipped; - const hasMore = rows.length > consumed; - return json({ - notifications, - count: notifications.length, + })) satisfies ChannelNotification[]; + let page = packChannelNotificationPage(candidates, { + limit: collection.limit, cursor: collection.offset, - next_cursor: hasMore || skipped > 0 ? collection.offset + consumed : null, - has_more: hasMore, - skipped_count: skipped, max_bytes: collection.maxBytes, + timeout_ms: collection.timeoutMs, + marked_read: markRequested ? Math.min(collection.limit, candidates.length) : 0, }); + if (markRequested && page.notifications.length > 0) { + const ids = page.notifications.map((notification) => notification.message_id); + const marked = await client.query( + `INSERT INTO channel_notification_reads (agent, message_id) + SELECT $1, x FROM unnest($2::bigint[]) AS x ON CONFLICT DO NOTHING`, + [who, ids], + ); + page = finalizeChannelNotificationPage({ ...page, marked_read: marked.rowCount }); + } + if (page.byte_length > collection.maxBytes) { + throw new ApiRequestValidationError(`channel notification envelope exceeds max_bytes (${page.byte_length} > ${collection.maxBytes})`); + } + return json(page); } if (sub === "channel-notifications/read" && method === "POST") { const body = await readJson(req); - const who = str(body.agent) ?? agent ?? undefined; - const ids = Array.isArray(body.message_ids) ? (body.message_ids as unknown[]).map(Number).filter((n) => Number.isFinite(n)) : []; + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const requestedAgent = body.agent === undefined ? undefined : typeof body.agent === "string" && body.agent.trim() + ? body.agent.trim() + : (() => { throw new ApiRequestValidationError("agent must be a non-empty string"); })(); + if (requestedAgent && requestedAgent.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "notification agent must match the authenticated agent" }, 403); + } + const who = agent; + if (body.message_ids !== undefined && !Array.isArray(body.message_ids)) { + throw new ApiRequestValidationError("message_ids must be an array of positive integers"); + } + const ids = Array.isArray(body.message_ids) ? (body.message_ids as unknown[]).map((value) => { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new ApiRequestValidationError("message_ids must contain only positive integers"); + } + return parsed; + }) : []; if (!who || ids.length === 0) return json({ marked: 0 }); const res = await client.query( `INSERT INTO channel_notification_reads (agent, message_id) @@ -2198,8 +2356,14 @@ async function handleChannelNotifications( if (sub === "channel-notifications/read-all" && method === "POST") { const body = await readJson(req); - const who = str(body.agent) ?? agent ?? undefined; - if (!who) return json({ error: "agent is required" }, 400); + if (!agent) return json({ error: "authenticated agent is required" }, 401); + const requestedAgent = body.agent === undefined ? undefined : typeof body.agent === "string" && body.agent.trim() + ? body.agent.trim() + : (() => { throw new ApiRequestValidationError("agent must be a non-empty string"); })(); + if (requestedAgent && requestedAgent.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "notification agent must match the authenticated agent" }, 403); + } + const who = agent; const params: unknown[] = [who]; let channelClause = ""; const channel = str(body.channel); @@ -2218,8 +2382,13 @@ async function handleChannelNotifications( const unsubMatch = sub.match(/^channel-notifications\/([^/]+)\/([^/]+)$/); if (unsubMatch && method === "DELETE") { + if (!agent) return json({ error: "authenticated agent is required" }, 401); const channelName = normalizeChannelName(decodeURIComponent(unsubMatch[1])); - const who = decodeURIComponent(unsubMatch[2]); + const requestedAgent = decodeURIComponent(unsubMatch[2]); + if (requestedAgent.toLowerCase() !== agent.toLowerCase()) { + return json({ error: "notification agent must match the authenticated agent" }, 403); + } + const who = agent; const res = await client.query(`DELETE FROM channel_subscriptions WHERE channel = $1 AND agent = $2`, [channelName, who]); if (res.rowCount === 0) return json({ error: "Subscription not found" }, 404); return json({ unsubscribed: true }); diff --git a/src/server/openapi.ts b/src/server/openapi.ts index 3c64c0d..0935933 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -25,6 +25,7 @@ export const openapiSpec = { type: "object", properties: { id: { type: "integer" }, + mention_id: { type: "integer" }, uuid: { type: "string" }, session_id: { type: "string" }, from_agent: { type: "string" }, @@ -105,6 +106,102 @@ export const openapiSpec = { query: { type: "string" }, }, }, + ChannelNotification: { + type: "object", + additionalProperties: false, + required: ["message_id", "channel", "from_agent", "created_at", "priority", "preview", "unread", "has_attachments"], + properties: { + message_id: { type: "integer" }, + channel: { type: "string" }, + from_agent: { type: "string" }, + created_at: { type: "string", format: "date-time" }, + priority: { type: "string", enum: ["low", "normal", "high", "urgent"] }, + preview: { type: "string" }, + unread: { type: "boolean" }, + has_attachments: { type: "boolean" }, + }, + }, + ChannelNotificationPage: { + type: "object", + additionalProperties: false, + required: [ + "notifications", "count", "limit", "cursor", "next_cursor", "has_more", "skipped_count", + "byte_length", "max_bytes", "timeout_ms", "marked_read", "compact", "detail_path", + ], + properties: { + notifications: { type: "array", items: { $ref: "#/components/schemas/ChannelNotification" } }, + count: { type: "integer" }, + limit: { type: "integer", maximum: 100 }, + cursor: { type: "integer", minimum: 0 }, + next_cursor: { type: "integer", nullable: true }, + has_more: { type: "boolean" }, + skipped_count: { type: "integer" }, + byte_length: { type: "integer" }, + max_bytes: { type: "integer", maximum: 65536 }, + timeout_ms: { type: "integer", maximum: 5000 }, + marked_read: { type: "integer" }, + compact: { type: "boolean", enum: [true] }, + detail_path: { type: "string", enum: ["messages/{id}"] }, + }, + }, + FullExportAuthorization: { + type: "object", + additionalProperties: false, + required: ["principal", "reason", "acknowledged"], + properties: { + principal: { type: "string", minLength: 1 }, + reason: { type: "string", minLength: 1 }, + acknowledged: { type: "boolean", enum: [true] }, + }, + }, + MessageExportRequest: { + type: "object", + additionalProperties: false, + properties: { + channel: { type: "string" }, + session_id: { type: "string" }, + from: { type: "string" }, + since: { type: "string", format: "date-time" }, + until: { type: "string", format: "date-time" }, + format: { type: "string", enum: ["json", "csv"], default: "json" }, + detail: { type: "string", enum: ["preview", "full"], default: "preview" }, + limit: { type: "integer", minimum: 1, maximum: 100 }, + max_bytes: { type: "integer", minimum: 512, maximum: 65536 }, + preview_bytes: { type: "integer", minimum: 1, maximum: 1024 }, + timeout_ms: { type: "integer", minimum: 1, maximum: 5000 }, + authorization: { $ref: "#/components/schemas/FullExportAuthorization" }, + }, + }, + MessageExportArtifact: { + type: "object", + additionalProperties: false, + required: [ + "artifact_id", "filename", "path", "download_path", "sha256", "format", "detail", "count", + "has_more", "skipped_count", "byte_length", "max_bytes", "timeout_ms", "created_at", + ], + properties: { + artifact_id: { type: "string", format: "uuid" }, + filename: { type: "string" }, + path: { type: "string", nullable: true, description: "Always null on the HTTP API." }, + download_path: { type: "string", nullable: true }, + sha256: { type: "string", pattern: "^[0-9a-f]{64}$" }, + format: { type: "string", enum: ["json", "csv"] }, + detail: { type: "string", enum: ["preview", "full"] }, + count: { type: "integer" }, + has_more: { type: "boolean" }, + skipped_count: { type: "integer" }, + byte_length: { type: "integer", maximum: 65536 }, + max_bytes: { type: "integer", maximum: 65536 }, + timeout_ms: { type: "integer", maximum: 5000 }, + created_at: { type: "string", format: "date-time" }, + }, + }, + MessageExportArtifactResponse: { + type: "object", + additionalProperties: false, + required: ["artifact"], + properties: { artifact: { $ref: "#/components/schemas/MessageExportArtifact" } }, + }, MessageResponse: { type: "object", required: ["message"], @@ -322,6 +419,8 @@ export const openapiSpec = { { name: "from", in: "query", schema: { type: "string" } }, { name: "channel", in: "query", schema: { type: "string" } }, { name: "session", in: "query", schema: { type: "string" } }, + { name: "id", in: "query", schema: { type: "integer", minimum: 1 } }, + { name: "since_id", in: "query", schema: { type: "integer", minimum: 0 } }, { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100 } }, { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }, { name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"] } }, @@ -329,7 +428,8 @@ export const openapiSpec = { { name: "unread_only", in: "query", schema: { type: "boolean" } }, { name: "threads_only", in: "query", schema: { type: "boolean" } }, { name: "pinned_only", in: "query", schema: { type: "boolean" } }, - { name: "reply_to", in: "query", schema: { type: "integer" } }, + { name: "reply_to", in: "query", schema: { type: "integer", minimum: 1 } }, + { name: "detail", in: "query", schema: { type: "string", enum: ["preview"] } }, { name: "max_bytes", in: "query", schema: { type: "integer", minimum: 512, maximum: 65536 } }, { name: "preview_bytes", in: "query", schema: { type: "integer", minimum: 1, maximum: 1024 } }, { name: "timeout_ms", in: "query", schema: { type: "integer", minimum: 1, maximum: 5000 } }, @@ -357,6 +457,63 @@ export const openapiSpec = { responses: { "201": { description: "created", content: { "application/json": { schema: okObject } } } }, }, }, + "/v1/messages/exports": { + post: { + operationId: "createMessageExport", + summary: "Create a bounded message export artifact", + description: + "Creates a preview-only artifact by default. Full detail requires an explicit authorization object whose principal matches the authenticated API-key principal. The response contains metadata only; retrieve the bounded artifact separately.", + requestBody: { + required: false, + content: { "application/json": { schema: { $ref: "#/components/schemas/MessageExportRequest" } } }, + }, + responses: { + "201": { description: "artifact created", content: { "application/json": { schema: { $ref: "#/components/schemas/MessageExportArtifactResponse" } } } }, + "400": { description: "malformed request" }, + "403": { description: "authorization principal mismatch" }, + }, + }, + }, + "/v1/messages/exports/{artifact_id}": { + get: { + operationId: "downloadMessageExport", + summary: "Download one bounded export artifact owned by the authenticated principal", + parameters: [{ name: "artifact_id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + responses: { + "200": { + description: "bounded artifact payload", + content: { + "application/json": { schema: { type: "string", format: "binary" } }, + "text/csv": { schema: { type: "string", format: "binary" } }, + }, + }, + "404": { description: "missing or not owned by this principal" }, + }, + }, + }, + "/v1/channel-notifications/inbox": { + get: { + operationId: "readChannelNotifications", + summary: "Read a bounded, cursored page of notifications for the authenticated principal", + description: "The optional agent filter must match the API-key principal. mark_read acknowledges only notification ids returned in this page.", + parameters: [ + { name: "agent", in: "query", schema: { type: "string" } }, + { name: "channel", in: "query", schema: { type: "string" } }, + { name: "since", in: "query", schema: { type: "string", format: "date-time" } }, + { name: "unread_only", in: "query", schema: { type: "boolean" } }, + { name: "mark_read", in: "query", schema: { type: "boolean" } }, + { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100 } }, + { name: "cursor", in: "query", schema: { type: "integer", minimum: 0 } }, + { name: "max_bytes", in: "query", schema: { type: "integer", minimum: 512, maximum: 65536 } }, + { name: "preview_bytes", in: "query", schema: { type: "integer", minimum: 1, maximum: 1024 } }, + { name: "timeout_ms", in: "query", schema: { type: "integer", minimum: 1, maximum: 5000 } }, + ], + responses: { + "200": { description: "notification page", content: { "application/json": { schema: { $ref: "#/components/schemas/ChannelNotificationPage" } } } }, + "403": { description: "agent does not match authenticated principal" }, + }, + }, + }, "/v1/messages/bulk": { post: { operationId: "bulkIngestMessages", diff --git a/src/server/serve.test.ts b/src/server/serve.test.ts index db4f250..b7b530c 100644 --- a/src/server/serve.test.ts +++ b/src/server/serve.test.ts @@ -4,7 +4,7 @@ import { sendMessage } from "../lib/messages"; import { createChannel, joinChannel } from "../lib/channels"; import { createProject } from "../lib/projects"; import { closeDb } from "../lib/db"; -import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { @@ -22,6 +22,7 @@ let restoreNetwork: () => void; beforeAll(() => { restoreEnv = enterHermeticTestEnv({ CONVERSATIONS_DB_PATH: TEST_STORE.dbPath, + CONVERSATIONS_EXPORT_DIR: `${TEST_STORE.dbPath}.exports`, CONVERSATIONS_DASHBOARD_DIST: TEST_DASHBOARD_DIST, }); restoreNetwork = installNetworkGuard({ allowLoopback: true }); @@ -577,15 +578,17 @@ describe("API /api/agents", () => { }); describe("API /api/export", () => { - test("GET exports messages as JSON by default", async () => { + test("GET creates a preview JSON artifact by default", async () => { sendMessage({ from: "export-user", to: "other", content: "export-test-msg" }); const res = await fetch(`${base()}/api/export`); expect(res.status).toBe(200); const ct = res.headers.get("content-type") || ""; expect(ct).toContain("application/json"); - const data = await res.json() as any[]; - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBeGreaterThanOrEqual(1); + const data = await res.json() as any; + expect(data.artifact.detail).toBe("preview"); + const payload = JSON.parse(readFileSync(data.artifact.path, "utf8")); + expect(payload.length).toBeGreaterThanOrEqual(1); + expect(payload.every((message: any) => message.content === undefined)).toBe(true); }); test("GET exports messages as CSV", async () => { @@ -593,9 +596,10 @@ describe("API /api/export", () => { const res = await fetch(`${base()}/api/export?format=csv`); expect(res.status).toBe(200); const ct = res.headers.get("content-type") || ""; - expect(ct).toContain("text/csv"); - const text = await res.text(); - expect(text.length).toBeGreaterThan(0); + expect(ct).toContain("application/json"); + const data = await res.json() as any; + expect(data.artifact.format).toBe("csv"); + expect(readFileSync(data.artifact.path, "utf8")).toContain("preview"); }); test("GET filters by channel", async () => { @@ -603,16 +607,18 @@ describe("API /api/export", () => { sendMessage({ from: "a", to: "export-sp", content: "export-sp-msg", channel: "export-sp" }); const res = await fetch(`${base()}/api/export?channel=export-sp`); expect(res.status).toBe(200); - const data = await res.json() as any[]; - expect(data.every((m: any) => m.channel === "export-sp")).toBe(true); + const data = await res.json() as any; + const payload = JSON.parse(readFileSync(data.artifact.path, "utf8")); + expect(payload.every((m: any) => m.channel === "export-sp")).toBe(true); }); test("GET filters by from", async () => { sendMessage({ from: "export-sender", to: "b", content: "export-from-msg" }); const res = await fetch(`${base()}/api/export?from=export-sender`); expect(res.status).toBe(200); - const data = await res.json() as any[]; - expect(data.every((m: any) => m.from_agent === "export-sender")).toBe(true); + const data = await res.json() as any; + const payload = JSON.parse(readFileSync(data.artifact.path, "utf8")); + expect(payload.every((m: any) => m.from_agent === "export-sender")).toBe(true); }); }); diff --git a/src/server/serve.ts b/src/server/serve.ts index 8594223..3c7fbfe 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -7,7 +7,8 @@ * conversations dashboard # Start dashboard server */ -import { readMessagePreviews, sendMessage, markRead, searchMessagePreviews, exportMessages, deleteMessage, editMessage, pinMessage, unpinMessage, getMessageById } from "../lib/messages.js"; +import { sendMessage, markRead, deleteMessage, editMessage, pinMessage, unpinMessage, getMessageById } from "../lib/messages.js"; +import { getStore } from "../lib/store/index.js"; import { listSessions, getSession } from "../lib/sessions.js"; import { listChannels, getChannel, createChannel, updateChannel, archiveChannel, unarchiveChannel, joinChannel, leaveChannel, getChannelMembers } from "../lib/channels.js"; import { listProjects, getProject, getProjectByName, createProject, updateProject, deleteProject } from "../lib/projects.js"; @@ -217,7 +218,7 @@ export function startDashboardServer(port = 0, host?: string) { const from = url.searchParams.get("from") || undefined; const to = url.searchParams.get("to") || undefined; try { - const page = readMessagePreviews({ + const page = await getStore().readMessagePreviews({ session_id: session, channel, from, @@ -276,7 +277,7 @@ export function startDashboardServer(port = 0, host?: string) { const from = url.searchParams.get("from") || undefined; const to = url.searchParams.get("to") || undefined; try { - const page = searchMessagePreviews({ + const page = await getStore().searchMessagePreviews({ query: q.trim(), channel, from, @@ -300,26 +301,27 @@ export function startDashboardServer(port = 0, host?: string) { const since = url.searchParams.get("since") || undefined; const until = url.searchParams.get("until") || undefined; const format = url.searchParams.get("format") === "csv" ? "csv" : "json"; - const result = exportMessages({ channel, session_id: session, from, since, until, format }); - - if (format === "csv") { - return new Response(result, { - status: 200, - headers: securityHeaders({ - "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": "attachment; filename=\"messages.csv\"", - "Cache-Control": "no-store", - }), - }); - } - return jsonResponse(JSON.parse(result)); + const artifact = await getStore().exportMessages({ + channel, + session_id: session, + from, + since, + until, + format, + detail: "preview", + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined, + max_bytes: url.searchParams.get("max_bytes") ? Number(url.searchParams.get("max_bytes")) : undefined, + preview_bytes: url.searchParams.get("preview_bytes") ? Number(url.searchParams.get("preview_bytes")) : undefined, + timeout_ms: url.searchParams.get("timeout_ms") ? Number(url.searchParams.get("timeout_ms")) : undefined, + }); + return jsonResponse({ artifact }); } if (path === "/api/messages/pinned" && req.method === "GET") { const channel = url.searchParams.get("channel") || undefined; const session_id = url.searchParams.get("session_id") || undefined; try { - const page = readMessagePreviews({ + const page = await getStore().readMessagePreviews({ pinned_only: true, channel, session_id, diff --git a/src/test/hermetic.ts b/src/test/hermetic.ts index e6756ea..31ca062 100644 --- a/src/test/hermetic.ts +++ b/src/test/hermetic.ts @@ -40,6 +40,9 @@ export const AMBIENT_TEST_ENV_KEYS = [ "CONVERSATIONS_DASHBOARD_HOST", "CONVERSATIONS_DASHBOARD_PORT", "CONVERSATIONS_REGISTRY_TIMEOUT_MS", + "CONVERSATIONS_LOCAL_READ_WORKER", + "HASNA_CONVERSATIONS_EXPORT_DIR", + "CONVERSATIONS_EXPORT_DIR", ] as const; export function enterHermeticTestEnv(overrides: Record = {}): () => void { diff --git a/src/types.ts b/src/types.ts index a3952a1..461fdae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,8 @@ export interface Message { */ export interface MessagePreview { id: number; + /** Present on mention collection reads; distinct from the message id. */ + mention_id?: number; uuid?: string; session_id: string; from_agent: string; @@ -134,6 +136,23 @@ export interface ChannelNotification { has_attachments: boolean; } +/** Bounded, cursored notification page shared by local and cloud transports. */ +export interface ChannelNotificationPage { + notifications: ChannelNotification[]; + count: number; + limit: number; + cursor: number; + next_cursor: number | null; + has_more: boolean; + skipped_count: number; + byte_length: number; + max_bytes: number; + timeout_ms: number; + marked_read: number; + compact: true; + detail_path: "messages/{id}"; +} + export interface ChannelInfo extends Channel { member_count: number; message_count: number; @@ -300,6 +319,55 @@ export interface ReadMessagePreviewsOptions extends ReadMessagesOptions { timeout_ms?: number; } +export type ExportDetail = "preview" | "full"; +export type ExportFormat = "json" | "csv"; + +/** + * Full exports are deliberately separate from ordinary preview exports. The + * acknowledgement is explicit and is bound to the authenticated principal by + * the HTTP surface (or to the local invoking identity by the CLI). + */ +export interface FullExportAuthorization { + principal: string; + reason: string; + acknowledged: true; +} + +export interface ExportMessagesOptions { + channel?: string; + session_id?: string; + from?: string; + since?: string; + until?: string; + format?: ExportFormat; + detail?: ExportDetail; + limit?: number; + max_bytes?: number; + preview_bytes?: number; + timeout_ms?: number; + authorization?: FullExportAuthorization; +} + +/** Export results are file artifacts; message bodies are never returned inline. */ +export interface MessageExportArtifact { + artifact_id: string; + filename: string; + /** Absolute local path for LocalStore artifacts; never exposed by the HTTP API. */ + path: string | null; + /** Authenticated HTTP retrieval path for remote artifacts. */ + download_path: string | null; + sha256: string; + format: ExportFormat; + detail: ExportDetail; + count: number; + has_more: boolean; + skipped_count: number; + byte_length: number; + max_bytes: number; + timeout_ms: number; + created_at: string; +} + export interface SearchMessagesOptions { query: string; channel?: string; From d0fa30cca0c98b86b488c5a3ab563807c2fd8808 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Sun, 19 Jul 2026 07:46:17 +0300 Subject: [PATCH 6/8] fix: close safe-read contract gaps --- scripts/generate-sdk.ts | 15 +++++ src/lib/channel-notifications.ts | 17 +++-- src/lib/local-read-runner.ts | 1 + src/lib/local-read-worker.ts | 5 ++ src/lib/message-previews.ts | 16 +++-- src/lib/messages.ts | 63 +++++++++++++++---- src/lib/safe-read-remediation.test.ts | 49 +++++++++++++++ src/lib/store/api-store.ts | 40 +++++++++--- src/lib/store/index.ts | 9 +++ src/mcp/tools/advanced.test.ts | 65 ++++++++++++++++++- src/mcp/tools/advanced.ts | 62 +++++++++++++----- src/sdk/index.ts | 6 +- src/sdk/message-preview.test.ts | 38 +++++++++++ src/server/api.test.ts | 73 ++++++++++++++++++++++ src/server/api.ts | 90 +++++++++++++++++++++------ src/server/openapi.ts | 14 ++++- src/types.ts | 11 ++++ 17 files changed, 499 insertions(+), 75 deletions(-) diff --git a/scripts/generate-sdk.ts b/scripts/generate-sdk.ts index fd3ab7b..e0d26d6 100644 --- a/scripts/generate-sdk.ts +++ b/scripts/generate-sdk.ts @@ -34,6 +34,21 @@ for (const interfaceName of ["IncidentProjectionEventV1", "IncidentProjectionRec } } +// The endpoint negotiates two artifact representations. The shared generator +// selects the first OpenAPI media type, while the runtime correctly parses JSON +// arrays and returns CSV as text. Preserve that media-type union in the public +// signature until the shared generator emits response-content unions itself. +const downloadExportArray = /async downloadMessageExport\(([^\n]+)\): Promise>/; +const downloadExportUnion = /async downloadMessageExport\(([^\n]+)\): Promise \| string>/; +if (downloadExportArray.test(generatedCode)) { + generatedCode = generatedCode.replace( + downloadExportArray, + "async downloadMessageExport($1): Promise | string>", + ); +} else if (!downloadExportUnion.test(generatedCode)) { + throw new Error("SDK generator output for downloadMessageExport no longer contains the expected artifact type"); +} + const header = "// @generated from src/server/openapi.ts by scripts/generate-sdk.ts — DO NOT EDIT.\n" + "// Regenerate: bun run sdk:generate\n\n"; diff --git a/src/lib/channel-notifications.ts b/src/lib/channel-notifications.ts index bb3732a..aa2849b 100644 --- a/src/lib/channel-notifications.ts +++ b/src/lib/channel-notifications.ts @@ -255,11 +255,18 @@ export function readChannelNotifications(opts: ReadChannelNotificationsOptions): let markedRead = 0; if (opts.mark_read && page.notifications.length > 0) { - markedRead = markChannelNotificationsRead(opts.agent, page.notifications.map((row) => row.message_id)); - page = finalizeChannelNotificationPage({ - ...page, - notifications: page.notifications.map((row) => ({ ...row, unread: false })), - marked_read: markedRead, + const ids = page.notifications.map((row) => row.message_id); + page = db.transaction(() => { + markedRead = markChannelNotificationsRead(opts.agent, ids); + const finalized = finalizeChannelNotificationPage({ + ...page, + notifications: page.notifications.map((row) => ({ ...row, unread: false })), + marked_read: markedRead, + }); + if (finalized.byte_length > maxBytes) { + throw new Error(`channel notification envelope exceeds max_bytes (${finalized.byte_length} > ${maxBytes})`); + } + return finalized; }); } diff --git a/src/lib/local-read-runner.ts b/src/lib/local-read-runner.ts index 46584e0..8bd5e39 100644 --- a/src/lib/local-read-runner.ts +++ b/src/lib/local-read-runner.ts @@ -8,6 +8,7 @@ export type LocalReadOperation = | "readMessagePreviews" | "searchMessagePreviews" | "getUnreadBlockerPreviews" + | "readMentionPreviews" | "getMessagesForAgent" | "getPinnedMessages" | "readChannelNotifications" diff --git a/src/lib/local-read-worker.ts b/src/lib/local-read-worker.ts index 2f25055..84fa78a 100644 --- a/src/lib/local-read-worker.ts +++ b/src/lib/local-read-worker.ts @@ -5,6 +5,7 @@ import { getMessagesForAgent, getPinnedMessages, getUnreadBlockerPreviews, + readMentionPreviews, readMessagePreviews, searchMessagePreviews, } from "./messages.js"; @@ -14,6 +15,7 @@ type LocalReadOperation = | "readMessagePreviews" | "searchMessagePreviews" | "getUnreadBlockerPreviews" + | "readMentionPreviews" | "getMessagesForAgent" | "getPinnedMessages" | "readChannelNotifications" @@ -78,6 +80,9 @@ self.onmessage = (event: MessageEvent) => { case "getUnreadBlockerPreviews": result = getUnreadBlockerPreviews(request.args[0] as string, request.args[1] as never); break; + case "readMentionPreviews": + result = readMentionPreviews(request.args[0] as string, request.args[1] as never); + break; case "getMessagesForAgent": result = getMessagesForAgent(request.args[0] as string, request.args[1] as never); break; diff --git a/src/lib/message-previews.ts b/src/lib/message-previews.ts index 3f063eb..5ebf86a 100644 --- a/src/lib/message-previews.ts +++ b/src/lib/message-previews.ts @@ -23,7 +23,10 @@ const REDACTION_RULES: Array<[RegExp, string]> = [ ]; function strictPositiveInteger(name: string, value: unknown, fallback: number): number { - if (value === undefined || value === null || value === "") return fallback; + if (value === undefined || value === null) return fallback; + if (typeof value === "string" && !value.trim()) { + throw new Error(`${name} must be a positive integer`); + } const parsed = typeof value === "number" ? value : Number(value); if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { throw new Error(`${name} must be a positive integer`); @@ -36,7 +39,10 @@ export function resolveCollectionLimit(value: unknown): number { } export function resolveCollectionOffset(value: unknown): number { - if (value === undefined || value === null || value === "") return 0; + if (value === undefined || value === null) return 0; + if (typeof value === "string" && !value.trim()) { + throw new Error("cursor must be a non-negative integer"); + } const parsed = typeof value === "number" ? value : Number(value); if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) { throw new Error("cursor must be a non-negative integer"); @@ -136,7 +142,7 @@ export function buildMessagePreview(row: Record, previewBytes = created_at: boundedSafeString(row.created_at, 64), edited_at: nullableSafeString(row.edited_at, 64), pinned_at: nullableSafeString(row.pinned_at, 64), - unread: row.unread === true || (row.unread === undefined && (row.read_at === null || row.read_at === undefined)), + unread: row.unread === true || row.unread === 1 || (row.unread === undefined && (row.read_at === null || row.read_at === undefined)), blocking: row.blocking === true || row.blocking === 1, reply_to: row.reply_to == null ? null : Number(row.reply_to), attachment_count: attachments, @@ -186,7 +192,7 @@ export function previewAsCompatibilityMessage(preview: MessagePreview): Message }; } -function finalizePage(page: MessagePreviewPage): MessagePreviewPage { +export function finalizeMessagePreviewPage(page: MessagePreviewPage): MessagePreviewPage { let finalized = page; for (let i = 0; i < 3; i++) { finalized = { ...finalized, byte_length: Buffer.byteLength(JSON.stringify(finalized), "utf8") }; @@ -207,7 +213,7 @@ export function packMessagePreviewPage( let messages: MessagePreview[] = []; let skippedCount = 0; - const build = (items: MessagePreview[], hasMore: boolean, skipped: number): MessagePreviewPage => finalizePage({ + const build = (items: MessagePreview[], hasMore: boolean, skipped: number): MessagePreviewPage => finalizeMessagePreviewPage({ messages: items, count: items.length, limit, diff --git a/src/lib/messages.ts b/src/lib/messages.ts index d2101f0..f5c896c 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -5,6 +5,7 @@ import type { SendMessageOptions, ReadMessagesOptions, ReadMessagePreviewsOptions, + ReadMentionPreviewsOptions, SearchMessagesOptions, SearchMessagePreviewsOptions, SearchResult, @@ -1513,48 +1514,86 @@ export function listUnreadCountsWithMentions(agent: string): MentionCount[] { return rows; } -/** Bounded preview compatibility reader for mention collections. */ -export function getMessagesForAgent(agent: string, opts?: { channel?: string; unread_only?: boolean; limit?: number }): Array<{ message: Message; mention_id: number }> { +/** Dedicated bounded @mention projection keyed by message_mentions.id/notified_at. */ +export function readMentionPreviews(agent: string, opts: ReadMentionPreviewsOptions = {}): MessagePreviewPage { assertOptionalFilter("agent", agent); - assertOptionalFilter("channel", opts?.channel); + assertOptionalFilter("channel", opts.channel); + const startedAt = performance.now(); + const timeoutMs = resolveCollectionTimeoutMs(opts.timeout_ms); + const limit = resolveCollectionLimit(opts.limit ?? 50); + const offset = resolveCollectionOffset(opts.offset); + const previewBytes = resolveCollectionPreviewBytes(opts.preview_bytes); const db = getDb(); const conditions = ["mm.mentioned_agent = ?"]; const params: (string | number)[] = [agent.toLowerCase()]; - if (opts?.channel) { + if (opts.channel) { conditions.push("m.channel = ?"); params.push(normalizeChannelName(opts.channel)); } - if (opts?.unread_only) conditions.push("mm.notified_at IS NULL"); - const limit = resolveCollectionLimit(opts?.limit ?? 50); + if (opts.unread_only) conditions.push("mm.notified_at IS NULL"); const rows = db.prepare( - `SELECT ${previewProjectionColumns("m")}, mm.id AS mention_id FROM messages m + `SELECT ${previewProjectionColumns("m")}, mm.id AS mention_id, + CASE WHEN mm.notified_at IS NULL THEN 1 ELSE 0 END AS unread + FROM messages m JOIN message_mentions mm ON mm.message_id = m.id WHERE ${conditions.join(" AND ")} - ORDER BY m.created_at DESC, m.id DESC LIMIT ${limit + 1}`, + ORDER BY m.created_at DESC, m.id DESC LIMIT ${limit + 1} OFFSET ${offset}`, ).all(...params) as Record[]; - const page = packMessagePreviewPage(rows.map((row) => buildMessagePreview(row)), { + assertCollectionDeadline(startedAt, timeoutMs); + const page = packMessagePreviewPage(rows.map((row) => buildMessagePreview(row, previewBytes)), { limit, + cursor: offset, + max_bytes: opts.max_bytes, + timeout_ms: timeoutMs, + }); + assertCollectionDeadline(startedAt, timeoutMs); + return page; +} + +/** Bounded preview compatibility reader for mention collections. */ +export function getMessagesForAgent(agent: string, opts?: { channel?: string; unread_only?: boolean; limit?: number }): Array<{ message: Message; mention_id: number }> { + const page = readMentionPreviews(agent, { + ...opts, max_bytes: COLLECTION_MAX_MAX_BYTES, }); return page.messages.map((preview) => ({ message: previewAsCompatibilityMessage(preview), - mention_id: preview.mention_id ?? preview.id, + mention_id: preview.mention_id!, })); } +/** Mark only explicitly returned mention rows for the named agent. */ +export function markMentionsReadByIds(agent: string, mentionIds: number[]): number { + assertOptionalFilter("agent", agent); + if (mentionIds.length === 0) return 0; + if (mentionIds.some((id) => !Number.isSafeInteger(id) || id <= 0)) { + throw new Error("mention_ids must contain only positive integers"); + } + const db = getDb(); + const placeholders = mentionIds.map(() => "?").join(","); + const result = db.prepare( + `UPDATE message_mentions + SET notified_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') + WHERE mentioned_agent = ? AND id IN (${placeholders}) AND notified_at IS NULL`, + ).run(agent.toLowerCase(), ...mentionIds); + return result.changes; +} + /** Mark mentions as notified (agent has seen them). */ export function markMentionsRead(agent: string, channel?: string): number { + assertOptionalFilter("agent", agent); + assertOptionalFilter("channel", channel); const db = getDb(); if (channel) { const normalized = normalizeChannelName(channel); const result = db.prepare( "UPDATE message_mentions SET notified_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE mentioned_agent = ? AND channel = ? AND notified_at IS NULL" - ).run(agent, normalized); + ).run(agent.toLowerCase(), normalized); return result.changes; } const result = db.prepare( "UPDATE message_mentions SET notified_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE mentioned_agent = ? AND notified_at IS NULL" - ).run(agent); + ).run(agent.toLowerCase()); return result.changes; } diff --git a/src/lib/safe-read-remediation.test.ts b/src/lib/safe-read-remediation.test.ts index 1b859fd..f43b554 100644 --- a/src/lib/safe-read-remediation.test.ts +++ b/src/lib/safe-read-remediation.test.ts @@ -10,6 +10,13 @@ import { readChannelNotifications, subscribeToChannelNotifications, } from "./channel-notifications.js"; +import { + resolveCollectionLimit, + resolveCollectionMaxBytes, + resolveCollectionOffset, + resolveCollectionPreviewBytes, + resolveCollectionTimeoutMs, +} from "./message-previews.js"; import { closeDb, getDb } from "./db.js"; import { LocalStore } from "./store/index.js"; import { @@ -160,6 +167,48 @@ describe("E-00051 safe public read boundaries", () => { expect(remaining.notifications.map((item) => item.message_id)).toEqual([first.id]); }); + test("a 775-byte notification envelope failure leaves unread state unchanged", () => { + createChannel("ops", "creator"); + subscribeToChannelNotifications("ops", "reader"); + sendMessage({ from: "alice", to: "ops", channel: "ops", content: "x".repeat(115) }); + sendMessage({ from: "alice", to: "ops", channel: "ops", content: "x".repeat(115) }); + + const before = readChannelNotifications({ + agent: "reader", + limit: 2, + max_bytes: 775, + timeout_ms: 1_000, + }); + expect(before.byte_length).toBe(775); + expect(before.notifications).toHaveLength(2); + expect(before.notifications.every((item) => item.unread)).toBe(true); + + expect(() => readChannelNotifications({ + agent: "reader", + limit: 2, + max_bytes: 775, + timeout_ms: 1_000, + mark_read: true, + })).toThrow("channel notification envelope exceeds max_bytes (777 > 775)"); + + const after = readChannelNotifications({ agent: "reader", limit: 20, max_bytes: 8 * 1024 }); + expect(after.notifications).toHaveLength(2); + expect(after.notifications.every((item) => item.unread)).toBe(true); + }); + + test("present-but-empty shared collection options fail closed", () => { + for (const resolve of [ + resolveCollectionLimit, + resolveCollectionOffset, + resolveCollectionMaxBytes, + resolveCollectionPreviewBytes, + resolveCollectionTimeoutMs, + ]) { + expect(() => resolve("")).toThrow(); + expect(() => resolve(" ")).toThrow(); + } + }); + test("local collection deadlines terminate the SQLite worker with no late mutation or worker leak", async () => { // Initialize the disposable schema before the worker starts its deliberately // long read. The only write in the worker is sequenced after that read. diff --git a/src/lib/store/api-store.ts b/src/lib/store/api-store.ts index 98542dc..2f3542f 100644 --- a/src/lib/store/api-store.ts +++ b/src/lib/store/api-store.ts @@ -38,7 +38,7 @@ type Q = Record; function prune(q: Q): Record { const out: Record = {}; - for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== "") out[k] = v; + for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null) out[k] = v; return out; } @@ -702,21 +702,32 @@ export class ApiStore implements ConversationsStore { timeout_ms: timeoutMs, }, timeoutMs) as never; }; - getMessagesForAgent: ConversationsStore["getMessagesForAgent"] = async (agent, opts) => { - const limit = resolveCollectionLimit(opts?.limit ?? 50); - const maxBytes = COLLECTION_MAX_MAX_BYTES; - const timeoutMs = resolveCollectionTimeoutMs(undefined); - const page = await this.getBounded("/messages/for-agent", { + readMentionPreviews: ConversationsStore["readMentionPreviews"] = async (agent, opts) => { + const o = opts ?? {}; + const limit = resolveCollectionLimit(o.limit ?? 50); + const offset = resolveCollectionOffset(o.offset); + const maxBytes = resolveCollectionMaxBytes(o.max_bytes); + const previewBytes = resolveCollectionPreviewBytes(o.preview_bytes); + const timeoutMs = resolveCollectionTimeoutMs(o.timeout_ms); + return await this.getBounded("/messages/for-agent", { agent, - channel: opts?.channel, - unread_only: opts?.unread_only ? true : undefined, + channel: o.channel, + unread_only: o.unread_only, limit, + offset, max_bytes: maxBytes, + preview_bytes: previewBytes, timeout_ms: timeoutMs, - }, timeoutMs); + }, timeoutMs) as never; + }; + getMessagesForAgent: ConversationsStore["getMessagesForAgent"] = async (agent, opts) => { + const page = await this.readMentionPreviews(agent, { + ...opts, + max_bytes: COLLECTION_MAX_MAX_BYTES, + }); return page.messages.map((preview) => ({ message: previewAsCompatibilityMessage(preview), - mention_id: preview.mention_id ?? preview.id, + mention_id: preview.mention_id!, })) as never; }; getMessageReadStatus: ConversationsStore["getMessageReadStatus"] = async (messageId, channel) => { @@ -741,6 +752,15 @@ export class ApiStore implements ConversationsStore { const res = await this.post<{ marked_unread?: number }>("/messages/unread", { ids }); return Number(res?.marked_unread ?? 0) as never; }; + markMentionsReadByIds: ConversationsStore["markMentionsReadByIds"] = async (agent, mentionIds) => { + if (mentionIds.length === 0) return 0 as never; + const res = await this.post<{ marked?: number }>("/messages/read", { + reader: agent, + mentions_only: true, + mention_ids: mentionIds, + }); + return Number(res?.marked ?? 0) as never; + }; markMentionsRead: ConversationsStore["markMentionsRead"] = async (agent, channel) => { const res = await this.post<{ marked?: number }>("/messages/read", { reader: agent, mentions_only: true, channel: channel ? normalizeChannelName(channel) : undefined }); return Number(res?.marked ?? 0) as never; diff --git a/src/lib/store/index.ts b/src/lib/store/index.ts index a744401..6a74d92 100644 --- a/src/lib/store/index.ts +++ b/src/lib/store/index.ts @@ -239,6 +239,7 @@ export interface ConversationsStore { getThreadReplies: Async; getUnreadBlockers: Async; getUnreadBlockerPreviews: Async; + readMentionPreviews: Async; getMessagesForAgent: Async; getMessageReadStatus: Async; markRead: Async; @@ -248,6 +249,7 @@ export interface ConversationsStore { markSessionRead: Async; markUnread: Async; markUnreadByIds: Async; + markMentionsReadByIds: Async; markMentionsRead: Async; listUnreadCounts: Async; listUnreadCountsWithMentions: Async; @@ -455,6 +457,12 @@ export class LocalStore implements ConversationsStore { [agent, opts], opts.timeout_ms, ); + readMentionPreviews: ConversationsStore["readMentionPreviews"] = async (agent, opts = {}) => + runLocalReadWorker>( + "readMentionPreviews", + [agent, opts], + opts.timeout_ms, + ); getMessagesForAgent: ConversationsStore["getMessagesForAgent"] = async (agent, opts = {}) => { return runLocalReadWorker>( "getMessagesForAgent", @@ -470,6 +478,7 @@ export class LocalStore implements ConversationsStore { markSessionRead: ConversationsStore["markSessionRead"] = async (...a) => messagesLib.markSessionRead(...a); markUnread: ConversationsStore["markUnread"] = async (...a) => messagesLib.markUnread(...a); markUnreadByIds: ConversationsStore["markUnreadByIds"] = async (...a) => messagesLib.markUnreadByIds(...a); + markMentionsReadByIds: ConversationsStore["markMentionsReadByIds"] = async (...a) => messagesLib.markMentionsReadByIds(...a); markMentionsRead: ConversationsStore["markMentionsRead"] = async (...a) => messagesLib.markMentionsRead(...a); listUnreadCounts: ConversationsStore["listUnreadCounts"] = async (...a) => messagesLib.listUnreadCounts(...a); listUnreadCountsWithMentions: ConversationsStore["listUnreadCountsWithMentions"] = async (...a) => messagesLib.listUnreadCountsWithMentions(...a); diff --git a/src/mcp/tools/advanced.test.ts b/src/mcp/tools/advanced.test.ts index d69333b..f9de0c9 100644 --- a/src/mcp/tools/advanced.test.ts +++ b/src/mcp/tools/advanced.test.ts @@ -3,7 +3,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAdvancedTools } from "./advanced"; -import { closeDb } from "../../lib/db"; +import { closeDb, getDb } from "../../lib/db"; import { createDisposableStore, enterHermeticTestEnv, installNetworkGuard } from "../../test/hermetic"; const TEST_STORE = createDisposableStore("advanced-mcp"); @@ -174,6 +174,69 @@ describe("advanced MCP tools", () => { expect(result.mentions[0].message.preview).toContain("bounded mention"); expect(result.mentions[0].message.content).toBeUndefined(); }); + + test("uses mention-row identity and state and marks only returned mention rows", async () => { + const { sendMessage } = await import("../../lib/messages"); + const first = sendMessage({ + from: "mention-projection-sender", + to: "mention-projection-channel", + channel: "mention-projection-channel", + content: "@mention-projection-reader first dedicated mention", + }); + const second = sendMessage({ + from: "mention-projection-sender", + to: "mention-projection-channel", + channel: "mention-projection-channel", + content: "@mention-projection-reader second dedicated mention", + }); + const db = getDb(); + const firstMention = db.prepare( + "SELECT id, notified_at FROM message_mentions WHERE message_id = ? AND mentioned_agent = ?", + ).get(first.id, "mention-projection-reader") as { id: number; notified_at: string | null }; + const secondMention = db.prepare( + "SELECT id, notified_at FROM message_mentions WHERE message_id = ? AND mentioned_agent = ?", + ).get(second.id, "mention-projection-reader") as { id: number; notified_at: string | null }; + + // Generic message read state is deliberately opposite to mention state. + // get_mentions must use mm.notified_at, not messages.read_at. + db.prepare("UPDATE messages SET read_at = created_at WHERE id IN (?, ?)").run(first.id, second.id); + + const peek = parseResult(await client.callTool({ + name: "get_mentions", + arguments: { + agent: "mention-projection-reader", + unread_only: true, + limit: 1, + mark_read: false, + }, + }) as any) as any; + + expect(peek.mentions).toHaveLength(1); + expect(peek.mentions[0].mention_id).toBe(secondMention.id); + expect(peek.mentions[0].message_id).toBe(second.id); + expect(peek.mentions[0].message.id).toBe(second.id); + expect(peek.mentions[0].message.mention_id).toBe(secondMention.id); + expect(peek.mentions[0].message.unread).toBe(true); + expect(db.prepare("SELECT notified_at FROM message_mentions WHERE id = ?").get(secondMention.id)).toEqual({ notified_at: null }); + + const consumed = parseResult(await client.callTool({ + name: "get_mentions", + arguments: { + agent: "mention-projection-reader", + unread_only: true, + limit: 1, + mark_read: true, + }, + }) as any) as any; + + expect(consumed.mentions).toHaveLength(1); + expect(consumed.mentions[0].mention_id).toBe(secondMention.id); + expect(consumed.mentions[0].message_id).toBe(second.id); + expect(consumed.mentions[0].message.unread).toBe(false); + expect(consumed.marked_read).toBe(1); + expect(db.prepare("SELECT notified_at FROM message_mentions WHERE id = ?").get(secondMention.id)).not.toEqual({ notified_at: null }); + expect(db.prepare("SELECT notified_at FROM message_mentions WHERE id = ?").get(firstMention.id)).toEqual({ notified_at: null }); + }); }); describe("mark_mentions_read", () => { diff --git a/src/mcp/tools/advanced.ts b/src/mcp/tools/advanced.ts index 4ea6370..e16c515 100644 --- a/src/mcp/tools/advanced.ts +++ b/src/mcp/tools/advanced.ts @@ -11,6 +11,7 @@ import { getStore } from "../../lib/store/index.js"; // below still read the local store (no cloud endpoint yet) — documented residual. import { resolveIdentity } from "../../lib/identity.js"; import { pageQueriedItems, summarizeMessage, windowItems } from "../../lib/compact-output.js"; +import { finalizeMessagePreviewPage } from "../../lib/message-previews.js"; import { jsonText, resolveMcpWindow } from "../compact.js"; export function registerAdvancedTools(server: McpServer, pkgVersion: string): void { @@ -131,35 +132,59 @@ export function registerAdvancedTools(server: McpServer, pkgVersion: string): vo server.registerTool("get_mentions", { description: "Get a bounded, redacted page of messages that @mention a specific agent. Use get_message for one exact full body.", inputSchema: { - agent: z.string().describe("Agent name to find mentions for"), - channel: z.string().optional().describe("Filter to a specific channel"), - unread_only: z.coerce.boolean().optional().describe("Only unread (not yet notified) mentions (default: true)"), - limit: z.coerce.number().optional().describe("Max results (default: 50)"), - cursor: z.coerce.number().optional().describe("Skip first N mention results"), - max_bytes: z.coerce.number().optional(), - preview_bytes: z.coerce.number().optional(), - timeout_ms: z.coerce.number().optional(), - verbose: z.coerce.boolean().optional().describe("Deprecated compatibility flag; collections remain preview-only"), + agent: z.string().trim().min(1).describe("Agent name to find mentions for"), + channel: z.string().trim().min(1).optional().describe("Filter to a specific channel"), + unread_only: z.boolean().optional().describe("Only unread (not yet notified) mentions (default: true)"), + mark_read: z.boolean().optional().describe("Acknowledge only mention rows returned in this page"), + limit: z.number().int().positive().optional().describe("Max results (default: 50)"), + cursor: z.number().int().nonnegative().optional().describe("Skip first N mention results"), + max_bytes: z.number().int().positive().optional(), + preview_bytes: z.number().int().positive().optional(), + timeout_ms: z.number().int().positive().optional(), + verbose: z.boolean().optional().describe("Deprecated compatibility flag; collections remain preview-only"), }, }, async (args: Record) => { - const page = await getStore().readMessagePreviews({ - mentions_only: args.agent as string, + const store = getStore(); + const page = await store.readMentionPreviews(args.agent as string, { channel: args.channel, unread_only: args.unread_only ?? true, limit: args.limit, offset: args.cursor, - order: "desc", max_bytes: args.max_bytes, preview_bytes: args.preview_bytes, timeout_ms: args.timeout_ms, }); - const { messages, ...metadata } = page; + const mentionIds = page.messages.map((message) => { + if (!Number.isSafeInteger(message.mention_id) || Number(message.mention_id) <= 0) { + throw new Error("mention projection returned an invalid mention_id"); + } + return Number(message.mention_id); + }); + let markedRead = 0; + let returnedPage = page; + if (args.mark_read === true && mentionIds.length > 0) { + const finalized = finalizeMessagePreviewPage({ + ...page, + messages: page.messages.map((message) => ({ ...message, unread: false })), + }); + if (finalized.byte_length > finalized.max_bytes) { + throw new Error(`mention preview envelope exceeds max_bytes (${finalized.byte_length} > ${finalized.max_bytes})`); + } + markedRead = await store.markMentionsReadByIds(args.agent as string, mentionIds); + returnedPage = finalized; + } + const { messages, ...metadata } = returnedPage; return { content: [{ type: "text", text: jsonText({ ...metadata, - mentions: messages.map((message) => ({ mention_id: message.id, message })), + marked_read: markedRead, + mentions: messages.map((message) => ({ + mention_id: message.mention_id, + message_id: message.id, + message, + })), hint: "Use get_message with an id for one exact full message.", }), }], @@ -169,11 +194,14 @@ export function registerAdvancedTools(server: McpServer, pkgVersion: string): vo server.registerTool("mark_mentions_read", { description: "Mark @mentions as seen for an agent. Clears unread mention counts.", inputSchema: { - agent: z.string().describe("Agent name"), - channel: z.string().optional().describe("Clear only mentions in this channel"), + agent: z.string().trim().min(1).describe("Agent name"), + channel: z.string().trim().min(1).optional().describe("Clear only mentions in this channel"), + mention_ids: z.array(z.number().int().positive()).optional().describe("Acknowledge only these mention-row ids"), }, }, async (args: Record) => { - const cleared = await getStore().markMentionsRead(args.agent as string, args.channel); + const cleared = Array.isArray(args.mention_ids) + ? await getStore().markMentionsReadByIds(args.agent as string, args.mention_ids) + : await getStore().markMentionsRead(args.agent as string, args.channel); return { content: [{ type: "text", text: JSON.stringify({ cleared }) }] }; }); diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 257f9a7..6f8f9eb 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -4,9 +4,9 @@ // @generated from OpenAPI by @hasna/contracts SDK generator — DO NOT EDIT. // Source: ConversationsClient 0.5.6 -export interface Message { "id"?: number; "mention_id"?: number; "uuid"?: string; "session_id"?: string; "from_agent"?: string; "to_agent"?: string; "channel"?: string | null; "project_id"?: string | null; "content"?: string; "priority"?: string; "blocking"?: boolean; "reply_to"?: number | null; "working_dir"?: string | null; "repository"?: string | null; "branch"?: string | null; "metadata"?: Record | null; "attachments"?: Array> | null; "created_at"?: string } +export interface Message { "id"?: number; "uuid"?: string; "session_id"?: string; "from_agent"?: string; "to_agent"?: string; "channel"?: string | null; "project_id"?: string | null; "content"?: string; "priority"?: string; "blocking"?: boolean; "reply_to"?: number | null; "working_dir"?: string | null; "repository"?: string | null; "branch"?: string | null; "metadata"?: Record | null; "attachments"?: Array> | null; "created_at"?: string } -export interface MessagePreview { "id": number; "uuid"?: string; "session_id": string; "from_agent": string; "to_agent": string; "channel": string | null; "project_id": string | null; "priority": "low" | "normal" | "high" | "urgent"; "working_dir": string | null; "repository": string | null; "branch": string | null; "created_at": string; "edited_at": string | null; "pinned_at": string | null; "unread": boolean; "blocking": boolean; "reply_to": number | null; "reply_count"?: number; "attachment_count": number; "has_attachments": boolean; "has_metadata": boolean; "preview": string; "preview_bytes": number; "content_bytes": number; "truncated": boolean; "redacted": boolean; "relevance_score"?: number } +export interface MessagePreview { "id": number; "mention_id"?: number; "uuid"?: string; "session_id": string; "from_agent": string; "to_agent": string; "channel": string | null; "project_id": string | null; "priority": "low" | "normal" | "high" | "urgent"; "working_dir": string | null; "repository": string | null; "branch": string | null; "created_at": string; "edited_at": string | null; "pinned_at": string | null; "unread": boolean; "blocking": boolean; "reply_to": number | null; "reply_count"?: number; "attachment_count": number; "has_attachments": boolean; "has_metadata": boolean; "preview": string; "preview_bytes": number; "content_bytes": number; "truncated": boolean; "redacted": boolean; "relevance_score"?: number } export interface MessagePreviewPage { "messages": Array; "count": number; "limit": number; "cursor": number; "next_cursor": number | null; "has_more": boolean; "skipped_count": number; "byte_length": number; "max_bytes": number; "timeout_ms": number; "compact": true; "detail_path": "messages/{id}"; "query"?: string } @@ -242,7 +242,7 @@ export class ConversationsClient { } /** Download one bounded export artifact owned by the authenticated principal */ - async downloadMessageExport(artifactId: string, init?: RequestInit): Promise { + async downloadMessageExport(artifactId: string, init?: RequestInit): Promise | string> { return this.request("GET", `/v1/messages/exports/${encodeURIComponent(String(artifactId))}`, { body: undefined, query: undefined, diff --git a/src/sdk/message-preview.test.ts b/src/sdk/message-preview.test.ts index 8c9c4d0..bb0c57a 100644 --- a/src/sdk/message-preview.test.ts +++ b/src/sdk/message-preview.test.ts @@ -2,14 +2,18 @@ import { describe, expect, test } from "bun:test"; import { ConversationsClient, type ChannelNotificationPage, + type Message, type MessageExportArtifactResponse, + type MessagePreview, type MessagePreviewPage, type MessageResponse, } from "./index"; +import { openapiSpec } from "../server/openapi"; const previewPage = { messages: [{ id: 41, + mention_id: 7, session_id: "channel:engineering", from_agent: "alice", to_agent: "engineering", @@ -48,6 +52,12 @@ const previewPage = { } satisfies MessagePreviewPage; describe("generated safe message-read client", () => { + test("models mention identity on previews, not exact full messages", () => { + expect(openapiSpec.components.schemas.MessagePreview.properties).toHaveProperty("mention_id"); + expect(openapiSpec.components.schemas.Message.properties).not.toHaveProperty("mention_id"); + expect(previewPage.messages[0].mention_id).toBe(7); + }); + test("types list/blocker reads as preview pages and keeps exact get typed separately", async () => { const requests: string[] = []; const exact: MessageResponse = { message: { id: 41, content: "exact coordination update" } }; @@ -155,4 +165,32 @@ describe("generated safe message-read client", () => { expect(requests[1].init?.method).toBe("POST"); expect(String(requests[1].init?.body)).toContain('"detail":"preview"'); }); + + test("downloadMessageExport returns parsed JSON records or CSV text with matching types", async () => { + const responses = [ + new Response(JSON.stringify(previewPage.messages), { + status: 200, + headers: { "content-type": "application/json" }, + }), + new Response("id,preview\n41,bounded coordination update", { + status: 200, + headers: { "content-type": "text/csv" }, + }), + ]; + const client = new ConversationsClient({ + baseUrl: "https://conversations.invalid", + fetch: (async () => responses.shift()!) as unknown as typeof fetch, + }); + + const jsonArtifact: Array | string = await client.downloadMessageExport( + "00000000-0000-4000-8000-000000000001", + ); + const csvArtifact: Array | string = await client.downloadMessageExport( + "00000000-0000-4000-8000-000000000002", + ); + + expect(Array.isArray(jsonArtifact)).toBe(true); + expect((jsonArtifact as MessagePreview[])[0].preview).toContain("bounded"); + expect(csvArtifact).toBe("id,preview\n41,bounded coordination update"); + }); }); diff --git a/src/server/api.test.ts b/src/server/api.test.ts index 0f58d8f..7417579 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -481,6 +481,79 @@ describe("conversations-serve", () => { } }); + test("dedicated broad routes reject empty or malformed values before widening", async () => { + const deps = makeDeps(); + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + const isolatedBase = `http://127.0.0.1:${isolated.port}`; + const headers = { "x-api-key": rwKey }; + try { + const cases: Array<{ path: string; init?: RequestInit }> = [ + { path: "/v1/messages?limit=" }, + { path: "/v1/messages?cursor=" }, + { path: "/v1/messages?unread_only=yes" }, + { path: "/v1/messages/pinned?channel=" }, + { path: "/v1/messages/pinned?session_id=" }, + { path: "/v1/messages/pinned?max_bytes=" }, + { path: "/v1/messages/blockers?agent=" }, + { path: "/v1/messages/blockers?cursor=" }, + { path: "/v1/messages/for-agent?agent=" }, + { path: "/v1/messages/for-agent?agent=test&channel=" }, + { path: "/v1/messages/for-agent?agent=test&unread_only=yes" }, + { path: "/v1/messages/for-agent?agent=test&timeout_ms=" }, + { path: "/v1/messages/export?limit=" }, + { path: "/v1/messages/export?since=not-a-date" }, + { path: "/v1/channel-notifications/inbox?channel=" }, + { path: "/v1/channel-notifications/inbox?since=not-a-date" }, + { path: "/v1/channel-notifications/inbox?mark_read=yes" }, + { path: "/v1/channel-notifications/inbox?preview_bytes=" }, + ]; + + for (const item of cases) { + const before = (deps.client as any).queryCalls.length; + const response = await fetch(`${isolatedBase}${item.path}`, { headers, ...item.init }); + expect(response.status, item.path).toBe(400); + expect((deps.client as any).queryCalls.length, item.path).toBe(before); + } + + for (const body of [ + { limit: "" }, + { max_bytes: "" }, + { since: "not-a-date" }, + { format: "xml" }, + ]) { + const before = (deps.client as any).queryCalls.length; + const response = await fetch(`${isolatedBase}/v1/messages/exports`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(body), + }); + expect(response.status, JSON.stringify(body)).toBe(400); + expect((deps.client as any).queryCalls.length, JSON.stringify(body)).toBe(before); + } + } finally { + isolated.stop(true); + } + }); + + test("an explicitly empty mention id set is an exact no-op, not a widened acknowledgement", async () => { + const deps = makeDeps(); + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + const isolatedBase = `http://127.0.0.1:${isolated.port}`; + try { + const before = (deps.client as any).queryCalls.length; + const response = await fetch(`${isolatedBase}/v1/messages/read`, { + method: "POST", + headers: { "x-api-key": rwKey, "content-type": "application/json" }, + body: JSON.stringify({ mentions_only: true, mention_ids: [] }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ marked: 0 }); + expect((deps.client as any).queryCalls.length).toBe(before); + } finally { + isolated.stop(true); + } + }); + test("notification pages bind to the authenticated principal and mark only returned ids", async () => { const deps = makeDeps(); const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); diff --git a/src/server/api.ts b/src/server/api.ts index c890660..1156a7f 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -342,9 +342,9 @@ function strictIntegerParam(raw: string | null, name: string, allowZero = false) function strictBooleanParam(raw: string | null, name: string): boolean { if (raw === null) return false; const normalized = raw.trim().toLowerCase(); - if (["true", "1", "yes"].includes(normalized)) return true; - if (["false", "0", "no"].includes(normalized)) return false; - throw new ApiRequestValidationError(`${name} must be true or false`); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + throw new ApiRequestValidationError(`${name} must be true, false, 1, or 0`); } function strictStringParam(raw: string | null, name: string): string | undefined { @@ -394,9 +394,16 @@ function collectionReadOptions(url: URL): { timeoutMs: number; } { try { + const rawOffset = url.searchParams.get("offset"); + const rawCursor = url.searchParams.get("cursor"); + const offset = resolveCollectionOffset(rawOffset); + const cursor = resolveCollectionOffset(rawCursor); + if (rawOffset !== null && rawCursor !== null && offset !== cursor) { + throw new Error("offset and cursor must match when both are provided"); + } return { limit: resolveCollectionLimit(url.searchParams.get("limit")), - offset: resolveCollectionOffset(url.searchParams.get("offset") ?? url.searchParams.get("cursor")), + offset: rawOffset !== null ? offset : cursor, maxBytes: resolveCollectionMaxBytes(url.searchParams.get("max_bytes")), previewBytes: resolveCollectionPreviewBytes(url.searchParams.get("preview_bytes")), timeoutMs: resolveCollectionTimeoutMs(url.searchParams.get("timeout_ms")), @@ -948,7 +955,7 @@ async function handleV1( // ---- messages ---- if (sub === "messages/blockers" && method === "GET") { if (!agent) return json({ error: "authenticated agent is required" }, 401); - const requestedAgent = str(url.searchParams.get("agent")); + const requestedAgent = strictStringParam(url.searchParams.get("agent"), "agent"); if (requestedAgent && requestedAgent.toLowerCase() !== agent.toLowerCase()) { return json({ error: "blocker agent must match the authenticated agent" }, 403); } @@ -1160,18 +1167,44 @@ async function handleV1( ? (body.ids as unknown[]).map(Number).filter((n) => Number.isFinite(n)) : []; const all = body.all === true; - const channel = str(body.channel); + const channel = body.channel === undefined + ? undefined + : typeof body.channel === "string" && body.channel.trim() + ? body.channel.trim() + : (() => { throw new ApiRequestValidationError("channel must be a non-empty string"); })(); const session = str(body.session) ?? str(body.session_id); // markMentionsRead: stamp notified_at on the agent's @mentions (optionally // scoped to one channel). Routed here because the client posts it to // /messages/read with mentions_only=true. - if (body.mentions_only) { - const res = channel + if (body.mentions_only !== undefined && typeof body.mentions_only !== "boolean") { + throw new ApiRequestValidationError("mentions_only must be a boolean"); + } + if (body.mentions_only === true) { + const mentionIdsProvided = body.mention_ids !== undefined; + if (mentionIdsProvided && !Array.isArray(body.mention_ids)) { + throw new ApiRequestValidationError("mention_ids must be an array of positive integers"); + } + const mentionIds = Array.isArray(body.mention_ids) ? (body.mention_ids as unknown[]).map((value) => { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new ApiRequestValidationError("mention_ids must contain only positive integers"); + } + return value; + }) : []; + if (mentionIdsProvided && mentionIds.length === 0) { + return json({ marked: 0 }); + } + const res = mentionIds.length > 0 ? await client.query( + `UPDATE message_mentions SET notified_at = NOW()::text + WHERE mentioned_agent = $1 AND id = ANY($2::bigint[]) AND notified_at IS NULL`, + [reader, mentionIds], + ) + : channel + ? await client.query( `UPDATE message_mentions SET notified_at = NOW()::text WHERE mentioned_agent = $1 AND channel = $2 AND notified_at IS NULL`, [reader, normalizeChannelName(channel)], ) - : await client.query( + : await client.query( `UPDATE message_mentions SET notified_at = NOW()::text WHERE mentioned_agent = $1 AND notified_at IS NULL`, [reader], ); @@ -1298,8 +1331,9 @@ async function handleV1( // ---- pinned messages ---- if (sub === "messages/pinned" && method === "GET") { const collection = collectionReadOptions(url); - const channel = str(url.searchParams.get("channel")); - const session = str(url.searchParams.get("session")) ?? str(url.searchParams.get("session_id")); + const channel = strictStringParam(url.searchParams.get("channel"), "channel"); + const session = strictStringParam(url.searchParams.get("session"), "session") + ?? strictStringParam(url.searchParams.get("session_id"), "session_id"); const clauses = ["pinned_at IS NOT NULL"]; const params: unknown[] = []; if (channel) { params.push(channel); clauses.push(`channel = $${params.length}`); } @@ -1352,11 +1386,21 @@ async function handleV1( }; const optionalNumber = (name: string): number | undefined => { const value = queryValue(name); - if (value === undefined || value === null || value === "") return undefined; - const parsed = typeof value === "number" ? value : Number(value); + if (value === undefined || value === null) return undefined; + if (typeof value !== "number" && typeof value !== "string") { + throw new ApiRequestValidationError(`${name} must be a positive integer`); + } + if (typeof value === "string" && !/^[1-9]\d*$/.test(value.trim())) { + throw new ApiRequestValidationError(`${name} must be a positive integer`); + } + const parsed = typeof value === "number" ? value : Number(value.trim()); if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new ApiRequestValidationError(`${name} must be a positive integer`); return parsed; }; + const requestedDetail = optionalString("detail"); + if (method === "GET" && requestedDetail !== undefined && requestedDetail !== "preview") { + throw new ApiRequestValidationError("detail must be preview for GET exports"); + } const authorization = method === "POST" && body.authorization && typeof body.authorization === "object" && !Array.isArray(body.authorization) ? body.authorization as Record : undefined; @@ -1370,7 +1414,7 @@ async function handleV1( since: optionalString("since"), until: optionalString("until"), format: optionalString("format") as ExportMessagesOptions["format"], - detail: (method === "GET" ? "preview" : optionalString("detail")) as ExportMessagesOptions["detail"], + detail: (method === "GET" ? "preview" : requestedDetail) as ExportMessagesOptions["detail"], limit: optionalNumber("limit"), max_bytes: optionalNumber("max_bytes"), preview_bytes: optionalNumber("preview_bytes"), @@ -1425,19 +1469,21 @@ async function handleV1( // ---- messages that @mention an agent ---- if (sub === "messages/for-agent" && method === "GET") { const collection = collectionReadOptions(url); - const who = str(url.searchParams.get("agent")); + const who = strictStringParam(url.searchParams.get("agent"), "agent"); if (!who) return json({ error: "agent is required" }, 400); const clauses = ["mm.mentioned_agent = $1"]; const params: unknown[] = [who.toLowerCase()]; - const channel = str(url.searchParams.get("channel")); + const channel = strictStringParam(url.searchParams.get("channel"), "channel"); if (channel) { params.push(normalizeChannelName(channel)); clauses.push(`m.channel = $${params.length}`); } - if (isTrue(url.searchParams.get("unread_only"))) clauses.push(`mm.notified_at IS NULL`); + if (strictBooleanParam(url.searchParams.get("unread_only"), "unread_only")) clauses.push(`mm.notified_at IS NULL`); params.push(collection.limit + 1); const limitIdx = params.length; params.push(collection.offset); const offsetIdx = params.length; const rows = await boundedCollectionQuery(client, collection.timeoutMs, (tx) => tx.many>( - `SELECT ${messagePreviewProjectionPg("m")}, mm.id AS mention_id FROM messages m + `SELECT ${messagePreviewProjectionPg("m")}, mm.id AS mention_id, + (mm.notified_at IS NULL) AS unread + FROM messages m JOIN message_mentions mm ON mm.message_id = m.id WHERE ${clauses.join(" AND ")} ORDER BY m.created_at DESC, m.id DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`, @@ -2271,6 +2317,7 @@ async function handleChannelNotifications( ? true : strictBooleanParam(url.searchParams.get("unread_only"), "unread_only"); if (unreadOnly) clauses.push("snr.message_id IS NULL"); + const markRequested = strictBooleanParam(url.searchParams.get("mark_read"), "mark_read"); const collection = collectionReadOptions(url); const restricted = restrictedMessagePredicatePg("m"); params.push(collection.limit + 1); @@ -2292,7 +2339,6 @@ async function handleChannelNotifications( ORDER BY m.created_at DESC, m.id DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`, params, )); - const markRequested = strictBooleanParam(url.searchParams.get("mark_read"), "mark_read"); const candidates = rows.map((r) => ({ message_id: Number(r.message_id), channel: r.channel, @@ -2366,7 +2412,11 @@ async function handleChannelNotifications( const who = agent; const params: unknown[] = [who]; let channelClause = ""; - const channel = str(body.channel); + const channel = body.channel === undefined + ? undefined + : typeof body.channel === "string" && body.channel.trim() + ? body.channel.trim() + : (() => { throw new ApiRequestValidationError("channel must be a non-empty string"); })(); if (channel) { params.push(normalizeChannelName(channel)); channelClause = `AND m.channel = $${params.length}`; } const res = await client.query( `INSERT INTO channel_notification_reads (agent, message_id) diff --git a/src/server/openapi.ts b/src/server/openapi.ts index 0935933..a2397ef 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -25,7 +25,6 @@ export const openapiSpec = { type: "object", properties: { id: { type: "integer" }, - mention_id: { type: "integer" }, uuid: { type: "string" }, session_id: { type: "string" }, from_agent: { type: "string" }, @@ -55,6 +54,7 @@ export const openapiSpec = { ], properties: { id: { type: "integer" }, + mention_id: { type: "integer", minimum: 1, description: "Mention-row id on dedicated mention projections; distinct from id." }, uuid: { type: "string" }, session_id: { type: "string" }, from_agent: { type: "string" }, @@ -483,7 +483,17 @@ export const openapiSpec = { "200": { description: "bounded artifact payload", content: { - "application/json": { schema: { type: "string", format: "binary" } }, + "application/json": { + schema: { + type: "array", + items: { + oneOf: [ + { $ref: "#/components/schemas/MessagePreview" }, + { $ref: "#/components/schemas/Message" }, + ], + }, + }, + }, "text/csv": { schema: { type: "string", format: "binary" } }, }, }, diff --git a/src/types.ts b/src/types.ts index 461fdae..25cc0b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -319,6 +319,17 @@ export interface ReadMessagePreviewsOptions extends ReadMessagesOptions { timeout_ms?: number; } +/** Dedicated @mention collection options; unread state belongs to the mention row. */ +export interface ReadMentionPreviewsOptions { + channel?: string; + unread_only?: boolean; + limit?: number; + offset?: number; + max_bytes?: number; + preview_bytes?: number; + timeout_ms?: number; +} + export type ExportDetail = "preview" | "full"; export type ExportFormat = "json" | "csv"; From 9507704c129bc480009782aee41f5803d74bcf7d Mon Sep 17 00:00:00 2001 From: andreihasna Date: Sun, 19 Jul 2026 08:23:44 +0300 Subject: [PATCH 7/8] fix: validate dashboard read queries --- src/lib/message-exports.ts | 5 +- src/lib/strict-query-values.test.ts | 76 ++++++++++++++++++ src/lib/strict-query-values.ts | 86 ++++++++++++++++++++ src/server/api.test.ts | 6 ++ src/server/api.ts | 73 +++++++++-------- src/server/serve.test.ts | 66 ++++++++++++++- src/server/serve.ts | 120 ++++++++++++++++------------ 7 files changed, 343 insertions(+), 89 deletions(-) create mode 100644 src/lib/strict-query-values.test.ts create mode 100644 src/lib/strict-query-values.ts diff --git a/src/lib/message-exports.ts b/src/lib/message-exports.ts index 74b847b..00d63ae 100644 --- a/src/lib/message-exports.ts +++ b/src/lib/message-exports.ts @@ -21,6 +21,7 @@ import { resolveCollectionPreviewBytes, resolveCollectionTimeoutMs, } from "./message-previews.js"; +import { resolveIso8601Date } from "./strict-query-values.js"; export interface ResolvedExportOptions { format: ExportFormat; @@ -50,9 +51,7 @@ export interface LoadedMessageExportArtifact { } function validateDate(value: string | undefined, name: string): void { - if (value !== undefined && !Number.isFinite(Date.parse(value))) { - throw new Error(`${name} must be a valid ISO 8601 date`); - } + resolveIso8601Date(value, name); } export function resolveMessageExportOptions(opts: ExportMessagesOptions = {}): ResolvedExportOptions { diff --git a/src/lib/strict-query-values.test.ts b/src/lib/strict-query-values.test.ts new file mode 100644 index 0000000..ea9e5a9 --- /dev/null +++ b/src/lib/strict-query-values.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + resolveAliasedString, + resolveCollectionQueryOptions, + resolveExportFormat, + resolveIso8601Date, + resolvePresentString, +} from "./strict-query-values"; + +describe("strict query values", () => { + test("distinguishes absent optional strings from present-empty values", () => { + expect(resolvePresentString(null, "channel")).toBeUndefined(); + expect(resolvePresentString(" conversations ", "channel")).toBe("conversations"); + expect(() => resolvePresentString("", "channel")).toThrow("channel must not be empty"); + expect(() => resolvePresentString(" ", "channel")).toThrow("channel must not be empty"); + }); + + test("validates both names of an aliased query value before choosing one", () => { + expect(resolveAliasedString(new URLSearchParams("session=one&session_id=one"), "session", "session_id")) + .toBe("one"); + expect(() => resolveAliasedString( + new URLSearchParams("session=one&session_id="), + "session", + "session_id", + )).toThrow("session_id must not be empty"); + expect(() => resolveAliasedString( + new URLSearchParams("session=one&session_id=two"), + "session", + "session_id", + )).toThrow("session and session_id must match when both are provided"); + }); + + test("accepts strict ISO 8601 dates and rejects Date.parse-only values", () => { + expect(resolveIso8601Date("2026-07-19", "since")).toBe("2026-07-19"); + expect(resolveIso8601Date("2026-07-19T05:00:00.123Z", "since")) + .toBe("2026-07-19T05:00:00.123Z"); + expect(resolveIso8601Date("2026-07-19T08:00:00+03:00", "since")) + .toBe("2026-07-19T08:00:00+03:00"); + for (const value of ["1", "July 19, 2026", "2026-02-30", "2026-07-19T05:00:00"]) { + expect(() => resolveIso8601Date(value, "since"), value) + .toThrow("since must be a valid ISO 8601 date"); + } + }); + + test("uses strict collection pagination and caps for every present value", () => { + const valid = resolveCollectionQueryOptions(new URLSearchParams( + "limit=5&cursor=2&offset=2&max_bytes=4096&preview_bytes=128&timeout_ms=500", + )); + expect(valid).toEqual({ + limit: 5, + offset: 2, + maxBytes: 4096, + previewBytes: 128, + timeoutMs: 500, + }); + + for (const query of [ + "limit=", + "cursor=", + "offset=", + "max_bytes=", + "preview_bytes=", + "timeout_ms=", + "cursor=1&offset=2", + ]) { + expect(() => resolveCollectionQueryOptions(new URLSearchParams(query)), query).toThrow(); + } + }); + + test("defaults export format only when absent", () => { + expect(resolveExportFormat(null)).toBe("json"); + expect(resolveExportFormat("csv")).toBe("csv"); + expect(() => resolveExportFormat("")).toThrow("format must not be empty"); + expect(() => resolveExportFormat("xml")).toThrow("format must be json or csv"); + }); +}); diff --git a/src/lib/strict-query-values.ts b/src/lib/strict-query-values.ts new file mode 100644 index 0000000..6de0b85 --- /dev/null +++ b/src/lib/strict-query-values.ts @@ -0,0 +1,86 @@ +import { + resolveCollectionLimit, + resolveCollectionMaxBytes, + resolveCollectionOffset, + resolveCollectionPreviewBytes, + resolveCollectionTimeoutMs, +} from "./message-previews.js"; + +export interface CollectionQueryOptions { + limit: number; + offset: number; + maxBytes: number; + previewBytes: number; + timeoutMs: number; +} + +const ISO_8601_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00)))?$/; + +export function resolvePresentString(value: unknown, name: string): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") throw new Error(`${name} must be a string`); + const normalized = value.trim(); + if (!normalized) throw new Error(`${name} must not be empty`); + return normalized; +} + +export function resolveAliasedString( + searchParams: Pick, + primary: string, + alias: string, +): string | undefined { + const primaryValue = resolvePresentString(searchParams.get(primary), primary); + const aliasValue = resolvePresentString(searchParams.get(alias), alias); + if (primaryValue !== undefined && aliasValue !== undefined && primaryValue !== aliasValue) { + throw new Error(`${primary} and ${alias} must match when both are provided`); + } + return primaryValue ?? aliasValue; +} + +function isCalendarDate(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1) return false; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return day <= days[month - 1]; +} + +export function resolveIso8601Date(value: unknown, name: string): string | undefined { + const normalized = resolvePresentString(value, name); + if (normalized === undefined) return undefined; + const match = ISO_8601_DATE.exec(normalized); + if ( + !match + || !isCalendarDate(Number(match[1]), Number(match[2]), Number(match[3])) + || !Number.isFinite(Date.parse(normalized)) + ) { + throw new Error(`${name} must be a valid ISO 8601 date`); + } + return normalized; +} + +export function resolveExportFormat(value: unknown): "json" | "csv" { + const format = resolvePresentString(value, "format") ?? "json"; + if (format !== "json" && format !== "csv") throw new Error("format must be json or csv"); + return format; +} + +/** + * Shared collection parser for both cloud API and local dashboard routes. + * URLSearchParams.get() preserves the absent-vs-present-empty distinction. + */ +export function resolveCollectionQueryOptions(searchParams: Pick): CollectionQueryOptions { + const rawOffset = searchParams.get("offset"); + const rawCursor = searchParams.get("cursor"); + const offset = resolveCollectionOffset(rawOffset); + const cursor = resolveCollectionOffset(rawCursor); + if (rawOffset !== null && rawCursor !== null && offset !== cursor) { + throw new Error("offset and cursor must match when both are provided"); + } + return { + limit: resolveCollectionLimit(searchParams.get("limit")), + offset: rawOffset !== null ? offset : cursor, + maxBytes: resolveCollectionMaxBytes(searchParams.get("max_bytes")), + previewBytes: resolveCollectionPreviewBytes(searchParams.get("preview_bytes")), + timeoutMs: resolveCollectionTimeoutMs(searchParams.get("timeout_ms")), + }; +} diff --git a/src/server/api.test.ts b/src/server/api.test.ts index 7417579..a408780 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -465,6 +465,9 @@ describe("conversations-serve", () => { "since_id=-1", "since_id=1.5", "since=not-a-date", + "since=1", + "session=fixture&session_id=", + "session=one&session_id=two", "unread_only=perhaps", "order=relevance", "q=%20%20", @@ -493,6 +496,7 @@ describe("conversations-serve", () => { { path: "/v1/messages?unread_only=yes" }, { path: "/v1/messages/pinned?channel=" }, { path: "/v1/messages/pinned?session_id=" }, + { path: "/v1/messages/pinned?session=fixture&session_id=" }, { path: "/v1/messages/pinned?max_bytes=" }, { path: "/v1/messages/blockers?agent=" }, { path: "/v1/messages/blockers?cursor=" }, @@ -502,6 +506,8 @@ describe("conversations-serve", () => { { path: "/v1/messages/for-agent?agent=test&timeout_ms=" }, { path: "/v1/messages/export?limit=" }, { path: "/v1/messages/export?since=not-a-date" }, + { path: "/v1/messages/export?since=1" }, + { path: "/v1/messages/export?session=fixture&session_id=" }, { path: "/v1/channel-notifications/inbox?channel=" }, { path: "/v1/channel-notifications/inbox?since=not-a-date" }, { path: "/v1/channel-notifications/inbox?mark_read=yes" }, diff --git a/src/server/api.ts b/src/server/api.ts index 1156a7f..964aa48 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -55,11 +55,14 @@ import { packMessagePreviewPage, redactSensitiveText, resolveCollectionLimit, - resolveCollectionMaxBytes, - resolveCollectionOffset, - resolveCollectionPreviewBytes, resolveCollectionTimeoutMs, } from "../lib/message-previews.js"; +import { + resolveAliasedString, + resolveCollectionQueryOptions, + resolveIso8601Date, + resolvePresentString, +} from "../lib/strict-query-values.js"; import { loadMessageExportArtifact, resolveMessageExportOptions, @@ -348,19 +351,19 @@ function strictBooleanParam(raw: string | null, name: string): boolean { } function strictStringParam(raw: string | null, name: string): string | undefined { - if (raw === null) return undefined; - const normalized = raw.trim(); - if (!normalized) throw new ApiRequestValidationError(`${name} must not be empty`); - return normalized; + try { + return resolvePresentString(raw, name); + } catch (error) { + throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); + } } function strictDateParam(raw: string | null, name: string): string | undefined { - const normalized = strictStringParam(raw, name); - if (normalized === undefined) return undefined; - if (!Number.isFinite(Date.parse(normalized))) { - throw new ApiRequestValidationError(`${name} must be a valid ISO 8601 date`); + try { + return resolveIso8601Date(raw, name); + } catch (error) { + throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); } - return normalized; } function clampLimit(raw: string | null, def = 50, max = 500): number { @@ -394,20 +397,7 @@ function collectionReadOptions(url: URL): { timeoutMs: number; } { try { - const rawOffset = url.searchParams.get("offset"); - const rawCursor = url.searchParams.get("cursor"); - const offset = resolveCollectionOffset(rawOffset); - const cursor = resolveCollectionOffset(rawCursor); - if (rawOffset !== null && rawCursor !== null && offset !== cursor) { - throw new Error("offset and cursor must match when both are provided"); - } - return { - limit: resolveCollectionLimit(url.searchParams.get("limit")), - offset: rawOffset !== null ? offset : cursor, - maxBytes: resolveCollectionMaxBytes(url.searchParams.get("max_bytes")), - previewBytes: resolveCollectionPreviewBytes(url.searchParams.get("preview_bytes")), - timeoutMs: resolveCollectionTimeoutMs(url.searchParams.get("timeout_ms")), - }; + return resolveCollectionQueryOptions(url.searchParams); } catch (error) { throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); } @@ -1065,8 +1055,12 @@ async function handleV1( const to = strictStringParam(url.searchParams.get("to"), "to"); const from = strictStringParam(url.searchParams.get("from"), "from"); const channel = strictStringParam(url.searchParams.get("channel"), "channel"); - const session = strictStringParam(url.searchParams.get("session"), "session") - ?? strictStringParam(url.searchParams.get("session_id"), "session_id"); + let session: string | undefined; + try { + session = resolveAliasedString(url.searchParams, "session", "session_id"); + } catch (error) { + throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); + } const projectId = strictStringParam(url.searchParams.get("project_id"), "project_id"); const since = strictDateParam(url.searchParams.get("since"), "since"); const id = strictIntegerParam(url.searchParams.get("id"), "id"); @@ -1332,8 +1326,12 @@ async function handleV1( if (sub === "messages/pinned" && method === "GET") { const collection = collectionReadOptions(url); const channel = strictStringParam(url.searchParams.get("channel"), "channel"); - const session = strictStringParam(url.searchParams.get("session"), "session") - ?? strictStringParam(url.searchParams.get("session_id"), "session_id"); + let session: string | undefined; + try { + session = resolveAliasedString(url.searchParams, "session", "session_id"); + } catch (error) { + throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); + } const clauses = ["pinned_at IS NOT NULL"]; const params: unknown[] = []; if (channel) { params.push(channel); clauses.push(`channel = $${params.length}`); } @@ -1380,9 +1378,11 @@ async function handleV1( const queryValue = (name: string): unknown => method === "POST" ? body[name] : url.searchParams.get(name) ?? undefined; const optionalString = (name: string): string | undefined => { const value = queryValue(name); - if (value === undefined || value === null) return undefined; - if (typeof value !== "string" || !value.trim()) throw new ApiRequestValidationError(`${name} must be a non-empty string`); - return value.trim(); + try { + return resolvePresentString(value, name); + } catch (error) { + throw new ApiRequestValidationError(error instanceof Error ? error.message : String(error)); + } }; const optionalNumber = (name: string): number | undefined => { const value = queryValue(name); @@ -1407,9 +1407,14 @@ async function handleV1( if (authorization && authorization.acknowledged !== true) { throw new ApiRequestValidationError("authorization.acknowledged must be true"); } + const sessionId = optionalString("session_id"); + const sessionAlias = optionalString("session"); + if (sessionId !== undefined && sessionAlias !== undefined && sessionId !== sessionAlias) { + throw new ApiRequestValidationError("session_id and session must match when both are provided"); + } const opts: ExportMessagesOptions = { channel: optionalString("channel"), - session_id: optionalString("session_id") ?? optionalString("session"), + session_id: sessionId ?? sessionAlias, from: optionalString("from"), since: optionalString("since"), until: optionalString("until"), diff --git a/src/server/serve.test.ts b/src/server/serve.test.ts index b7b530c..be8c70f 100644 --- a/src/server/serve.test.ts +++ b/src/server/serve.test.ts @@ -1,10 +1,11 @@ -import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test"; +import { describe, test, expect, beforeAll, afterAll, beforeEach, spyOn } from "bun:test"; import { startDashboardServer } from "./serve"; import { sendMessage } from "../lib/messages"; import { createChannel, joinChannel } from "../lib/channels"; import { createProject } from "../lib/projects"; import { closeDb } from "../lib/db"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { getStore } from "../lib/store"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { @@ -47,6 +48,67 @@ afterAll(() => { const base = () => `http://localhost:${server.port}`; +describe("dashboard broad-read query validation", () => { + test("rejects malformed reviewer repros before store reads or export artifacts", async () => { + const store = getStore(); + const readSpy = spyOn(store, "readMessagePreviews"); + const searchSpy = spyOn(store, "searchMessagePreviews"); + const exportSpy = spyOn(store, "exportMessages"); + const exportDir = process.env.CONVERSATIONS_EXPORT_DIR!; + const artifactFileCount = () => existsSync(exportDir) ? readdirSync(exportDir).length : 0; + const beforeArtifacts = artifactFileCount(); + const cases = [ + ["/api/messages?from=", "from must not be empty"], + ["/api/messages?limit=", "limit must be a positive integer"], + ["/api/messages?session=", "session must not be empty"], + ["/api/messages?session=fixture&session_id=", "session_id must not be empty"], + ["/api/messages?session=one&session_id=two", "session and session_id must match when both are provided"], + ["/api/messages?cursor=", "cursor must be a non-negative integer"], + ["/api/messages?offset=", "cursor must be a non-negative integer"], + ["/api/messages?max_bytes=", "max_bytes must be a positive integer"], + ["/api/messages/search?q=fixture&channel=", "channel must not be empty"], + ["/api/messages/search?q=fixture&limit=", "limit must be a positive integer"], + ["/api/messages/search?q=fixture&since=1", "since must be a valid ISO 8601 date"], + ["/api/messages/search?q=fixture&until=1", "until must be a valid ISO 8601 date"], + ["/api/messages/pinned?channel=", "channel must not be empty"], + ["/api/messages/pinned?session_id=", "session_id must not be empty"], + ["/api/messages/pinned?session=fixture&session_id=", "session_id must not be empty"], + ["/api/messages/pinned?cursor=", "cursor must be a non-negative integer"], + ["/api/export?format=xml", "format must be json or csv"], + ["/api/export?format=", "format must not be empty"], + ["/api/export?channel=", "channel must not be empty"], + ["/api/export?session=", "session must not be empty"], + ["/api/export?session=fixture&session_id=", "session_id must not be empty"], + ["/api/export?from=", "from must not be empty"], + ["/api/export?since=1", "since must be a valid ISO 8601 date"], + ["/api/export?until=1", "until must be a valid ISO 8601 date"], + ["/api/export?limit=", "limit must be a positive integer"], + ] as const; + + try { + const results: Array<{ path: string; status: number; body: unknown }> = []; + for (const [path] of cases) { + const response = await fetch(`${base()}${path}`); + results.push({ path, status: response.status, body: await response.json() }); + } + + expect(results).toEqual(cases.map(([path, error]) => ({ + path, + status: 400, + body: { error }, + }))); + expect(readSpy).toHaveBeenCalledTimes(0); + expect(searchSpy).toHaveBeenCalledTimes(0); + expect(exportSpy).toHaveBeenCalledTimes(0); + expect(artifactFileCount()).toBe(beforeArtifacts); + } finally { + readSpy.mockRestore(); + searchSpy.mockRestore(); + exportSpy.mockRestore(); + } + }); +}); + describe("API /api/status", () => { test("returns status object", async () => { const res = await fetch(`${base()}/api/status`); diff --git a/src/server/serve.ts b/src/server/serve.ts index 3c7fbfe..6e7155b 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -20,6 +20,13 @@ import { getRelated, getAgentNetwork, getGraphStats } from "../lib/graph.js"; import { listLocks } from "../lib/locks.js"; import { handleMcpRequest, healthPayload } from "../mcp/http.js"; import { buildServer } from "../mcp/index.js"; +import { + resolveAliasedString, + resolveCollectionQueryOptions, + resolveExportFormat, + resolveIso8601Date, + resolvePresentString, +} from "../lib/strict-query-values.js"; import { join, resolve, sep } from "path"; import { existsSync } from "fs"; @@ -213,24 +220,28 @@ export function startDashboardServer(port = 0, host?: string) { } if (path === "/api/messages" && req.method === "GET") { - const session = url.searchParams.get("session") || undefined; - const channel = url.searchParams.get("channel") || undefined; - const from = url.searchParams.get("from") || undefined; - const to = url.searchParams.get("to") || undefined; try { + const session = resolveAliasedString(url.searchParams, "session", "session_id"); + const channel = resolvePresentString(url.searchParams.get("channel"), "channel"); + const from = resolvePresentString(url.searchParams.get("from"), "from"); + const to = resolvePresentString(url.searchParams.get("to"), "to"); + const since = resolveIso8601Date(url.searchParams.get("since"), "since"); + const fields = resolvePresentString(url.searchParams.get("fields"), "fields"); + const collection = resolveCollectionQueryOptions(url.searchParams); const page = await getStore().readMessagePreviews({ session_id: session, channel, from, to, - limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined, - offset: url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : undefined, - max_bytes: url.searchParams.get("max_bytes") ? Number(url.searchParams.get("max_bytes")) : undefined, - preview_bytes: url.searchParams.get("preview_bytes") ? Number(url.searchParams.get("preview_bytes")) : undefined, - timeout_ms: url.searchParams.get("timeout_ms") ? Number(url.searchParams.get("timeout_ms")) : undefined, + since, + limit: collection.limit, + offset: collection.offset, + max_bytes: collection.maxBytes, + preview_bytes: collection.previewBytes, + timeout_ms: collection.timeoutMs, order: "desc", }); - return jsonResponse(applyFields(page.messages, url.searchParams.get("fields"))); + return jsonResponse(applyFields(page.messages, fields)); } catch (error) { return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } @@ -269,24 +280,27 @@ export function startDashboardServer(port = 0, host?: string) { } if (path === "/api/messages/search" && req.method === "GET") { - const q = url.searchParams.get("q") || ""; - if (!q.trim()) { - return jsonResponse({ error: "Query parameter 'q' is required" }, 400); - } - const channel = url.searchParams.get("channel") || undefined; - const from = url.searchParams.get("from") || undefined; - const to = url.searchParams.get("to") || undefined; try { + const q = resolvePresentString(url.searchParams.get("q"), "q"); + if (!q) return jsonResponse({ error: "Query parameter 'q' is required" }, 400); + const channel = resolvePresentString(url.searchParams.get("channel"), "channel"); + const from = resolvePresentString(url.searchParams.get("from"), "from"); + const to = resolvePresentString(url.searchParams.get("to"), "to"); + const since = resolveIso8601Date(url.searchParams.get("since"), "since"); + const until = resolveIso8601Date(url.searchParams.get("until"), "until"); + const collection = resolveCollectionQueryOptions(url.searchParams); const page = await getStore().searchMessagePreviews({ - query: q.trim(), + query: q, channel, from, to, - limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined, - offset: url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : undefined, - max_bytes: url.searchParams.get("max_bytes") ? Number(url.searchParams.get("max_bytes")) : undefined, - preview_bytes: url.searchParams.get("preview_bytes") ? Number(url.searchParams.get("preview_bytes")) : undefined, - timeout_ms: url.searchParams.get("timeout_ms") ? Number(url.searchParams.get("timeout_ms")) : undefined, + since, + until, + limit: collection.limit, + offset: collection.offset, + max_bytes: collection.maxBytes, + preview_bytes: collection.previewBytes, + timeout_ms: collection.timeoutMs, }); return jsonResponse(page.messages); } catch (error) { @@ -295,41 +309,47 @@ export function startDashboardServer(port = 0, host?: string) { } if (path === "/api/export" && req.method === "GET") { - const channel = url.searchParams.get("channel") || undefined; - const session = url.searchParams.get("session") || undefined; - const from = url.searchParams.get("from") || undefined; - const since = url.searchParams.get("since") || undefined; - const until = url.searchParams.get("until") || undefined; - const format = url.searchParams.get("format") === "csv" ? "csv" : "json"; - const artifact = await getStore().exportMessages({ - channel, - session_id: session, - from, - since, - until, - format, - detail: "preview", - limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined, - max_bytes: url.searchParams.get("max_bytes") ? Number(url.searchParams.get("max_bytes")) : undefined, - preview_bytes: url.searchParams.get("preview_bytes") ? Number(url.searchParams.get("preview_bytes")) : undefined, - timeout_ms: url.searchParams.get("timeout_ms") ? Number(url.searchParams.get("timeout_ms")) : undefined, - }); - return jsonResponse({ artifact }); + try { + const channel = resolvePresentString(url.searchParams.get("channel"), "channel"); + const session = resolveAliasedString(url.searchParams, "session", "session_id"); + const from = resolvePresentString(url.searchParams.get("from"), "from"); + const since = resolveIso8601Date(url.searchParams.get("since"), "since"); + const until = resolveIso8601Date(url.searchParams.get("until"), "until"); + const format = resolveExportFormat(url.searchParams.get("format")); + const collection = resolveCollectionQueryOptions(url.searchParams); + const artifact = await getStore().exportMessages({ + channel, + session_id: session, + from, + since, + until, + format, + detail: "preview", + limit: collection.limit, + max_bytes: collection.maxBytes, + preview_bytes: collection.previewBytes, + timeout_ms: collection.timeoutMs, + }); + return jsonResponse({ artifact }); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } } if (path === "/api/messages/pinned" && req.method === "GET") { - const channel = url.searchParams.get("channel") || undefined; - const session_id = url.searchParams.get("session_id") || undefined; try { + const channel = resolvePresentString(url.searchParams.get("channel"), "channel"); + const session_id = resolveAliasedString(url.searchParams, "session", "session_id"); + const collection = resolveCollectionQueryOptions(url.searchParams); const page = await getStore().readMessagePreviews({ pinned_only: true, channel, session_id, - limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined, - offset: url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : undefined, - max_bytes: url.searchParams.get("max_bytes") ? Number(url.searchParams.get("max_bytes")) : undefined, - preview_bytes: url.searchParams.get("preview_bytes") ? Number(url.searchParams.get("preview_bytes")) : undefined, - timeout_ms: url.searchParams.get("timeout_ms") ? Number(url.searchParams.get("timeout_ms")) : undefined, + limit: collection.limit, + offset: collection.offset, + max_bytes: collection.maxBytes, + preview_bytes: collection.previewBytes, + timeout_ms: collection.timeoutMs, order: "desc", }); return jsonResponse(page.messages); From a466636154f692d9bc2115faabaa026889bc74dd Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 5 Aug 2026 17:45:19 +0300 Subject: [PATCH 8/8] fix: preserve cloud search query contract Agent: unresolved-account003 --- src/lib/store/api-store.test.ts | 20 ++++++++++++++++++++ src/lib/store/api-store.ts | 5 ++++- src/sdk/index.ts | 2 +- src/server/api.test.ts | 25 +++++++++++++++++++++++++ src/server/api.ts | 2 ++ src/server/openapi.test.ts | 7 +++++++ src/server/openapi.ts | 2 ++ 7 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/lib/store/api-store.test.ts b/src/lib/store/api-store.test.ts index 506020a..228d782 100644 --- a/src/lib/store/api-store.test.ts +++ b/src/lib/store/api-store.test.ts @@ -192,6 +192,26 @@ describe("ApiStore message transport", () => { }); }); + test("uses a server-supported cloud search order and preserves the until bound", async () => { + const search = capturingClient(previewPage()); + await new ApiStore(search.client).searchMessagePreviews({ + query: "deployment", + until: "2026-07-19T01:00:00.000Z", + limit: 5, + }); + + expect(search.calls[0].resource).toBe("/messages"); + expect(search.calls[0].options).toMatchObject({ + query: { + q: "deployment", + order: "desc", + until: "2026-07-19T01:00:00.000Z", + limit: 5, + }, + retry: false, + }); + }); + test("posts bounded artifact export options without requesting inline bodies", async () => { const artifact = { artifact_id: "00000000-0000-4000-8000-000000000001", diff --git a/src/lib/store/api-store.ts b/src/lib/store/api-store.ts index 2f3542f..eca1681 100644 --- a/src/lib/store/api-store.ts +++ b/src/lib/store/api-store.ts @@ -614,7 +614,10 @@ export class ApiStore implements ConversationsStore { max_bytes: maxBytes, preview_bytes: previewBytes, timeout_ms: timeoutMs, - order: opts.sort === "recent" ? "desc" : "relevance", + // The cloud collection endpoint accepts chronological ordering only. + // Local FTS may rank by relevance, but sending that value over HTTP + // makes the supported cloud search path fail closed with a 400. + order: "desc", channel: opts.channel ? normalizeChannelName(opts.channel) : undefined, from: opts.from, to: opts.to, diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 6f8f9eb..0a20b54 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -197,7 +197,7 @@ export class ConversationsClient { } /** List bounded, redacted message previews */ - async listMessages(query?: { "to"?: string; "from"?: string; "channel"?: string; "session"?: string; "id"?: number; "since_id"?: number; "limit"?: number; "offset"?: number; "order"?: "asc" | "desc"; "q"?: string; "unread_only"?: boolean; "threads_only"?: boolean; "pinned_only"?: boolean; "reply_to"?: number; "detail"?: "preview"; "max_bytes"?: number; "preview_bytes"?: number; "timeout_ms"?: number }, init?: RequestInit): Promise { + async listMessages(query?: { "to"?: string; "from"?: string; "channel"?: string; "session"?: string; "id"?: number; "since_id"?: number; "since"?: string; "until"?: string; "limit"?: number; "offset"?: number; "order"?: "asc" | "desc"; "q"?: string; "unread_only"?: boolean; "threads_only"?: boolean; "pinned_only"?: boolean; "reply_to"?: number; "detail"?: "preview"; "max_bytes"?: number; "preview_bytes"?: number; "timeout_ms"?: number }, init?: RequestInit): Promise { return this.request("GET", `/v1/messages`, { body: undefined, query, diff --git a/src/server/api.test.ts b/src/server/api.test.ts index a408780..488f2ce 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -466,6 +466,8 @@ describe("conversations-serve", () => { "since_id=1.5", "since=not-a-date", "since=1", + "until=not-a-date", + "until=1", "session=fixture&session_id=", "session=one&session_id=two", "unread_only=perhaps", @@ -484,6 +486,29 @@ describe("conversations-serve", () => { } }); + test("cloud search accepts chronological ordering and enforces the until bound", async () => { + const deps = makeDeps(); + const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); + const isolatedBase = `http://127.0.0.1:${isolated.port}`; + const until = "2026-07-19T01:00:00.000Z"; + try { + const response = await fetch( + `${isolatedBase}/v1/messages?q=deployment&order=desc&until=${encodeURIComponent(until)}`, + { headers: { "x-api-key": rwKey } }, + ); + expect(response.status).toBe(200); + + const query = (deps.client as any).queryCalls.find( + (call: { sql: string }) => /content ILIKE/.test(call.sql), + ); + expect(query).toBeTruthy(); + expect(query.sql).toContain("created_at <="); + expect(query.params).toContain(until); + } finally { + isolated.stop(true); + } + }); + test("dedicated broad routes reject empty or malformed values before widening", async () => { const deps = makeDeps(); const isolated = startApiServer({ port: 0, host: "127.0.0.1", deps }); diff --git a/src/server/api.ts b/src/server/api.ts index 964aa48..74e2ad2 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -1063,6 +1063,7 @@ async function handleV1( } const projectId = strictStringParam(url.searchParams.get("project_id"), "project_id"); const since = strictDateParam(url.searchParams.get("since"), "since"); + const until = strictDateParam(url.searchParams.get("until"), "until"); const id = strictIntegerParam(url.searchParams.get("id"), "id"); const replyTo = strictIntegerParam(url.searchParams.get("reply_to"), "reply_to"); const sinceId = strictIntegerParam(url.searchParams.get("since_id"), "since_id", true); @@ -1096,6 +1097,7 @@ async function handleV1( if (projectId) { params.push(projectId); clauses.push(`project_id = $${params.length}`); } if (uuid) { params.push(uuid); clauses.push(`uuid = $${params.length}`); } if (since) { params.push(since); clauses.push(`created_at > $${params.length}`); } + if (until) { params.push(until); clauses.push(`created_at <= $${params.length}`); } if (sinceId !== undefined) { params.push(sinceId); clauses.push(`id > $${params.length}`); } if (q) { params.push(`%${q}%`); clauses.push(`content ILIKE $${params.length}`); } if (mentionsOnly) { diff --git a/src/server/openapi.test.ts b/src/server/openapi.test.ts index 8e8f7a2..67a53bf 100644 --- a/src/server/openapi.test.ts +++ b/src/server/openapi.test.ts @@ -33,4 +33,11 @@ describe("incident projection public contract", () => { expect(sdk).toContain("reply_to"); expect(sdk).toContain("metadata"); }); + + test("publishes both message search time bounds", () => { + const parameters = (openapiSpec.paths["/v1/messages"].get.parameters as readonly { name: string }[]) + .map((parameter) => parameter.name); + expect(parameters).toContain("since"); + expect(parameters).toContain("until"); + }); }); diff --git a/src/server/openapi.ts b/src/server/openapi.ts index a2397ef..2d6732b 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -421,6 +421,8 @@ export const openapiSpec = { { name: "session", in: "query", schema: { type: "string" } }, { name: "id", in: "query", schema: { type: "integer", minimum: 1 } }, { name: "since_id", in: "query", schema: { type: "integer", minimum: 0 } }, + { name: "since", in: "query", schema: { type: "string", format: "date-time" } }, + { name: "until", in: "query", schema: { type: "string", format: "date-time" } }, { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100 } }, { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }, { name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"] } },