diff --git a/README.md b/README.md index d7a4e26..acc436f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ await close(); With no principal, every route except the list and counts answers `403`. Another tenant's artifact, or an unknown id, answers `404`. Set a request-body limit on the host for the upload and file-revise routes; they buffer the body before checking `MAX_UPLOAD_BYTES`. +Every write reports `contentSha256`, the hex sha256 stored with the new version: over the UTF-8 text, or over the file's bytes for an upload. `POST /artifacts` and `POST /artifacts/upload` carry it on each artifact, and `POST /artifacts/:id/versions` returns it beside `artifactId` and `version`. It is `null` only for a version written before digests existed. + ### `createWorkflowArtifactRoutes(deps)` Run-scoped routes for a workflow run, which authenticates with a bearer token and an `x-workflow-run-address` header instead of a session. @@ -102,6 +104,8 @@ Run-scoped routes for a workflow run, which authenticates with a bearer token an | `maxBinaryBytes` | `number` | Optional. Byte ceiling for `POST /artifacts/binary`. Defaults to `MAX_UPLOAD_BYTES`. | | `maxContentChars` | `number` | Optional. Character ceiling on the resulting content — full text or edited text — for `POST /artifacts` and `PATCH /artifacts/:id/versions`. Defaults to 64,000. | +Every write (`POST /artifacts`, `POST /artifacts/binary`, `POST /artifacts/link-file`, `PATCH /artifacts/:id`) answers `{ data: { id, version, contentSha256 } }`, with the same digest the tenant routes report. For `link-file` the digest covers the stored `preview` text (empty when none is given), not the workspace file at `path`. + ### `runArtifactMigrations(config, { schema })` Takes the same `DBConfig` and `schema` as Interchange's `runMigrations`. `schema` holds the host's `tenant` and `principal` tables; this package's tables always go in `artifacts`. @@ -110,7 +114,7 @@ Takes the same `DBConfig` and `schema` as Interchange's `runMigrations`. `schema `artifacts` is an `@intx/agent` tool pack of three tools. They call the run-scoped routes through the agent's `hub` credential. -- `artifact_write` creates an artifact when no id is given, from content or a workspace file path. With an id it revises, either with the full text or with `edits`: exact passages, each of which must appear once, or nothing is written. `expectedVersion` refuses the write if a newer version exists. +- `artifact_write` creates an artifact when no id is given, from content or a workspace file path. With an id it revises, either with the full text or with `edits`: exact passages, each of which must appear once, or nothing is written. `expectedVersion` refuses the write if a newer version exists. It returns `{ id, version, contentSha256 }`. - `artifact_read` returns the content, a past version with `version`, or a range with `offset` and `limit`. - `artifact_search` lists the newest artifacts, filtered by `kind` and by a `query` matched against title and content. diff --git a/e2e/agent-token-mount.test.ts b/e2e/agent-token-mount.test.ts index d0641e5..d958250 100644 --- a/e2e/agent-token-mount.test.ts +++ b/e2e/agent-token-mount.test.ts @@ -4,6 +4,7 @@ import { type AgentTokenAuth, type ResolvedWorkflowRunScope, } from "../src/workflow-mount.js"; +import { sha256Hex } from "../src/artifacts.js"; import { InlineContentStore } from "../src/content-store.js"; import { seedArtifact } from "./fixtures.js"; import { testDb } from "./helpers.js"; @@ -134,9 +135,11 @@ describe("the routes the artifact tools call", () => { }); expect(revised.status).toBe(200); expect( - (await revised.json()) as { data: { id: string; version: number } }, + (await revised.json()) as { + data: { id: string; version: number; contentSha256: string | null }; + }, ).toEqual({ - data: { id: artifact.id, version: 2 }, + data: { id: artifact.id, version: 2, contentSha256: sha256Hex("second") }, }); const read = await app.request(`/artifacts/${artifact.id}/read`, { diff --git a/e2e/mount.test.ts b/e2e/mount.test.ts index 2392542..3e69dbd 100644 --- a/e2e/mount.test.ts +++ b/e2e/mount.test.ts @@ -185,6 +185,7 @@ describe("POST /artifacts", () => { expect(body.artifact).toMatchObject({ kind: "document", source: { origin: "manual" }, + contentSha256: sha256Hex("body"), }); }); @@ -722,6 +723,7 @@ describe("versions", () => { expect(await res.json()).toMatchObject({ version: 2, metadata: { kind: "run", supersededByNodeId: null }, + contentSha256: sha256Hex("v2"), }); }); diff --git a/e2e/workflow-mount.test.ts b/e2e/workflow-mount.test.ts index 17e74d1..cbd250b 100644 --- a/e2e/workflow-mount.test.ts +++ b/e2e/workflow-mount.test.ts @@ -5,7 +5,11 @@ import { type ResolvedWorkflowRunScope, } from "../src/workflow-mount.js"; import { InlineContentStore } from "../src/content-store.js"; -import { getArtifact, setArtifactArchived } from "../src/artifacts.js"; +import { + getArtifact, + setArtifactArchived, + sha256Hex, +} from "../src/artifacts.js"; import { seedArtifact } from "./fixtures.js"; import { testDb } from "./helpers.js"; import type { ArtifactDb } from "../src/db.js"; @@ -104,9 +108,10 @@ describe("POST /artifacts", () => { ); expect(res.status).toBe(201); const { data } = (await res.json()) as { - data: { id: string; version: number }; + data: { id: string; version: number; contentSha256: string | null }; }; expect(data.version).toBe(1); + expect(data.contentSha256).toBe(sha256Hex("hello")); const row = await getArtifact(db, data.id); expect(row?.tenantId).toBe("acme"); @@ -409,7 +414,10 @@ describe("POST /artifacts/binary", () => { json({ filename: "brief.html", mimeType: "text/html", contentBase64 }), ); expect(res.status).toBe(201); - const { data } = (await res.json()) as { data: { id: string } }; + const { data } = (await res.json()) as { + data: { id: string; contentSha256: string | null }; + }; + expect(data.contentSha256).toBe(sha256Hex("