Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions e2e/mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 26 additions & 1 deletion e2e/workflow-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 14 additions & 1 deletion src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
3 changes: 3 additions & 0 deletions src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
65 changes: 44 additions & 21 deletions src/workflow-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { type } from "arktype";
import { Hono, type Context, type MiddlewareHandler } from "hono";
import {
ArtifactNotFoundError,
ArtifactSizeError,
createArtifact,
findArtifactByTitle,
getArtifact,
Expand Down Expand Up @@ -191,6 +192,16 @@ function readFailure(c: Context<WorkflowArtifactEnv>, err: unknown): Response {
throw err;
}

/** An archived or missing artifact reads as 404, an oversized field as 400. */
function writeFailure(c: Context<WorkflowArtifactEnv>, 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);
Expand Down Expand Up @@ -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<ReturnType<typeof createArtifact>>;
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,
Expand Down Expand Up @@ -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<ReturnType<typeof writeArtifactVersion>>;
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,
Expand Down
Loading