diff --git a/e2e/mount.test.ts b/e2e/mount.test.ts index c3fab91..f4c1f1f 100644 --- a/e2e/mount.test.ts +++ b/e2e/mount.test.ts @@ -408,6 +408,23 @@ describe("GET /artifacts", () => { const db = await testDb(); const app = host(db); expect((await app.request("/artifacts?cursor=garbage")).status).toBe(400); + for (const at of [ + "2026-02-30T00:00:00.000000Z", + "2026-01-01", + "+002026-01-01T00:00:00.000000Z", + ]) { + expect( + ( + await app.request( + `/artifacts?cursor=${encodeURIComponent(`${at}__x`)}`, + ) + ).status, + ).toBe(400); + } + expect( + (await app.request("/artifacts?cursor=2026-01-01T00:00:00.000000Z__x")) + .status, + ).toBe(200); expect((await app.request("/artifacts?createdAfter=nonsense")).status).toBe( 400, ); @@ -1195,6 +1212,20 @@ describe("onArtifactCreated: the host's grant-provisioning seam", () => { }); describe("POST /artifacts/upload", () => { + test("an upload whose filename is over the title limit is 400", async () => { + const db = await testDb(); + const form = new FormData(); + form.append( + "files", + new File(["a"], `${"a".repeat(600)}.txt`, { type: "text/plain" }), + ); + const res = await host(db).request("/artifacts/upload", { + method: "POST", + body: form, + }); + expect(res.status).toBe(400); + }); + const form = (files: File[], generatedBy?: string) => { const data = new FormData(); for (const file of files) data.append("files", file); diff --git a/e2e/workflow-mount.test.ts b/e2e/workflow-mount.test.ts index 1967905..4edf9c7 100644 --- a/e2e/workflow-mount.test.ts +++ b/e2e/workflow-mount.test.ts @@ -4,7 +4,7 @@ import { type ResolvedWorkflowRunScope, } from "../src/workflow-mount.js"; import { InlineContentStore } from "../src/content-store.js"; -import { getArtifact } from "../src/artifacts.js"; +import { getArtifact, setArtifactArchived } from "../src/artifacts.js"; import { seedArtifact } from "./fixtures.js"; import { testDb } from "./helpers.js"; import type { ArtifactDb } from "../src/db.js"; @@ -80,6 +80,14 @@ describe("auth", () => { }); describe("POST /artifacts", () => { + test("an oversized title is 400", async () => { + const res = await host(await testDb()).request( + "/artifacts", + json({ title: "t".repeat(600), kind: "document", content: "hello" }), + ); + expect(res.status).toBe(400); + }); + test("creates a workflow-origin artifact scoped to the run's tenant", async () => { const db = await testDb(); const app = host(db); @@ -317,6 +325,23 @@ describe("POST /artifacts/binary", () => { }); describe("PATCH /artifacts/:id", () => { + test("revising an archived artifact is 404 and an oversized title is 400", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { + tenantId: "acme", + title: "Draft", + content: "v1", + }); + const long = patchJson({ title: "t".repeat(600) }); + expect((await app.request(`/artifacts/${row.id}`, long)).status).toBe(400); + await setArtifactArchived(db, row, true); + expect( + (await app.request(`/artifacts/${row.id}`, patchJson({ content: "v2" }))) + .status, + ).toBe(404); + }); + test("sets metadata and returns it in the response", async () => { const db = await testDb(); const app = host(db); diff --git a/src/artifacts.ts b/src/artifacts.ts index 8acbe3a..f7d5f04 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -678,11 +678,24 @@ const dateBound = (endOfDay: boolean) => return parsed; }); +// Only the shape CURSOR_TIMESTAMP_SQL emits; the round trip rejects dates +// Date.parse rolls over (Feb 30) that Postgres would refuse. +const CURSOR_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/; + +function isCursorTimestamp(at: string): boolean { + if (!CURSOR_TIMESTAMP.test(at)) return false; + const parsed = new Date(at); + return ( + !Number.isNaN(parsed.getTime()) && + parsed.toISOString().slice(0, 23) === at.slice(0, 23) + ); +} + const ListCursor = type("string").pipe((raw, ctx) => { const separatorIndex = raw.lastIndexOf("__"); const at = raw.slice(0, separatorIndex); const id = raw.slice(separatorIndex + 2); - if (separatorIndex === -1 || Number.isNaN(Date.parse(at)) || id === "") { + if (separatorIndex === -1 || !isCursorTimestamp(at) || id === "") { return ctx.error("a valid updatedAt__id cursor"); } return { at, id }; diff --git a/src/mount.ts b/src/mount.ts index 9b2b972..1b46430 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -628,6 +628,9 @@ export function createArtifactRoutes({ if (err instanceof UnsupportedUploadTypeError) { return c.json({ error: err.message }, 415); } + if (err instanceof ArtifactSizeError) { + return c.json({ error: err.message }, 400); + } throw err; } diff --git a/src/workflow-mount.ts b/src/workflow-mount.ts index 8f1fc9d..972ce2d 100644 --- a/src/workflow-mount.ts +++ b/src/workflow-mount.ts @@ -20,6 +20,7 @@ import { type } from "arktype"; import { Hono, type Context, type MiddlewareHandler } from "hono"; import { ArtifactNotFoundError, + ArtifactSizeError, createArtifact, findArtifactByTitle, getArtifact, @@ -191,6 +192,16 @@ function readFailure(c: Context, err: unknown): Response { throw err; } +/** An archived or missing artifact reads as 404, an oversized field as 400. */ +function writeFailure(c: Context, err: unknown): Response { + if (err instanceof ArtifactNotFoundError) { + return c.json({ error: "Artifact not found" }, 404); + } + if (err instanceof ArtifactSizeError) + return c.json({ error: err.message }, 400); + throw err; +} + function parseRecentLimit(raw: string | undefined): number { if (raw === undefined || raw === "") return DEFAULT_RECENT_LIMIT; const n = Number.parseInt(raw, 10); @@ -282,20 +293,27 @@ export function createWorkflowArtifactRoutes({ } const scope = c.get("workflowRunScope"); - const row = await db.transaction((tx) => - createArtifact(tx, { - scope: { tenantId: scope.tenantId, principalId: scope.principalId }, - // Workflow-authored artifacts have no human owner-member by default; - // a human only enters the picture as the approver who let the - // finalize tool call through, not as an owner. - ownerPrincipalId: null, - kind: parsed.kind, - title: parsed.title, - content: parsed.content, - source: { origin: "workflow", runId: scope.runId }, - ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}), - }), - ); + let row: Awaited>; + try { + row = await db.transaction((tx) => + createArtifact(tx, { + scope: { tenantId: scope.tenantId, principalId: scope.principalId }, + // Workflow-authored artifacts have no human owner-member by default; + // a human only enters the picture as the approver who let the + // finalize tool call through, not as an owner. + ownerPrincipalId: null, + kind: parsed.kind, + title: parsed.title, + content: parsed.content, + source: { origin: "workflow", runId: scope.runId }, + ...(parsed.metadata !== undefined + ? { metadata: parsed.metadata } + : {}), + }), + ); + } catch (err) { + return writeFailure(c, err); + } const created: CreatedWorkflowArtifact = { id: row.id, version: row.version, @@ -462,13 +480,18 @@ export function createWorkflowArtifactRoutes({ if (existing === null || existing.tenantId !== scope.tenantId) { return c.json({ error: "Artifact not found" }, 404); } - const written = await writeArtifactVersion(db, { - scope: { tenantId: scope.tenantId, principalId: scope.principalId }, - artifactId, - ...(parsed.title !== undefined ? { title: parsed.title } : {}), - ...(parsed.content !== undefined ? { content: parsed.content } : {}), - ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}), - }); + let written: Awaited>; + try { + written = await writeArtifactVersion(db, { + scope: { tenantId: scope.tenantId, principalId: scope.principalId }, + artifactId, + ...(parsed.title !== undefined ? { title: parsed.title } : {}), + ...(parsed.content !== undefined ? { content: parsed.content } : {}), + ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}), + }); + } catch (err) { + return writeFailure(c, err); + } const revised: CreatedWorkflowArtifact = { id: written.artifactId, version: written.version,