diff --git a/prisma/migrations/20260810163750_add_report_url_and_bundle/migration.sql b/prisma/migrations/20260810163750_add_report_url_and_bundle/migration.sql new file mode 100644 index 0000000000..bdf07719fa --- /dev/null +++ b/prisma/migrations/20260810163750_add_report_url_and_bundle/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "stakwork_runs" ADD COLUMN "report_bundle" JSONB, +ADD COLUMN "report_bundle_hash" TEXT, +ADD COLUMN "report_partial" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "report_url" TEXT, +ADD COLUMN "report_url_rejection_reason" TEXT, +ADD COLUMN "schema_unsupported" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 898b28d198..8e24a746e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1034,6 +1034,25 @@ model Phase { @@map("phases") } +/// StakworkRun tracks AI workflow executions dispatched to the Stakwork platform. +/// +/// ARTIFACT OWNERSHIP — two separate report artifacts, do NOT conflate: +/// +/// 1. "Chat report" — an AI-written narrative produced post-run by the org-canvas agent. +/// Fields: reportStatus / reportConversationId / reportChatPath (all nested in `result` +/// JSON via mergeIntoRunResult in src/services/legal-benchmark-report.ts). +/// +/// 2. "Run report bundle" — a structured JSON bundle produced by the Harvey LAB runner +/// and uploaded to S3. Fields: reportUrl (canonical bundle-pointer, server-side only), +/// reportBundle (sanitized projection persisted at ingest), reportBundleHash (SHA-256 of +/// the raw bundle for re-sanitize integrity), reportPartial (projection truncated due to +/// size), schemaUnsupported (schema_version not handled by this Hive version), +/// reportUrlRejectionReason (why a supplied report_url was rejected — never the URL). +/// +/// reportUrl and webhookUrl are globally omitted from Prisma reads (src/lib/db.ts) and +/// must be accessed via an explicit select. webhookUrl embeds a raw HMAC run_token in its +/// query string; leaking it to clients collapses the run_token gate. See follow-up ticket +/// for moving the token to a dedicated hashed column. model StakworkRun { id String @id @default(cuid()) webhookUrl String @map("webhook_url") @@ -1053,6 +1072,26 @@ model StakworkRun { promptVersionId String? @map("prompt_version_id") evalSetId String? @map("eval_set_id") userId String? @map("user_id") + + // ── Run report bundle fields (see ARTIFACT OWNERSHIP note above) ────────── + /// S3 URL of the raw report bundle. Server-side only — globally omitted from + /// Prisma reads. Never forwarded to clients. Captured at webhook ingest under + /// run_token + S3-allowlist gate. + reportUrl String? @map("report_url") + /// Sanitized, redacted projection of the bundle stored at ingest while the + /// presigned URL is still valid. The view path reads only this field. + reportBundle Json? @map("report_bundle") + /// SHA-256 hex digest of the raw bundle bytes, for future re-sanitize passes. + reportBundleHash String? @map("report_bundle_hash") + /// True when the projection was truncated (source_docs html bodies dropped) + /// because the serialized projection exceeded the size cap. + reportPartial Boolean @default(false) @map("report_partial") + /// True when the bundle's schema_version is not supported by this Hive version. + schemaUnsupported Boolean @default(false) @map("schema_unsupported") + /// Opaque reason code recorded when a supplied report_url failed validation + /// (never the URL itself, never the S3 host, never a query string). + reportUrlRejectionReason String? @map("report_url_rejection_reason") + agentLogs AgentLog[] feature Feature? @relation(fields: [featureId], references: [id]) task Task? @relation(fields: [taskId], references: [id]) diff --git a/src/__tests__/support/mocks/prisma.ts b/src/__tests__/support/mocks/prisma.ts index 908ad06176..866e87f9ad 100644 --- a/src/__tests__/support/mocks/prisma.ts +++ b/src/__tests__/support/mocks/prisma.ts @@ -101,6 +101,7 @@ const { db: dbMock, reset } = hoisted; vi.mock("@/lib/db", () => ({ db: dbMock, + dbAdapter: dbMock, })); export { dbMock }; diff --git a/src/__tests__/unit/services/report-url-omit.test.ts b/src/__tests__/unit/services/report-url-omit.test.ts new file mode 100644 index 0000000000..211c8a5b07 --- /dev/null +++ b/src/__tests__/unit/services/report-url-omit.test.ts @@ -0,0 +1,224 @@ +/** + * Unit tests for the reportUrl/webhookUrl global Prisma omit and hasReport derivation. + * + * Test cases: + * 1. Global invariant: getStakworkRuns response never contains reportUrl, webhookUrl, or report_url + * 2. hasReport derives from reportBundle presence, NOT from reportUrl column + * 3. hasReport is false when reportBundle is null + * 4. hasReport is true when reportBundle is non-null + * 5. Explicit select for webhookUrl is opt-in — not a hard removal (the write path still works) + * 6. reportPartial derives from the reportPartial column, not from reportUrl + * 7. schemaUnsupported derives from the schemaUnsupported column + * 8. reportBundle is not forwarded to the mapped output (projection stripped, only flags) + */ + +import { describe, test, expect, vi, beforeEach } from "vitest"; + +// ─── Stable mock references (hoisted) ──────────────────────────────────────── + +const mockDbWorkspaceFindUnique = vi.hoisted(() => vi.fn()); +const mockDbStakworkRunCount = vi.hoisted(() => vi.fn()); +const mockDbStakworkRunFindMany = vi.hoisted(() => vi.fn()); + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock("@/lib/db", () => ({ + db: { + workspace: { + findUnique: mockDbWorkspaceFindUnique, + }, + stakworkRun: { + count: mockDbStakworkRunCount, + findMany: mockDbStakworkRunFindMany, + }, + }, +})); + +// ─── Import subject under test ──────────────────────────────────────────────── + +import { getStakworkRuns } from "@/services/stakwork-run"; +import { StakworkRunType, WorkflowStatus } from "@prisma/client"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const WORKSPACE_ID = "ws-test-1"; +const USER_ID = "user-1"; + +function makeWorkspace() { + return { + id: WORKSPACE_ID, + ownerId: USER_ID, + deleted: false, + members: [], + }; +} + +function makeDbRun(overrides: Record = {}) { + return { + id: "run-1", + type: StakworkRunType.LEGAL_BENCHMARK_RUNNER, + status: WorkflowStatus.COMPLETED, + workspaceId: WORKSPACE_ID, + featureId: null, + projectId: 42, + dataType: "json", + decision: null, + feedback: null, + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-01T01:00:00Z"), + taskId: null, + autoAccept: false, + promptVersionId: null, + evalSetId: null, + userId: null, + // Note: reportBundle, reportPartial, schemaUnsupported are selected by getStakworkRuns + reportBundle: null, + reportPartial: false, + schemaUnsupported: false, + // Note: webhookUrl and reportUrl are NOT selected (globally omitted) + feature: null, + ...overrides, + }; +} + +const BASE_QUERY = { + workspaceId: WORKSPACE_ID, + limit: 20, + offset: 0, + includeResult: false, +}; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("getStakworkRuns — reportUrl/webhookUrl omit + hasReport derivation", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockDbWorkspaceFindUnique.mockResolvedValue(makeWorkspace()); + mockDbStakworkRunCount.mockResolvedValue(1); + }); + + test("1. response never contains reportUrl, webhookUrl, or report_url", async () => { + mockDbStakworkRunFindMany.mockResolvedValue([makeDbRun()]); + + const result = await getStakworkRuns(BASE_QUERY, USER_ID); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("reportUrl"); + expect(serialized).not.toContain("report_url"); + expect(serialized).not.toContain("webhookUrl"); + expect(serialized).not.toContain("webhook_url"); + + // Also verify at the object level on each run + for (const run of result.runs) { + const runObj = run as Record; + expect(runObj).not.toHaveProperty("reportUrl"); + expect(runObj).not.toHaveProperty("report_url"); + expect(runObj).not.toHaveProperty("webhookUrl"); + expect(runObj).not.toHaveProperty("webhook_url"); + } + }); + + test("2. hasReport is false when reportBundle is null (derives from projection, not URL column)", async () => { + mockDbStakworkRunFindMany.mockResolvedValue([ + makeDbRun({ reportBundle: null }), + ]); + + const result = await getStakworkRuns(BASE_QUERY, USER_ID); + expect(result.runs).toHaveLength(1); + expect(result.runs[0].hasReport).toBe(false); + }); + + test("3. hasReport is true when reportBundle is non-null", async () => { + const projection = { schema_version: 1, page_data: {}, source_docs: [] }; + mockDbStakworkRunFindMany.mockResolvedValue([ + makeDbRun({ reportBundle: projection }), + ]); + + const result = await getStakworkRuns(BASE_QUERY, USER_ID); + expect(result.runs).toHaveLength(1); + expect(result.runs[0].hasReport).toBe(true); + }); + + test("4. hasReport does NOT depend on the reportUrl column value", async () => { + // reportBundle is null → hasReport false, regardless of what reportUrl would be + mockDbStakworkRunFindMany.mockResolvedValue([ + makeDbRun({ reportBundle: null }), + ]); + + const result = await getStakworkRuns(BASE_QUERY, USER_ID); + expect(result.runs[0].hasReport).toBe(false); + + // Verify that the select clause passed to findMany does NOT include reportUrl or webhookUrl + const selectArg = mockDbStakworkRunFindMany.mock.calls[0][0].select; + expect(selectArg).not.toHaveProperty("reportUrl"); + expect(selectArg).not.toHaveProperty("webhookUrl"); + // But does include the projection fields + expect(selectArg).toHaveProperty("reportBundle", true); + expect(selectArg).toHaveProperty("reportPartial", true); + expect(selectArg).toHaveProperty("schemaUnsupported", true); + }); + + test("5. reportPartial flag derives from the reportPartial column", async () => { + mockDbStakworkRunFindMany.mockResolvedValue([ + makeDbRun({ + reportBundle: { schema_version: 1 }, + reportPartial: true, + schemaUnsupported: false, + }), + ]); + + const result = await getStakworkRuns(BASE_QUERY, USER_ID); + expect(result.runs[0].reportPartial).toBe(true); + expect(result.runs[0].schemaUnsupported).toBe(false); + }); + + test("6. schemaUnsupported flag derives from the schemaUnsupported column", async () => { + mockDbStakworkRunFindMany.mockResolvedValue([ + makeDbRun({ + reportBundle: { schema_version: 99 }, + reportPartial: false, + schemaUnsupported: true, + }), + ]); + + const result = await getStakworkRuns(BASE_QUERY, USER_ID); + expect(result.runs[0].schemaUnsupported).toBe(true); + expect(result.runs[0].reportPartial).toBe(false); + expect(result.runs[0].hasReport).toBe(true); + }); + + test("7. reportBundle column is NOT forwarded to the mapped output", async () => { + const projection = { schema_version: 1, source_docs: [{ id: "d1" }] }; + mockDbStakworkRunFindMany.mockResolvedValue([ + makeDbRun({ reportBundle: projection }), + ]); + + const result = await getStakworkRuns(BASE_QUERY, USER_ID); + const runObj = result.runs[0] as Record; + // The raw projection must not leak to the response + expect(runObj).not.toHaveProperty("reportBundle"); + // Only the derived flag should be present + expect(runObj.hasReport).toBe(true); + }); + + test("8. multiple runs mapped independently — each derives hasReport independently", async () => { + mockDbStakworkRunFindMany.mockResolvedValue([ + makeDbRun({ id: "run-1", reportBundle: null }), + makeDbRun({ id: "run-2", reportBundle: { schema_version: 1 } }), + ]); + mockDbStakworkRunCount.mockResolvedValue(2); + + const result = await getStakworkRuns(BASE_QUERY, USER_ID); + expect(result.runs).toHaveLength(2); + + const run1 = result.runs.find((r) => r.id === "run-1"); + const run2 = result.runs.find((r) => r.id === "run-2"); + expect(run1?.hasReport).toBe(false); + expect(run2?.hasReport).toBe(true); + + // Neither run has webhookUrl or reportUrl in its output + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("webhookUrl"); + expect(serialized).not.toContain("reportUrl"); + }); +}); diff --git a/src/app/api/stakwork/ai/generate/route.ts b/src/app/api/stakwork/ai/generate/route.ts index 25eb9907bf..94468b0aa0 100644 --- a/src/app/api/stakwork/ai/generate/route.ts +++ b/src/app/api/stakwork/ai/generate/route.ts @@ -44,7 +44,7 @@ export async function POST(request: NextRequest) { status: run.status, workspaceId: run.workspaceId, featureId: run.featureId, - webhookUrl: run.webhookUrl, + // webhookUrl is globally omitted (embeds raw HMAC run_token) projectId: run.projectId, createdAt: run.createdAt, }, diff --git a/src/hooks/useLegalBenchmarkRunList.ts b/src/hooks/useLegalBenchmarkRunList.ts index 9170e24ffc..6a1a2f9d83 100644 --- a/src/hooks/useLegalBenchmarkRunList.ts +++ b/src/hooks/useLegalBenchmarkRunList.ts @@ -30,6 +30,13 @@ export interface BenchmarkRunListRow { reportStatus?: string; /** Relative link to the report chat, e.g. "/org/?chat=" */ reportChatPath?: string; + // Run report bundle flags — derived server-side from the persisted projection + /** True when a sanitized run report bundle is available for this run */ + hasReport?: boolean; + /** True when the bundle was truncated due to size limits */ + reportPartialBundle?: boolean; + /** True when the bundle's schema_version is unsupported by this Hive version */ + schemaUnsupported?: boolean; } interface UseLegalBenchmarkRunListResult { @@ -76,6 +83,9 @@ export function useLegalBenchmarkRunList( result: string | null; createdAt: string; updatedAt: string; + hasReport?: boolean; + reportPartial?: boolean; + schemaUnsupported?: boolean; }> = data.runs ?? []; const mapped: BenchmarkRunListRow[] = rawRows.map((r) => { @@ -97,6 +107,10 @@ export function useLegalBenchmarkRunList( generateReport: parsed?.generateReport, reportStatus: parsed?.reportStatus, reportChatPath: parsed?.reportChatPath, + // Run report bundle flags from the server-side mapper + hasReport: r.hasReport ?? false, + reportPartialBundle: r.reportPartial ?? false, + schemaUnsupported: r.schemaUnsupported ?? false, // Unified judge precedence: operator choice takes priority over runner-echoed value. // Format mirrors stakwork-run.ts — if the server-side format string changes, update this line to match. judgeNotes: diff --git a/src/lib/auth/nextauth.ts b/src/lib/auth/nextauth.ts index 820c11b167..dd17c184cb 100644 --- a/src/lib/auth/nextauth.ts +++ b/src/lib/auth/nextauth.ts @@ -1,4 +1,4 @@ -import { db } from "@/lib/db"; +import { db, dbAdapter } from "@/lib/db"; import { EncryptionService } from "@/lib/encryption"; import { logger } from "@/lib/logger"; import { ensureMockWorkspaceForUser, ensureStakworkMockWorkspace, ensureMockOrgData, ensureMockLlmModels } from "@/utils/mockSetup"; @@ -180,7 +180,7 @@ const getProviders = () => { export const authOptions: NextAuthOptions = { // Only use PrismaAdapter when not using credentials provider - ...(process.env.POD_URL ? {} : { adapter: PrismaAdapter(db) }), + ...(process.env.POD_URL ? {} : { adapter: PrismaAdapter(dbAdapter) }), providers: getProviders(), callbacks: { async signIn({ user, account }) { diff --git a/src/lib/db.ts b/src/lib/db.ts index 02d1727fc0..af00115c53 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,14 +1,45 @@ import { PrismaClient } from "@prisma/client"; +// The omit config makes reportUrl and webhookUrl unreachable from every default +// read so they cannot leak to clients. +// - reportUrl embeds a presigned S3 URL (short-lived capability) +// - webhookUrl embeds a raw HMAC run_token — leaking it collapses the run_token gate +// Both fields are opt-in via explicit select; writes are unaffected by omit. +// +// We cast both exports to PrismaClient (not typeof ) deliberately: +// the Prisma omit generic resolves to a deeply-nested type that OOMs tsc during +// `next build` on memory-constrained CI runners. The runtime behaviour is +// identical — the omit config is applied when the instance is constructed, and +// the cast is safe because PrismaClient is a super-type of the omit-configured +// variant. +const dbInstance = new PrismaClient({ + log: ["info", "warn", "error"], + omit: { + stakworkRun: { + reportUrl: true, + webhookUrl: true, + }, + }, +}) as unknown as PrismaClient; + +// A separate plain client is needed only for PrismaAdapter (NextAuth), which +// requires a standard PrismaClient with no omit configuration. It is not used +// anywhere else — callers should always use `db`. +const dbAdapterInstance = new PrismaClient({ log: ["info", "warn", "error"] }); + const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; + prismaAdapter: PrismaClient | undefined; }; -export const db = - globalForPrisma.prisma ?? - new PrismaClient({ - // log: ["query"], - log: ["info", "warn", "error"], - }); +export const db: PrismaClient = globalForPrisma.prisma ?? dbInstance; + +// Exported only for use by PrismaAdapter in src/lib/auth/nextauth.ts. +// Do not use this elsewhere — it bypasses the global field omit. +export const dbAdapter: PrismaClient = + globalForPrisma.prismaAdapter ?? dbAdapterInstance; -if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db; +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = db; + globalForPrisma.prismaAdapter = dbAdapter; +} diff --git a/src/services/stakwork-run.ts b/src/services/stakwork-run.ts index 7c4d7481cd..17e5e11a8a 100644 --- a/src/services/stakwork-run.ts +++ b/src/services/stakwork-run.ts @@ -1983,19 +1983,35 @@ export async function getStakworkRuns( feedback: true, createdAt: true, updatedAt: true, - webhookUrl: true, + // webhookUrl is globally omitted — never forwarded to clients taskId: true, autoAccept: true, promptVersionId: true, evalSetId: true, userId: true, + // Report bundle presence flags (not the URL — that stays server-side) + reportBundle: true, + reportPartial: true, + schemaUnsupported: true, ...(query.includeResult ? { result: true } : {}), feature: { select: { id: true, title: true } }, }, }); + // Derive hasReport from the presence of the persisted projection, + // never by selecting and forwarding the URL column. + const mappedRuns = runs.map((run) => { + const { reportBundle, reportPartial, schemaUnsupported, ...rest } = run; + return { + ...rest, + hasReport: reportBundle !== null, + reportPartial: reportPartial ?? false, + schemaUnsupported: schemaUnsupported ?? false, + }; + }); + return { - runs, + runs: mappedRuns, total, limit: query.limit, offset: query.offset, diff --git a/src/types/stakwork.ts b/src/types/stakwork.ts index c973d86c19..d26d948139 100644 --- a/src/types/stakwork.ts +++ b/src/types/stakwork.ts @@ -113,7 +113,7 @@ export type StakworkRunQuery = z.infer; // API Response types export interface StakworkRunResponse { id: string; - webhookUrl: string; + // webhookUrl is globally omitted (embeds raw HMAC run_token — never sent to clients) projectId: number | null; type: StakworkRunType; featureId: string | null; @@ -125,6 +125,10 @@ export interface StakworkRunResponse { decision: StakworkRunDecision | null; createdAt: Date; updatedAt: Date; + // Run report bundle flags — derived from the persisted projection, never from reportUrl + hasReport: boolean; + reportPartial: boolean; + schemaUnsupported: boolean; } export interface StakworkRunListResponse {