From c1a86e9a767c0eead6aff6d0bbc8f20847851ba3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 12:02:45 -0700 Subject: [PATCH 1/3] Add tests for workflow-artifacts routing under @corbits/artifacts (CL-8188) Point every workflow-artifacts client mock and memory-hub's own test import at the shapes @corbits/artifacts' mountWorkflowArtifacts/mountArtifacts actually serve, ahead of the implementation swap. --- apps/web/test/library-artifact-deep-link.test.tsx | 6 ++++-- apps/web/test/library-upload-confirmation.test.tsx | 10 +++++++--- packages/scout-agent/src/artifact-client.test.ts | 4 +++- workflows/attio-task-agent/src/artifact-client.test.ts | 4 +++- .../collateral-generation/src/artifact-client.test.ts | 4 +++- workflows/diligence-brief/src/artifact-client.test.ts | 4 +++- workflows/exa-topic-watch/src/artifact-client.test.ts | 4 +++- workflows/granola-call/src/artifact-client.test.ts | 4 +++- .../last-30-days-research/src/artifact-client.test.ts | 4 +++- workflows/morning-brief/src/artifact-client.test.ts | 4 +++- .../pain-point-collateral/src/artifact-client.test.ts | 4 +++- .../process-granola-call/src/artifact-client.test.ts | 4 +++- .../src/artifact-client.test.ts | 4 +++- 13 files changed, 44 insertions(+), 16 deletions(-) diff --git a/apps/web/test/library-artifact-deep-link.test.tsx b/apps/web/test/library-artifact-deep-link.test.tsx index 1efce3ec64..6a827f208c 100644 --- a/apps/web/test/library-artifact-deep-link.test.tsx +++ b/apps/web/test/library-artifact-deep-link.test.tsx @@ -54,12 +54,14 @@ function routeFetch(input: RequestInfo | URL): Promise { } if (url.includes("/artifacts/art_1")) { return Promise.resolve( - jsonResponse({ ...listedArtifact, content: "# Q3\nGrowth is up." }), + jsonResponse({ + artifact: { ...listedArtifact, content: "# Q3\nGrowth is up." }, + }), ); } if (url.includes("/artifacts")) { return Promise.resolve( - jsonResponse({ data: [listedArtifact], nextCursor: null }), + jsonResponse({ artifacts: [listedArtifact], nextCursor: null }), ); } return Promise.reject(new Error(`unrouted fetch in library test: ${url}`)); diff --git a/apps/web/test/library-upload-confirmation.test.tsx b/apps/web/test/library-upload-confirmation.test.tsx index d8ab16265b..488f054a4c 100644 --- a/apps/web/test/library-upload-confirmation.test.tsx +++ b/apps/web/test/library-upload-confirmation.test.tsx @@ -105,7 +105,7 @@ describe("Files upload confirmation", () => { if (url.includes("/artifacts/upload") && init?.method === "POST") { return Promise.resolve( jsonResponse({ - data: [ + artifacts: [ { id: "art_new", kind: "document", @@ -126,7 +126,9 @@ describe("Files upload confirmation", () => { ); } if (url.includes("/artifacts")) { - return Promise.resolve(jsonResponse({ data: [], nextCursor: null })); + return Promise.resolve( + jsonResponse({ artifacts: [], nextCursor: null }), + ); } return Promise.reject(new Error(`unrouted fetch: ${url}`)); }) as typeof fetch; @@ -153,7 +155,9 @@ describe("Files upload confirmation", () => { return Promise.resolve(jsonResponse({ error: "boom" }, 500)); } if (url.includes("/artifacts")) { - return Promise.resolve(jsonResponse({ data: [], nextCursor: null })); + return Promise.resolve( + jsonResponse({ artifacts: [], nextCursor: null }), + ); } return Promise.reject(new Error(`unrouted fetch: ${url}`)); }) as typeof fetch; diff --git a/packages/scout-agent/src/artifact-client.test.ts b/packages/scout-agent/src/artifact-client.test.ts index c767830c70..6378438ae3 100644 --- a/packages/scout-agent/src/artifact-client.test.ts +++ b/packages/scout-agent/src/artifact-client.test.ts @@ -31,7 +31,9 @@ describe("createScoutArtifact", () => { ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(capturedUrl).toBe("https://hub.example/api/workflow-artifacts/"); + expect(capturedUrl).toBe( + "https://hub.example/api/workflow-artifacts/artifacts", + ); expect(capturedInit?.method).toBe("POST"); expect(JSON.parse(String(capturedInit?.body))).toEqual({ title: "Diligence note", diff --git a/workflows/attio-task-agent/src/artifact-client.test.ts b/workflows/attio-task-agent/src/artifact-client.test.ts index 7760b27568..2a4088cc47 100644 --- a/workflows/attio-task-agent/src/artifact-client.test.ts +++ b/workflows/attio-task-agent/src/artifact-client.test.ts @@ -27,7 +27,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); diff --git a/workflows/collateral-generation/src/artifact-client.test.ts b/workflows/collateral-generation/src/artifact-client.test.ts index 7760b27568..2a4088cc47 100644 --- a/workflows/collateral-generation/src/artifact-client.test.ts +++ b/workflows/collateral-generation/src/artifact-client.test.ts @@ -27,7 +27,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); diff --git a/workflows/diligence-brief/src/artifact-client.test.ts b/workflows/diligence-brief/src/artifact-client.test.ts index d5484d7425..4af392cfff 100644 --- a/workflows/diligence-brief/src/artifact-client.test.ts +++ b/workflows/diligence-brief/src/artifact-client.test.ts @@ -27,7 +27,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); diff --git a/workflows/exa-topic-watch/src/artifact-client.test.ts b/workflows/exa-topic-watch/src/artifact-client.test.ts index 7760b27568..2a4088cc47 100644 --- a/workflows/exa-topic-watch/src/artifact-client.test.ts +++ b/workflows/exa-topic-watch/src/artifact-client.test.ts @@ -27,7 +27,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); diff --git a/workflows/granola-call/src/artifact-client.test.ts b/workflows/granola-call/src/artifact-client.test.ts index 2cf5ae25eb..898ce73544 100644 --- a/workflows/granola-call/src/artifact-client.test.ts +++ b/workflows/granola-call/src/artifact-client.test.ts @@ -27,7 +27,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); diff --git a/workflows/last-30-days-research/src/artifact-client.test.ts b/workflows/last-30-days-research/src/artifact-client.test.ts index 7760b27568..2a4088cc47 100644 --- a/workflows/last-30-days-research/src/artifact-client.test.ts +++ b/workflows/last-30-days-research/src/artifact-client.test.ts @@ -27,7 +27,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); diff --git a/workflows/morning-brief/src/artifact-client.test.ts b/workflows/morning-brief/src/artifact-client.test.ts index 7760b27568..2a4088cc47 100644 --- a/workflows/morning-brief/src/artifact-client.test.ts +++ b/workflows/morning-brief/src/artifact-client.test.ts @@ -27,7 +27,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); diff --git a/workflows/pain-point-collateral/src/artifact-client.test.ts b/workflows/pain-point-collateral/src/artifact-client.test.ts index 7760b27568..2a4088cc47 100644 --- a/workflows/pain-point-collateral/src/artifact-client.test.ts +++ b/workflows/pain-point-collateral/src/artifact-client.test.ts @@ -27,7 +27,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); diff --git a/workflows/process-granola-call/src/artifact-client.test.ts b/workflows/process-granola-call/src/artifact-client.test.ts index 7760b27568..2a4088cc47 100644 --- a/workflows/process-granola-call/src/artifact-client.test.ts +++ b/workflows/process-granola-call/src/artifact-client.test.ts @@ -27,7 +27,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); diff --git a/workflows/reddit-opportunity-scanner/src/artifact-client.test.ts b/workflows/reddit-opportunity-scanner/src/artifact-client.test.ts index 78fc48d5ab..8c3ad14af2 100644 --- a/workflows/reddit-opportunity-scanner/src/artifact-client.test.ts +++ b/workflows/reddit-opportunity-scanner/src/artifact-client.test.ts @@ -31,7 +31,9 @@ test("posts to the workflow-artifacts endpoint and returns id/version", async () ); expect(result).toEqual({ id: "art_1", version: 1 }); - expect(captured.url).toBe("https://hub.example.com/api/workflow-artifacts/"); + expect(captured.url).toBe( + "https://hub.example.com/api/workflow-artifacts/artifacts", + ); const headers = captured.init?.headers as Record; expect(headers["authorization"]).toBe("Bearer sc-token"); expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); From b9ecefd0aeaecf3aab40b0d3364f1617b1fac760 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 12:03:07 -0700 Subject: [PATCH 2/3] feat(hub)!: artifacts ride on @corbits/artifacts; drop artifacts-hub (CL-8188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump every @corbits/artifacts pin to 348c26e (v0.2.0), which now covers the whole artifacts-hub HTTP surface natively: counts, preview, and mountWorkflowArtifacts' run-scoped create/recent/get/binary routes. apps/hub mounts @corbits/artifacts' mountArtifacts directly on the tenant prefix (countSegments built from @corbits/artifact-ui's LIBRARY_KIND_SEGMENTS) and mountWorkflowArtifacts at /api/workflow-artifacts, resolving run scope through the authenticator apps/hub/src/workflow-run-tenant-auth.ts already owned. packages/artifacts-hub is deleted; the generic sidecar-token + run-address authenticator it used to own moves into that same workflow-run-tenant-auth.ts, and packages/memory-hub declares its own local structural WorkflowRunAuthenticator/ResolvedWorkflowRunScope types (matching the convention every other workflow-run-authenticated package already follows) instead of importing the retired package's concrete ones. mountWorkflowArtifacts' fixed route shape (POST/GET .../artifacts, .../recent, .../binary) puts every route one segment deeper than the old ad hoc mount, so every workflow-artifacts client (scout-agent, and each Routine's own duplicated artifact-client.ts) now calls /api/workflow-artifacts/artifacts... instead of /api/workflow-artifacts/. The tenant Library surface's wire envelope also changed (list responses key rows under "artifacts" not "data"; single-artifact reads wrap the row under "artifact"); apps/web's api.ts schemas and the Files page/upload helper/command palette follow. packages/turn-artifacts is kept: it never actually depended on artifacts-hub (only mentioned it in comments). Known issue: @corbits/artifacts@348c26e's own src (artifacts.ts, counts.ts) fails this repo's `exactOptionalPropertyTypes: true` typecheck in three places — genuine bugs in the pinned commit, surfaced because the package ships no build step / dist and resolves to raw src under bun. Needs a fix upstream in corbits-artifacts before `bun run typecheck` is fully green. --- apps/hub/package.json | 4 +- apps/hub/src/index.ts | 80 ++- apps/hub/src/workflow-run-tenant-auth.ts | 56 ++ apps/web/src/api.ts | 15 +- apps/web/src/command-palette-provider.tsx | 2 +- apps/web/src/pages/library-page.tsx | 8 +- apps/web/src/shell/library-artifacts.ts | 6 +- bun.lock | 40 +- package.json | 3 +- packages/artifacts-hub/LICENSE | 176 ----- packages/artifacts-hub/README.md | 56 -- packages/artifacts-hub/package.json | 32 - packages/artifacts-hub/src/index.ts | 28 - packages/artifacts-hub/src/routes.test.ts | 609 ------------------ packages/artifacts-hub/src/routes.ts | 572 ---------------- .../artifacts-hub/src/workflow-auth.test.ts | 91 --- packages/artifacts-hub/src/workflow-auth.ts | 76 --- .../src/workflow-routes.rate-limiter.test.ts | 67 -- .../artifacts-hub/src/workflow-routes.test.ts | 476 -------------- packages/artifacts-hub/src/workflow-routes.ts | 472 -------------- packages/artifacts-hub/tsconfig.json | 35 - packages/artifacts-hub/tsconfig.src.json | 33 - packages/scout-agent/package.json | 2 +- packages/scout-agent/src/artifact-client.ts | 4 +- packages/scout-agent/src/definition.ts | 2 +- tsconfig.build.json | 3 - .../attio-task-agent/src/artifact-client.ts | 2 +- .../src/artifact-client.ts | 2 +- .../diligence-brief/src/artifact-client.ts | 2 +- .../exa-topic-watch/src/artifact-client.ts | 2 +- workflows/granola-call/src/artifact-client.ts | 2 +- .../src/artifact-client.ts | 2 +- .../morning-brief/src/artifact-client.ts | 2 +- .../src/artifact-client.ts | 2 +- .../src/artifact-client.ts | 2 +- .../src/artifact-client.ts | 2 +- 36 files changed, 165 insertions(+), 2803 deletions(-) delete mode 100644 packages/artifacts-hub/LICENSE delete mode 100644 packages/artifacts-hub/README.md delete mode 100644 packages/artifacts-hub/package.json delete mode 100644 packages/artifacts-hub/src/index.ts delete mode 100644 packages/artifacts-hub/src/routes.test.ts delete mode 100644 packages/artifacts-hub/src/routes.ts delete mode 100644 packages/artifacts-hub/src/workflow-auth.test.ts delete mode 100644 packages/artifacts-hub/src/workflow-auth.ts delete mode 100644 packages/artifacts-hub/src/workflow-routes.rate-limiter.test.ts delete mode 100644 packages/artifacts-hub/src/workflow-routes.test.ts delete mode 100644 packages/artifacts-hub/src/workflow-routes.ts delete mode 100644 packages/artifacts-hub/tsconfig.json delete mode 100644 packages/artifacts-hub/tsconfig.src.json diff --git a/apps/hub/package.json b/apps/hub/package.json index 71104b09f8..32e3732f4d 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -16,8 +16,8 @@ "@corbits/agent-directory": "workspace:*", "@corbits/agent-runtime": "workspace:*", "@corbits/approvals": "workspace:*", - "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#dc435ba749b4378f19e5de772159c2a57e932938", - "@corbits/artifacts-hub": "workspace:*", + "@corbits/artifact-ui": "workspace:*", + "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#5309a15d4aa504f132c54b4da08bb672b4903873", "@corbits/attio-task-agent-workflow": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/code-review-workflow": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index e2ad04f682..e6ad03a07d 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -164,7 +164,17 @@ import { readProcessProvisionerConfig, type ProcessProvisionerRole, } from "@corbits/process-provisioner"; -import { createWorkflowRunAuthenticator } from "@corbits/artifacts-hub"; +import { + InlineContentStore, + mountArtifacts, + mountWorkflowArtifacts, + runArtifactMigrations, + type WorkflowArtifactEnv, +} from "@corbits/artifacts"; +import { + artifactMatchesLibraryKindSegment, + LIBRARY_KIND_SEGMENTS, +} from "@corbits/artifact-ui/kind-filter"; import { createConnectionRoutes, isInferenceProvider, @@ -223,7 +233,10 @@ import { createInFlightRequestTracker, withInFlightRequestTracking, } from "./in-flight-requests"; -import { withWorkflowRunTenantAuth } from "./workflow-run-tenant-auth"; +import { + createWorkflowRunAuthenticator, + withWorkflowRunTenantAuth, +} from "./workflow-run-tenant-auth"; // Host policy constants, not configuration. const MAX_TARBALL_BYTES = 10 * 1024 * 1024; @@ -674,6 +687,10 @@ export async function createHub(config: HubConfig) { }, ) => void; } = {}; + // Package-owned artifacts tables (CL-8188): idempotent, advisory-locked, + // safe on every boot of every replica — see @corbits/artifacts' README. + await runArtifactMigrations(db); + const artifactContentStore = InlineContentStore; const baseEventCollectors = createEventCollectorRegistry({ db: withTurnPartWriteDefaults(db), // CL-7418: deliberately no terminal-status settle here. The folded @@ -1468,18 +1485,53 @@ export async function createHub(config: HubConfig) { `${TENANT_PREFIX}/inbox`, createInboxRoutes({ db: mailboxDb, bus: mailboxBus }), ); - // CL-8160: the Workbench-composed workflow detail route - // (`@corbits/workflows`'s former `./detail/detail-route.ts`, mounted - // here at this same `/workflows/definitions` prefix as the vendored - // `createWorkflowDefinitionRoutes`) and the scheduled-workflow routes - // (`./schedule/scheduled-route.ts`: list/run-now/available-catalog) are - // both deleted per the owner ruling that workflow detail and schedule - // state derive from stock `@intx/hub-api` reads, not a hub-composed - // route. Stock's `GET /workflows/definitions` (mounted below by - // `createWorkflowDefinitionRoutes`) does not yet expose enough to - // reconstruct what these routes gave (no manifest/package metadata, no - // wire projection, no grant snapshot) — see the PR for CL-8160 for the - // upstream ask. + // Library/artifacts plane (CL-8188): `@corbits/artifacts` mounted directly + // rather than through the retired `@corbits/artifacts-hub` wrapper — the + // library now owns counts, preview, and every other Library HTTP surface + // this app previously reimplemented. `countSegments` keeps the segment + // taxonomy (what makes an artifact a "document" or a "routine") host-owned, + // per the library's README; the predicates themselves live in + // `@corbits/artifact-ui` so the web Files nav and this count walk share one + // mapping. + { + const artifactsApi = new Hono(); + mountArtifacts(artifactsApi, { + db, + contentStore: artifactContentStore, + requireGrant: createRequireGrant({ + grantStore: chatGrantStore, + conditionRegistry: grantConditionRegistry, + }), + countSegments: Object.fromEntries( + LIBRARY_KIND_SEGMENTS.map((segment) => [ + segment, + (row: { kind: string; title: string }) => + artifactMatchesLibraryKindSegment(row, segment), + ]), + ), + }); + app.route(TENANT_PREFIX, artifactsApi); + } + // Myra's own artifact tools (`@corbits/artifact-tools`, CL-6000) and every + // Routine's own `finalize-tool`/`artifact-client` duplicate: the + // workflow-run-authenticated counterpart to the tenant-session mount just + // above, at the same `/api/workflow-artifacts` prefix the sidecar's + // `hubArtifactsUrl` already points at. Every route lives under this + // mount's own `/artifacts` prefix per `@corbits/artifacts`' fixed shape + // (`POST /artifacts`, `GET /artifacts/recent`, `GET /artifacts/:id`, + // `POST /artifacts/binary`) — callers reach it at + // `/api/workflow-artifacts/artifacts...`. + { + const workflowArtifactsApi = new Hono(); + const workflowRunAuthenticator = createWorkflowRunAuthenticator({ db }); + mountWorkflowArtifacts(workflowArtifactsApi, { + db, + contentStore: artifactContentStore, + resolveRunScope: (token, runAddress) => + workflowRunAuthenticator.resolve(token, runAddress), + }); + app.route("/api/workflow-artifacts", workflowArtifactsApi); + } { const mailboxApp = new Hono(); mountMailbox(mailboxApp, { diff --git a/apps/hub/src/workflow-run-tenant-auth.ts b/apps/hub/src/workflow-run-tenant-auth.ts index bb6a8c9ff1..e9fd82bc63 100644 --- a/apps/hub/src/workflow-run-tenant-auth.ts +++ b/apps/hub/src/workflow-run-tenant-auth.ts @@ -21,8 +21,11 @@ // before. The `:tenantId` path segment is never trusted: the middleware sets // the tenant from the authenticated run's own scope, so a run cannot widen // its reach by naming another tenant in the URL. +import { eq } from "drizzle-orm"; import { Hono } from "hono"; +import { sha256 } from "@intx/crypto"; import type { DB } from "@intx/db"; +import { sidecar, workflowRun } from "@intx/db/schema"; import { createWorkflowRunDeployAuth, type AppEnv, @@ -34,6 +37,59 @@ export type WorkflowRunTenantAuthDeps = { authenticator: WorkflowRunAuthenticator; }; +/** + * The tenant + principal + run a presented sidecar token and run address + * resolve to. This is the one concrete implementation of the + * `WorkflowRunAuthenticator` shape every workflow-run-authenticated surface + * in this app takes structurally (agent-directory, chat, connections, + * memory-hub, `@corbits/artifacts`' `mountWorkflowArtifacts`, and the + * `withWorkflowRunTenantAuth` wrap below) — a single sidecar-token + + * run-address check, reused everywhere rather than re-verified per surface. + */ +export type ResolvedWorkflowRunScope = { + readonly tenantId: string; + readonly principalId: string; + readonly runId: string; +}; + +export type CreateWorkflowRunAuthenticatorDeps = { + db: DB["db"]; +}; + +export type ConcreteWorkflowRunAuthenticator = { + resolve( + token: string, + runAddress: string, + ): Promise; +}; + +export function createWorkflowRunAuthenticator( + deps: CreateWorkflowRunAuthenticatorDeps, +): ConcreteWorkflowRunAuthenticator { + return { + async resolve(token, runAddress) { + if (token === "" || runAddress === "") return null; + + const tokenHash = await sha256(token); + const sidecarRow = await deps.db.query.sidecar.findFirst({ + where: eq(sidecar.tokenHashSha256, tokenHash), + }); + if (sidecarRow === undefined) return null; + + const run = await deps.db.query.workflowRun.findFirst({ + where: eq(workflowRun.address, runAddress), + }); + if (run === undefined || run.principalId === null) return null; + + return { + tenantId: run.tenantId, + principalId: run.principalId, + runId: run.id, + }; + }, + }; +} + export function withWorkflowRunTenantAuth( nativeApp: Hono, deps: WorkflowRunTenantAuthDeps, diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index effff10a6e..98d208203c 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -49,16 +49,21 @@ export const ArtifactListItemSchema = type({ updatedAt: "string", }); export const ArtifactListPageSchema = type({ - data: ArtifactListItemSchema.array(), + artifacts: ArtifactListItemSchema.array(), nextCursor: "string | null", }); -export const ArtifactDetailSchema = ArtifactListItemSchema.merge( +export const ArtifactRowSchema = ArtifactListItemSchema.merge( type({ content: "string", }), ); +// GET /api/tenants/:id/artifacts/:artifactId wraps the row in an `artifact` +// envelope key (`@corbits/artifacts`' `mountArtifacts`, CL-8188). +export const ArtifactDetailSchema = type({ + artifact: ArtifactRowSchema, +}); export const ArtifactUploadResponseSchema = type({ - data: ArtifactDetailSchema.array(), + artifacts: ArtifactRowSchema.array(), }); // GET /api/tenants/:id/artifacts/counts — honest per-kind-segment counts @@ -79,7 +84,7 @@ export type Approval = typeof ApprovalResponse.infer; export type AssetRow = typeof AssetWithOriginResponse.infer; export type ArtifactListItem = typeof ArtifactListItemSchema.infer; export type ArtifactListPage = typeof ArtifactListPageSchema.infer; -export type ArtifactDetail = typeof ArtifactDetailSchema.infer; +export type ArtifactDetail = typeof ArtifactRowSchema.infer; export type ArtifactCounts = typeof ArtifactCountsSchema.infer; /** * The envelope paginatedSchema validates, stated structurally: the generic @@ -247,5 +252,5 @@ export async function fetchArtifactDetail( `Unexpected artifact response shape: ${parsed.summary}`, ); } - return parsed; + return parsed.artifact; } diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 93cf2544df..d82e26f594 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -427,7 +427,7 @@ export function CommandPaletteProvider({ const libraryItems = useMemo( () => artifactsQuery.kind === "ready" - ? artifactsQuery.data.data.map((artifact) => ({ + ? artifactsQuery.data.artifacts.map((artifact) => ({ id: `entity:library:${artifact.id}`, title: artifact.title, subtitle: artifactKindLabel(artifact.kind), diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx index 405ea64ad0..478d9097e8 100644 --- a/apps/web/src/pages/library-page.tsx +++ b/apps/web/src/pages/library-page.tsx @@ -733,7 +733,7 @@ export function LibraryRoute({ path }: { readonly path: string }) { // Drop selection when the filtered list no longer contains the id. useEffect(() => { if (selectedId === null || page.kind !== "ready") return; - const stillThere = mapArtifactListToSummaries(page.data.data) + const stillThere = mapArtifactListToSummaries(page.data.artifacts) .filter((row) => artifactMatchesLibraryKindSegment(row, kindSegment)) .some((row) => row.id === selectedId); if (!stillThere) setSelectedId(null); @@ -796,8 +796,8 @@ export function LibraryRoute({ path }: { readonly path: string }) { return ( {(rows) => { - const artifacts = mapArtifactListToSummaries(rows.data).filter((row) => - artifactMatchesLibraryKindSegment(row, kindSegment), + const artifacts = mapArtifactListToSummaries(rows.artifacts).filter( + (row) => artifactMatchesLibraryKindSegment(row, kindSegment), ); return ( async (_c, next) => { - await next(); -}; - -type Row = { - id: string; - kind: string; - title: string; - source: { origin: string }; - version: number; - ownerPrincipalId: string; - ownerName: string | null; - archivedAt: string | null; - createdAt: string; - updatedAt: string; - content: string; - mimeType: string; - _tenantId: string; -}; - -function sampleRow(id: string, tenantId: string, content = ""): Row { - return { - id, - kind: "file", - title: `doc-${id}.txt`, - source: { origin: "library-upload" }, - version: 1, - ownerPrincipalId: PRINCIPAL.id, - ownerName: null, - archivedAt: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - content, - mimeType: "text/plain", - _tenantId: tenantId, - }; -} - -function stripTenant(row: Row) { - const { _tenantId: _t, ...rest } = row; - return rest; -} - -function memoryStore(): ArtifactRoutesStore & { rows: Row[] } { - const rows: Row[] = []; - return { - rows, - async list(tenantId, opts) { - let data = rows - .filter((r) => r._tenantId === tenantId) - .map((r) => { - const { content: _c, ...item } = stripTenant(r); - return item; - }); - if (opts.query !== null) { - const q = opts.query.toLowerCase(); - data = data.filter((r) => r.title.toLowerCase().includes(q)); - } - return { - data: data.slice(0, opts.limit), - nextCursor: null, - }; - }, - async get(tenantId, artifactId) { - const row = rows.find( - (r) => r.id === artifactId && r._tenantId === tenantId, - ); - if (row === undefined) return null; - return stripTenant(row); - }, - async upload( - tenantId: string, - principalId: string, - files: readonly ArtifactUploadInput[], - ) { - return files.map((file, index) => { - const item = sampleRow(`up_${rows.length + index}`, tenantId); - item.title = file.filename; - item.ownerPrincipalId = principalId; - item.content = new TextDecoder().decode(file.bytes); - item.mimeType = file.mimeType; - rows.push(item); - return stripTenant(item); - }); - }, - async preview(tenantId, artifactId): Promise { - const row = rows.find( - (r) => r.id === artifactId && r._tenantId === tenantId, - ); - if (row === undefined) return { status: "not_found" }; - if (row.mimeType !== "text/html") return { status: "unsupported" }; - return { status: "ok", html: row.content }; - }, - async update(tenantId, _principalId, artifactId, content) { - const row = rows.find( - (r) => r.id === artifactId && r._tenantId === tenantId, - ); - if (row === undefined) return null; - row.content = content; - row.version += 1; - return stripTenant(row); - }, - }; -} - -function mount(store: ArtifactRoutesStore) { - const app = new Hono(); - app.use("*", async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", PRINCIPAL); - await next(); - }); - app.route( - "/artifacts", - createArtifactRoutes({ store, requireGrant: allowAll }), - ); - return app; -} - -describe("artifact routes", () => { - let store: ReturnType; - let app: ReturnType; - - beforeEach(() => { - store = memoryStore(); - app = mount(store); - }); - - test("GET / lists empty data for a tenant with no artifacts", async () => { - const res = await app.request("/artifacts"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ data: [], nextCursor: null }); - }); - - test("GET / returns only the calling tenant's rows", async () => { - store.rows.push(sampleRow("a1", TENANT.id, "mine")); - store.rows.push(sampleRow("b1", OTHER.id, "theirs")); - const res = await app.request("/artifacts"); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: { id: string }[] }; - expect(body.data.map((r) => r.id)).toEqual(["a1"]); - }); - - test("GET /?q= filters by title", async () => { - store.rows.push({ - ...sampleRow("a1", TENANT.id, "x"), - title: "Quarterly report.pdf", - }); - store.rows.push({ - ...sampleRow("a2", TENANT.id, "y"), - title: "notes.txt", - }); - const res = await app.request("/artifacts?q=report"); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: { id: string }[] }; - expect(body.data.map((r) => r.id)).toEqual(["a1"]); - }); - - test("GET /:id returns 404 for a foreign tenant row", async () => { - store.rows.push(sampleRow("b1", OTHER.id, "secret")); - const res = await app.request("/artifacts/b1"); - expect(res.status).toBe(404); - }); - - test("GET /:id returns the row for the calling tenant", async () => { - store.rows.push(sampleRow("a1", TENANT.id, "hello")); - const res = await app.request("/artifacts/a1"); - expect(res.status).toBe(200); - const body = (await res.json()) as { id: string; content: string }; - expect(body.id).toBe("a1"); - expect(body.content).toBe("hello"); - }); - - test("PUT /:id saves a new version and returns the updated row", async () => { - store.rows.push(sampleRow("a1", TENANT.id, "hello")); - const res = await app.request("/artifacts/a1", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: "edited" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { content: string; version: number }; - expect(body.content).toBe("edited"); - expect(body.version).toBe(2); - }); - - test("PUT /:id returns 404 for a foreign tenant row", async () => { - store.rows.push(sampleRow("b1", OTHER.id, "secret")); - const res = await app.request("/artifacts/b1", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: "edited" }), - }); - expect(res.status).toBe(404); - }); - - test("PUT /:id returns 400 without a string content field", async () => { - store.rows.push(sampleRow("a1", TENANT.id, "hello")); - const res = await app.request("/artifacts/a1", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - }); - - test("GET /:id/preview serves HTML with a strict sandbox CSP", async () => { - const row = sampleRow("html1", TENANT.id, "hi"); - row.mimeType = "text/html"; - store.rows.push(row); - const res = await app.request("/artifacts/html1/preview"); - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); - expect(res.headers.get("Content-Security-Policy")).toBe( - "sandbox allow-scripts; default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'unsafe-inline'", - ); - expect(res.headers.get("X-Frame-Options")).toBeNull(); - expect(await res.text()).toBe("hi"); - }); - - test("GET /:id/preview answers 415 for a non-HTML artifact", async () => { - store.rows.push(sampleRow("txt1", TENANT.id, "plain text")); - const res = await app.request("/artifacts/txt1/preview"); - expect(res.status).toBe(415); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("unsupported_media_type"); - }); - - test("GET /:id/preview answers 404 for a foreign tenant row", async () => { - const row = sampleRow("html2", OTHER.id, ""); - row.mimeType = "text/html"; - store.rows.push(row); - const res = await app.request("/artifacts/html2/preview"); - expect(res.status).toBe(404); - }); - - test("POST /upload creates artifacts from multipart files", async () => { - const form = new FormData(); - form.append( - "file", - new File(["hello library"], "hello.txt", { type: "text/plain" }), - ); - const res = await app.request("/artifacts/upload", { - method: "POST", - body: form, - }); - expect(res.status).toBe(201); - const body = (await res.json()) as { - data: { title: string; content: string }[]; - }; - expect(body.data).toHaveLength(1); - expect(body.data[0]?.title).toBe("hello.txt"); - expect(body.data[0]?.content).toBe("hello library"); - expect(store.rows).toHaveLength(1); - }); - - test("POST /upload rejects an empty multipart body", async () => { - const form = new FormData(); - form.append("note", "not a file"); - const res = await app.request("/artifacts/upload", { - method: "POST", - body: form, - }); - expect(res.status).toBe(400); - }); - - test("POST /upload rejects a file over the per-file byte limit instead of storing it empty", async () => { - const oversized = new Uint8Array(10 * 1024 * 1024 + 1); - const form = new FormData(); - form.append( - "file", - new File([oversized], "huge.txt", { type: "text/plain" }), - ); - const res = await app.request("/artifacts/upload", { - method: "POST", - body: form, - }); - expect(res.status).toBe(413); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("payload_too_large"); - // The whole point: an oversized file never reaches the store at all, so - // there is no empty/partial artifact row left behind to look uploaded. - expect(store.rows).toHaveLength(0); - }); - - test("GET /counts is all zero for a tenant with no artifacts", async () => { - const res = await app.request("/artifacts/counts"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - all: 0, - document: 0, - sheet: 0, - pdf: 0, - routine: 0, - }); - }); - - test("GET /counts buckets by kind segment and ignores other tenants", async () => { - store.rows.push({ - ...sampleRow("doc1", TENANT.id), - kind: "document", - title: "brief", - }); - store.rows.push({ - ...sampleRow("sheet1", TENANT.id), - kind: "file", - title: "budget.csv", - }); - store.rows.push({ - ...sampleRow("pdf1", TENANT.id), - kind: "pdf", - title: "contract.pdf", - }); - store.rows.push({ - ...sampleRow("routine1", TENANT.id), - kind: "routine", - title: "weekly digest", - }); - store.rows.push({ - ...sampleRow("other-tenant", OTHER.id), - kind: "document", - title: "not mine", - }); - const res = await app.request("/artifacts/counts"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - all: 4, - document: 1, - sheet: 1, - pdf: 1, - routine: 1, - }); - }); -}); - -describe("GET /counts pagination", () => { - function paginatedStore(rows: Row[], pageLimit: number): ArtifactRoutesStore { - return { - async list(tenantId, opts) { - const tenantRows = rows - .filter((r) => r._tenantId === tenantId) - .map((r) => { - const { content: _c, ...item } = stripTenant(r); - return item; - }); - const offset = - opts.cursor === null ? 0 : Number.parseInt(opts.cursor, 10); - const limit = Math.min(opts.limit, pageLimit); - const page = tenantRows.slice(offset, offset + limit); - const nextOffset = offset + page.length; - return { - data: page, - nextCursor: - nextOffset < tenantRows.length ? String(nextOffset) : null, - }; - }, - async get() { - return null; - }, - async upload() { - return []; - }, - async preview() { - return { status: "not_found" }; - }, - async update() { - return null; - }, - }; - } - - test("walks every cursor page without double-counting or dropping a row at the boundary", async () => { - const rows: Row[] = []; - const expected = { all: 0, document: 0, sheet: 0, pdf: 0, routine: 0 }; - function rowKindAndTitle(i: number): { kind: string; title: string } { - switch (i % 4) { - case 0: - return { kind: "document", title: `brief-${i}` }; - case 1: - return { kind: "pdf", title: `contract-${i}.pdf` }; - case 2: - return { kind: "routine", title: `digest-${i}` }; - default: - return { kind: "file", title: `budget-${i}.csv` }; // sheet, by extension - } - } - for (let i = 0; i < 250; i++) { - const { kind, title } = rowKindAndTitle(i); - rows.push({ ...sampleRow(`r${i}`, TENANT.id), kind, title }); - expected.all += 1; - if (kind === "document") expected.document += 1; - if (kind === "pdf") expected.pdf += 1; - if (kind === "routine") expected.routine += 1; - if (kind === "file") expected.sheet += 1; - } - // Store's own page size (25) is smaller than the route's per-page walk - // limit (100, capped by MAX_LIMIT) so a real multi-page walk exercises - // several boundaries, not just the one the route itself requests. - const app = mount(paginatedStore(rows, 25)); - - const res = await app.request("/artifacts/counts"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual(expected); - }); - - test("answers 503 instead of a partial count when the tenant list exceeds the page cap", async () => { - const neverEndingStore: ArtifactRoutesStore = { - async list(_tenantId, opts) { - const next = - opts.cursor === null - ? "1" - : String(Number.parseInt(opts.cursor, 10) + 1); - return { data: [], nextCursor: next }; - }, - async get() { - return null; - }, - async upload() { - return []; - }, - async preview() { - return { status: "not_found" }; - }, - async update() { - return null; - }, - }; - const app = mount(neverEndingStore); - - const res = await app.request("/artifacts/counts"); - expect(res.status).toBe(503); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("counts_unavailable"); - }); - - test("answers 503 instead of hanging when the store's cursor stops advancing", async () => { - const stuckCursorStore: ArtifactRoutesStore = { - async list(_tenantId, opts) { - const next = opts.cursor === null ? "A" : opts.cursor; - return { data: [], nextCursor: next }; - }, - async get() { - return null; - }, - async upload() { - return []; - }, - async preview() { - return { status: "not_found" }; - }, - async update() { - return null; - }, - }; - const app = mount(stuckCursorStore); - - const res = await app.request("/artifacts/counts"); - expect(res.status).toBe(503); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("counts_unavailable"); - }); -}); - -describe("unavailable artifact routes", () => { - test("every surface answers 503", async () => { - const app = new Hono(); - app.use("*", async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", PRINCIPAL); - await next(); - }); - app.route("/artifacts", createUnavailableArtifactRoutes(allowAll)); - - for (const path of [ - "/artifacts", - "/artifacts/upload", - "/artifacts/counts", - "/artifacts/x", - "/artifacts/x/preview", - ]) { - const method = path.endsWith("/upload") ? "POST" : "GET"; - const res = await app.request(path, { method }); - expect(res.status).toBe(503); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("unavailable"); - } - }); -}); - -describe("resolvePreviewableContent", () => { - const NOT_CALLED: ArtifactDb = undefined as unknown as ArtifactDb; - - function blobBackedRow(overrides: Partial = {}): ArtifactRow { - return { - id: "a1", - tenantId: "tenant_a", - principalId: "prin_1", - ownerPrincipalId: "prin_1", - kind: "file", - title: "SKILL.md", - content: "", - source: { origin: "library-upload", upload: { id: "up_1" } }, - version: 1, - archivedAt: null, - createdAt: new Date("2026-01-01T00:00:00.000Z"), - updatedAt: new Date("2026-01-01T00:00:00.000Z"), - ...overrides, - } as ArtifactRow; - } - - function contentStoreReturning( - blob: { filename: string; mimeType: string; bytes: Uint8Array } | null, - ): ContentStore { - return { - put: () => { - throw new Error( - "put should not be called by resolvePreviewableContent", - ); - }, - get: async () => blob, - }; - } - - test("inlines the out-of-band blob when it decodes as text", async () => { - const bytes = new TextEncoder().encode("---\nname: SKILL\n---\nbody"); - const store = contentStoreReturning({ - filename: "SKILL.md", - mimeType: "text/markdown", - bytes, - }); - const content = await resolvePreviewableContent( - NOT_CALLED, - store, - blobBackedRow(), - ); - expect(content).toBe("---\nname: SKILL\n---\nbody"); - }); - - test("leaves content empty for a non-text-decodable blob instead of decoding garbage", async () => { - const store = contentStoreReturning({ - filename: "photo.png", - mimeType: "image/png", - bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), - }); - const content = await resolvePreviewableContent( - NOT_CALLED, - store, - blobBackedRow({ title: "photo.png" }), - ); - expect(content).toBe(""); - }); - - test("never touches the content store when the row already carries inline content", async () => { - const store: ContentStore = { - put: () => { - throw new Error("put should not be called"); - }, - get: () => { - throw new Error( - "get should not be called when content is already inline", - ); - }, - }; - const content = await resolvePreviewableContent( - NOT_CALLED, - store, - blobBackedRow({ content: "already inline", source: {} }), - ); - expect(content).toBe("already inline"); - }); - - test("leaves content empty when the referenced blob cannot be found", async () => { - const store = contentStoreReturning(null); - const content = await resolvePreviewableContent( - NOT_CALLED, - store, - blobBackedRow(), - ); - expect(content).toBe(""); - }); -}); diff --git a/packages/artifacts-hub/src/routes.ts b/packages/artifacts-hub/src/routes.ts deleted file mode 100644 index e3161bea30..0000000000 --- a/packages/artifacts-hub/src/routes.ts +++ /dev/null @@ -1,572 +0,0 @@ -/** - * Tenant-scoped Library HTTP surface over the mounted `@corbits/artifacts` - * engine: list (newest-first, paginated, optional text search), get-by-id, - * multipart upload, and per-kind-segment counts for the Library nav. - * - * Authz uses the existing `asset` resource family so Library grants keep - * working without inventing a parallel vocabulary. - * - * The store is injected so tests can exercise happy/empty/cross-tenant - * without a live Postgres. - */ -import { - ARTIFACT_UPLOAD_POLICY, - createFileArtifact, - getArtifact, - listArtifacts, - resolveDownload, - serializeArtifact, - serializeArtifactListItem, - UnsupportedUploadTypeError, - writeArtifactVersion, - type ArtifactDb, - type ArtifactRow, - type ContentStore, - type SerializedArtifact, - type SerializedArtifactListItem, -} from "@corbits/artifacts"; -import { - LIBRARY_KIND_SEGMENTS, - artifactMatchesLibraryKindSegment, - type LibraryKindSegment, -} from "@corbits/artifact-ui/kind-filter"; -import { isTextDecodableMediaType } from "@corbits/artifact-ui/renderer-kind"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { Hono } from "hono"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -const DEFAULT_LIMIT = 50; -const MAX_LIMIT = 100; -const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; -const MAX_UPLOAD_FILE_COUNT = 50; -const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024; -// Safety cap on the counts walk: 200 pages of MAX_LIMIT rows each is 20,000 -// artifacts, far past any real tenant today. A tenant that legitimately -// exceeds it — or a store whose cursor stops advancing — gets an honest -// "can't count that" instead of a route that hangs or lies with a partial -// total. -const MAX_COUNT_PAGES = 200; - -/** Thrown when the counts walk cannot finish honestly — capped out or the - * store's cursor stopped advancing — rather than ever returning a partial - * count as if it were the whole tenant. */ -export class ArtifactCountsIncompleteError extends Error {} - -export type ArtifactListPage = { - readonly data: readonly SerializedArtifactListItem[]; - readonly nextCursor: string | null; -}; - -/** Per-kind-segment counts for the Library nav, plus the tenant total. */ -export type ArtifactCounts = { - readonly all: number; -} & { readonly [K in (typeof LIBRARY_KIND_SEGMENTS)[number]]: number }; - -export type ArtifactUploadInput = { - readonly filename: string; - readonly mimeType: string; - readonly bytes: Uint8Array; -}; - -/** Outcome of resolving an artifact's sandboxed HTML preview. */ -export type ArtifactPreviewResult = - | { readonly status: "ok"; readonly html: string } - | { readonly status: "not_found" } - | { readonly status: "unsupported" }; - -/** Minimal port the routes need — production wraps the engine db. */ -export type ArtifactRoutesStore = { - list( - tenantId: string, - opts: { limit: number; cursor: string | null; query: string | null }, - ): Promise; - get(tenantId: string, artifactId: string): Promise; - upload( - tenantId: string, - principalId: string, - files: readonly ArtifactUploadInput[], - ): Promise; - preview(tenantId: string, artifactId: string): Promise; - /** - * Writes a new version of a document-kind artifact's content (CL-8189): - * the artifact editor's one save path now that co-edit presence is gone. - * Returns `null` for an artifact the tenant can't see, so the route can - * answer 404 the same way `get` does. - */ - update( - tenantId: string, - principalId: string, - artifactId: string, - content: string, - ): Promise; -}; - -/** Bare MIME type, stripped of any `; charset=...` parameter. */ -function baseMimeType(mimeType: string): string { - return mimeType.split(";")[0]?.trim().toLowerCase() ?? ""; -} - -/** - * `createFileArtifact` (the ONE path a browser upload goes through) always - * leaves the artifact row's own `content` column empty — the bytes it just - * accepted live out-of-band, in `ContentStore`, referenced by - * `source.upload.id`. That is correct for the store, but a naive detail read - * of `content` alone reads back as "no content" even when the upload was a - * plain-text file (Markdown, CSV, JSON, ...) whose bytes are intact. - * - * This resolves the out-of-band blob and inlines it into `content` whenever - * it decodes as text — the same thing `/preview` already does for HTML, - * generalized to every text-decodable MIME type. A non-text blob (an image, - * a real PDF, a legacy `.docx`) is left as `""`: its bytes are still safe in - * the content store, this route only ever projects them into `content` when - * doing so is an honest text rendering. - */ -export async function resolvePreviewableContent( - db: ArtifactDb, - contentStore: ContentStore, - row: ArtifactRow, -): Promise { - if (row.content !== "") return row.content; - const download = await resolveDownload(db, contentStore, row, false); - if ("status" in download || !isTextDecodableMediaType(download.mimeType)) { - return row.content; - } - return typeof download.body === "string" - ? download.body - : new TextDecoder().decode(download.body); -} - -export type CreateArtifactRoutesDeps = { - store: ArtifactRoutesStore; - requireGrant: RequireGrant; -}; - -function parseLimit(raw: string | undefined): number { - if (raw === undefined || raw === "") return DEFAULT_LIMIT; - const n = Number.parseInt(raw, 10); - if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT; - return Math.min(n, MAX_LIMIT); -} - -function parseCursor( - raw: string | undefined, -): { at: string; id: string } | undefined { - if (raw === undefined || raw === "") return undefined; - const sep = raw.lastIndexOf("__"); - if (sep <= 0 || sep === raw.length - 2) return undefined; - const at = raw.slice(0, sep); - const id = raw.slice(sep + 2); - if (!at || !id) return undefined; - return { at, id }; -} - -function parseQuery(raw: string | undefined): string | null { - if (raw === undefined) return null; - const trimmed = raw.trim(); - if (trimmed === "") return null; - return trimmed.slice(0, 200); -} - -/** - * Walks every page of a tenant's artifacts (unfiltered, so the same row - * never gets double-counted or missed at a page boundary) and buckets each - * row by the Library kind nav's segment predicate. Real counts over the - * full tenant list, not an estimate from one page. - */ -async function countArtifactsByKindSegment( - store: Pick, - tenantId: string, -): Promise { - let all = 0; - const bySegment: Record = { - document: 0, - sheet: 0, - pdf: 0, - routine: 0, - }; - - let cursor: string | null = null; - let pages = 0; - for (;;) { - const page = await store.list(tenantId, { - limit: MAX_LIMIT, - cursor, - query: null, - }); - pages += 1; - for (const row of page.data) { - all += 1; - for (const segment of LIBRARY_KIND_SEGMENTS) { - if (artifactMatchesLibraryKindSegment(row, segment)) { - bySegment[segment] += 1; - } - } - } - if (page.nextCursor === null) break; - if (page.nextCursor === cursor) { - throw new ArtifactCountsIncompleteError( - `Artifact list cursor for tenant ${tenantId} did not advance past page ${pages}`, - ); - } - if (pages >= MAX_COUNT_PAGES) { - throw new ArtifactCountsIncompleteError( - `Artifact list for tenant ${tenantId} exceeds ${MAX_COUNT_PAGES} pages — counts would be incomplete`, - ); - } - cursor = page.nextCursor; - } - - return { - all, - document: bySegment.document, - sheet: bySegment.sheet, - pdf: bySegment.pdf, - routine: bySegment.routine, - }; -} - -/** Prefer the browser-declared type when the upload policy accepts it. */ -function resolveUploadMime(file: { name: string; type: string }): string { - if (file.type !== "" && ARTIFACT_UPLOAD_POLICY.accepts(file.type)) { - return file.type; - } - return file.type === "" ? "application/octet-stream" : file.type; -} - -/** Production store over an artifacts engine db handle + content store. */ -export function createArtifactDbStore( - db: ArtifactDb, - contentStore: ContentStore, -): ArtifactRoutesStore { - return { - async list(tenantId, opts) { - const cursor = parseCursor(opts.cursor ?? undefined); - const withCursor = - cursor !== undefined - ? { limit: opts.limit, cursor } - : { limit: opts.limit }; - const filters = - opts.query !== null ? { ...withCursor, query: opts.query } : withCursor; - const result = await listArtifacts(db, tenantId, filters); - return { - data: result.rows.map(serializeArtifactListItem), - nextCursor: result.nextCursor, - }; - }, - async get(tenantId, artifactId) { - const row = await getArtifact(db, artifactId); - if (row === null || row.tenantId !== tenantId) return null; - const content = await resolvePreviewableContent(db, contentStore, row); - return { ...serializeArtifact(row), content }; - }, - async upload(tenantId, principalId, files) { - const scope = { tenantId, principalId }; - const rows = await db.transaction(async (tx) => { - const created = []; - for (const file of files) { - created.push( - await createFileArtifact(tx, contentStore, { - scope, - ownerPrincipalId: principalId, - filename: file.filename, - mimeType: file.mimeType, - policy: ARTIFACT_UPLOAD_POLICY, - bytes: file.bytes, - origin: "library-upload", - }), - ); - } - return created; - }); - return rows.map(serializeArtifact); - }, - async preview(tenantId, artifactId) { - const row = await getArtifact(db, artifactId); - if (row === null || row.tenantId !== tenantId) - return { status: "not_found" }; - const download = await resolveDownload(db, contentStore, row, false); - if ("status" in download) { - return { - status: download.status === 404 ? "not_found" : "unsupported", - }; - } - if (baseMimeType(download.mimeType) !== "text/html") { - return { status: "unsupported" }; - } - const html = - typeof download.body === "string" - ? download.body - : new TextDecoder().decode(download.body); - return { status: "ok", html }; - }, - async update(tenantId, principalId, artifactId, content) { - const row = await getArtifact(db, artifactId); - if (row === null || row.tenantId !== tenantId) return null; - await writeArtifactVersion(db, { - scope: { tenantId, principalId }, - artifactId, - content, - }); - const written = await getArtifact(db, artifactId); - if (written === null) return null; - return { ...serializeArtifact(written), content: written.content }; - }, - }; -} - -export function createArtifactRoutes( - deps: CreateArtifactRoutesDeps, -): Hono { - const app = new Hono(); - - app.get("/", deps.requireGrant("asset:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const limit = parseLimit(c.req.query("limit")); - const cursor = c.req.query("cursor") ?? null; - const query = parseQuery(c.req.query("q") ?? undefined); - const page = await deps.store.list(tenant.id, { limit, cursor, query }); - return c.json(page); - }); - - app.post("/upload", deps.requireGrant("asset:*", "write"), async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - - let parsed: Record; - try { - parsed = (await c.req.parseBody({ all: true })) as Record< - string, - unknown - >; - } catch { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "Invalid multipart body", - }), - 400, - ); - } - - const files: File[] = []; - for (const value of Object.values(parsed)) { - for (const entry of Array.isArray(value) ? value : [value]) { - if (entry instanceof File) files.push(entry); - } - } - - if (files.length === 0) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "Expected at least one file field", - }), - 400, - ); - } - if (files.length > MAX_UPLOAD_FILE_COUNT) { - return c.json( - makeErrorEnvelope({ - code: "payload_too_large", - userMessage: `Too many files: ${files.length} exceeds the ${MAX_UPLOAD_FILE_COUNT} file limit`, - }), - 413, - ); - } - - let totalBytes = 0; - const inputs: ArtifactUploadInput[] = []; - for (const file of files) { - if (file.size > MAX_UPLOAD_BYTES) { - return c.json( - makeErrorEnvelope({ - code: "payload_too_large", - userMessage: `File "${file.name}" exceeds the ${MAX_UPLOAD_BYTES} byte limit`, - }), - 413, - ); - } - totalBytes += file.size; - if (totalBytes > MAX_UPLOAD_TOTAL_BYTES) { - return c.json( - makeErrorEnvelope({ - code: "payload_too_large", - userMessage: `Upload exceeds the ${MAX_UPLOAD_TOTAL_BYTES} byte aggregate limit`, - }), - 413, - ); - } - inputs.push({ - filename: file.name, - mimeType: resolveUploadMime(file), - bytes: new Uint8Array(await file.arrayBuffer()), - }); - } - - try { - const data = await deps.store.upload(tenant.id, principal.id, inputs); - return c.json({ data }, 201); - } catch (err) { - if (err instanceof UnsupportedUploadTypeError) { - return c.json( - makeErrorEnvelope({ - code: "unsupported_media_type", - userMessage: err.message, - }), - 415, - ); - } - throw err; - } - }); - - app.get("/counts", deps.requireGrant("asset:*", "read"), async (c) => { - const tenant = c.get("tenant"); - try { - const counts = await countArtifactsByKindSegment(deps.store, tenant.id); - return c.json(counts); - } catch (err) { - if (err instanceof ArtifactCountsIncompleteError) { - return c.json( - makeErrorEnvelope({ - code: "counts_unavailable", - userMessage: err.message, - }), - 503, - ); - } - throw err; - } - }); - - // Sandboxed HTML preview: served with a locked-down CSP so a self-contained - // page renders visually but can reach nothing on the hub's own origin. - // - `sandbox allow-scripts` (the header, mirrored by the iframe's own - // `sandbox` attribute): scripts may run, but the document sits in an - // opaque unique origin — no cookies, no storage, no same-origin fetches, - // no top-level navigation, no popups. - // - `default-src 'none'`: nothing loads unless a more specific directive - // below allows it — no network reach to the hub API or anywhere else. - // - `style-src 'unsafe-inline'`: inline `