From 0e58b42ecb4e3576e3630e0c174762097c42164d Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Mon, 10 Aug 2026 17:17:26 +0000 Subject: [PATCH 1/4] Generated with Hive: Add reportUrl and reportBundle to StakworkRun with global Prisma omit for reportUrl and webhookUrl --- .../migration.sql | 7 + prisma/schema.prisma | 39 +++ .../unit/services/report-url-omit.test.ts | 224 ++++++++++++++++++ src/app/api/stakwork/ai/generate/route.ts | 2 +- src/hooks/useLegalBenchmarkRunList.ts | 14 ++ src/lib/auth/nextauth.ts | 4 +- src/lib/db.ts | 38 ++- src/services/stakwork-run.ts | 20 +- src/types/stakwork.ts | 6 +- 9 files changed, 340 insertions(+), 14 deletions(-) create mode 100644 prisma/migrations/20260810163750_add_report_url_and_bundle/migration.sql create mode 100644 src/__tests__/unit/services/report-url-omit.test.ts 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 669c72039f..9e307cd4c3 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__/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..6bb99a73fe 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,14 +1,36 @@ import { PrismaClient } from "@prisma/client"; +// Instantiate the clients first so we can use `typeof` for the global cache type. +const dbInstance = new PrismaClient({ + // log: ["query"], + log: ["info", "warn", "error"], + omit: { + // reportUrl embeds a presigned S3 URL — a short-lived, expiring capability. + // webhookUrl embeds a raw HMAC run_token in its query string — leaking it + // to clients collapses the run_token gate the entire ingest design relies on. + // Both fields are opt-in via explicit select; writes are unaffected by omit. + stakworkRun: { + reportUrl: true, + webhookUrl: true, + }, + }, +}); + +// Plain client without omit — required for PrismaAdapter which expects a +// standard PrismaClient type with no omit configuration. +const dbAdapterInstance = new PrismaClient({ log: ["info", "warn", "error"] }); + const globalForPrisma = globalThis as unknown as { - prisma: PrismaClient | undefined; + prisma: typeof dbInstance | undefined; + prismaAdapter: PrismaClient | undefined; }; -export const db = - globalForPrisma.prisma ?? - new PrismaClient({ - // log: ["query"], - log: ["info", "warn", "error"], - }); +export const db: typeof dbInstance = globalForPrisma.prisma ?? dbInstance; + +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 { From 0fd956f37a994abbc23b599009f537cfa0dd42ad Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Mon, 10 Aug 2026 18:07:28 +0000 Subject: [PATCH 2/4] Generated with Hive: Fix dbAdapter initialization bug and ensure correct global Prisma client export --- src/lib/db.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/db.ts b/src/lib/db.ts index 6bb99a73fe..3ab9a25a17 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,6 +1,6 @@ import { PrismaClient } from "@prisma/client"; -// Instantiate the clients first so we can use `typeof` for the global cache type. +// Instantiate with omit config first so we can derive its type via `typeof`. const dbInstance = new PrismaClient({ // log: ["query"], log: ["info", "warn", "error"], @@ -25,7 +25,8 @@ const globalForPrisma = globalThis as unknown as { prismaAdapter: PrismaClient | undefined; }; -export const db: typeof dbInstance = globalForPrisma.prisma ?? dbInstance; +export const db: typeof dbInstance = + globalForPrisma.prisma ?? dbInstance; export const dbAdapter: PrismaClient = globalForPrisma.prismaAdapter ?? dbAdapterInstance; From e8fb81769da67f198441d20ff2ebe59530841f86 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Mon, 10 Aug 2026 18:32:27 +0000 Subject: [PATCH 3/4] Generated with Hive: Refactor Prisma client initialization and enforce global omit for reportUrl and webhookUrl --- src/lib/auth/nextauth.ts | 4 ++-- src/lib/db.ts | 49 +++++++++++++++------------------------- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/lib/auth/nextauth.ts b/src/lib/auth/nextauth.ts index dd17c184cb..820c11b167 100644 --- a/src/lib/auth/nextauth.ts +++ b/src/lib/auth/nextauth.ts @@ -1,4 +1,4 @@ -import { db, dbAdapter } from "@/lib/db"; +import { db } 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(dbAdapter) }), + ...(process.env.POD_URL ? {} : { adapter: PrismaAdapter(db) }), providers: getProviders(), callbacks: { async signIn({ user, account }) { diff --git a/src/lib/db.ts b/src/lib/db.ts index 3ab9a25a17..3902a8fdab 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,37 +1,24 @@ import { PrismaClient } from "@prisma/client"; -// Instantiate with omit config first so we can derive its type via `typeof`. -const dbInstance = new PrismaClient({ - // log: ["query"], - log: ["info", "warn", "error"], - omit: { - // reportUrl embeds a presigned S3 URL — a short-lived, expiring capability. - // webhookUrl embeds a raw HMAC run_token in its query string — leaking it - // to clients collapses the run_token gate the entire ingest design relies on. - // Both fields are opt-in via explicit select; writes are unaffected by omit. - stakworkRun: { - reportUrl: true, - webhookUrl: true, - }, - }, -}); - -// Plain client without omit — required for PrismaAdapter which expects a -// standard PrismaClient type with no omit configuration. -const dbAdapterInstance = new PrismaClient({ log: ["info", "warn", "error"] }); - const globalForPrisma = globalThis as unknown as { - prisma: typeof dbInstance | undefined; - prismaAdapter: PrismaClient | undefined; + prisma: PrismaClient | undefined; }; -export const db: typeof dbInstance = - globalForPrisma.prisma ?? dbInstance; - -export const dbAdapter: PrismaClient = - globalForPrisma.prismaAdapter ?? dbAdapterInstance; +export const db = + globalForPrisma.prisma ?? + new PrismaClient({ + // log: ["query"], + log: ["info", "warn", "error"], + omit: { + // reportUrl embeds a presigned S3 URL — a short-lived, expiring capability. + // webhookUrl embeds a raw HMAC run_token in its query string — leaking it + // to clients collapses the run_token gate the entire ingest design relies on. + // Both fields are opt-in via explicit select; writes are unaffected by omit. + stakworkRun: { + reportUrl: true, + webhookUrl: true, + }, + }, + }); -if (process.env.NODE_ENV !== "production") { - globalForPrisma.prisma = db; - globalForPrisma.prismaAdapter = dbAdapter; -} +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db; From 15286aea3d7e8cdde7ffbe5db7ea7f7e7b60d82b Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Tue, 11 Aug 2026 10:05:29 +0000 Subject: [PATCH 4/4] Generated with Hive: Add global Prisma omit for reportUrl and webhookUrl with dbAdapter for NextAuth --- src/__tests__/support/mocks/prisma.ts | 1 + src/lib/auth/nextauth.ts | 4 +- src/lib/db.ts | 55 ++++++++++++++++++--------- 3 files changed, 41 insertions(+), 19 deletions(-) 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/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 3902a8fdab..af00115c53 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,24 +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"], - omit: { - // reportUrl embeds a presigned S3 URL — a short-lived, expiring capability. - // webhookUrl embeds a raw HMAC run_token in its query string — leaking it - // to clients collapses the run_token gate the entire ingest design relies on. - // Both fields are opt-in via explicit select; writes are unaffected by omit. - stakworkRun: { - reportUrl: true, - webhookUrl: true, - }, - }, - }); +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; +}