Skip to content

Commit 348c26e

Browse files
Add counts, HTML preview, and a run-scoped workflow mount (CL-8188) (#10)
Closes the Workbench gap check: GET /artifacts/counts tallies a host-supplied predicate/segment map over a full tenant walk, GET /artifacts/:id/preview serves a sandboxed text/html body with the same locked-down CSP Workbench's artifacts-hub used, and mountWorkflowArtifacts adds a parallel bearer-token-authenticated mount (create / recent / get / binary create) for workflow-run callers that have no browser session. Per-run rate limiting stays host-side.
1 parent 78a6648 commit 348c26e

11 files changed

Lines changed: 1084 additions & 1 deletion

README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,44 @@ this module mounted and the acceptance suite pointed at it. Start there if you n
8585
whole `createApp` wiring, including host middleware that sets `tenant`/`principal` and
8686
a host-owned `RequireGrant`.
8787

88+
## Run-scoped mount (workflow runs)
89+
90+
`mountArtifacts` reads `tenant`/`principal` off a `TenantEnv` context — it has no
91+
bearer-token auth surface, and never will: mixing a browser-session convention and a
92+
sidecar-token convention into one mount would make each harder to reason about. A
93+
workflow run has no browser session, so it authenticates a different way (a sidecar
94+
bearer token + a run address header). `mountWorkflowArtifacts` is the parallel mount for
95+
that caller:
96+
97+
```ts
98+
import { Hono } from "hono";
99+
import { InlineContentStore, mountWorkflowArtifacts } from "@corbits/artifacts";
100+
101+
const workflowApi = new Hono();
102+
mountWorkflowArtifacts(workflowApi, {
103+
db: hub.db,
104+
contentStore: InlineContentStore,
105+
// Host-owned: verify the sidecar's bearer token and run address however the
106+
// host issues them, and return the run's tenant/principal/run id, or null.
107+
resolveRunScope: async (bearerToken, runAddress) =>
108+
hub.resolveWorkflowRun(bearerToken, runAddress),
109+
});
110+
app.route("/workflow-artifacts", workflowApi);
111+
```
112+
113+
Routes: `POST /artifacts` (create), `GET /artifacts/recent`, `GET /artifacts/:id`
114+
(read-back — 404s a skill-draft or another tenant's row, same as `mountArtifacts`'
115+
detail route), and `POST /artifacts/binary` (base64 `contentBase64` body, for a render
116+
step that needs to persist bytes rather than text). Every route is behind
117+
`resolveRunScope`; there is no unauthenticated case here the way collection reads have
118+
one on the tenant-session mount, since a workflow run always presents credentials.
119+
120+
**Rate limiting is host-side.** `mountWorkflowArtifacts` mints no per-run quota — a host
121+
that wants one wraps `resolveRunScope` (returning `null` to reject) or puts its own
122+
middleware in front of the mounted app. `maxContentChars` (default 64,000) and
123+
`maxBinaryBytes` (default `MAX_UPLOAD_BYTES`) are the two size ceilings this package
124+
does own, since they protect the same storage `mountArtifacts` protects.
125+
88126
## The options
89127

90128
Three options have no sensible default; the rest fail closed and degrade a *feature*,
@@ -98,6 +136,7 @@ never safety.
98136
| `decorate` | no | No-op — rows carry no decoration. Display-only by contract, so it can never change what is returned or who sees it. Clients resolve display names from `ownerPrincipalId` when they need them. |
99137
| `onArtifactCreated` | no | No-op. Runs inside the same transaction as artifact creation, once per row — the seam a host uses to provision grants (e.g. a `creator`-origin grant on `artifact:<id>` for `write`/`archive`) for the row it just made. See `examples/reference-host`'s `grantOwnership` for a worked example against a real grant store. |
100138
| `uploadPolicy` | no | `ARTIFACT_UPLOAD_POLICY` — the standard document/image/spreadsheet allowlist. |
139+
| `countSegments` | no | `{}``GET /api/artifacts/counts` answers just `{ all }`. See [Counts](#counts). |
101140

102141
Who the request runs as is **not** an option: the host's auth/tenant middleware puts
103142
`tenant` and `principal` on the `TenantEnv` context, and this package reads them. No
@@ -120,6 +159,8 @@ happen in major versions.
120159
| `GET`/`POST /api/artifacts/:id/versions` | Version history (paginated, no content bodies) and revision |
121160
| `POST /api/artifacts/:id/(un)archive` | Idempotent soft-hide |
122161
| `GET /api/artifacts/:id/download` | One path over three storage conventions |
162+
| `GET /api/artifacts/counts` | Per-segment counts over the tenant. See [Counts](#counts) |
163+
| `GET /api/artifacts/:id/preview` | Sandboxed `text/html`-only preview. See [Preview](#preview) |
123164
| `…/api/instances/:id/mail-attachments` | Artifact↔message associations |
124165

125166
Every route carries `describeRoute`, so it appears in the host's `/openapi.json`.
@@ -232,6 +273,55 @@ suite. The contract owed to a parsing host is **parse before you store** — the
232273
failure leaves no orphan artifact and no orphan bytes, and a success writes bytes, row
233274
and version 1 in one transaction.
234275

276+
## Counts
277+
278+
`GET /api/artifacts/counts` answers `{ all, ...perSegmentCounts }` — a real walk over
279+
every page of the tenant's (non-archived) artifacts, bucketed by whichever predicates
280+
the host passes as `countSegments`. This package owns none of the segment taxonomy
281+
(what makes an artifact a "sheet" or a "routine" is entirely product-specific); it only
282+
walks the rows once and tallies:
283+
284+
```ts
285+
mountArtifacts(app, {
286+
db,
287+
contentStore,
288+
requireGrant,
289+
countSegments: {
290+
document: (row) => row.kind === "document",
291+
sheet: (row) => row.kind === "sheet",
292+
routine: (row) => row.kind === "routine",
293+
},
294+
});
295+
// GET /api/artifacts/counts -> { all: 42, document: 30, sheet: 10, routine: 2 }
296+
```
297+
298+
Omit `countSegments` and the route still answers with just the tenant-wide `all`. The
299+
walk caps at 200 pages (`MAX_COUNT_PAGES`, exported from the package) and throws
300+
`ArtifactCountsIncompleteError` — mapped to HTTP `503` — rather than ever returning a
301+
partial count as if it were the whole tenant. `countArtifactsBySegments` (also exported)
302+
is the underlying function, usable directly outside the mounted route.
303+
304+
## Preview
305+
306+
`GET /api/artifacts/:id/preview` serves an artifact's body as a **sandboxed HTML
307+
document** — for a self-contained page an artifact stores (a rendered report, a chart
308+
export) that a host wants to render visually without giving it any reach into the host's
309+
own origin. Only an artifact whose resolved content type is exactly `text/html` is
310+
previewable; anything else answers `415`. The response carries:
311+
312+
```
313+
Content-Security-Policy: sandbox allow-scripts; default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'unsafe-inline'
314+
X-Content-Type-Options: nosniff
315+
```
316+
317+
`sandbox allow-scripts` puts the document in an opaque unique origin — scripts may run,
318+
but there is no cookie/storage access, no same-origin fetch, no top-level navigation, no
319+
popups. `default-src 'none'` blocks any further network reach; `style-src`/`img-src
320+
data:`/`script-src 'unsafe-inline'` are exactly what a single-file page needs and no
321+
more. `X-Frame-Options` is deliberately never set, so the host's own canvas/iframe can
322+
still embed it. `resolveArtifactPreview` and `artifactPreviewHeaders` are exported
323+
directly for a host that wants to serve the same preview from its own route.
324+
235325
## ContentStore
236326

237327
Where file bytes live is a port. Two impls ship and both pass the same suite:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@corbits/artifacts",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"type": "module",
55
"license": "LGPL-2.1-only",
66
"description": "Artifacts, versions and uploads with a pluggable ContentStore, mountable onto any Interchange host. Requires @intx 0.2.2 or newer.",

src/counts.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
ArtifactCountsIncompleteError,
4+
countArtifactsBySegments,
5+
MAX_COUNT_PAGES,
6+
} from "./counts.js";
7+
import { setArtifactArchived } from "./artifacts.js";
8+
import { seedArtifact, testDb } from "./test-helpers.js";
9+
import type { ArtifactDb } from "./db.js";
10+
import { listArtifacts } from "./artifacts.js";
11+
12+
describe("countArtifactsBySegments", () => {
13+
test("returns just `all` when no segments are supplied", async () => {
14+
const db = await testDb();
15+
await seedArtifact(db, { kind: "document" });
16+
await seedArtifact(db, { kind: "document" });
17+
18+
const counts = await countArtifactsBySegments(db, "acme", {});
19+
expect(counts).toEqual({ all: 2 });
20+
});
21+
22+
test("tallies each row against every predicate", async () => {
23+
const db = await testDb();
24+
await seedArtifact(db, { kind: "document" });
25+
await seedArtifact(db, { kind: "document" });
26+
await seedArtifact(db, { kind: "sheet" });
27+
28+
const counts = await countArtifactsBySegments(db, "acme", {
29+
document: (row) => row.kind === "document",
30+
sheet: (row) => row.kind === "sheet",
31+
routine: (row) => row.kind === "routine",
32+
});
33+
expect(counts).toEqual({ all: 3, document: 2, sheet: 1, routine: 0 });
34+
});
35+
36+
// The walk pages at 100 rows/page — seed past that boundary so a passing
37+
// test proves the cursor loop, not just a single-page happy path.
38+
test("walks past a single page", async () => {
39+
const db = await testDb();
40+
await Promise.all(
41+
Array.from({ length: 120 }, (_, i) =>
42+
seedArtifact(db, { kind: "document", title: `doc-${i}` }),
43+
),
44+
);
45+
46+
const counts = await countArtifactsBySegments(db, "acme", {
47+
document: (row) => row.kind === "document",
48+
});
49+
expect(counts).toEqual({ all: 120, document: 120 });
50+
});
51+
52+
test("never counts another tenant's artifacts", async () => {
53+
const db = await testDb();
54+
await seedArtifact(db, { tenantId: "acme" });
55+
await seedArtifact(db, { tenantId: "other" });
56+
57+
const counts = await countArtifactsBySegments(db, "acme", {});
58+
expect(counts).toEqual({ all: 1 });
59+
});
60+
61+
test("excludes archived artifacts, matching GET /artifacts' default view", async () => {
62+
const db = await testDb();
63+
const row = await seedArtifact(db, { kind: "document" });
64+
await seedArtifact(db, { kind: "document" });
65+
await setArtifactArchived(db, row, true);
66+
67+
const counts = await countArtifactsBySegments(db, "acme", {});
68+
expect(counts).toEqual({ all: 1 });
69+
});
70+
71+
});
72+
73+
// `ArtifactCountsIncompleteError` and the page cap it backstops are exercised
74+
// end to end at the route level (mount.test.ts), where the honest-503
75+
// contract lives; the walk's cursor plumbing itself is real Postgres
76+
// pagination, not something worth faking a stalled cursor for here.
77+
test("MAX_COUNT_PAGES is the documented cap", () => {
78+
expect(MAX_COUNT_PAGES).toBe(200);
79+
expect(ArtifactCountsIncompleteError.prototype).toBeInstanceOf(Error);
80+
});
81+
82+
// Sanity: countArtifactsBySegments' walk must agree with what GET /artifacts
83+
// itself would page through — same underlying listArtifacts call, same
84+
// exclusions.
85+
test("agrees with listArtifacts' own row set", async () => {
86+
const db = await testDb();
87+
await seedArtifact(db, { kind: "document" });
88+
await seedArtifact(db, { kind: "sheet" });
89+
90+
const [counts, page] = await Promise.all([
91+
countArtifactsBySegments(db, "acme", {}),
92+
listArtifacts(db, "acme", {}),
93+
]);
94+
expect(counts.all).toBe(page.rows.length);
95+
});

src/counts.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { ArtifactDb } from "./db.js";
2+
import { listArtifacts, type ArtifactListRow } from "./artifacts.js";
3+
4+
// Safety cap on the counts walk: 200 pages of MAX_LIST_LIMIT rows each is
5+
// tens of thousands of artifacts, far past any real tenant today. A tenant
6+
// that legitimately exceeds it — or a store whose cursor stops advancing —
7+
// gets an honest "can't count that" instead of a route that hangs or lies
8+
// with a partial total.
9+
export const MAX_COUNT_PAGES = 200;
10+
11+
/** One page's worth of rows per walk step, matching `listArtifacts`' own cap. */
12+
const COUNT_PAGE_LIMIT = 100;
13+
14+
/** Thrown when the counts walk cannot finish honestly — capped out or the
15+
* underlying cursor stopped advancing — rather than ever returning a partial
16+
* count as if it were the whole tenant. */
17+
export class ArtifactCountsIncompleteError extends Error {}
18+
19+
/** A host-supplied predicate bucketing rows into a named segment. The
20+
* segment taxonomy (what a "sheet" or "routine" artifact is) is entirely
21+
* host-owned — this module only walks the tenant's artifacts once and tallies
22+
* whichever predicates the host hands it. */
23+
export type ArtifactCountSegments = Readonly<
24+
Record<string, (row: ArtifactListRow) => boolean>
25+
>;
26+
27+
/** Per-segment counts plus the tenant total. Always includes `all`, plus one
28+
* key per segment name passed to `countArtifactsBySegments`. */
29+
export type ArtifactCounts = { readonly all: number } & Readonly<
30+
Record<string, number>
31+
>;
32+
33+
/**
34+
* Walks every page of a tenant's (non-archived) artifacts — the same rows
35+
* `GET /artifacts` would page through, so a row is never double-counted or
36+
* missed at a page boundary — and buckets each row by every predicate in
37+
* `segments`. Real counts over the full tenant list, not an estimate from one
38+
* page.
39+
*/
40+
export async function countArtifactsBySegments(
41+
db: ArtifactDb,
42+
tenantId: string,
43+
segments: ArtifactCountSegments,
44+
): Promise<ArtifactCounts> {
45+
let all = 0;
46+
const bySegment: Record<string, number> = Object.fromEntries(
47+
Object.keys(segments).map((name) => [name, 0]),
48+
);
49+
50+
let cursor: string | undefined;
51+
let pages = 0;
52+
for (;;) {
53+
const page = await listArtifacts(db, tenantId, {
54+
limit: COUNT_PAGE_LIMIT,
55+
...(cursor !== undefined ? { cursor: parseCursor(cursor) } : {}),
56+
});
57+
pages += 1;
58+
for (const row of page.rows) {
59+
all += 1;
60+
for (const [name, predicate] of Object.entries(segments)) {
61+
if (predicate(row)) bySegment[name] += 1;
62+
}
63+
}
64+
if (page.nextCursor === null) break;
65+
if (page.nextCursor === cursor) {
66+
throw new ArtifactCountsIncompleteError(
67+
`Artifact list cursor for tenant ${tenantId} did not advance past page ${pages}`,
68+
);
69+
}
70+
if (pages >= MAX_COUNT_PAGES) {
71+
throw new ArtifactCountsIncompleteError(
72+
`Artifact list for tenant ${tenantId} exceeds ${MAX_COUNT_PAGES} pages — counts would be incomplete`,
73+
);
74+
}
75+
cursor = page.nextCursor;
76+
}
77+
78+
return { all, ...bySegment };
79+
}
80+
81+
/** `listArtifacts`' cursor filter wants `{ at, id }`, not the opaque
82+
* `at__id` string it hands back — mirror its own encoding here. */
83+
function parseCursor(raw: string): { at: string; id: string } {
84+
const sep = raw.lastIndexOf("__");
85+
return { at: raw.slice(0, sep), id: raw.slice(sep + 2) };
86+
}

src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22
export { mountArtifacts } from "./mount.js";
33
export type { MountArtifactsOpts } from "./mount.js";
44

5+
export { mountWorkflowArtifacts } from "./workflow-mount.js";
6+
export type {
7+
CreatedWorkflowArtifact,
8+
MountWorkflowArtifactsOpts,
9+
ResolvedWorkflowRunScope,
10+
WorkflowArtifactEnv,
11+
WorkflowRunResolver,
12+
} from "./workflow-mount.js";
13+
14+
export { ArtifactCountsIncompleteError, countArtifactsBySegments, MAX_COUNT_PAGES } from "./counts.js";
15+
export type { ArtifactCounts, ArtifactCountSegments } from "./counts.js";
16+
17+
export { artifactPreviewHeaders, resolveArtifactPreview } from "./preview.js";
18+
export type { ArtifactPreviewResult } from "./preview.js";
19+
520
export { runArtifactMigrations, MigrationChecksumError, MigrationAdoptError } from "./migrations.js";
621
export type { RunArtifactMigrationsOptions } from "./migrations.js";
722

0 commit comments

Comments
 (0)