diff --git a/apps/hub/src/artifacts/approval-package.test.ts b/apps/hub/src/artifacts/approval-package.test.ts new file mode 100644 index 00000000..83a2f554 --- /dev/null +++ b/apps/hub/src/artifacts/approval-package.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import type { ApprovalPackageBundle } from "@corbits-builder/schemas/artifact"; +import { ProjectError } from "../projects/errors.ts"; +import { + EXCLUDED_PACKAGE_SOURCE_KINDS, + scanApprovalPackageForSecrets, + stripApprovalPackageBinaries, + validateApprovalPackage, + type StoredPackageSource, +} from "./approval-package.ts"; +import { mountArtifactRoutes } from "./routes.ts"; +import type { createArtifactService } from "./service.ts"; +import { createArtifactTools } from "./tools.ts"; + +const BRIEF_ID = "11111111-1111-4111-8111-111111111111"; +const BRIEF_SHA = "a".repeat(64); +const REQUIREMENTS_ID = "22222222-2222-4222-8222-222222222222"; +const REQUIREMENTS_SHA = "b".repeat(64); + +function validBundle( + overrides: Partial = {}, +): ApprovalPackageBundle { + return { + manifest: { + audience: "Product steering committee", + decisionNeeded: "Approve scope and architecture for version 1", + tone: "Executive concise", + length: "5 slides", + sources: [ + { + artifactId: BRIEF_ID, + kind: "problem_brief", + title: "Problem Brief", + version: 2, + sha256: BRIEF_SHA, + }, + { + artifactId: REQUIREMENTS_ID, + kind: "product_requirements", + title: "Product Requirements", + version: 1, + sha256: REQUIREMENTS_SHA, + }, + ], + }, + deck: { + theme: { + colors: { + primary: "#6D28D9", + secondary: "#0EA5E9", + accent: "#F59E0B", + background: "#FFFFFF", + text: "#1B1B1F", + muted: "#6B7280", + }, + fonts: { heading: "Georgia", body: "Inter" }, + }, + slides: [ + { + layout: "title", + title: "Weather Forecast Hub", + subtitle: "Approval Package", + notes: "Walk through key decisions.", + }, + ], + }, + pptxBase64: Buffer.from("fake-pptx-binary").toString("base64"), + pdfBase64: Buffer.from("fake-pdf-binary-data").toString("base64"), + documents: { + "executive-summary.md": "# Executive Summary\n\nReady for build.", + }, + ...overrides, + }; +} + +function createFixtureStore() { + const stored = new Map([ + [`${BRIEF_ID}@2`, { kind: "problem_brief", version: 2, sha256: BRIEF_SHA }], + [ + `${REQUIREMENTS_ID}@1`, + { + kind: "product_requirements", + version: 1, + sha256: REQUIREMENTS_SHA, + }, + ], + ]); + const readSource = async (artifactId: string, version: number) => + stored.get(`${artifactId}@${String(version)}`); + return { stored, readSource }; +} + +function createRouteHarness() { + const { readSource } = createFixtureStore(); + const filed: { kind: string; title: string; content: string }[] = []; + const app = new Hono(); + app.onError((error, c) => { + if (error instanceof ProjectError) + return c.json({ code: error.code, ...error.details }, error.status); + throw error; + }); + app.use("*", async (c, next) => { + c.set("tenant", { id: "tenant-1" } as TenantEnv["Variables"]["tenant"]); + c.set("principal", { + id: "principal-1", + } as TenantEnv["Variables"]["principal"]); + await next(); + }); + const service = { + write: async ( + _actor: unknown, + _projectId: string, + raw: { kind: string; title: string; content: string }, + ) => { + if (raw.kind === "approval_package") { + await validateApprovalPackage(raw.content, readSource); + } + filed.push(raw); + return { + artifact: { + artifactId: "33333333-3333-4333-8333-333333333333", + kind: raw.kind, + version: filed.length, + sha256: "c".repeat(64), + }, + }; + }, + } as unknown as ReturnType; + mountArtifactRoutes(app, service); + return { app, filed }; +} + +describe("approval package validation and secret scanning", () => { + test("files a valid approval_package with matching source version and sha256 cleanly", async () => { + const { app, filed } = createRouteHarness(); + const bundle = validBundle(); + const response = await app.request("/project-1/artifacts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: "approval_package", + title: "Q4 Approval Package", + content: JSON.stringify(bundle), + }), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + artifact: { kind: string; version: number }; + }; + expect(body.artifact.kind).toBe("approval_package"); + expect(body.artifact.version).toBe(1); + expect(filed).toHaveLength(1); + }); + + test("refuses stale or mismatched source versions and hashes with invalid_package_source", async () => { + const { readSource } = createFixtureStore(); + const wrongHash = validBundle({ + manifest: { + ...validBundle().manifest, + sources: [ + { + artifactId: BRIEF_ID, + kind: "problem_brief", + title: "Problem Brief", + version: 2, + sha256: "f".repeat(64), + }, + ], + }, + }); + await expect( + validateApprovalPackage(wrongHash, readSource), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_package_source", + }); + + const wrongVersion = validBundle({ + manifest: { + ...validBundle().manifest, + sources: [ + { + artifactId: BRIEF_ID, + kind: "problem_brief", + title: "Problem Brief", + version: 99, + sha256: BRIEF_SHA, + }, + ], + }, + }); + await expect( + validateApprovalPackage(wrongVersion, readSource), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_package_source", + }); + }); + + test("fails closed with package_contains_secret naming the file and never leaking the secret value", async () => { + const { app } = createRouteHarness(); + const secretKey = "sk-ant-api03-secretvalue1234567890abcdef"; + const awsKey = "AKIAIOSFODNN7EXAMPLE"; + const privateKey = "-----BEGIN RSA PRIVATE KEY-----"; + + const withDeckSecret = validBundle({ + deck: { + slides: [ + { + layout: "title", + title: `Leaked key ${secretKey}`, + }, + ], + }, + documents: { + "appendix.md": `Backup key ${awsKey} and ${privateKey}`, + }, + }); + + let caught: ProjectError | undefined; + try { + scanApprovalPackageForSecrets(withDeckSecret); + } catch (error) { + if (error instanceof ProjectError) caught = error; + else throw error; + } + expect(caught).toBeDefined(); + expect(caught?.status).toBe(422); + expect(caught?.code).toBe("package_contains_secret"); + expect(caught?.issues).toEqual([ + { path: "deck", message: "api_key" }, + { path: "documents/appendix.md", message: "api_key" }, + { path: "documents/appendix.md", message: "private_key" }, + ]); + expect(JSON.stringify(caught)).not.toContain(secretKey); + expect(JSON.stringify(caught)).not.toContain(awsKey); + expect(JSON.stringify(caught)).not.toContain(privateKey); + + const response = await app.request("/project-1/artifacts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: "approval_package", + title: "Package with secret", + content: JSON.stringify(withDeckSecret), + }), + }); + expect(response.status).toBe(422); + const text = await response.text(); + expect(text).not.toContain(secretKey); + expect(text).not.toContain(awsKey); + expect(text).not.toContain(privateKey); + const parsed = JSON.parse(text) as { + code: string; + issues: { path: string; message: string }[]; + }; + expect(parsed.code).toBe("package_contains_secret"); + expect(parsed.issues).toEqual([ + { path: "deck", message: "api_key" }, + { path: "documents/appendix.md", message: "api_key" }, + { path: "documents/appendix.md", message: "private_key" }, + ]); + }); + + test("refuses kind: app or approval_package sources and live-app URLs or demoLogin blocks", async () => { + const { readSource } = createFixtureStore(); + expect(EXCLUDED_PACKAGE_SOURCE_KINDS.has("app")).toBe(true); + expect(EXCLUDED_PACKAGE_SOURCE_KINDS.has("approval_package")).toBe(true); + + for (const excludedKind of ["app", "approval_package"]) { + const withExcludedSource = validBundle({ + manifest: { + ...validBundle().manifest, + sources: [ + { + artifactId: BRIEF_ID, + kind: excludedKind, + title: "Excluded", + version: 1, + sha256: BRIEF_SHA, + }, + ], + }, + }); + await expect( + validateApprovalPackage(withExcludedSource, readSource), + ).rejects.toMatchObject({ + status: 422, + code: "excluded_package_source_kind", + issues: [{ path: "manifest.sources", message: excludedKind }], + }); + } + + for (const [forbiddenText, expectedLabel] of [ + ["Connect at http://localhost:3000/app", "live_app_url"], + ["Internal preview http://127.0.0.1:4173", "live_app_url"], + ['{"demoLogin":{"email":"admin@example.com"}}', "demo_login"], + ] as const) { + const withLiveApp = validBundle({ + documents: { "notes.md": forbiddenText }, + }); + await expect( + validateApprovalPackage(withLiveApp, readSource), + ).rejects.toMatchObject({ + status: 422, + code: "package_contains_secret", + issues: [{ path: "documents/notes.md", message: expectedLabel }], + }); + } + }); + + test("read_artifact strips pptxBase64 and pdfBase64 from approval_package content for agents", async () => { + const bundle = validBundle({ + pptxBase64: Buffer.alloc(12_000, 0x41).toString("base64"), + pdfBase64: Buffer.alloc(15_000, 0x42).toString("base64"), + }); + const stripped = JSON.parse( + stripApprovalPackageBinaries(JSON.stringify(bundle)), + ) as Record; + expect(stripped["pptxBase64"]).toBeUndefined(); + expect(stripped["pdfBase64"]).toBeUndefined(); + expect(stripped["pptxBytes"]).toBe(12_000); + expect(stripped["pdfBytes"]).toBe(15_000); + expect(stripped["manifest"]).toEqual(bundle.manifest); + + const fetch = async (path: string) => { + if (path === "/api/workflow-project-scope") { + const deployment = { + source: { + kind: "asset" as const, + assetId: "asset-1", + package: { format: "tarball" as const }, + }, + entry: "dist/index.js", + pin: "@corbits-builder/writer@0.1.0", + sourceOfferingIds: ["offering-1"], + defaultSourceOfferingId: "offering-1", + }; + return Response.json({ + tenantId: "tenant-1", + projectId: "project-1", + domain: "example.invalid", + role: "coordinator", + workflows: { + writer: deployment, + reviewer: deployment, + coder: deployment, + designer: deployment, + }, + openNetwork: false, + build: { + assetDirectory: "/tmp/assets", + browserCli: "/tmp/browser", + childPath: "/tmp/child.js", + dependencyRoots: ["/tmp/deps"], + ripgrepPath: "/usr/bin/rg", + runtimeVersion: "1.0.0", + }, + }); + } + if (path.endsWith("/artifacts/approval_package")) { + return Response.json({ + artifactId: "33333333-3333-4333-8333-333333333333", + kind: "approval_package", + title: "Approval Package", + version: 1, + sha256: "c".repeat(64), + filedAt: new Date(0).toISOString(), + content: JSON.stringify(bundle), + }); + } + return Response.json({ code: "not_found" }, { status: 404 }); + }; + const capabilities = { + resolve: () => ({ + resolve: async () => ({ kind: "http", fetch }), + }), + } as unknown as RuntimeCapabilities; + const tool = createArtifactTools("coordinator")({ capabilities } as never); + const result = await tool.run( + { + id: "call-1", + name: "read_artifact", + arguments: { kind: "approval_package" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeUndefined(); + const contentText = String(result.content); + expect(contentText.length).toBeLessThan(10_000); + expect(contentText).not.toContain(bundle.pptxBase64); + expect(contentText).not.toContain(bundle.pdfBase64); + const parsedToolOutput = JSON.parse(contentText) as { + content: string; + }; + const inner = JSON.parse(parsedToolOutput.content) as { + pptxBytes: number; + pdfBytes: number; + }; + expect(inner.pptxBytes).toBe(12_000); + expect(inner.pdfBytes).toBe(15_000); + }); +}); diff --git a/apps/hub/src/artifacts/approval-package.ts b/apps/hub/src/artifacts/approval-package.ts new file mode 100644 index 00000000..c66d10e6 --- /dev/null +++ b/apps/hub/src/artifacts/approval-package.ts @@ -0,0 +1,183 @@ +import { type } from "arktype"; +import { + getArtifact, + getArtifactVersion, + type ArtifactDb, +} from "@corbits/artifacts"; +import { + ApprovalPackageBundle, + type ApprovalPackageSource, +} from "@corbits-builder/schemas/artifact"; +import { parseJson } from "../lib/json.ts"; +import { ProjectError, type ProjectErrorIssue } from "../projects/errors.ts"; + +// An approval package summarizes planning and design documents for stakeholders; the built app and +// earlier approval packages are never pinned as package sources. +export const EXCLUDED_PACKAGE_SOURCE_KINDS = new Set([ + "app", + "approval_package", +]); + +const SECRET_PATTERNS: readonly { label: string; pattern: RegExp }[] = [ + { + label: "api_key", + pattern: + /(?:sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|xai-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35})/, + }, + { + label: "private_key", + pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, + }, + { + label: "demo_login", + pattern: /demoLogin/, + }, + { + label: "live_app_url", + pattern: /http:\/\/(?:127\.0\.0\.1|localhost):/, + }, +]; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export type StoredPackageSource = { + kind: string; + version: number; + sha256: string; +}; + +export type StoredSourceReader = ( + artifactId: string, + version: number, +) => Promise; + +function scanSectionForSecrets( + path: string, + text: string, +): ProjectErrorIssue[] { + const issues: ProjectErrorIssue[] = []; + for (const { label, pattern } of SECRET_PATTERNS) { + if (pattern.test(text)) issues.push({ path, message: label }); + } + return issues; +} + +export function scanApprovalPackageForSecrets( + bundle: ApprovalPackageBundle, +): void { + const manifestText = [ + bundle.manifest.audience, + bundle.manifest.decisionNeeded, + bundle.manifest.tone, + bundle.manifest.length, + ...bundle.manifest.sources.map((source) => source.title), + ].join("\n"); + const issues: ProjectErrorIssue[] = [ + ...scanSectionForSecrets("manifest", manifestText), + ...scanSectionForSecrets("deck", JSON.stringify(bundle.deck)), + ]; + if (bundle.documents !== undefined) { + for (const [name, content] of Object.entries(bundle.documents)) { + issues.push(...scanSectionForSecrets(`documents/${name}`, content)); + } + } + if (issues.length > 0) + throw new ProjectError(422, "package_contains_secret", issues); +} + +export function parseApprovalPackageBundle( + content: string, +): ApprovalPackageBundle { + const bundle = ApprovalPackageBundle(parseJson(content)); + if (bundle instanceof type.errors) + throw new ProjectError(400, "invalid_approval_package", { + issues: bundle.summary, + }); + return bundle; +} + +async function verifySourcePin( + source: ApprovalPackageSource, + readSource: StoredSourceReader, +): Promise { + if (EXCLUDED_PACKAGE_SOURCE_KINDS.has(source.kind)) + throw new ProjectError(422, "excluded_package_source_kind", [ + { path: "manifest.sources", message: source.kind }, + ]); + const stored = await readSource(source.artifactId, source.version); + if ( + stored === undefined || + stored.kind !== source.kind || + stored.version !== source.version || + stored.sha256 !== source.sha256 + ) + throw new ProjectError(422, "invalid_package_source", [ + { + path: "manifest.sources", + message: `${source.artifactId}@${String(source.version)}`, + }, + ]); +} + +export async function validateApprovalPackage( + input: string | ApprovalPackageBundle, + readSource: StoredSourceReader, +): Promise { + const bundle = + typeof input === "string" ? parseApprovalPackageBundle(input) : input; + for (const source of bundle.manifest.sources) { + if (EXCLUDED_PACKAGE_SOURCE_KINDS.has(source.kind)) + throw new ProjectError(422, "excluded_package_source_kind", [ + { path: "manifest.sources", message: source.kind }, + ]); + } + scanApprovalPackageForSecrets(bundle); + for (const source of bundle.manifest.sources) + await verifySourcePin(source, readSource); + return bundle; +} + +export function readProjectPackageSource( + db: ArtifactDb, + tenantId: string, +): StoredSourceReader { + return async (artifactId, version) => { + if (!UUID_RE.test(artifactId)) return undefined; + const row = await getArtifact(db, artifactId); + if (!row || row.tenantId !== tenantId) return undefined; + const stored = await getArtifactVersion(db, artifactId, version); + if (!stored?.contentSha256) return undefined; + return { + kind: row.kind, + version: stored.version, + sha256: stored.contentSha256, + }; + }; +} + +function base64PaddingBytes(encoded: string): number { + if (encoded.endsWith("==")) return 2; + if (encoded.endsWith("=")) return 1; + return 0; +} + +function base64ByteLength(encoded: string): number { + return Math.max( + 0, + Math.floor((encoded.length * 3) / 4) - base64PaddingBytes(encoded), + ); +} + +// Agents reading an approval_package need its manifest, deck structure and markdown documents, not +// megabytes of base64 binary that would trip the tool-output size cap. +export function stripApprovalPackageBinaries(content: string): string { + const bundle = ApprovalPackageBundle(parseJson(content)); + if (bundle instanceof type.errors) return content; + const { pptxBase64, pdfBase64, ...rest } = bundle; + return JSON.stringify({ + ...rest, + pptxBytes: base64ByteLength(pptxBase64), + pdfBytes: base64ByteLength(pdfBase64), + }); +} diff --git a/apps/hub/src/artifacts/deck-tools.test.ts b/apps/hub/src/artifacts/deck-tools.test.ts index 79a8c91f..7eeab720 100644 --- a/apps/hub/src/artifacts/deck-tools.test.ts +++ b/apps/hub/src/artifacts/deck-tools.test.ts @@ -1,24 +1,17 @@ import { expect, test } from "bun:test"; -import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Hono } from "hono"; -import { DeckBundle } from "@corbits-builder/schemas/artifact"; import { unzip } from "@corbits/zip"; -import type { TenantEnv } from "@intx/hub-api"; -import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; -import { ProjectError } from "../projects/errors.ts"; import { DECK_JSON, DECK_PDF, DECK_PPTX, checkDeckInWorkspace, createDeckTools, + type CheckedDeckArtifact, } from "./deck-tools.ts"; -import { mountArtifactRoutes } from "./routes.ts"; -import type { createArtifactService } from "./service.ts"; const sampleDeck = { theme: { @@ -61,142 +54,18 @@ const sampleDeck = { ], }; -const toolEnv = (toolCwd: string, capabilities?: RuntimeCapabilities) => - ({ - toolCwd, - ...(capabilities ? { capabilities } : {}), - }) as unknown as Parameters>[0]; +const toolEnv = (toolCwd: string) => + ({ toolCwd }) as unknown as Parameters>[0]; -function createDeckArtifactHarness() { - const versions = new Map< - string, - { - artifactId: string; - kind: string; - title: string; - version: number; - sha256: string; - filedAt: string; - content: string; - } - >(); - let versionSeq = 0; - const artifactId = "44444444-4444-4444-8444-444444444444"; - - const app = new Hono(); - app.onError((error, c) => { - if (error instanceof ProjectError) - return c.json({ code: error.code, ...error.details }, error.status); - throw error; - }); - app.use("*", async (c, next) => { - c.set("tenant", { id: "tenant-1" } as TenantEnv["Variables"]["tenant"]); - c.set("principal", { - id: "principal-1", - } as TenantEnv["Variables"]["principal"]); - await next(); - }); - - const service = { - write: async ( - _actor: unknown, - _projectId: string, - raw: { kind: string; title: string; content: string }, - ) => { - DeckBundle.assert(JSON.parse(raw.content)); - versionSeq += 1; - const sha256 = createHash("sha256").update(raw.content).digest("hex"); - const stored = { - artifactId, - kind: raw.kind, - title: raw.title, - version: versionSeq, - sha256, - filedAt: new Date(versionSeq * 1000).toISOString(), - content: raw.content, - }; - versions.set(`${artifactId}@${String(versionSeq)}`, stored); - return { - artifact: { - artifactId, - kind: raw.kind, - version: versionSeq, - sha256, - }, - }; - }, - read: async ( - _actor: unknown, - _projectId: string, - raw: { artifactId?: string; version?: number }, - ) => { - const targetVersion = raw.version ?? versionSeq; - const stored = versions.get(`${artifactId}@${String(targetVersion)}`); - if (!stored) throw new ProjectError(404, "artifact_not_found"); - return stored; - }, - } as unknown as ReturnType; - - mountArtifactRoutes(app, service); - - const fetch = async (path: string, init?: RequestInit) => { - if (path === "/api/workflow-project-scope") { - const deployment = { - source: { - kind: "asset" as const, - assetId: "asset-1", - package: { format: "tarball" as const }, - }, - entry: "dist/index.js", - pin: "@corbits-builder/writer@0.1.0", - sourceOfferingIds: ["offering-1"], - defaultSourceOfferingId: "offering-1", - }; - return Response.json({ - tenantId: "tenant-1", - projectId: "project-1", - domain: "example.invalid", - role: "writer", - workflows: { - writer: deployment, - reviewer: deployment, - coder: deployment, - designer: deployment, - }, - openNetwork: false, - build: { - assetDirectory: "/tmp/assets", - browserCli: "/tmp/browser", - childPath: "/tmp/child.js", - dependencyRoots: ["/tmp/deps"], - ripgrepPath: "/usr/bin/rg", - runtimeVersion: "1.0.0", - }, - }); - } - const prefix = "/api/tenants/tenant-1/projects"; - const subPath = path.startsWith(prefix) ? path.slice(prefix.length) : path; - return app.request(subPath, init); - }; - - const capabilities = { - resolve: () => ({ - resolve: async () => ({ kind: "http", fetch }), - }), - } as unknown as RuntimeCapabilities; - - const getArtifactVersion = (id: string, version: number) => - versions.get(`${id}@${String(version)}`); - - return { app, capabilities, versions, getArtifactVersion }; -} - -test("write_deck and check_deck write .builder/deck.json, .builder/deck.pptx and .builder/deck.pdf and file a durable deck artifact on check_deck pass", async () => { +test("write_deck and check_deck write .builder/deck.json, .builder/deck.pptx and .builder/deck.pdf and verify issues", async () => { const workspace = await mkdtemp(join(tmpdir(), "deck-tools-")); - const { capabilities, versions, getArtifactVersion } = - createDeckArtifactHarness(); try { - const tool = createDeckTools()(toolEnv(workspace, capabilities)); + const checkedArtifacts: CheckedDeckArtifact[] = []; + const tool = createDeckTools({ + onDeckChecked: async (artifact) => { + checkedArtifacts.push(artifact); + }, + })(toolEnv(workspace)); expect(tool.definitions.map((d) => d.name)).toEqual([ "write_deck", "check_deck", @@ -219,8 +88,6 @@ test("write_deck and check_deck write .builder/deck.json, .builder/deck.pptx and passed: true, issues: [], }); - // write_deck alone does not file an artifact version before check_deck passes. - expect(versions.size).toBe(0); const pptxBytes = new Uint8Array( await readFile(join(workspace, DECK_PPTX)), @@ -234,6 +101,7 @@ test("write_deck and check_deck write .builder/deck.json, .builder/deck.pptx and expect(new TextDecoder("latin1").decode(pdfBytes)).toContain( "(Quarter Review) Tj", ); + expect(checkedArtifacts).toHaveLength(0); const checkRes = await tool.run( { @@ -244,34 +112,16 @@ test("write_deck and check_deck write .builder/deck.json, .builder/deck.pptx and new AbortController().signal, ); expect(checkRes.isError).toBeUndefined(); - const checkedBody = JSON.parse(String(checkRes.content)); - expect(checkedBody).toEqual({ + expect(JSON.parse(String(checkRes.content))).toEqual({ passed: true, issues: [], - artifact: { - artifactId: "44444444-4444-4444-8444-444444444444", - kind: "deck", - version: 1, - sha256: expect.stringMatching(/^[a-f0-9]{64}$/), - }, }); - - // Remove the sidecar workspace completely and verify the filed artifact version remains - // durable and readable via getArtifactVersion. - await rm(workspace, { recursive: true, force: true }); - const durable = getArtifactVersion( - checkedBody.artifact.artifactId, - checkedBody.artifact.version, - ); - expect(durable).toBeDefined(); - const bundle = DeckBundle.assert(JSON.parse(durable!.content)); - expect(bundle.deck).toEqual(sampleDeck); - expect(new Uint8Array(Buffer.from(bundle.pptxBase64, "base64"))).toEqual( - pptxBytes, - ); - expect(new Uint8Array(Buffer.from(bundle.pdfBase64, "base64"))).toEqual( - pdfBytes, - ); + expect(checkedArtifacts).toHaveLength(1); + expect(checkedArtifacts[0]?.pptxBytes.byteLength).toBe(written.pptxBytes); + expect(checkedArtifacts[0]?.pdfBytes.byteLength).toBe(written.pdfBytes); + expect(checkedArtifacts[0]?.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(checkedArtifacts[0]?.pptxSha256).toMatch(/^[a-f0-9]{64}$/); + expect(checkedArtifacts[0]?.pdfSha256).toMatch(/^[a-f0-9]{64}$/); } finally { await rm(workspace, { recursive: true, force: true }); } @@ -279,9 +129,13 @@ test("write_deck and check_deck write .builder/deck.json, .builder/deck.pptx and test("write_deck and check_deck return isError: true naming the slide and field on validation or check failures", async () => { const workspace = await mkdtemp(join(tmpdir(), "deck-tools-err-")); - const { capabilities, versions } = createDeckArtifactHarness(); try { - const tool = createDeckTools()(toolEnv(workspace, capabilities)); + let checkedCallbackCount = 0; + const tool = createDeckTools({ + onDeckChecked: async () => { + checkedCallbackCount += 1; + }, + })(toolEnv(workspace)); const invalidRes = await tool.run( { @@ -306,8 +160,7 @@ test("write_deck and check_deck return isError: true naming the slide and field }), ]); - // Calling write_deck with a check-only failure (valid schema, low contrast) must refuse with - // isError: true and leave .builder/deck.json, .pptx and .pdf unwritten. + // Call write_deck with a check-only failure (low contrast) and verify it returns isError: true and writes NONE of deck.json, deck.pptx, or deck.pdf. const lowContrastDeck = { ...sampleDeck, slides: [ @@ -323,34 +176,38 @@ test("write_deck and check_deck return isError: true naming the slide and field }, ], }; - const writeContrastRes = await tool.run( + const writeCheckFailRes = await tool.run( { - id: "call-write-contrast-fail", + id: "call-write-check-fail", name: "write_deck", arguments: { deck: lowContrastDeck }, }, new AbortController().signal, ); - expect(writeContrastRes.isError).toBe(true); - const writeContrastBody = JSON.parse(String(writeContrastRes.content)); - expect(writeContrastBody.passed).toBe(false); - expect(writeContrastBody.issues).toEqual([ + expect(writeCheckFailRes.isError).toBe(true); + const writeCheckFailBody = JSON.parse(String(writeCheckFailRes.content)); + expect(writeCheckFailBody.passed).toBe(false); + expect(writeCheckFailBody.pptxBytes).toBe(0); + expect(writeCheckFailBody.pdfBytes).toBe(0); + expect(writeCheckFailBody.issues).toEqual([ expect.objectContaining({ slide: 0, path: "slides[0].body[0].text[0]", message: expect.stringContaining("contrast"), }), ]); - expect(await Bun.file(join(workspace, DECK_JSON)).exists()).toBe(false); - expect(await Bun.file(join(workspace, DECK_PPTX)).exists()).toBe(false); - expect(await Bun.file(join(workspace, DECK_PDF)).exists()).toBe(false); + await expect(readFile(join(workspace, DECK_JSON))).rejects.toThrow(); + await expect(readFile(join(workspace, DECK_PPTX))).rejects.toThrow(); + await expect(readFile(join(workspace, DECK_PDF))).rejects.toThrow(); - // Seed the failing deck directly on disk to exercise checkDeckInWorkspace and check_deck. + // Write the low-contrast deck.json manually to verify check_deck also refuses and does not invoke onDeckChecked. await mkdir(join(workspace, ".builder"), { recursive: true }); await writeFile( join(workspace, DECK_JSON), - JSON.stringify(lowContrastDeck), + `${JSON.stringify(lowContrastDeck, null, 2)}\n`, + "utf8", ); + const directCheck = await checkDeckInWorkspace(workspace); expect(directCheck.passed).toBe(false); @@ -372,6 +229,7 @@ test("write_deck and check_deck return isError: true naming the slide and field message: expect.stringContaining("contrast"), }), ]); + expect(checkedCallbackCount).toBe(0); // Also verify missing image asset in .builder/deck.json fails check_deck. await mkdir(join(workspace, ".builder"), { recursive: true }); @@ -403,7 +261,6 @@ test("write_deck and check_deck return isError: true naming the slide and field path: "slides[0].image", }), ]); - expect(versions.size).toBe(0); } finally { await rm(workspace, { recursive: true, force: true }); } diff --git a/apps/hub/src/artifacts/deck-tools.ts b/apps/hub/src/artifacts/deck-tools.ts index 62a82b63..98a4e105 100644 --- a/apps/hub/src/artifacts/deck-tools.ts +++ b/apps/hub/src/artifacts/deck-tools.ts @@ -1,4 +1,5 @@ -import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve, sep } from "node:path"; import { Deck, @@ -10,30 +11,69 @@ import { writeDeckPdf, writePptx, type Assets, - type Deck as DeckType, + type Deck as SlideDeck, type DeckIssue, } from "@corbits/slidedeck"; -import { WorkflowProjectScope } from "@corbits-builder/schemas/installed-workflows"; import { defineTool, type BaseEnv } from "@intx/agent/authoring"; -import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; -import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; -import { workflowHttpJson } from "../workflows/http-json.ts"; -import { HubArtifactReceipt } from "./tools.ts"; interface DeckToolEnv extends BaseEnv { toolCwd: string; - capabilities?: RuntimeCapabilities; } -const HubProjectScope = WorkflowProjectScope.onDeepUndeclaredKey("ignore"); +export interface CheckedDeckArtifact { + deck: SlideDeck; + jsonBytes: Uint8Array; + pptxBytes: Uint8Array; + pdfBytes: Uint8Array; + sha256: string; + pptxSha256: string; + pdfSha256: string; +} + +export interface DeckToolsOptions { + assets?: Assets; + onDeckChecked?: (artifact: CheckedDeckArtifact) => Promise; +} export const DECK_JSON = ".builder/deck.json"; export const DECK_PPTX = ".builder/deck.pptx"; export const DECK_PDF = ".builder/deck.pdf"; +const DECK_CHECK_PATH = ".builder/deck-check.json"; + +function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function isAssetsMap( + optionsOrAssets: Assets | DeckToolsOptions, +): optionsOrAssets is Assets { + return optionsOrAssets instanceof Map; +} + +function normalizeDeckToolsOptions( + optionsOrAssets?: Assets | DeckToolsOptions, +): DeckToolsOptions { + if (!optionsOrAssets) return {}; + if (isAssetsMap(optionsOrAssets)) { + return { assets: optionsOrAssets }; + } + return optionsOrAssets; +} + +async function readExistingBytes( + filePath: string, +): Promise { + try { + return new Uint8Array(await readFile(filePath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} async function resolveWorkspaceAssets( workspace: string, - deck: DeckType, + deck: SlideDeck, provided?: Assets, ): Promise { const merged = new Map>(provided); @@ -69,13 +109,26 @@ export async function writeDeckToWorkspace( const { deck } = validated; const resolvedAssets = await resolveWorkspaceAssets(workspace, deck, assets); const issues = checkDeck(deck, resolvedAssets); - if (issues.length > 0) throw new DeckError(issues); - const pptx = await writePptx(deck, resolvedAssets); - const pdf = writeDeckPdf(deck, resolvedAssets); + if (issues.length > 0) { + await rm(join(workspace, DECK_JSON), { force: true }); + await rm(join(workspace, DECK_PPTX), { force: true }); + await rm(join(workspace, DECK_PDF), { force: true }); + await rm(join(workspace, DECK_CHECK_PATH), { force: true }); + return { + deck, + slideCount: deck.slides.length, + pptxBytes: 0, + pdfBytes: 0, + passed: false, + issues, + }; + } const jsonTarget = join(workspace, DECK_JSON); await mkdir(dirname(jsonTarget), { recursive: true }); await writeFile(jsonTarget, `${JSON.stringify(deck, null, 2)}\n`, "utf8"); + const pptx = await writePptx(deck, resolvedAssets); + const pdf = writeDeckPdf(deck, resolvedAssets); await writeFile(join(workspace, DECK_PPTX), pptx); await writeFile(join(workspace, DECK_PDF), pdf); @@ -84,12 +137,16 @@ export async function writeDeckToWorkspace( slideCount: deck.slides.length, pptxBytes: pptx.byteLength, pdfBytes: pdf.byteLength, - passed: issues.length === 0, + passed: true, issues, }; } -export async function checkDeckInWorkspace(workspace: string, assets?: Assets) { +export async function checkDeckInWorkspace( + workspace: string, + optionsOrAssets?: Assets | DeckToolsOptions, +) { + const { assets, onDeckChecked } = normalizeDeckToolsOptions(optionsOrAssets); const raw = await readFile(join(workspace, DECK_JSON), "utf8"); const parsed: unknown = JSON.parse(raw); const validated = validateDeck(parsed); @@ -105,81 +162,31 @@ export async function checkDeckInWorkspace(workspace: string, assets?: Assets) { assets, ); const issues = checkDeck(validated.deck, resolvedAssets); - if (issues.length > 0) { - return { - passed: false, - issues, - }; + const passed = issues.length === 0; + if (passed && onDeckChecked) { + const jsonBytes = new TextEncoder().encode(raw); + const pptxBytes = + (await readExistingBytes(join(workspace, DECK_PPTX))) ?? + (await writePptx(validated.deck, resolvedAssets)); + const pdfBytes = + (await readExistingBytes(join(workspace, DECK_PDF))) ?? + writeDeckPdf(validated.deck, resolvedAssets); + await onDeckChecked({ + deck: validated.deck, + jsonBytes, + pptxBytes, + pdfBytes, + sha256: sha256Hex(jsonBytes), + pptxSha256: sha256Hex(pptxBytes), + pdfSha256: sha256Hex(pdfBytes), + }); } - const pptx = await writePptx(validated.deck, resolvedAssets); - const pdf = writeDeckPdf(validated.deck, resolvedAssets); - await writeFile(join(workspace, DECK_PPTX), pptx); - await writeFile(join(workspace, DECK_PDF), pdf); return { - passed: true, + passed, issues, }; } -function deckTitle(deck: DeckType): string { - for (const slide of deck.slides) { - if ("title" in slide && typeof slide.title === "string") { - const trimmed = slide.title.trim(); - if (trimmed.length > 0) return trimmed.slice(0, 512); - } - } - return "Slide deck"; -} - -export async function fileCheckedDeck( - workspace: string, - capabilities: RuntimeCapabilities, - signal: AbortSignal, -) { - const [rawDeck, pptxBytes, pdfBytes] = await Promise.all([ - readFile(join(workspace, DECK_JSON), "utf8"), - readFile(join(workspace, DECK_PPTX)), - readFile(join(workspace, DECK_PDF)), - ]); - const parsed: unknown = JSON.parse(rawDeck); - const validated = validateDeck(parsed); - if (!validated.ok) throw new DeckError(validated.issues); - const content = JSON.stringify({ - deck: validated.deck, - pptxBase64: Buffer.from(pptxBytes).toString("base64"), - pdfBase64: Buffer.from(pdfBytes).toString("base64"), - }); - const scope = HubProjectScope.assert( - await workflowHttpJson({ - capabilities, - handle: HUB_CREDENTIAL_HANDLE, - path: "/api/workflow-project-scope", - maxResponseBytes: 16384, - signal, - }), - ); - const projectPath = - "/api/tenants/" + - encodeURIComponent(scope.tenantId) + - "/projects/" + - encodeURIComponent(scope.projectId); - const { artifact } = HubArtifactReceipt.assert( - await workflowHttpJson({ - capabilities, - handle: HUB_CREDENTIAL_HANDLE, - path: projectPath + "/artifacts", - body: { - kind: "deck", - title: deckTitle(validated.deck), - content, - }, - maxResponseBytes: 16384, - signal, - }), - ); - return artifact; -} - function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -194,7 +201,8 @@ function errorIssues(path: string, error: unknown): DeckIssue[] { ]; } -export function createDeckTools(assets?: Assets) { +export function createDeckTools(optionsOrAssets?: Assets | DeckToolsOptions) { + const { assets, onDeckChecked } = normalizeDeckToolsOptions(optionsOrAssets); const definitions = [ { name: "write_deck", @@ -227,12 +235,11 @@ export function createDeckTools(assets?: Assets) { return defineTool({ id: "@corbits-builder/artifacts/deck-tools", - credentials: [{ handle: HUB_CREDENTIAL_HANDLE }], requires: ["toolCwd"], definitions: [{ name: "write_deck" }, { name: "check_deck" }], factory: (env) => ({ definitions, - run: async (call, signal) => { + run: async (call) => { if (call.name === "write_deck") { const deckArg = isObjectRecord(call.arguments) && "deck" in call.arguments @@ -253,6 +260,7 @@ export function createDeckTools(assets?: Assets) { passed: written.passed, issues: written.issues, }), + ...(written.passed ? {} : { isError: true }), }; } catch (error) { return { @@ -268,23 +276,14 @@ export function createDeckTools(assets?: Assets) { if (call.name === "check_deck") { try { - const checked = await checkDeckInWorkspace(env.toolCwd, assets); - if (!checked.passed) { - return { - callId: call.id, - content: JSON.stringify(checked), - isError: true, - }; - } - const artifact = env.capabilities - ? await fileCheckedDeck(env.toolCwd, env.capabilities, signal) - : undefined; + const checked = await checkDeckInWorkspace(env.toolCwd, { + ...(assets ? { assets } : {}), + ...(onDeckChecked ? { onDeckChecked } : {}), + }); return { callId: call.id, - content: JSON.stringify({ - ...checked, - ...(artifact ? { artifact } : {}), - }), + content: JSON.stringify(checked), + ...(checked.passed ? {} : { isError: true }), }; } catch (error) { return { diff --git a/apps/hub/src/artifacts/routes.ts b/apps/hub/src/artifacts/routes.ts index e8fd456d..c5eaedf6 100644 --- a/apps/hub/src/artifacts/routes.ts +++ b/apps/hub/src/artifacts/routes.ts @@ -24,6 +24,7 @@ import { DesignTweaks, DesignTweaksReceipt, LiveApp, + MAX_APPROVAL_PACKAGE_BYTES, MaterialReceipt, } from "@corbits-builder/schemas/artifact"; import { ProjectActivity } from "@corbits-builder/schemas/activity"; @@ -123,6 +124,10 @@ export function mountArtifactRoutes( ); app.post( "/:projectId/artifacts", + bodyLimit({ + maxSize: MAX_APPROVAL_PACKAGE_BYTES + 1024 * 1024, + onError: (c) => c.json({ code: "artifact_too_large" }, 413), + }), describeRoute({ tags: ["Artifacts"], operationId: "writeProjectArtifact", diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index 470981f6..dd88aa3b 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -39,10 +39,9 @@ import { ArtifactApprovalRecord, AppBundle, AppVersionMetadata, - DeckBundle, DocumentContent, MAX_APP_BYTES, - MAX_DECK_BYTES, + MAX_APPROVAL_PACKAGE_BYTES, MATERIAL_KIND, ArtifactKindWrite, DesignBundle, @@ -145,6 +144,11 @@ import { materialContent, materialMimeType, } from "./materials.ts"; +import { + parseApprovalPackageBundle, + readProjectPackageSource, + validateApprovalPackage, +} from "./approval-package.ts"; import { requestErrorMetadata } from "../request-error.ts"; const log = getLogger(["corbits-builder", "hub", "artifacts"]); @@ -155,15 +159,11 @@ const log = getLogger(["corbits-builder", "hub", "artifacts"]); // so no approval covers it. const APPROVAL_COVERS: Record boolean> = { request_approval: (kind) => - kind !== "app" && - kind !== "design" && - kind !== "design_option" && - kind !== "deck", - instruct_coder: (kind) => - kind !== "app" && kind !== "design_option" && kind !== "deck", + kind !== "app" && kind !== "design" && kind !== "design_option", + instruct_coder: (kind) => kind !== "app" && kind !== "design_option", }; -// Whole documents, the filed app and deck among them. -const MAX_READ_CHARS = MAX_DECK_BYTES; +// Whole documents, the filed app and approval package among them. +const MAX_READ_CHARS = MAX_APPROVAL_PACKAGE_BYTES; const RECENT_FAILURES = 5; const RunError = type({ "+": "delete", message: "string", "code?": "string" }); const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -202,7 +202,7 @@ function validateArtifactWriteContent( input.kind === "design" || input.kind === "design_option" || input.kind === "app" || - input.kind === "deck"; + input.kind === "approval_package"; if (bundled && input.openQuestions) throw new ProjectError(400, "invalid_artifact_write", { issues: [ @@ -248,18 +248,14 @@ function validateArtifactWriteContent( bytes, limit: MAX_APP_BYTES, }); - } else if (input.kind === "deck") { + } else if (input.kind === "approval_package") { const bytes = Buffer.byteLength(input.content); - if (bytes > MAX_DECK_BYTES) - throw new ProjectError(413, "deck_too_large", { + if (bytes > MAX_APPROVAL_PACKAGE_BYTES) + throw new ProjectError(413, "approval_package_too_large", { bytes, - limit: MAX_DECK_BYTES, - }); - const bundle = DeckBundle(parseJson(input.content)); - if (bundle instanceof type.errors) - throw new ProjectError(400, "invalid_deck", { - issues: typeIssues(bundle, "content"), + limit: MAX_APPROVAL_PACKAGE_BYTES, }); + parseApprovalPackageBundle(input.content); } else { const document = DocumentContent(input.content); if (document instanceof type.errors) @@ -1348,6 +1344,11 @@ export function createArtifactService( throw new ProjectError(403, "artifact_write_refused", { kind: input.kind, }); + if (input.kind === "approval_package") + await validateApprovalPackage( + input.content, + readProjectPackageSource(db, actor.tenantId), + ); const [active] = await rawDB .select({ kind: principal.kind, refId: principal.refId }) .from(principal) diff --git a/apps/hub/src/artifacts/tools.ts b/apps/hub/src/artifacts/tools.ts index 28d5dd46..065c60b2 100644 --- a/apps/hub/src/artifacts/tools.ts +++ b/apps/hub/src/artifacts/tools.ts @@ -12,9 +12,11 @@ import { ArtifactList, ArtifactRead, ArtifactReceipt, + MAX_APPROVAL_PACKAGE_BYTES, WriterDocumentWrite, } from "@corbits-builder/schemas/artifact"; import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; +import { stripApprovalPackageBinaries } from "./approval-package.ts"; import { unpackDesign } from "./design-tools.ts"; import { projectScopeFailure, @@ -26,7 +28,7 @@ interface ArtifactToolEnv extends BaseEnv { capabilities: RuntimeCapabilities; } -const MAX_RESULT_BYTES = 300 * 1024; +const MAX_RESULT_BYTES = MAX_APPROVAL_PACKAGE_BYTES + 256 * 1024; // A deployed agent keeps the tool bundle it started with while the hub moves on; a field a newer // hub adds must not fail every read of an older agent. @@ -219,20 +221,27 @@ export function createArtifactTools( }), }; } + const prepared = + document.kind === "approval_package" + ? { + ...document, + content: stripApprovalPackageBinaries(document.content), + } + : document; const parts = Math.max( 1, - Math.ceil(document.content.length / PART_CHARS), + Math.ceil(prepared.content.length / PART_CHARS), ); const part = Math.min(input.part ?? 1, parts); if (parts === 1) - return { callId: call.id, content: JSON.stringify(document) }; + return { callId: call.id, content: JSON.stringify(prepared) }; return { callId: call.id, content: JSON.stringify({ - ...document, + ...prepared, part, parts, - content: document.content.slice( + content: prepared.content.slice( (part - 1) * PART_CHARS, part * PART_CHARS, ), diff --git a/apps/hub/src/projects/errors.test.ts b/apps/hub/src/projects/errors.test.ts index 16ad5b08..c32889ac 100644 --- a/apps/hub/src/projects/errors.test.ts +++ b/apps/hub/src/projects/errors.test.ts @@ -173,7 +173,7 @@ test("typeIssues names the rule for narrow checks, unions and extra keys without { path: "kind", message: - 'must be "app", "approaches", "build_plan", "constraints", "deck", "design", "design_option", "problem_brief" or "product_requirements"', + 'must be "app", "approaches", "approval_package", "build_plan", "constraints", "design", "design_option", "problem_brief" or "product_requirements"', }, { path: "extra", message: "is not allowed here" }, ]); diff --git a/apps/hub/src/projects/errors.ts b/apps/hub/src/projects/errors.ts index d4e202e8..7af9319f 100644 --- a/apps/hub/src/projects/errors.ts +++ b/apps/hub/src/projects/errors.ts @@ -2,21 +2,34 @@ import { type, type ArkError, type ArkErrors } from "arktype"; import { ProjectId } from "@corbits-builder/schemas/project"; // Agents see a rejection's reasons only in this list form. -type Issues = readonly { path: string; message: string }[]; +export type ProjectErrorIssue = { path: string; message: string }; +type Issues = readonly ProjectErrorIssue[]; export class ProjectError extends Error { readonly details: Record; + readonly issues?: readonly ProjectErrorIssue[]; constructor( - readonly status: 400 | 403 | 404 | 409 | 413 | 503, + readonly status: 400 | 403 | 404 | 409 | 413 | 422 | 503, readonly code: string, - { - issues, - ...details - }: { issues?: Issues | ArkErrors; [detail: string]: unknown } = {}, + rawDetails: + | { issues?: Issues | ArkErrors | string; [detail: string]: unknown } + | readonly ProjectErrorIssue[] = {}, options?: ErrorOptions, ) { super(code, options); + if (Array.isArray(rawDetails)) { + this.issues = rawDetails; + this.details = { issues: rawDetails }; + return; + } + const { issues, ...details } = rawDetails as { + issues?: Issues | ArkErrors | string; + [detail: string]: unknown; + }; const listed = issues instanceof type.errors ? typeIssues(issues) : issues; + if (Array.isArray(listed)) { + this.issues = listed; + } this.details = listed === undefined ? details : { ...details, issues: listed }; } diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 29d140b6..c7d67bbc 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -103,6 +103,7 @@ export const wording = { product_requirements: "Requirements", build_plan: "Build Plan", design: "Design Plan", + approval_package: "Approval", app: "App", } as Record, documentUnread: "new version", @@ -178,6 +179,8 @@ export const wording = { shareDocument: "Share as Markdown", downloadDesign: "Download the design", shareDesign: "Share the design", + downloadPackagePdf: "Download the package PDF", + sharePackagePdf: "Share the package PDF", downloadSheet: "Download as CSV", shareSheet: "Share as CSV", downloaded: (name: string) => `Downloaded ${name}.`, @@ -515,6 +518,41 @@ export const wording = { font: (stack: string) => `font ${stack}`, }, }, + /** The approval package viewer: slides, PDF preview, and pinned source manifest. */ + approvalPackage: { + label: "Approval package", + viewLabel: "Approval package view", + reminder: + "Builder packages your approved documents into a presentation deck and PDF handout so you can download a .zip per reviewer, record their sign-off or feedback, and send requested revisions back to the coordinator.", + tabs: { + slides: "Slides", + pdf: "PDF", + sources: "Sources", + }, + slideCount: (index: number, total: number) => + `Slide ${String(index)} of ${String(total)}`, + pageCount: (index: number, total: number) => + `Page ${String(index)} of ${String(total)}`, + noSlides: "This approval package contains no slides.", + speakerNotes: "Speaker notes", + downloadPdf: "Download PDF", + openPdf: "Open PDF", + downloadPptx: "Download PPTX", + pdfTitle: "Approval package PDF preview", + manifest: { + audience: "Audience", + decisionNeeded: "Decision needed", + tone: "Tone", + length: "Length", + sources: "Pinned source documents", + sourceKind: "Document", + sourceTitle: "Title", + sourceVersion: "Version", + sourceHash: "SHA-256", + documents: "Supporting documents", + }, + unreadable: "This approval package could not be read.", + }, /** The approaches document's comparison, above its prose. */ approaches: { label: "Approaches side by side", @@ -744,6 +782,7 @@ export const wording = { product_requirements: "requirements", build_plan: "build plan", design: "design", + approval_package: "approval package", } as Record, openOnRight: "It's open on the right.", /** Takes the reading pane back to the document the card asks about. */ diff --git a/apps/web/src/pages/workspace/approval-package-preview.test.tsx b/apps/web/src/pages/workspace/approval-package-preview.test.tsx new file mode 100644 index 00000000..946ddc50 --- /dev/null +++ b/apps/web/src/pages/workspace/approval-package-preview.test.tsx @@ -0,0 +1,194 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ApprovalPackageBundle } from "@corbits-builder/schemas/artifact"; +import { + APPROVAL_PACKAGE_MEDIA_TYPE, + ApprovalPackagePreview, + approvalPackagePdfFile, + parseApprovalPackage, +} from "./approval-package-preview.tsx"; + +const sampleBundle: ApprovalPackageBundle = { + manifest: { + audience: "Executive steering committee", + decisionNeeded: "Approve Phase 1 rollout", + tone: "Executive", + length: "5 slides", + sources: [ + { + artifactId: "11111111-1111-4111-8111-111111111111", + kind: "problem_brief", + title: "Customer Portal Brief", + version: 2, + sha256: "abcdef0123456789".repeat(4), + }, + { + artifactId: "22222222-2222-4222-8222-222222222222", + kind: "product_requirements", + title: "Portal Requirements", + version: 1, + sha256: "9876543210fedcba".repeat(4), + }, + ], + }, + deck: { + theme: { + colors: { + primary: "#1e293b", + secondary: "#334155", + accent: "#2563eb", + background: "#ffffff", + text: "#0f172a", + muted: "#64748b", + }, + fonts: { + heading: "Inter", + body: "Inter", + }, + }, + slides: [ + { + layout: "title", + title: "Customer Portal Approval Package", + subtitle: "Phase 1 scope and rollout plan", + notes: "Welcome the steering committee.", + }, + { + layout: "comparison", + title: "Option Trade-offs", + left: { + heading: "Phased Rollout", + body: [ + { + type: "list", + items: [ + "Lower risk", + [{ text: "Faster", bold: true }, " initial launch"], + ], + }, + ], + }, + right: { + heading: "Big Bang", + body: [{ type: "paragraph", text: "Single cutover across regions." }], + }, + }, + ], + }, + pptxBase64: Buffer.from("fake-pptx").toString("base64"), + pdfBase64: Buffer.from("%PDF-1.4 fake-pdf").toString("base64"), + documents: { + "executive-summary.md": "# Executive Summary\n\nReady for Phase 1.", + }, +}; + +describe("approval package preview", () => { + test("parses valid approval_package JSON and rejects malformed payloads", () => { + const parsed = parseApprovalPackage(JSON.stringify(sampleBundle)); + expect(parsed).not.toBeNull(); + expect(parsed?.manifest.audience).toBe("Executive steering committee"); + expect(parseApprovalPackage("{not-json")).toBeNull(); + expect(parseApprovalPackage(JSON.stringify({ manifest: {} }))).toBeNull(); + + const pdfFile = approvalPackagePdfFile("portal-package-v1", sampleBundle); + expect(pdfFile.name).toBe("portal-package-v1.pdf"); + expect(pdfFile.type).toBe("application/pdf"); + expect(APPROVAL_PACKAGE_MEDIA_TYPE).toContain("approval-package+json"); + }); + + test("renders slides view with theme colors, comparison columns, and speaker notes", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Slides"); + expect(html).toContain("PDF"); + expect(html).toContain("Sources"); + expect(html).toContain("Slide 1 of 2"); + expect(html).toContain("Customer Portal Approval Package"); + expect(html).toContain("Phase 1 scope and rollout plan"); + expect(html).toContain("Option Trade-offs"); + expect(html).toContain("Phased Rollout"); + expect(html).toContain("Lower risk"); + expect(html).toContain("Single cutover across regions."); + expect(html).toContain("#1e293b"); + }); + + test("renders PDF view with print-accurate page sheets and download link without blocked data: iframe", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain(" { + const darkDeckBundle: ApprovalPackageBundle = { + ...sampleBundle, + deck: { + theme: { + colors: { + primary: "#0369a1", + secondary: "#0369a1", + accent: "#0ea5e9", + background: "#0a1120", + text: "#f8fafc", + muted: "#64748b", + }, + }, + slides: [ + { + layout: "title", + title: "Aurora Weather Executive Package", + subtitle: "Phase 1 architecture and launch readiness", + notes: "Highlight sub-second radar tile caching.", + }, + ], + }, + }; + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-slide-tone="dark"'); + expect(html).toContain("color:#f8fafc"); + expect(html).toContain("color:#cbd5e1"); + expect(html).not.toContain("color:#0369a1"); + }); + + test("renders Sources view with manifest metadata, clickable pinned source documents table and supporting markdown", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Executive steering committee"); + expect(html).toContain("Approve Phase 1 rollout"); + expect(html).toContain("Executive"); + expect(html).toContain("5 slides"); + expect(html).toContain("Customer Portal Brief"); + expect(html).toContain("Portal Requirements"); + expect(html).toContain("approval-package-source-link"); + expect(html).toContain("v2"); + expect(html).toContain("abcdef012345"); + expect(html).toContain("executive-summary.md"); + expect(html).toContain("Ready for Phase 1."); + }); +}); diff --git a/apps/web/src/pages/workspace/approval-package-preview.tsx b/apps/web/src/pages/workspace/approval-package-preview.tsx new file mode 100644 index 00000000..fe9e5fb8 --- /dev/null +++ b/apps/web/src/pages/workspace/approval-package-preview.tsx @@ -0,0 +1,819 @@ +import { Badge, Button } from "@corbits/react-ui"; +import { + ApprovalPackageBundle, + type ApprovalPackageSource, +} from "@corbits-builder/schemas/artifact"; +import { type } from "arktype"; +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { wording } from "../../config.ts"; +import { Markdown } from "../../markdown.jsx"; + +export const APPROVAL_PACKAGE_MEDIA_TYPE = + "application/vnd.corbits-builder.approval-package+json"; + +const text = wording.approvalPackage; + +export type ApprovalPackageViewTab = "slides" | "pdf" | "sources"; + +interface DeckThemeColors { + primary?: string; + secondary?: string; + accent?: string; + background?: string; + text?: string; + muted?: string; +} + +interface DeckTheme { + colors?: DeckThemeColors | undefined; + fonts?: + | { + heading?: string; + body?: string; + } + | undefined; +} + +interface ResolvedSlidePalette { + isDark: boolean; + background: string; + text: string; + heading: string; + subtitle: string; + accentBar: string; + columnHeading: string; + columnBg: string; + columnBorder: string; +} + +type SlideBodyBlock = + | string + | { + type?: string; + kind?: string; + text?: unknown; + ordered?: boolean; + items?: unknown[]; + }; + +interface DeckSlide { + layout?: string; + title?: string; + subtitle?: string; + body?: string | SlideBodyBlock[]; + left?: unknown; + right?: unknown; + elements?: unknown[]; + caption?: string; + notes?: string; + speakerNotes?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeHex(color: string | undefined): string | undefined { + if (color === undefined || color.trim() === "") return undefined; + const trimmed = color.trim(); + if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed}`; + return trimmed; +} + +function parseHexRgb(hex: string | undefined): [number, number, number] | null { + const normalized = normalizeHex(hex); + if (normalized === undefined) return null; + const raw = normalized.startsWith("#") ? normalized.slice(1) : normalized; + if (/^[0-9a-fA-F]{3}$/.test(raw)) { + const r = Number.parseInt(raw.charAt(0) + raw.charAt(0), 16); + const g = Number.parseInt(raw.charAt(1) + raw.charAt(1), 16); + const b = Number.parseInt(raw.charAt(2) + raw.charAt(2), 16); + return [r, g, b]; + } + if (/^[0-9a-fA-F]{6}$/.test(raw)) { + const r = Number.parseInt(raw.slice(0, 2), 16); + const g = Number.parseInt(raw.slice(2, 4), 16); + const b = Number.parseInt(raw.slice(4, 6), 16); + return [r, g, b]; + } + return null; +} + +function channelLuminance(channel: number): number { + const srgb = channel / 255; + return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(hex: string | undefined): number | null { + const rgb = parseHexRgb(hex); + if (rgb === null) return null; + return ( + 0.2126 * channelLuminance(rgb[0]) + + 0.7152 * channelLuminance(rgb[1]) + + 0.0722 * channelLuminance(rgb[2]) + ); +} + +function pickLightOnDark( + color: string | undefined, + minLum: number, + fallback: string, +): string { + if (color === undefined) return fallback; + const lum = relativeLuminance(color); + return lum !== null && lum >= minLum ? color : fallback; +} + +function pickDarkOnLight( + color: string | undefined, + maxLum: number, + fallback: string, +): string { + if (color === undefined) return fallback; + const lum = relativeLuminance(color); + return lum !== null && lum <= maxLum ? color : fallback; +} + +// Dark-background decks can still declare dark brand tokens for primary/secondary; +// clamping by relative luminance keeps headings and subtitles legible on the stage. +function resolveSlidePalette(theme: DeckTheme): ResolvedSlidePalette { + const colors = theme.colors; + const rawBg = normalizeHex(colors?.background); + const bgLum = relativeLuminance(rawBg); + const isDark = bgLum !== null && bgLum < 0.25; + + const primary = normalizeHex(colors?.primary); + const secondary = normalizeHex(colors?.secondary ?? colors?.muted); + const accent = normalizeHex(colors?.accent ?? colors?.primary); + const rawText = normalizeHex(colors?.text); + + if (isDark) { + const brightPrimary = pickLightOnDark(primary, 0.35, "#38bdf8"); + return { + isDark: true, + background: rawBg === undefined ? "#0f172a" : rawBg, + text: pickLightOnDark(rawText, 0.55, "#f8fafc"), + heading: pickLightOnDark(primary, 0.55, "#f8fafc"), + subtitle: pickLightOnDark(secondary, 0.5, "#cbd5e1"), + accentBar: pickLightOnDark(accent, 0.25, brightPrimary), + columnHeading: pickLightOnDark(accent, 0.35, brightPrimary), + columnBg: "rgba(255, 255, 255, 0.055)", + columnBorder: "rgba(255, 255, 255, 0.14)", + }; + } + + return { + isDark: false, + background: rawBg === undefined ? "#ffffff" : rawBg, + text: pickDarkOnLight(rawText, 0.35, "#0f172a"), + heading: pickDarkOnLight(primary, 0.45, "#0f172a"), + subtitle: pickDarkOnLight(secondary, 0.45, "#475569"), + accentBar: primary ?? accent ?? "#0284c7", + columnHeading: pickDarkOnLight(primary, 0.45, "#0284c7"), + columnBg: "rgba(15, 23, 42, 0.035)", + columnBorder: "rgba(15, 23, 42, 0.1)", + }; +} + +export function parseApprovalPackage( + content: string, +): ApprovalPackageBundle | null { + try { + const parsed: unknown = JSON.parse(content); + const result = ApprovalPackageBundle(parsed); + if (result instanceof type.errors) return null; + return result; + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + return null; + } +} + +export function approvalPackagePdfFile( + stem: string, + bundle: ApprovalPackageBundle, +): File { + const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(bundle.pdfBase64); + const encoded = ( + dataUrlMatch === null ? bundle.pdfBase64 : (dataUrlMatch[1] as string) + ).replace(/\s+/g, ""); + let bytes = new Uint8Array(0); + try { + const binary = atob(encoded); + bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + } catch (error) { + if (!(error instanceof Error)) throw error; + bytes = new Uint8Array(0); + } + return new File([bytes], `${stem}.pdf`, { type: "application/pdf" }); +} + +function readDeckTheme(deck: Record): DeckTheme { + const rawTheme = deck["theme"]; + if (!isRecord(rawTheme)) { + return { colors: undefined, fonts: undefined }; + } + const colors = isRecord(rawTheme["colors"]) + ? (rawTheme["colors"] as DeckThemeColors) + : undefined; + const fonts = isRecord(rawTheme["fonts"]) + ? (rawTheme["fonts"] as DeckTheme["fonts"]) + : undefined; + return { colors, fonts }; +} + +function readDeckSlides(deck: Record): DeckSlide[] { + const rawSlides = deck["slides"]; + if (!Array.isArray(rawSlides)) return []; + return rawSlides.filter(isRecord) as DeckSlide[]; +} + +function slideStageStyle( + theme: DeckTheme, + palette: ResolvedSlidePalette, +): CSSProperties { + const style: Record = { + backgroundColor: palette.background, + color: palette.text, + "--slide-bg": palette.background, + "--slide-fg": palette.text, + "--slide-heading": palette.heading, + "--slide-subtitle": palette.subtitle, + "--slide-accent": palette.accentBar, + "--slide-column-bg": palette.columnBg, + "--slide-column-border": palette.columnBorder, + }; + if (theme.fonts?.body !== undefined) { + style["fontFamily"] = theme.fonts.body; + } + return style as CSSProperties; +} + +function renderRichRun(run: unknown, key: number): ReactNode { + if (typeof run === "string") return run; + if (!isRecord(run) || typeof run["text"] !== "string") return null; + let node: ReactNode = run["text"]; + if (run["bold"] === true || Number(run["weight"]) >= 600) { + node = {node}; + } + if (run["italic"] === true) { + node = {node}; + } + if (run["underline"] === true) { + node = {node}; + } + if (run["strike"] === true) { + node = {node}; + } + return {node}; +} + +function renderRichText(value: unknown): ReactNode { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value.map((run, index) => renderRichRun(run, index)); + } + return null; +} + +function renderNestedList(nestedItems: unknown[], ordered: boolean): ReactNode { + if (nestedItems.length === 0) return null; + if (ordered) { + return
    {renderListItems(nestedItems)}
; + } + return
    {renderListItems(nestedItems)}
; +} + +function renderListItems(items: unknown[]) { + return items.map((item, index) => { + if (isRecord(item) && "text" in item) { + const nestedOrdered = item["ordered"] === true; + return ( +
  • + {renderRichText(item["text"])} + {Array.isArray(item["items"]) + ? renderNestedList(item["items"], nestedOrdered) + : null} +
  • + ); + } + return
  • {renderRichText(item)}
  • ; + }); +} + +function SlideBodyBlockView({ block }: { block: SlideBodyBlock }) { + if (typeof block === "string") return

    {block}

    ; + const blockType = block.type ?? block.kind; + if (blockType === "paragraph") { + const rendered = renderRichText(block.text); + return rendered === null ? null :

    {rendered}

    ; + } + if (blockType === "list" && Array.isArray(block.items)) { + if (block.ordered === true) { + return
      {renderListItems(block.items)}
    ; + } + return
      {renderListItems(block.items)}
    ; + } + const fallback = renderRichText(block.text); + return fallback === null ? null :

    {fallback}

    ; +} + +function SlideBodyContent({ body }: { body: unknown }) { + if (typeof body === "string") { + return ( +
    +

    {body}

    +
    + ); + } + if (!Array.isArray(body) || body.length === 0) return null; + return ( +
    + {body.map((block, index) => ( + + ))} +
    + ); +} + +function SlideColumns({ + slide, + headingColor, +}: { + slide: DeckSlide; + headingColor: string; +}) { + if (slide.layout === "two-column") { + return ( +
    +
    + +
    +
    + +
    +
    + ); + } + if (slide.layout === "comparison") { + const leftSide = isRecord(slide.left) ? slide.left : undefined; + const rightSide = isRecord(slide.right) ? slide.right : undefined; + const leftHeading = + typeof leftSide?.["heading"] === "string" + ? leftSide["heading"] + : undefined; + const rightHeading = + typeof rightSide?.["heading"] === "string" + ? rightSide["heading"] + : undefined; + return ( +
    +
    + {leftHeading === undefined ? null : ( +

    + {leftHeading} +

    + )} + +
    +
    + {rightHeading === undefined ? null : ( +

    + {rightHeading} +

    + )} + +
    +
    + ); + } + return null; +} + +function collectFreeParagraphs(elements: unknown[]): ReactNode[] { + const out: ReactNode[] = []; + for (const [elIdx, el] of elements.entries()) { + if (!isRecord(el)) continue; + if (el["type"] === "group" && Array.isArray(el["children"])) { + out.push(...collectFreeParagraphs(el["children"])); + continue; + } + if (el["type"] === "text" && Array.isArray(el["paragraphs"])) { + for (const [pIdx, para] of el["paragraphs"].entries()) { + if (!isRecord(para) || !Array.isArray(para["runs"])) continue; + out.push( +

    + {renderRichText(para["runs"])} +

    , + ); + } + } + } + return out; +} + +function SlideCanvas({ + slide, + index, + total, + theme, + pageFooter, +}: { + slide: DeckSlide; + index: number; + total: number; + theme: DeckTheme; + pageFooter?: string; +}) { + const palette = resolveSlidePalette(theme); + const layout = slide.layout ?? "content"; + const titleStyle: CSSProperties = { color: palette.heading }; + if (theme.fonts?.heading !== undefined) { + titleStyle.fontFamily = theme.fonts.heading; + } + const freeParagraphs = + layout === "free" && Array.isArray(slide.elements) + ? collectFreeParagraphs(slide.elements) + : []; + return ( +
    + + ); +} + +function SlideCard({ + slide, + index, + total, + theme, +}: { + slide: DeckSlide; + index: number; + total: number; + theme: DeckTheme; +}) { + return ( +
    +
    + + {text.slideCount(index + 1, total)} + + {typeof slide.layout === "string" ? ( + {slide.layout} + ) : null} +
    + +
    + ); +} + +export function SlidesView({ deck }: { deck: Record }) { + const theme = readDeckTheme(deck); + const slides = readDeckSlides(deck); + if (slides.length === 0) { + return

    {text.noSlides}

    ; + } + return ( +
    + {slides.map((slide, index) => ( + + ))} +
    + ); +} + +// Chromium and Brave block data:application/pdf URLs inside