You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 8e02859
Browse filesBrowse the repository at this point in the historyBrowse files
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>
**`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.
describe('[#20378] a member without an authoring capability is refused /diffand /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',()=>{
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
245
266
// `error` with a code and a message, and nothing beside it.
246
267
expect(shape(res)).toEqual(shape(listing));
247
268
// 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.
0 commit comments