diff --git a/migrations/sql/0061_feedback_transitions_view.sql b/migrations/sql/0061_feedback_transitions_view.sql new file mode 100644 index 0000000..4fa64bb --- /dev/null +++ b/migrations/sql/0061_feedback_transitions_view.sql @@ -0,0 +1,95 @@ +-- Replayable, minimized feedback transitions for app-scoped agent patrols. +-- +-- audit_logs remains the single append-only source of truth. This view strips +-- actor and arbitrary payload data, expands a combined status+assignee audit +-- into a fixed status-then-assignee order, and exposes no reporter identity, +-- comment body, contact, device, webhook data, or secret-bearing field. + +CREATE TRIGGER audit_logs_no_update BEFORE UPDATE ON audit_logs BEGIN SELECT RAISE(ABORT, 'audit logs are immutable'); END; + +CREATE TRIGGER audit_logs_no_delete BEFORE DELETE ON audit_logs WHEN EXISTS (SELECT 1 FROM apps WHERE id = OLD.app_id) BEGIN SELECT RAISE(ABORT, 'audit logs are durable while app exists'); END; + +CREATE INDEX idx_audit_app_action ON audit_logs(app_id, action); +CREATE INDEX idx_audit_action_created ON audit_logs(action, created_at); + +CREATE VIEW feedback_transitions AS +WITH safe_logs AS ( + SELECT rowid AS audit_rowid, app_id, action, created_at, + CASE WHEN json_valid(payload) THEN payload ELSE '{}' END AS payload + FROM audit_logs +), expanded AS ( + SELECT l.audit_rowid, + 1 AS event_order, + l.app_id, + json_extract(l.payload, '$.ticket_id') AS ticket_id, + 'status_changed' AS transition_type, + json_extract(l.payload, '$.previous_status') AS previous_value, + json_extract(l.payload, '$.status') AS value, + l.created_at AS occurred_at + FROM safe_logs l + WHERE l.action = 'feedback.update' + AND json_type(l.payload, '$.ticket_id') = 'text' + AND json_type(l.payload, '$.previous_status') = 'text' + AND json_type(l.payload, '$.status') = 'text' + AND json_extract(l.payload, '$.previous_status') IS NOT json_extract(l.payload, '$.status') + + UNION ALL + + SELECT l.audit_rowid, + 2, + l.app_id, + json_extract(l.payload, '$.ticket_id'), + 'assignee_changed', + json_extract(l.payload, '$.previous_assignee'), + json_extract(l.payload, '$.assignee'), + l.created_at + FROM safe_logs l + WHERE l.action = 'feedback.update' + AND json_type(l.payload, '$.ticket_id') = 'text' + AND json_type(l.payload, '$.previous_assignee') IN ('text', 'null') + AND json_type(l.payload, '$.assignee') IN ('text', 'null') + AND json_extract(l.payload, '$.previous_assignee') IS NOT json_extract(l.payload, '$.assignee') + + UNION ALL + + SELECT l.audit_rowid, + 1, + l.app_id, + json_extract(l.payload, '$.ticket_id'), + 'comment_visibility', + NULL, + CASE WHEN json_extract(l.payload, '$.internal') = 1 THEN 'internal' ELSE 'reporter' END, + l.created_at + FROM safe_logs l + WHERE l.action = 'feedback.comment' + AND json_type(l.payload, '$.ticket_id') = 'text' + AND json_type(l.payload, '$.internal') IN ('true', 'false') + + UNION ALL + + SELECT l.audit_rowid, + 1, + l.app_id, + json_extract(l.payload, '$.ticket_id'), + 'comment_visibility', + NULL, + 'reporter', + l.created_at + FROM safe_logs l + WHERE l.action = 'feedback.reporter_comment' + AND json_type(l.payload, '$.ticket_id') = 'text' +), numbered AS ( + SELECT app_id, + ROW_NUMBER() OVER ( + PARTITION BY app_id ORDER BY audit_rowid, event_order + ) AS sequence, + ticket_id, + transition_type, + previous_value, + value, + occurred_at + FROM expanded +) +SELECT app_id, sequence, ticket_id, transition_type, + previous_value, value, occurred_at +FROM numbered; diff --git a/migrations/validation/0061_feedback_transitions_view.sql b/migrations/validation/0061_feedback_transitions_view.sql new file mode 100644 index 0000000..5a8e3be --- /dev/null +++ b/migrations/validation/0061_feedback_transitions_view.sql @@ -0,0 +1,61 @@ +-- Post-migration / restore validator for the feedback transition projection. +-- A valid database returns zero rows. +WITH malformed_source AS ( + SELECT l.app_id, + 'malformed_' || l.action AS violation + FROM audit_logs l + WHERE ( + l.action = 'feedback.update' + AND ( + NOT json_valid(l.payload) + OR json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.ticket_id') IS NOT 'text' + OR NOT ( + ( + json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.previous_status') = 'text' + AND json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.status') = 'text' + ) + OR + ( + json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.previous_assignee') IN ('text', 'null') + AND json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.assignee') IN ('text', 'null') + ) + ) + ) + ) + OR ( + l.action = 'feedback.comment' + AND ( + NOT json_valid(l.payload) + OR json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.ticket_id') IS NOT 'text' + OR COALESCE(json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.internal'), 'missing') NOT IN ('true', 'false') + ) + ) + OR ( + l.action = 'feedback.reporter_comment' + AND ( + NOT json_valid(l.payload) + OR json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.ticket_id') IS NOT 'text' + ) + ) +), projection_stats AS ( + SELECT app_id, + COUNT(*) AS event_count, + MIN(sequence) AS min_sequence, + MAX(sequence) AS max_sequence, + COUNT(*) - COUNT(DISTINCT sequence) AS duplicate_count + FROM feedback_transitions + GROUP BY app_id +), projection_violations AS ( + SELECT app_id, + CASE + WHEN min_sequence != 1 THEN 'transition_sequence_not_one_based' + WHEN event_count != max_sequence THEN 'transition_sequence_gap' + WHEN duplicate_count != 0 THEN 'duplicate_transition_sequence' + ELSE NULL + END AS violation + FROM projection_stats +) +SELECT app_id, violation FROM malformed_source +UNION ALL +SELECT app_id, violation FROM projection_violations WHERE violation IS NOT NULL +ORDER BY app_id, violation; diff --git a/worker/src/index.ts b/worker/src/index.ts index 6c0cea1..1bf946b 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -76,6 +76,7 @@ import { handlePublicMinidumpSubmit, handleListFeedback, handleListFeedbackMaterialDelta, + handleListFeedbackTransitions, handleGetFeedback, handleUpdateFeedback, handleAddFeedbackComment, @@ -761,6 +762,11 @@ admin.get( requireAppRole("viewer"), handleListFeedbackMaterialDelta, ); +admin.get( + "/api/apps/:appId/feedback/transitions", + requireAppRole("viewer"), + handleListFeedbackTransitions, +); admin.get("/api/apps/:appId/feedback/:ticketId", requireAppRole("viewer"), handleGetFeedback); admin.patch("/api/apps/:appId/feedback/:ticketId", requireFeedbackTriageRole(), handleUpdateFeedback); admin.post("/api/apps/:appId/feedback/:ticketId/comments", requireFeedbackTriageRole(), handleAddFeedbackComment); diff --git a/worker/src/openapi/feedback.ts b/worker/src/openapi/feedback.ts index 53479c9..24bb9b3 100644 --- a/worker/src/openapi/feedback.ts +++ b/worker/src/openapi/feedback.ts @@ -279,6 +279,41 @@ export function registerFeedbackRoutes(registry: OpenApiRegistry) { }, }); + register(registry, { + method: "get", + path: "/api/apps/{appId}/feedback/transitions", + tags: ["Feedback"], + summary: "Replay feedback status, assignee, and comment-visibility transitions", + description: + "Returns a minimized transition stream derived from append-only audit records. coverage_started_at is reported per transition type; null or a report window before that timestamp means the corresponding report section is incomplete. Process a whole page before persisting next_cursor.", + security: auth, + request: { + params: AppIdParam, + query: z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }), + }, + responses: { + 200: success( + "Complete app-visible feedback transitions within each declared coverage window.", + z.object({ + transitions: z.array(GenericObject), + next_cursor: z.string(), + has_more: z.boolean(), + coverage_started_at: z.object({ + status_changed: z.number().int().nonnegative().nullable(), + assignee_changed: z.number().int().nonnegative().nullable(), + comment_visibility: z.number().int().nonnegative().nullable(), + }), + }), + ), + 400: error("Invalid feedback transitions cursor or limit."), + 403: error("Current principal cannot view feedback."), + 500: error("Feedback transition projection is unavailable."), + }, + }); + register(registry, { method: "get", path: "/api/apps/{appId}/feedback/stats", diff --git a/worker/src/routes/auth.ts b/worker/src/routes/auth.ts index 084ca83..5dc94fa 100644 --- a/worker/src/routes/auth.ts +++ b/worker/src/routes/auth.ts @@ -1449,6 +1449,17 @@ export async function handleAgentManifest(c: Context<{ Bindings: Env }>) { limit: { type: "number", in: "query", required: false, description: "Page size from 1 to 200; defaults to 100." }, }, }, + { + name: "list-feedback-transitions", + description: + "Replay minimized feedback status, assignee, and comment-visibility transitions after an opaque per-app cursor. Requires app viewer. coverage_started_at is per transition type; a null or later coverage timestamp makes that report section incomplete. Process the whole page before persisting next_cursor.", + endpoint: { method: "GET", path: "/api/apps/{app_id}/feedback/transitions" }, + parameters: { + app_id: { type: "string", in: "path", required: true, description: "App UUID." }, + cursor: { type: "string", in: "query", required: false, description: "Opaque feedback-transitions-v1 cursor from the previous page; omit for the coverage epoch." }, + limit: { type: "number", in: "query", required: false, description: "Page size from 1 to 200; defaults to 100." }, + }, + }, { name: "bind-reporter-webhook", description: diff --git a/worker/src/routes/feedback.ts b/worker/src/routes/feedback.ts index 23d8fcc..3b936e1 100644 --- a/worker/src/routes/feedback.ts +++ b/worker/src/routes/feedback.ts @@ -32,6 +32,8 @@ const TICKET_KINDS = ["feedback", "bug", "crash", "error"] as const; const SUBMISSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const MATERIAL_DELTA_DEFAULT_LIMIT = 100; const MATERIAL_DELTA_MAX_LIMIT = 200; +const FEEDBACK_TRANSITIONS_DEFAULT_LIMIT = 100; +const FEEDBACK_TRANSITIONS_MAX_LIMIT = 200; function encodeMaterialCursor(appId: string, sequence: number): string { return btoa(JSON.stringify(["material-v1", appId, sequence])) @@ -63,6 +65,42 @@ function decodeMaterialCursor(value: string | undefined, appId: string): number } } +function encodeFeedbackTransitionsCursor( + appId: string, + sequence: number, +): string { + return btoa(JSON.stringify(["feedback-transitions-v1", appId, sequence])) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +} + +function decodeFeedbackTransitionsCursor( + value: string | undefined, + appId: string, +): number | null { + if (value === undefined) return 0; + if (!value) return null; + try { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const decoded = JSON.parse(atob(padded)); + if ( + !Array.isArray(decoded) + || decoded.length !== 3 + || decoded[0] !== "feedback-transitions-v1" + || decoded[1] !== appId + || !Number.isSafeInteger(decoded[2]) + || decoded[2] < 0 + ) { + return null; + } + return decoded[2]; + } catch { + return null; + } +} + function timingDuration(start: number, end: number): string { return Math.max(0, end - start).toFixed(1); } @@ -2032,6 +2070,127 @@ export async function handleListFeedbackMaterialDelta(c: AdminContext) { }); } +export async function handleListFeedbackTransitions(c: AdminContext) { + const appId = c.req.param("appId") ?? ""; + const state = await c.env.DB.prepare( + `SELECT a.created_at AS app_created_at, + COALESCE((SELECT MAX(sequence) FROM feedback_transitions WHERE app_id = a.id), 0) AS high_water, + (SELECT MIN(created_at) FROM audit_logs + WHERE action = 'feedback.update' AND json_valid(payload) + AND json_type(payload, '$.previous_status') = 'text' + AND json_type(payload, '$.status') = 'text') AS status_coverage_started_at, + (SELECT MIN(created_at) FROM audit_logs + WHERE action = 'feedback.update' AND json_valid(payload) + AND json_type(payload, '$.previous_assignee') IN ('text', 'null') + AND json_type(payload, '$.assignee') IN ('text', 'null')) AS assignee_coverage_started_at, + (SELECT MIN(created_at) FROM audit_logs + WHERE action = 'feedback.comment' AND json_valid(payload) + AND json_type(payload, '$.internal') IN ('true', 'false')) AS staff_comment_coverage_started_at, + (SELECT MIN(created_at) FROM audit_logs + WHERE action = 'feedback.reporter_comment' AND json_valid(payload) + AND json_type(payload, '$.ticket_id') = 'text') AS reporter_comment_coverage_started_at + FROM apps a + WHERE a.id = ?1`, + ).bind(appId).first<{ + app_created_at: number; + high_water: number; + status_coverage_started_at: number | null; + assignee_coverage_started_at: number | null; + staff_comment_coverage_started_at: number | null; + reporter_comment_coverage_started_at: number | null; + }>(); + if ( + !state + || !Number.isSafeInteger(state.high_water) + || state.high_water < 0 + || !Number.isSafeInteger(state.app_created_at) + || state.app_created_at < 0 + ) { + return c.json({ error: "feedback transition projection is unavailable" }, 500); + } + + const cursor = decodeFeedbackTransitionsCursor( + c.req.query("cursor"), + appId, + ); + if (cursor === null || cursor > state.high_water) { + return c.json({ error: "invalid feedback transitions cursor" }, 400); + } + + const rawLimit = c.req.query("limit"); + const limit = rawLimit === undefined ? FEEDBACK_TRANSITIONS_DEFAULT_LIMIT : Number(rawLimit); + if (!Number.isInteger(limit) || limit < 1 || limit > FEEDBACK_TRANSITIONS_MAX_LIMIT) { + return c.json({ + error: `limit must be an integer between 1 and ${FEEDBACK_TRANSITIONS_MAX_LIMIT}`, + }, 400); + } + + const { results } = await c.env.DB.prepare( + `SELECT sequence, ticket_id, transition_type, previous_value, value, occurred_at + FROM feedback_transitions + WHERE app_id = ?1 AND sequence > ?2 + ORDER BY sequence ASC + LIMIT ?3`, + ).bind(appId, cursor, limit + 1).all<{ + sequence: number; + ticket_id: string; + transition_type: "status_changed" | "assignee_changed" | "comment_visibility"; + previous_value: string | null; + value: string | null; + occurred_at: number; + }>(); + + const page = results.slice(0, limit); + const lastSequence = page.at(-1)?.sequence ?? cursor; + if (!Number.isSafeInteger(lastSequence) || lastSequence < 0) { + return c.json({ error: "invalid feedback transition sequence state" }, 500); + } + const transitions = page.map((row) => row.transition_type === "comment_visibility" + ? { + ticket_id: row.ticket_id, + type: row.transition_type, + visibility: row.value, + occurred_at: row.occurred_at, + } + : { + ticket_id: row.ticket_id, + type: row.transition_type, + from: row.previous_value, + to: row.value, + occurred_at: row.occurred_at, + }); + const statusCoverage = state.status_coverage_started_at === null + ? null + : Math.max(state.app_created_at, state.status_coverage_started_at); + const assigneeCoverage = state.assignee_coverage_started_at === null + ? null + : Math.max(state.app_created_at, state.assignee_coverage_started_at); + // Comment visibility is complete only after both staff and reporter audit + // sources exist. The later source is the safe coverage boundary; using the + // earlier one would silently describe the intervening window as zero. + const commentCoverage = state.staff_comment_coverage_started_at === null + || state.reporter_comment_coverage_started_at === null + ? null + : Math.max( + state.app_created_at, + state.staff_comment_coverage_started_at, + state.reporter_comment_coverage_started_at, + ); + return c.json({ + transitions, + next_cursor: encodeFeedbackTransitionsCursor( + appId, + lastSequence, + ), + has_more: results.length > limit, + coverage_started_at: { + status_changed: statusCoverage, + assignee_changed: assigneeCoverage, + comment_visibility: commentCoverage, + }, + }); +} + const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; type TicketResolution = diff --git a/worker/test/feedback_material_delta_route.test.ts b/worker/test/feedback_material_delta_route.test.ts index cfc9881..3d3de6e 100644 --- a/worker/test/feedback_material_delta_route.test.ts +++ b/worker/test/feedback_material_delta_route.test.ts @@ -12,6 +12,7 @@ import { handlePurgeApp } from "../src/routes/apps"; import { handleAddFeedbackComment, handleListFeedbackMaterialDelta, + handleListFeedbackTransitions, handlePublicFeedbackSubmit, handlePublicMinidumpSubmit, handleUpdateFeedback, @@ -700,6 +701,7 @@ describe("feedback material delta production routes", () => { it("publishes the admin route in OpenAPI and the agent action manifest", async () => { expect(openApiDocument.paths?.["/api/apps/{appId}/feedback/material-delta"]?.get).toBeDefined(); + expect(openApiDocument.paths?.["/api/apps/{appId}/feedback/transitions"]?.get).toBeDefined(); const response = await handleAgentManifest({ env: { RAFT_CLIENT_ID: "hands-test" }, req: { url: "https://hands.test/.well-known/raft-agent-manifest.json" }, @@ -711,5 +713,198 @@ describe("feedback material delta production routes", () => { .toMatchObject({ endpoint: { method: "GET", path: "/api/apps/{app_id}/feedback/material-delta" }, }); + expect(manifest.actions.find((action: any) => action.name === "list-feedback-transitions")) + .toMatchObject({ + endpoint: { method: "GET", path: "/api/apps/{app_id}/feedback/transitions" }, + }); + }); + + it("exports a complete minimized transition stream with an explicit coverage epoch", async () => { + const { sqlite, env } = environment(); + const ticketId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + sqlite.prepare( + `INSERT INTO feedback_tickets + (id, app_id, kind, status, message, metadata_json, created_at, updated_at) + VALUES (?, 'app-a', 'feedback', 'open', 'private ticket body', '{}', 10, 10)`, + ).run(ticketId); + + const audit = sqlite.prepare( + `INSERT INTO audit_logs (id, app_id, action, actor, payload, created_at) + VALUES (?, 'app-a', ?, 'private-actor', ?, ?)`, + ); + audit.run("noop", "feedback.update", JSON.stringify({ + ticket_id: ticketId, + previous_status: "open", + status: "open", + previous_assignee: null, + assignee: null, + }), 11); + expect(sqlite.prepare("SELECT COUNT(*) AS n FROM feedback_transitions").get()) + .toEqual({ n: 0 }); + + audit.run("combined", "feedback.update", JSON.stringify({ + ticket_id: ticketId, + previous_status: "open", + status: "in_progress", + previous_assignee: null, + assignee: "owner-account", + }), 12); + audit.run("unassign", "feedback.update", JSON.stringify({ + ticket_id: ticketId, + previous_status: "in_progress", + status: "in_progress", + previous_assignee: "owner-account", + assignee: null, + }), 13); + audit.run("visible", "feedback.comment", JSON.stringify({ + ticket_id: ticketId, + comment_id: "comment-visible", + internal: false, + extra_secret: "visible secret body", + }), 14); + audit.run("internal", "feedback.comment", JSON.stringify({ + ticket_id: ticketId, + comment_id: "comment-internal", + internal: true, + extra_secret: "internal secret body", + }), 15); + audit.run("reporter", "feedback.reporter_comment", JSON.stringify({ + ticket_id: ticketId, + comment_id: "comment-reporter", + reporter_hash: "secret-hash", + audit_key_version: "private-key-version", + }), 16); + + const first = await handleListFeedbackTransitions(jsonContext( + env, + { appId: "app-a" }, + {}, + { limit: "2" }, + )); + expect(first.status).toBe(200); + const firstBody = await first.json() as any; + expect(firstBody).toMatchObject({ + coverage_started_at: { + status_changed: 11, + assignee_changed: 11, + comment_visibility: 16, + }, + has_more: true, + transitions: [ + { + ticket_id: ticketId, + type: "status_changed", + from: "open", + to: "in_progress", + occurred_at: 12, + }, + { + ticket_id: ticketId, + type: "assignee_changed", + from: null, + to: "owner-account", + occurred_at: 12, + }, + ], + }); + const second = await handleListFeedbackTransitions(jsonContext( + env, + { appId: "app-a" }, + {}, + { cursor: firstBody.next_cursor, limit: "10" }, + )); + const secondBody = await second.json() as any; + expect(secondBody).toMatchObject({ + coverage_started_at: { + status_changed: 11, + assignee_changed: 11, + comment_visibility: 16, + }, + has_more: false, + transitions: [ + { type: "assignee_changed", from: "owner-account", to: null, occurred_at: 13 }, + { type: "comment_visibility", visibility: "reporter", occurred_at: 14 }, + { type: "comment_visibility", visibility: "internal", occurred_at: 15 }, + { type: "comment_visibility", visibility: "reporter", occurred_at: 16 }, + ], + }); + const serialized = JSON.stringify([firstBody, secondBody]); + for (const forbidden of [ + "private ticket body", + "visible secret body", + "internal secret body", + "secret-hash", + "reporter_id", + "reporter_integration", + "author_actor", + "comment-visible", + "comment-internal", + "comment-reporter", + "private-key-version", + ]) { + expect(serialized).not.toContain(forbidden); + } + + expect((await handleListFeedbackTransitions(jsonContext( + env, + { appId: "app-b" }, + {}, + { cursor: firstBody.next_cursor }, + ))).status).toBe(400); + expect((await handleListFeedbackTransitions(jsonContext( + env, + { appId: "app-a" }, + {}, + { cursor: cursor(["feedback-transitions-v1", "app-a", 999]) }, + ))).status).toBe(400); + }); + + it("keeps feedback transitions on the app-viewer boundary", async () => { + const { sqlite, env } = environment(); + const reporter = generateDeployToken(); + const viewer = generateDeployToken(); + const integrationId = "55555555-5555-4555-8555-555555555555"; + sqlite.prepare( + `INSERT INTO app_reporter_integrations (id, app_id, name, created_at, updated_at) + VALUES (?, 'app-a', 'inbox', 1, 1)`, + ).run(integrationId); + sqlite.prepare( + `INSERT INTO app_deploy_tokens + (id, app_id, name, token_prefix, token_hash, app_role, scopes_json, + reporter_integration_id, created_by_actor, created_at) + VALUES ('reporter-token', 'app-a', 'reporter', ?, ?, NULL, ?, ?, 'test', 1)`, + ).run( + reporter.token_prefix, + await hashDeployToken(reporter.token), + JSON.stringify(["feedback:read", "feedback:comment"]), + integrationId, + ); + sqlite.prepare( + `INSERT INTO app_deploy_tokens + (id, app_id, name, token_prefix, token_hash, app_role, scopes_json, + reporter_integration_id, created_by_actor, created_at) + VALUES ('viewer-token', 'app-a', 'viewer', ?, ?, 'viewer', NULL, NULL, 'test', 1)`, + ).run( + viewer.token_prefix, + await hashDeployToken(viewer.token), + ); + const mini = new Hono(); + mini.use("*", authMiddleware); + mini.get( + "/api/apps/:appId/feedback/transitions", + requireAppRole("viewer"), + handleListFeedbackTransitions, + ); + const path = "https://hands.test/api/apps/app-a/feedback/transitions"; + expect((await mini.request( + path, + { headers: { authorization: `Bearer ${reporter.token}` } }, + env, + )).status).toBe(403); + expect((await mini.request( + path, + { headers: { authorization: `Bearer ${viewer.token}` } }, + env, + )).status).toBe(200); }); }); diff --git a/worker/test/feedback_transitions_view.test.ts b/worker/test/feedback_transitions_view.test.ts new file mode 100644 index 0000000..77e3c07 --- /dev/null +++ b/worker/test/feedback_transitions_view.test.ts @@ -0,0 +1,229 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; + +const MIGRATION_DIR = fileURLToPath(new URL("../../migrations/sql/", import.meta.url)); +const VALIDATION_SQL = readFileSync( + new URL("../../migrations/validation/0061_feedback_transitions_view.sql", import.meta.url), + "utf8", +); + +function database() { + const db = new Database(":memory:"); + db.pragma("foreign_keys = ON"); + for (const name of readdirSync(MIGRATION_DIR).sort()) { + if (name.endsWith(".sql")) db.exec(readFileSync(`${MIGRATION_DIR}${name}`, "utf8")); + } + return db; +} + +function app(db: Database.Database, id: string) { + db.prepare( + "INSERT INTO apps (id, slug, name, platform, created_at) VALUES (?,?,?,'android',1)", + ).run(id, id, id); +} + +function audit( + db: Database.Database, + id: string, + appId: string, + action: string, + payload: unknown, + createdAt: number, +) { + db.prepare( + `INSERT INTO audit_logs (id, app_id, action, actor, payload, created_at) + VALUES (?, ?, ?, 'private-actor', ?, ?)`, + ).run(id, appId, action, typeof payload === "string" ? payload : JSON.stringify(payload), createdAt); +} + +describe("migration 0061 — minimized feedback transition view", () => { + it("derives stable dense app-local sequences from the append-only audit SSOT", () => { + const db = database(); + app(db, "app-a"); + app(db, "app-b"); + audit(db, "a-status", "app-a", "feedback.update", { + ticket_id: "ticket-a", + previous_status: "open", + status: "in_progress", + previous_assignee: null, + assignee: null, + }, 10); + audit(db, "b-comment", "app-b", "feedback.comment", { + ticket_id: "ticket-b", + comment_id: "private-comment-id", + internal: false, + arbitrary_secret: "never-project", + }, 11); + audit(db, "a-combined", "app-a", "feedback.update", { + ticket_id: "ticket-a", + previous_status: "in_progress", + status: "resolved", + previous_assignee: null, + assignee: "owner", + }, 12); + + expect(db.prepare( + `SELECT app_id, sequence, ticket_id, transition_type, + previous_value, value, occurred_at + FROM feedback_transitions ORDER BY app_id, sequence`, + ).all()).toEqual([ + { + app_id: "app-a", + sequence: 1, + ticket_id: "ticket-a", + transition_type: "status_changed", + previous_value: "open", + value: "in_progress", + occurred_at: 10, + }, + { + app_id: "app-a", + sequence: 2, + ticket_id: "ticket-a", + transition_type: "status_changed", + previous_value: "in_progress", + value: "resolved", + occurred_at: 12, + }, + { + app_id: "app-a", + sequence: 3, + ticket_id: "ticket-a", + transition_type: "assignee_changed", + previous_value: null, + value: "owner", + occurred_at: 12, + }, + { + app_id: "app-b", + sequence: 1, + ticket_id: "ticket-b", + transition_type: "comment_visibility", + previous_value: null, + value: "reporter", + occurred_at: 11, + }, + ]); + expect(db.prepare("PRAGMA table_info(feedback_transitions)").all() + .map((row: any) => row.name)).toEqual([ + "app_id", + "sequence", + "ticket_id", + "transition_type", + "previous_value", + "value", + "occurred_at", + ]); + expect(JSON.stringify(db.prepare("SELECT * FROM feedback_transitions").all())) + .not.toContain("never-project"); + }); + + it("suppresses every no-op and preserves both nullable assignee directions", () => { + const db = database(); + app(db, "app-a"); + audit(db, "noop", "app-a", "feedback.update", { + ticket_id: "ticket", + previous_status: "open", + status: "open", + previous_assignee: null, + assignee: null, + }, 1); + audit(db, "assign", "app-a", "feedback.update", { + ticket_id: "ticket", + previous_status: "open", + status: "open", + previous_assignee: null, + assignee: "owner", + }, 2); + audit(db, "unassign", "app-a", "feedback.update", { + ticket_id: "ticket", + previous_status: "open", + status: "open", + previous_assignee: "owner", + assignee: null, + }, 3); + expect(db.prepare( + "SELECT sequence, previous_value, value FROM feedback_transitions ORDER BY sequence", + ).all()).toEqual([ + { sequence: 1, previous_value: null, value: "owner" }, + { sequence: 2, previous_value: "owner", value: null }, + ]); + }); + + it("projects staff/reporter comment visibility without identities or arbitrary payload", () => { + const db = database(); + app(db, "app-a"); + audit(db, "visible", "app-a", "feedback.comment", { + ticket_id: "ticket", + comment_id: "visible-id", + internal: false, + }, 1); + audit(db, "internal", "app-a", "feedback.comment", { + ticket_id: "ticket", + comment_id: "internal-id", + internal: true, + }, 2); + audit(db, "reporter", "app-a", "feedback.reporter_comment", { + ticket_id: "ticket", + comment_id: "reporter-id", + reporter_hash: "private-reporter-hash", + audit_key_version: "private-key-version", + }, 3); + const rows = db.prepare( + "SELECT transition_type, value FROM feedback_transitions ORDER BY sequence", + ).all(); + expect(rows).toEqual([ + { transition_type: "comment_visibility", value: "reporter" }, + { transition_type: "comment_visibility", value: "internal" }, + { transition_type: "comment_visibility", value: "reporter" }, + ]); + expect(JSON.stringify(rows)).not.toMatch(/private|hash|key.version|comment.*id/); + }); + + it("makes the dynamic sequence source immutable while preserving app purge", () => { + const db = database(); + app(db, "app-a"); + audit(db, "event", "app-a", "feedback.comment", { + ticket_id: "ticket", + comment_id: "comment", + internal: false, + }, 1); + expect(() => db.prepare("UPDATE audit_logs SET created_at=2 WHERE id='event'").run()) + .toThrow(/audit logs are immutable/); + expect(() => db.prepare("DELETE FROM audit_logs WHERE id='event'").run()) + .toThrow(/audit logs are durable/); + db.prepare("DELETE FROM apps WHERE id='app-a'").run(); + expect(db.prepare("SELECT COUNT(*) AS n FROM audit_logs").get()).toEqual({ n: 0 }); + }); + + it("uses app/action-bounded sources for paging and action/time coverage", () => { + const db = database(); + const pagePlan = db.prepare( + `EXPLAIN QUERY PLAN + SELECT sequence, ticket_id FROM feedback_transitions + WHERE app_id='app-a' AND sequence>0 ORDER BY sequence LIMIT 101`, + ).all() as Array<{ detail: string }>; + expect(pagePlan.filter((row) => row.detail.includes("SEARCH audit_logs"))) + .toHaveLength(4); + expect(pagePlan.filter((row) => row.detail.includes("idx_audit_app_action"))) + .toHaveLength(4); + const coveragePlan = db.prepare( + `EXPLAIN QUERY PLAN + SELECT MIN(created_at) FROM audit_logs WHERE action='feedback.update'`, + ).all() as Array<{ detail: string }>; + expect(coveragePlan.some((row) => row.detail.includes("idx_audit_action_created"))) + .toBe(true); + }); + + it("fails validation on malformed relevant source rows instead of silently claiming coverage", () => { + const db = database(); + app(db, "app-a"); + audit(db, "malformed", "app-a", "feedback.comment", "not-json", 1); + expect(db.prepare("SELECT COUNT(*) AS n FROM feedback_transitions").get()).toEqual({ n: 0 }); + expect(db.prepare(VALIDATION_SQL).all()).toEqual([ + { app_id: "app-a", violation: "malformed_feedback.comment" }, + ]); + }); +});