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 7fa3e3e
Browse filesBrowse the repository at this point in the historyBrowse files
authored
fix(rest): /meta/:type/:name/diff and /history are authoring doors, refused as /meta/_drafts refuses (#20378) (#20440)
Fixes#20378
Clause-②: no
`GET /api/v1/meta/:type/:name/diff` and `GET
/api/v1/meta/:type/:name/history` are now authoring doors. A caller that
`mayReadPendingDrafts` does not admit is refused as `GET
/api/v1/meta/_drafts` refuses: `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 item,
event or version is read. This executes ruling `5865708652` on the card
(letter B, the maintainer's 「同意」 through the director seat). That ruling
narrows item 2 of ruling B on #20156 (`5856774816`) for these two doors
only.
## Why
Both doors read `sys_metadata_history`, the authoring commit log
(ADR-0067). A draft save appends a row there exactly as an active save
does, and nothing on the row says which kind it was. A member with no
authoring capability who could open an item could therefore read two
things:
- its pending draft through `/diff`, by naming the draft save's version
in `from`/`to`, or through the default range once a draft is pending;
- its draft-save events through `/history`.
ADR-0106 D4 says 「draft/preview reads are admin-gated upstream already」.
The version store has no published-only answer to fall back to, so these
two doors take the `/meta/_drafts` shape (refuse). They do not take the
draft switches' shape (answer as if the switch were absent).
## What changed
- **`packages/rest/src/rest-server.ts`: a guard at the head of each
handler.** Each door resolves its caller once (`resolveExecCtx`,
memoised per request) and asks `mayReadPendingDrafts`. That is the one
predicate `/meta/_drafts` and every draft switch already ask, so there
is no second rule. The org partition further down reuses the same
resolved caller. Callers it admits read exactly what they read before,
including the `#20156` per-caller gate and the author exemption on
`/diff`.
- **What the refusal carries.** It has no item name, version or event.
Its message names the door ("version history" or "stored versions"),
never drafts, so the answer is the same for a published item, a
draft-only item and a name with nothing behind it. The door is not an
existence oracle.
- **Unchanged:** `/layers`, `?layers=true` and `/audit`.
- **`packages/rest/src/meta-history-diff-authoring-door.test.ts`
(new).** It boots the real stack as
`meta-draft-read-builder-gate.test.ts` does: better-sqlite3 in memory,
the real `sys_metadata*` objects, a real
`ObjectStackProtocolImplementation` and the real routes. The only stubs
are `resolveExecCtx` and the `tenancy` service probe. It pins four
things:
- For `app` and `view` on both doors, a member without an authoring
capability gets `403 FORBIDDEN`. The envelope keys equal those of the
member's own `/meta/_drafts` answer. The answers for a published item, a
draft-only item and a missing name are byte-identical. No protocol read
is reached: spies on `getMetaItem`, `getMetaItemLayered`,
`historyMetaItem` and `diffMetaItem` stay uncalled, and a builder call
on the same door proves the spies are live.
- A draft-save range, the default range and an unparseable bound all get
the member the same refusal. The unparseable bound is answered `400` to
a builder, which shows the member's refusal is decided before the query
parse.
- Every builder (`studio.access`, `setup.access`, `manage_metadata`)
reads both doors as before, with author-whole versus pruned on `/diff`
for `app`.
- The lit control: the member still reads `/layers` and `?layers=true`,
and gets the active row pruned as the plain read prunes it.
- **`packages/rest/src/meta-alternate-door-read-gates.test.ts`, the
`#20156` census.**
- The `/diff` and `/history` rows gain `authoring: true`. For a caller
that `/meta/_drafts` refuses, each census cell asserts `403 FORBIDDEN`,
asserts that no secret appears in the answer, and asserts that
`getMetaItem`, `diffMetaItem` and `historyMetaItem` were never called.
- The presenter edge now expects the refusal on `/diff`.
- The two edges that ask what an admitted caller sees on `/diff` and
`/history` now drive the admitted `reader` caller.
- The `/layers` and `?layers=true` rows are untouched.
- **Docs.** In `content/docs/api/client-sdk.mdx`, one line beside the
`client.meta.diffItem` example states the authoring-only rule. In
`content/docs/ui/apps.mdx`, the paragraph that told every other caller
they read `/diff` pruned now states that `/diff` and `/history` need the
capability outright.
- **Changeset:** `@objectstack/rest` `patch`, `Clause-②: no`. It pulls
the declared contract (ADR-0106 D4) back in.
## Takeover of pushed work
A previous dev pushed this branch (`5a09278bad` the guard, `1f4a1faf06`
the pins, docs and changeset, `24c384d8d3` a merge). Its session ended
before a PR or report. Every hunk was re-read against the card and the
ruling. All were kept but one:
- `meta-history-diff-authoring-door.test.ts` had a TS2345. The inferred
union of the three `/diff` query literals is not assignable to the
helper's `Record` type, and `check:test-typecheck` was red on a file its
ledger does not cover. `db05a9e270` fixes it.
`origin/main` was merged twice: `f4c601e889`, then `c0675573df`. The
second merge carries the landing of #20404 on the same file. That PR
moved the list chain, `isPublicAudienceRead` and the item read, and
touched neither handler here. After the merge both guards still sit at
the head of their handlers, and the branch's delta against `main` is the
same six files. The runtime dispatcher still serves neither `/history`
nor `/diff`.
## Verification (all at head `c0675573df`)
- Build: `pnpm --workspace-concurrency=2 --filter
'@objectstack/rest^...' build` exited 0. `pnpm exec turbo run build
--filter='./packages/*' --filter='./packages/*/*' --concurrency=2`
exited 0 with 71/71 tasks, which the whole-tree gates need.
- `pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2`: 215 files passed; 3904 tests passed and 26 skipped.
- `pnpm --filter @objectstack/rest exec vitest run --project repo
--maxWorkers=2`: 1 file passed, 8 tests passed.
- `pnpm --filter @objectstack/rest typecheck` exited 0:
`check:test-typecheck: OK`, with 0 files in the debt ledger.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 91 commands. All 91 were run and each exited 0.
`--ran` reports: `91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN`.
- `pnpm lint`, the whole repository, exited 0 in 37s.
- `node scripts/check-issue-citations.mjs --base origin/main` exited 0:
7 citations, all resolve.
**Ablation, per door.** The source is imported relatively
(`./rest-server.js`), so no `dist` sits between the mutation and the
tests. Each run went through `scripts/ablation-replace.mjs`, whose
anchor must hit (1 to 0). The mutation replaced the door's guard with
`if (false && !mayReadPendingDrafts(...))` and then ran both test files.
The restore was proven: the blob is `e0f7a215dbe0`, equal to `HEAD`, and
`git diff HEAD` is empty.
| guard removed | red | green |
|:--|:--|:--|
| `/diff` | 14 of 310, every one a `/diff` refusal pin: the 9 census
cells for a caller that may not read drafts, the presenter edge, the
member pins for `app` and `view`, the range pin, and the one-predicate
pin | every builder pin, every `/history` pin, both `/layers` controls |
| `/history` | 13 of 310, every one a `/history` refusal pin: 9 census
cells, the member pins for `app` and `view`, the `limit` pin, and the
one-predicate pin | every builder pin, every `/diff` pin, both `/layers`
controls |
## Acceptance notes
- **The refusal message.** It is the one byte-level difference from
`/meta/_drafts`. The status, the code and the envelope's key set are
identical and pinned. The message names the door rather than drafts,
because a refusal worded about drafts would read as "this item has one".
It is not pinned, since no consumer parses it.
- **`/audit` is outside this change.** The ruling names `/diff` and
`/history` only. `/audit` serves `sys_metadata_audit` rows (actor, time,
operation, outcome, no bodies). Whether a draft save writes an audit row
that a member then reads was not measured; this note is read at source
only. Carrier: none.
- **objectui at the pin `f8a9d0fb05`.**
- `/history` is consumed by `MetadataResourceHistoryPage`, on the
metadata-designer routes, an authoring surface.
- `MetadataClient.diff` has no caller.
- A caller without an authoring capability who opens that route now
receives the `403`.
- **The sibling card is separate.** The mislabelled default `/diff`
range (#20397) is not addressed here and proceeds unchanged.
## Declared narrowing: the verify lock
`scripts/pm/os-verify-lock.sh` printed this for every build, test and
ablation 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 --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2
The same disclosure was printed for each of the other wrapped commands:
the two builds, the `repo` project run, the typecheck and the two
ablation runs.
---
_Generated by [Claude
Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
**`GET /api/v1/meta/:type/:name/diff` and `GET /api/v1/meta/:type/:name/history` are now authoring doors: a caller without an authoring capability is refused, as `GET /api/v1/meta/_drafts` refuses.** Before this release, any signed-in caller who could open an item could read its version diff and its change history. Both doors read the metadata version log, which records a draft save exactly as it records a published save. So a member could read an item's unpublished draft through `/diff`, either by naming the draft save's version in `from`/`to` or through the default range once a draft was pending. Through `/history`, the same member could read the draft-save events. This follows the maintainer's ruling on #20378 (letter B, comment 5865708652), which pulls both doors back into the declared contract: draft and preview reads are admin-gated upstream (ADR-0106 D4). It narrows the earlier ruling that let every caller who may open an app read `/diff` pruned, for these two doors only.
6
+
7
+
Clause-②: no
8
+
9
+
-**Who may read them:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts` 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 query is parsed and before any item or version 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 item name, version or event. The message names the door, not drafts.
11
+
-**Unchanged:** callers with an authoring capability read both doors exactly as before, per-caller pruning included: on `/diff`, whoever may save an app reads both sides whole, and any other admitted caller reads them pruned. `/layers` and the deprecated `?layers=true` read the active row, so they keep answering every caller who may open the app with the pruned plain-read answer. `/audit` is unchanged.
12
+
13
+
A client that read `/diff` or `/history` as a member now receives `403 FORBIDDEN`. To read them, call as a caller holding one of the three capabilities above.
0 commit comments