Skip to content

Commit 8e02859

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(rest): /meta/:type/:name/audit is an authoring door, refused as /history and /diff refuse (#20441) (#20472)
Fixes #20441 Clause-②: no `GET /api/v1/meta/:type/:name/audit` is now an authoring door. A caller that `mayReadPendingDrafts` does not admit is refused as `/history`, `/diff` and `GET /api/v1/meta/_drafts` refuse: `403`, code `FORBIDDEN`, in the same nested `error` envelope. The decision is made on the caller before the protocol is resolved, before the query is parsed and before any event is read. This executes triage's grade `5871509797` on the card, which carries ruling `5865708652` (letter B, the maintainer's 「同意」 through the director seat, on #20378) to this door. ## Reach, measured before any edit (H0) On `main` at `acd009521e`, on the real-stack harness of `meta-history-diff-authoring-door.test.ts` (better-sqlite3 in memory, the real `sys_metadata*` objects, a real `ObjectStackProtocolImplementation`, the real routes; the stubs are `resolveExecCtx` and the `tenancy` probe). A system caller published `app/atlas` and `view/opportunity.pipeline`; an author (`manage_metadata`) then saved a draft of each, and a draft of the never-published `app/beacon` and `view/opportunity.forecast`. A member with no authoring capability (`/meta/_drafts` answers them `403`) then read: | member reads | plain read | `/audit` | |:--|:--|:--| | `app/atlas` | `200` | `200`, two events; one is `note: "draft"`, `actor: "u_author"`, with its time | | `view/opportunity.pipeline` | `200` | `200`, the same shape | | `app/beacon` (draft only) | `404` | `200`, one event, `note: "draft"`, `actor: "u_author"` | | `view/opportunity.forecast` (draft only) | `404` | `200`, the same | | `app/nowhere` (missing) | `404` | `200 { "events": [] }` | So the card's premise holds, and the reading is one step wider than the card: for an item with nothing published, `/audit` also told a member that it exists (one event against `{ "events": [] }` for a missing name), where the plain read answers `404` (ADR-0045 §3). Both are closed by the same guard. ## No member-facing consumer (H1, the ruling's stop valve) - **objectui at the pin `dd3f7e1be3`:** the one caller is `AuditPanel` (`client.audit(type, name)`). It is mounted only in `MetadataResourceEditPage`, the metadata designer on the `metadata/:type/:name` routes, as the audit sheet beside the history sheet `#20440` already gated. That is an authoring surface. - **cloud at `origin/main` `3efda046`:** zero callers. `git grep` for `auditMetaItem`, `getAudit`, `/audit` and `.audit(` exits `1`. The control query of the same shape (`/meta/`, `historyMetaItem`, `/history`) hits. - **SDK:** `client.meta.getAudit` (`packages/client`) has no in-repo caller outside its own tests. `packages/client-react` has none (`git grep` exits `1`). There is one docs example. - The runtime dispatcher's `/meta` domain serves no `/audit` (its three-segment branch answers `/published` only), so this handler is the one owner. ## What changed - **`packages/rest/src/rest-server.ts`: one shared refusal.** A module function `refuseNonAuthoringCaller(caller, res, reading)` sits beside `mayReadPendingDrafts`. It asks that predicate. If the predicate refuses, it sends the `403 FORBIDDEN` nested envelope and answers `true`, following the `refuseRepeatedQueryParams` convention. `#20440` wrote this guard inline at the head of `/history` and `/diff`. Both heads now call the helper with their own door names, so their answers are byte-identical to before. `/audit` calls it at its head too. Three inline copies of one refusal would be three places to drift, so the three doors share one function. Only the door's own name differs: "Reading a metadata item's audit trail" here. - **The `/audit` handler.** It resolves its caller once at the head (`auditCtx`). The organization scope further down reads that same value instead of a second resolution. Admitted callers read exactly what they read before: the `#20156` per-caller refusal (`eventDoorRefusal`), the `#9426` `501`, the `#20139` `limit` parse and the `#8747` organization scope are unchanged. The `#8747` comment that said the route "carries no capability gate" now says that was true then, and that the scope still does the tenant separation for the builders the gate admits. - **Tests.** - `meta-history-diff-authoring-door.test.ts` (real stack). The existing authoring-door file now runs every pin over `/diff`, `/history` and `/audit`, and its drafts are saved by the `author` caller, so the actor a refusal must never carry is a real one: - For `app` and `view`, the member gets `403 FORBIDDEN`. The envelope keys equal those of the member's own `/meta/_drafts` answer. - The published, draft-only and missing names answer byte-identically. - The answer carries no event key, `note`, `actor` or `occurredAt`. `auditMetaItem` joins the spies that stay uncalled, and a builder's call on the same door proves the spy is live. - An unparseable `limit` answers the member the same refusal, while a builder gets `400`, so the member's refusal is decided before the parse. - Every builder (`studio.access`, `setup.access`, `manage_metadata`) reads both saves of `app/atlas` and `view/opportunity.pipeline`: `save:allowed:active`, and `save:allowed:draft` by `u_author`. Each builder also reads the draft-only items' `draft` event. - The one-predicate pin covers all four doors. - `meta-alternate-door-read-gates.test.ts` (the `#20156` census). The `/audit` row gains `authoring: true`. Each refused census cell also asserts that `auditMetaItem` was never called. - `execctx-consumer-census.test.ts`. With the umbrella isolated, `/audit` now refuses an absent context at its own gate, so it moves from the serving list to the refusing list, as `/meta/_drafts` sits there. The case's title stated the serving list's length wrongly before this change ("six", for a list of five). It now says "four", its length after the move. The caller resolution keeps its `.catch` on the invocation line and adds no prose mention, so the census's 66 sites, 90 mentions and 13 same-line catches do not move. - `meta-audit-capability-gap.test.ts` and `rest-server-audit-org-scope.test.ts` ask what an admitted caller gets (the `501`, and the organization of the read), so they now call as a `manage_metadata` holder. Their comments that said the route has no capability gate are corrected. - **Docs.** In `content/docs/ui/apps.mdx`, the sentence that names the doors needing the capability outright now names `/audit` beside `/diff` and `/history`. In `content/docs/api/client-sdk.mdx`, one comment beside the `client.meta.getAudit` example states the authoring-only rule, as the line beside `diffItem` does. - **Changeset:** `@objectstack/rest` `patch`, `Clause-②: no`. It pulls the declared contract (ADR-0106 D4, 「draft/preview reads are admin-gated upstream」) back in, as the ruling graded the sibling doors. ## Verification All of the following ran at head `dc5963dc3a` (the branch after merging `origin/main` `e956924e17`) unless a line says otherwise. - Build: `pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build` exited 0. `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2` exited 0, `71 successful, 71 total`; the whole-tree gates need it. - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: `216 passed (216)` files, `3916 passed | 26 skipped (3942)` tests. - `pnpm --filter @objectstack/rest exec vitest run --project repo --maxWorkers=2`: 1 file, `8 passed (8)`. - `pnpm --filter @objectstack/rest typecheck` exited 0: `check:test-typecheck: OK`, 0 files in the debt ledger. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 91 commands; all 91 ran and each exited 0. `--ran` with the exit codes recorded: `91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero). - `pnpm lint` (the whole repository) exited 0 in 29s. - `node scripts/check-issue-citations.mjs --base origin/main` exited 0: 8 citations, all resolve. **Ablation (H3), at `8ca554d06c`, before the merge; the merge touched no file here.** The tests import `./rest-server.js` relatively, so no `dist` sits between the mutation and the run. Through `scripts/ablation-replace.mjs`, inside a script with its own `EXIT INT TERM` restore trap, the `/audit` head's `if (refuseNonAuthoringCaller(auditCtx, res,` became `if (false && refuseNonAuthoringCaller(auditCtx, res,`. The anchor went from 1 to 0 and the marker from 0 to 1, read on disk inside the mutation. The six related files then ran: | red | green | |:--|:--| | 14 of 356, every one an `/audit` refusal pin: the 9 census cells for the caller who may not read drafts; the member pins for `app` and `view`; the `limit` pin; the one-predicate pin; and the execctx census's isolated-umbrella case | every builder pin; every `/diff` and `/history` pin; both `/layers` controls; the capability-gap and org-scope files; the draft-door census | The restore was proven: the blob is `0ab6c5c1edbb`, equal to `HEAD`, and `git diff HEAD` is empty (0 bytes), with a clean `git status`. ## Acceptance notes - **The refusal message** is the one byte-level difference from `/meta/_drafts`, as on the sibling doors. It names the door ("audit trail"), never drafts. It is not pinned, since no consumer parses it. - **The helper reaches past the claim's surface.** The claim named "the `/audit` handler only". Sharing one refusal meant replacing the inline guard at the head of `/history` and `/diff` with a call that sends the same bytes, which is partition 3 of the dispatch. The `#20378` pins for those two doors are unchanged and green, and the ablation shows none of them depends on the `/audit` guard. - **Who loses the door.** `/audit` also lists denied and forced attempts, not only draft saves. A caller without an authoring capability now reads none of them. Triage's grade makes this choice over a member log with only the draft rows removed, because such a log reads as true and complete. A `manage_org_presentation` holder, who may save org-scoped views, is refused `/audit`, as they already are `/history`, `/diff` and `/meta/_drafts`. - **objectui.** A caller without an authoring capability who opens the metadata designer's audit sheet now gets the `403` there, as the history sheet has answered them since `#20440`. No objectui change is needed; `AuditPanel` renders load errors. - **The sibling card is separate.** The mislabelled default `/diff` range (#20397) is not addressed here. ## Declared narrowing: the verify lock `scripts/pm/os-verify-lock.sh` printed this for every build, test, lint and ablation run above (this host is macOS): **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm lint It printed the same disclosure for each of the other wrapped commands: the two builds, the two `rest` test projects, the typecheck, the targeted runs and the ablation. The 91 derived gates ran directly, as the lock covers only builds and tests. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e8b492 commit 8e02859

9 files changed

Lines changed: 217 additions & 77 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
**`GET /api/v1/meta/:type/:name/audit` is now an authoring door: a caller without an authoring capability is refused, as `/diff`, `/history` and `GET /api/v1/meta/_drafts` refuse.** Before this release, any signed-in caller who could open an item could read its protection-audit trail. Every save appends a row to that trail, a draft save included, and the row carries `note: "draft"`, the actor and the time. So a member could learn that an item had unpublished authoring work, who saved it and when. For an item that had never been published, where the plain read answers `404`, the member could learn that it existed at all. This carries the maintainer's ruling on #20378 (letter B, comment 5865708652) to this door, as triage graded on #20441: draft and preview reads are admin-gated upstream (ADR-0106 D4), and the audit trail, like the version log, has no published-only answer to fall back to.
6+
7+
Clause-②: no
8+
9+
- **Who may read it:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts`, `/diff`, `/history` and every draft switch already ask, not a second rule.
10+
- **Everyone else:** `403` with code `FORBIDDEN`, in the same nested `error` envelope `/meta/_drafts` answers. The refusal is decided on the caller before the protocol is resolved, before the query is parsed and before any event is read. So it is the same answer for an item that exists, one that does not, and one that exists only as a draft, and it carries no event, actor or item name. The message names the door, not drafts.
11+
- **Unchanged:** callers with an authoring capability read the trail exactly as before, including the per-caller refusal of an item the plain read refuses them and the organization scope of the read.
12+
13+
A client that read `/audit` (`client.meta.getAudit`) as a member now receives `403 FORBIDDEN`. To read it, call as a caller holding one of the three capabilities above.

‎content/docs/api/client-sdk.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 })
233233
// Introspection & governance
234234
const bad = await client.meta.getDiagnostics({ severity: 'error' });
235235
const refs = await client.meta.getReferences('object', 'account');
236+
// Authoring-only too, like diffItem: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN
236237
const trail = await client.meta.getAudit('object', 'account', { limit: 20 });
237238
const tree = await client.meta.getBookTree('handbook');
238239

‎content/docs/ui/apps.mdx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -379,12 +379,13 @@ an app an author may not open is refused on these doors too. Reading a
379379
needs an authoring capability (`studio.access`, `setup.access` or
380380
`manage_metadata`: the check `GET /api/v1/meta/_drafts` makes). A caller
381381
without one is answered as if the parameter were absent: the published app,
382-
or `404` for an app that has never been published. `/diff`, and the change log
383-
`/history` beside it, need that capability outright: both read the version log,
384-
which records a draft save like any other, so they have no published-only
385-
answer to fall back to. A caller without one is refused with `403`, as
386-
`GET /api/v1/meta/_drafts` refuses, before anything is read — the same answer
387-
whether or not the app exists.
382+
or `404` for an app that has never been published. `/diff`, the change log
383+
`/history` beside it and the protection-audit trail `/audit` need that
384+
capability outright: the version log and the audit trail each record a draft
385+
save like any other, so none of them has a published-only answer to fall back
386+
to. A caller without one is refused with `403`, as `GET /api/v1/meta/_drafts`
387+
refuses, before anything is read — the same answer whether or not the app
388+
exists.
388389

389390
`visible` did **not** move server-side with them, and that asymmetry is
390391
deliberate: CEL is evaluated in the browser because server-side evaluation needs

‎packages/rest/src/execctx-consumer-census.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ describe('[#13160] §4 the 20 locally-caught sites — the half with no shared f
542542
}
543543
}, 180_000);
544544

545-
it('⭐ with the umbrella ISOLATED, six of the inner sites do NOT refuse on their own reading', async () => {
545+
it('⭐ with the umbrella ISOLATED, four of the inner sites do NOT refuse on their own reading', async () => {
546546
// ⛔ Counterfactual, not a production posture — production mounts the
547547
// umbrella, and section 4's first case measures that it refuses. What
548548
// this separates is DOUBLE-guarded from SINGLE-guarded: an absent
@@ -560,12 +560,16 @@ describe('[#13160] §4 the 20 locally-caught sites — the half with no shared f
560560
'DELETE /api/v1/meta/:type/:name',
561561
'POST /api/v1/meta/:type/:name/publish',
562562
'POST /api/v1/meta/:type/:name/rollback',
563+
// [#20441] An authoring door now, like `_drafts`: the authoring
564+
// capability is asked at its head, so an absent context is refused
565+
// there even with the umbrella isolated. It moved from the list
566+
// below, whose length the title states.
567+
'GET /api/v1/meta/:type/:name/audit',
563568
];
564569
const SERVES_ON_ITS_OWN = [
565570
'GET /api/v1/meta/:type', // list — org scope only
566571
'GET /api/v1/meta/:type/:name', // item read — org scope only
567572
'GET /api/v1/meta/:type/:name/layers',
568-
'GET /api/v1/meta/:type/:name/audit',
569573
'GET /api/v1/meta/:type/:name/published',
570574
];
571575

‎packages/rest/src/meta-alternate-door-read-gates.test.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@
6060
* refuses — 403 `FORBIDDEN`, before any read. Everything this census says
6161
* about them holds for the callers they admit. `/layers` and
6262
* `?layers=true` keep the pruned plain-read answer for everyone.
63+
*
64+
* [#20441] **`/audit` is the third** (triage's grade 5871509797 carrying
65+
* ruling 5865708652 to it): `sys_metadata_audit` records a draft save with
66+
* `note: 'draft'`, its actor and its time, so it takes the same refusal
67+
* before any read.
6368
* - **`/references`** is declared exempt: it serves the identities of OTHER
6469
* items that point at this one, never a member of this item's document.
6570
*
@@ -358,9 +363,10 @@ type DoorKind = 'document' | 'stored' | 'events' | 'exempt';
358363
* envelope (its `item`).
359364
*/
360365
/**
361-
* `authoring` — [#20378] ruling 5865708652: the door refuses a caller who may
362-
* not read drafts (`readsDrafts`) with the `GET /meta/_drafts` 403, before any
363-
* read; the rest of its row holds for the callers it admits.
366+
* `authoring` — [#20378] ruling 5865708652 (and [#20441] its carriage to
367+
* `/audit`): the door refuses a caller who may not read drafts (`readsDrafts`)
368+
* with the `GET /meta/_drafts` 403, before any read; the rest of its row holds
369+
* for the callers it admits.
364370
*/
365371
interface Door { kind: DoorKind; suffix: string; query?: Record<string, string>; reason?: string; serves?: 'layers' | 'diff' | 'draft'; authoring?: true }
366372

@@ -373,7 +379,7 @@ const DOORS: Record<string, Door> = {
373379
'/published': { kind: 'document', suffix: '/published' },
374380
'/diff': { kind: 'stored', suffix: '/diff', serves: 'diff', authoring: true },
375381
'/history': { kind: 'events', suffix: '/history', authoring: true },
376-
'/audit': { kind: 'events', suffix: '/audit' },
382+
'/audit': { kind: 'events', suffix: '/audit', authoring: true },
377383
'/references': {
378384
kind: 'exempt',
379385
suffix: '/references',
@@ -669,6 +675,7 @@ describe(`[#20156] every alternate door answers what the plain read answers, or
669675
expect(protocol.getMetaItem).not.toHaveBeenCalled();
670676
expect(protocol.diffMetaItem).not.toHaveBeenCalled();
671677
expect(protocol.historyMetaItem).not.toHaveBeenCalled();
678+
expect(protocol.auditMetaItem).not.toHaveBeenCalled();
672679
return;
673680
}
674681
if (door.serves === 'draft' && CALLERS[callerName].ctx) {

‎packages/rest/src/meta-audit-capability-gap.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,10 @@ function boot(protocol: Record<string, unknown>) {
114114
// A RESOLVABLE execution context. Measured in #8747's pin on this same
115115
// route: an `undefined` context is refused by the anonymous floor
116116
// (`enforceAuth`) with a 401 BEFORE the handler body runs, so the branch
117-
// under test would never be reached.
118-
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', tenantId: 'org_alpha' });
117+
// under test would never be reached. [#20441] And an ADMITTED one: `/audit`
118+
// is an authoring door, so a caller without an authoring capability is
119+
// refused 403 before the protocol is resolved, and would not reach it either.
120+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', tenantId: 'org_alpha', systemPermissions: ['manage_metadata'] });
119121
rest.registerRoutes();
120122

121123
const found = (rest as any).getRoutes().find(

‎packages/rest/src/meta-history-diff-authoring-door.test.ts‎

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@
3636
* `ObjectStackProtocolImplementation` and the real routes — booted exactly as
3737
* `meta-draft-read-builder-gate.test.ts` boots it. The stubs are the auth
3838
* boundary (`resolveExecCtx`) and the service probe that says `tenancy` is off.
39+
*
40+
* ## [#20441] `/audit` is the third authoring door
41+
*
42+
* `saveMetaItem` appends a success row to `sys_metadata_audit` for every save,
43+
* a draft save included, and `auditMetaItem` serves its `note: 'draft'`, its
44+
* actor and its time. Measured on this harness before the fix (`main` at
45+
* `acd009521e`): the member read `note: 'draft'` and `actor: 'u_author'` for
46+
* `app/atlas` and `view/opportunity.pipeline`, and for the never-published
47+
* `app/beacon` and `view/opportunity.forecast`, whose plain read answers them
48+
* `404`, it read that event while a missing name read `{ events: [] }` — an
49+
* existence oracle. Triage's grade 5871509797 carried ruling 5865708652's
50+
* letter to this door: the same refusal, before any read. The three doors share
51+
* one refusal (`refuseNonAuthoringCaller`), and every pin below runs over all
52+
* three. The draft saves here are made by the `author` caller, so the actor a
53+
* refusal must never carry is a real one.
3954
*/
4055

4156
import { describe, it, expect, afterEach, vi } from 'vitest';
@@ -140,7 +155,7 @@ function makeRes() {
140155
const META = '/api/v1/meta';
141156

142157
/** The protocol members a refused caller must never reach. */
143-
const READS = ['getMetaItem', 'getMetaItemLayered', 'historyMetaItem', 'diffMetaItem'] as const;
158+
const READS = ['getMetaItem', 'getMetaItemLayered', 'historyMetaItem', 'diffMetaItem', 'auditMetaItem'] as const;
144159

145160
async function boot() {
146161
const engine = new ObjectQL();
@@ -189,19 +204,21 @@ async function boot() {
189204
as(who, 'GET', `${META}/:type/:name${suffix}`, { path: `${META}/${type}/${name}${suffix}`, params: { type, name }, query });
190205
/** `GET /meta/_drafts` — the door whose refusal these two now give. */
191206
const drafts = (who: CallerName) => as(who, 'GET', `${META}/_drafts`, { path: `${META}/_drafts` });
192-
const save = async (type: string, item: { name: string }, query: Record<string, string>) => {
193-
const res = await as('system', 'PUT', `${META}/:type/:name`, {
207+
const save = async (type: string, item: { name: string }, query: Record<string, string>, who: CallerName = 'system') => {
208+
const res = await as(who, 'PUT', `${META}/:type/:name`, {
194209
path: `${META}/${type}/${item.name}`, params: { type, name: item.name }, query, body: item,
195210
});
196211
if (res.statusCode !== 200) throw new Error(`seeding ${type}/${item.name} failed: ${JSON.stringify(res.body)}`);
197212
};
198213

214+
// The published rows are machine writes; every draft is an AUTHOR's save
215+
// (#20441: the actor `/audit` records, which a refusal must never carry).
199216
await save('app', ATLAS, {});
200-
await save('app', ATLAS_DRAFT, { mode: 'draft' });
201-
await save('app', BEACON_DRAFT, { mode: 'draft' });
217+
await save('app', ATLAS_DRAFT, { mode: 'draft' }, 'author');
218+
await save('app', BEACON_DRAFT, { mode: 'draft' }, 'author');
202219
await save('view', PIPELINE, {});
203-
await save('view', PIPELINE_DRAFT, { mode: 'draft' });
204-
await save('view', FORECAST_DRAFT, { mode: 'draft' });
220+
await save('view', PIPELINE_DRAFT, { mode: 'draft' }, 'author');
221+
await save('view', FORECAST_DRAFT, { mode: 'draft' }, 'author');
205222

206223
/** Spies on every protocol read a refused caller must never reach, armed AFTER seeding. */
207224
const spies = Object.fromEntries(READS.map((m) => [m, vi.spyOn(protocol, m)])) as Record<(typeof READS)[number], ReturnType<typeof vi.spyOn>>;
@@ -222,9 +239,13 @@ const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e
222239
/** The keys of a body and of its nested `error` — the envelope's SHAPE, never its prose. */
223240
const shape = (res: any) => ({ top: Object.keys(res.body ?? {}).sort(), error: Object.keys(res.body?.error ?? {}).sort() });
224241

225-
const AUTHORING_DOORS = ['/diff', '/history'] as const;
242+
const AUTHORING_DOORS = ['/diff', '/history', '/audit'] as const;
243+
/** The protocol read each authoring door makes for a caller it admits — the live-spy control. */
244+
const DOOR_READ = { '/diff': 'diffMetaItem', '/history': 'historyMetaItem', '/audit': 'auditMetaItem' } as const;
245+
/** What an event or version answer carries, and a refusal never does. */
246+
const ANSWER_KEYS = ['fromVersion', 'toVersion', 'events', 'added', 'changed', 'note', 'actor', 'occurredAt', 'u_author'];
226247

227-
describe('[#20378] a member without an authoring capability is refused /diff and /history — the /meta/_drafts refusal, before any read', () => {
248+
describe('[#20378 · #20441] a member without an authoring capability is refused /diff, /history and /audit — the /meta/_drafts refusal, before any read', () => {
228249
for (const suffix of AUTHORING_DOORS) {
229250
for (const type of Object.keys(SUBJECTS) as SubjectType[]) {
230251
it(`${suffix} ${type}: 403 FORBIDDEN in the /meta/_drafts envelope — one answer for a published item, a draft-only one and a missing name, and nothing read`, async () => {
@@ -245,10 +266,10 @@ describe('[#20378] a member without an authoring capability is refused /diff and
245266
// `error` with a code and a message, and nothing beside it.
246267
expect(shape(res)).toEqual(shape(listing));
247268
// No item or version detail: not the name, not a draft
248-
// string, not a version, not an event.
269+
// string, not a version, not an event, not its actor.
249270
for (const name of Object.values(names)) expect(text(res)).not.toContain(name);
250271
for (const s of DRAFT_ONLY_TEXT) expect(text(res)).not.toContain(s);
251-
for (const k of ['fromVersion', 'toVersion', 'events', 'added', 'changed']) expect(text(res)).not.toContain(k);
272+
for (const k of ANSWER_KEYS) expect(text(res)).not.toContain(k);
252273
}
253274
// No existence oracle: the three answers are byte-identical.
254275
expect(answers[1].body).toEqual(answers[0].body);
@@ -260,7 +281,7 @@ describe('[#20378] a member without an authoring capability is refused /diff and
260281
// reading, not a harness that never sees a call.
261282
const builder = await door('author', suffix, type, names.published);
262283
expect(builder.statusCode).toBe(200);
263-
expect(spies[suffix === '/diff' ? 'diffMetaItem' : 'historyMetaItem']).toHaveBeenCalled();
284+
expect(spies[DOOR_READ[suffix]]).toHaveBeenCalled();
264285
}, 60_000);
265286
}
266287
}
@@ -281,18 +302,20 @@ describe('[#20378] a member without an authoring capability is refused /diff and
281302
expect(builder.statusCode).toBe(400);
282303
}, 60_000);
283304

284-
it('/history: an unparseable `limit` answers the member the same refusal — decided before the query parse', async () => {
285-
const { door } = await boot();
286-
const plain = await door('member', '/history', 'app', 'atlas');
287-
const res = await door('member', '/history', 'app', 'atlas', { limit: 'abc' });
288-
expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' });
289-
expect(res.body).toEqual(plain.body);
290-
const builder = await door('studioBuilder', '/history', 'app', 'atlas', { limit: 'abc' });
291-
expect(builder.statusCode).toBe(400);
292-
}, 60_000);
305+
for (const suffix of ['/history', '/audit'] as const) {
306+
it(`${suffix}: an unparseable \`limit\` answers the member the same refusal — decided before the query parse`, async () => {
307+
const { door } = await boot();
308+
const plain = await door('member', suffix, 'app', 'atlas');
309+
const res = await door('member', suffix, 'app', 'atlas', { limit: 'abc' });
310+
expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' });
311+
expect(res.body).toEqual(plain.body);
312+
const builder = await door('studioBuilder', suffix, 'app', 'atlas', { limit: 'abc' });
313+
expect(builder.statusCode).toBe(400);
314+
}, 60_000);
315+
}
293316
});
294317

295-
describe('[#20378] builders read /diff and /history as before — the control', () => {
318+
describe('[#20378 · #20441] builders read /diff, /history and /audit as before — the control', () => {
296319
it('/diff app: every builder reads the draft version; the author reads it whole, every other builder pruned as the plain read prunes', async () => {
297320
const { door, draftVersion } = await boot();
298321
const v = await draftVersion('app', 'atlas');
@@ -334,6 +357,26 @@ describe('[#20378] builders read /diff and /history as before — the control',
334357
}
335358
}
336359
}, 60_000);
360+
361+
it('/audit: every builder reads the audit trail of an app and a view, the author\'s draft save included, and of a draft-only item', async () => {
362+
const { door } = await boot();
363+
for (const who of BUILDERS) {
364+
for (const [type, name] of [['app', 'atlas'], ['view', 'opportunity.pipeline']] as const) {
365+
const res = await door(who, '/audit', type, name);
366+
expect(res.statusCode, `${who} ${type}`).toBe(200);
367+
// The published save and the draft save, each `allowed`.
368+
const notes = (res.body?.events ?? []).map((e: any) => `${e.operation}:${e.outcome}:${e.note}`).sort();
369+
expect(notes, `${who} ${type}`).toEqual(['save:allowed:active', 'save:allowed:draft']);
370+
const draft = res.body.events.find((e: any) => e.note === 'draft');
371+
expect(draft?.actor, `${who} ${type}`).toBe('u_author');
372+
}
373+
for (const [type, name] of [['app', 'beacon'], ['view', 'opportunity.forecast']] as const) {
374+
const res = await door(who, '/audit', type, name);
375+
expect(res.statusCode, `${who} ${type}`).toBe(200);
376+
expect(res.body?.events?.map((e: any) => e.note), `${who} ${type}`).toEqual(['draft']);
377+
}
378+
}
379+
}, 60_000);
337380
});
338381

339382
describe('[#20378] /layers and ?layers=true are unchanged for the member — the lit control', () => {
@@ -363,8 +406,8 @@ describe('[#20378] /layers and ?layers=true are unchanged for the member — the
363406
}, 60_000);
364407
});
365408

366-
describe('[#20378] one predicate: /diff and /history refuse exactly the callers /meta/_drafts refuses', () => {
367-
it('for each caller, the three doors agree', async () => {
409+
describe('[#20378 · #20441] one predicate: /diff, /history and /audit refuse exactly the callers /meta/_drafts refuses', () => {
410+
it('for each caller, the four doors agree', async () => {
368411
const { door, drafts } = await boot();
369412
for (const who of ['member', ...BUILDERS] as const) {
370413
const refused = (await drafts(who)).statusCode === 403;

0 commit comments

Comments
 (0)