Skip to content

Commit 2bcd5cf

Browse files
fix(runtime,rest): the dispatcher /meta item reads ask the per-caller read gate RestServer asks (#20193) (#20236)
Fixes #20193 Clause-②: yes The runtime dispatcher's `/meta` item read and its `/published` read now use the per-caller read gate that `RestServer` uses. It is one function with two callers. `RestServer`'s gate moved unchanged into `packages/rest/src/meta-item-read-gate.ts`, and `handleMetadataRequest` calls it. There is no second audience resolver in `packages/runtime`, as ruling `5793362670` item 1 requires. #20156 remains open (its pending decision on the partial `app` cells of `/layers`, `?layers=true` and `/diff` is untouched here). #20139 remains open. ## The measurement that picked route A Triage said the choice between B and A is a measurement. Here it is. ### Host census: which in-repo host compositions reach `handleMetadataRequest`'s item branch | host composition | REST mounted? | who answers `GET /meta/:type/:name` | can REST be mounted there? | |:--|:--|:--|:--| | `@objectstack/hono` `createHonoApp` (published; README and `content/docs/plugins/packages.mdx` name it the edge / serverless adapter) | **no** | **the dispatcher**: `app.all(prefix + '/*')`, then `dispatch()`, the domain registry, and `handleMetadataRequest` | **No, as shipped.** `RestServer` registers on an `IHttpServer` (`http.server` service). `createHonoApp` builds a bare Hono app whose catch-all is terminal by design (ADR-0076, open question 9), and `@objectstack/hono` does not depend on `@objectstack/rest`. | | a thin adapter on the public `HttpDispatcher` API (`packages.mdx`: "build a thin adapter on the public `HttpDispatcher` API") | no | the dispatcher (`dispatch()` / `handleMetadata()`) | No, by construction | | `plugin-hono-server` + `createRestApiPlugin` + `createDispatcherPlugin`: the CLI's `serve` / `dev` (`packages/cli/src/commands/serve.ts`) and `plugin-dev` | yes | `RestServer`. `createDispatcherPlugin` mounts explicit routes and **no** `/meta` route, so the dispatcher item branch is never reached | already mounted | | `packages/qa/http-conformance` `node:http` reference adapter (private QA) | yes | `RestServer` | already mounted | | `packages/verify` handle | no | never reaches `/meta` (it dispatches `/automation` and `/actions` only) | n/a | | cloud hosts (`objectstack-ai/cloud`, per ADR-0076 item 9: plugin routes with the `createHonoApp` catch-all underneath) | yes | `RestServer` first; the dispatcher answers REST misses | NOT MEASURED here: another repository | Reading. The documented embed shape cannot mount REST, and ADR-0076 item 9 records the dispatcher's `/meta` branches as "the cloud fallback fabric, not dead code", not killable while the catch-all stands. So **B** (retire the dispatcher's item reads) would remove a published answer (`meta.getItem` / `meta.getPublished` in the route ledger) from a documented host, and would reverse an accepted ADR. **A** is the route: one transport-neutral gate that both transports call. **C** is ruled out by `5793362670`. ### What the two item reads serve (B's scope, for the record) - **Dispatcher item branch:** `GET` / `HEAD /meta/:type/:name`, `/published`, `?package=`, `?preview=draft`, and a `MetadataService.getItem` fallback. The body is enveloped `{ success, data }`. Objects get the ADR-0106 mask on the plain read. - **REST:** the same two reads, plus `/layers`, `?layers=`, `/history`, `/audit`, `/diff`, `/references`, `?state=draft`, locale collapse (`resolveDocLocale` / translation), and ETag / 304 on the cached arm. The body is the bare `{ type, name, item, … }` envelope. Route A retires nothing, so no answer is dropped. The item branch keeps every one of its own parameters. ## Before and after All rows are driven through `dispatch()`, the delegate of `createHonoApp`'s catch-all: identity resolution, the domain registry and the handler. The caller is an authenticated member who does not hold `crm_admin`, on the fixtures of the REST door census (`meta-alternate-door-read-gates.test.ts`). The "before" column is at base `7e7fab73`. The "after" column is at head `0fcb064a2`, and the first five rows were also re-read through a **real `createHonoApp` app** (`app.request(…)`, a real `HttpDispatcher`, as a one-off probe that is not committed). | request (non-holder) | before | after | `RestServer`, same caller | |:--|:--|:--|:--| | `GET /meta/doc/crm_admin_runbook` | 200 + the gated body | **403 `PERMISSION_DENIED`** | 403 `PERMISSION_DENIED` | | `GET /meta/doc/crm_admin_runbook/published` | 200 + the gated body | **403 `PERMISSION_DENIED`** | 403 `PERMISSION_DENIED` | | `GET /meta/book/admin_guide` (set-gated) | 200 + the book | **403 `PERMISSION_DENIED`** | 403 `PERMISSION_DENIED` | | `GET /meta/app/crm` | 200, 3 entries | **200, `[nav_leads]`** | 200, `[nav_leads]` | | `GET /meta/app/crm/published` | 200, 3 entries | **200, `[nav_leads]`** | 200, `[nav_leads]` | | `GET /meta/book/admin_guide/published` | 200 | 403 `PERMISSION_DENIED` | 403 | | `GET /meta/app/payroll` (+ `/published`) | 200 | 403 `PERMISSION_DENIED` | 403 | | `GET /meta/app/launchpad` (unpublished, + `/published`) | 200 | 404 `RESOURCE_NOT_FOUND`, byte-identical to a missing name on this transport | 404 `RESOURCE_NOT_FOUND` | | `GET /meta/dashboard/ops` (+ `/published`, holder too) | 200, both widgets | 200, `[w_open_cases]` | 200, `[w_open_cases]` | | `GET /meta/object/invoice/published` | 200, `[amount, secret_margin]` | 200, `[amount]` | 200, `[amount]` | | `GET /meta/docs/crm_admin_runbook` (plural) | 200 + body | 403 `PERMISSION_DENIED` | 403 | Controls, unchanged: a holder reads the doc body (both doors), the book, and the whole app. `GET /meta/object/invoice` is masked for the member and served whole to the exempt holder. An anonymous caller gets `401 UNAUTHENTICATED` before any read. Through the real `createHonoApp`, at head: non-holder doc / doc `/published` / book → `403 PERMISSION_DENIED` with no secret on the wire; app and app `/published` → `200 [nav_leads]`. Holder: `200` with the doc and book bodies, and all 3 app entries. ## How - **`packages/rest/src/meta-item-read-gate.ts` (new).** `createMetaItemReadGate(sources, metaType, name, documents, policy)` is the former `RestServer#metaItemReadGate` body. The helpers it calls came with it unchanged: `filterAppForUserWithReason`, `filterDashboardForUser`, `resolveDocsAudience`, `resolveAudienceCaller`, the fault-reporting books and doc-corpus reads, `resolveRegisteredServices`, `resolveNavServability`, `resolveNavDocAudience` and `loadObjectItems`. Its verdict is **data** (`serve`, or `refuse` with `absent` / `app-permission` / `docs-audience`). Each transport supplies only I/O (`MetaItemReadGateSources`): the caller, a list read, the security service, a service probe, and a prune-log dedupe set. - **`RestServer`** keeps every private helper name as a one-line delegate, so its list routes, book tree and doors call the same code. `metaItemReadGate` maps the data verdict to the emitters it always used (`sendMetaItemAbsent`, `sendError`, `sendDeclaredFault`). REST's answers are byte-for-byte unchanged: the 196-case door census and the rest of the package's 3,558 tests pass. - **`handleMetadataRequest`** calls the gate on whichever lookup answers the item read and `/published`. The call sits *outside* the lookups' own swallowing `try`s, so a gate fault is answered as that fault and never as "not found". It uses policy `{ arms: 'all', app: 'gate' }`, the same one `RestServer`'s plain read and `/published` use. Refusals use the dispatcher's own envelope with `RestServer`'s status and code. `absent` is the same `deps.error('Not found', 404)` a missing name gets. - **`@objectstack/rest` exports** `createMetaItemReadGate` and its types. This is the same pattern as `repeatedQueryParamMessage`: the decision travels, nothing transport-shaped does. **Also closed in the same claimed branch (bounded in-place fix, all four conditions hold).** The dispatcher's `/published` did not apply the ADR-0106 object mask that its own plain read applies. `GET /meta/object/invoice/published` served `secret_margin` to a member whose readable set is `[amount]` (row above). This is the same defect class: this transport's `/meta` item doors skipping a per-caller read gate that `RestServer` applies. The fix is mechanical: the dispatcher's existing `maskObjectSchema` call, ordered after the gate as in `RestServer`'s `/published`. It is in the claimed `/published` branch and pinned by the same census. The card's own reading assumed "objects are masked here". That held for the plain read and not for `/published`. ## Tests (head `0fcb064a2`) - **Pin:** `packages/runtime/src/domains/meta-item-read-gate-parity.test.ts`, 77 cases. It drives the same fixtures through `dispatch()` and through `RestServer`. For each caller (holder, non-holder, anonymous) it asserts equal `status` + `code` and the same served document (nav ids, widget ids, field names, secrets present or absent) on the plain read and on `/published`, for doc, book, app ×3, dashboard, object and view. It also asserts the reference answers `RestServer` gives, plus five controls. - `pnpm --filter @objectstack/runtime exec vitest run --project local`: **280 files, 3986 passed, 1 skipped**. - `pnpm --filter @objectstack/rest exec vitest run --project local`: **200 files, 3558 passed, 1 skipped**. - `pnpm --filter @objectstack/rest typecheck` and `pnpm --filter @objectstack/runtime typecheck`: exit 0. Both test layers are OK, with the runtime debt ledger unchanged. - The `execctx-consumer-census` counts move by the gate's relocation: 70 → 68 sites and 93 → 92 mentions. Three same-line-caught sites left with the gate, and one caught caller port replaced them. Each number carries its arithmetic in the test. **Ablation.** The fix was committed first. Each leg went through `scripts/ablation-replace.mjs` (WRAP mode, anchor hit 1 → 0, blob changed), with a shell `trap` restore, and each restore was proven by blob equals `HEAD` and an empty `git diff HEAD`. The subject resolves from `src`: `../http-dispatcher.js`, and `@objectstack/rest` is aliased to `src` in the runtime vitest config, so no `dist` leg applies. - Gate leg (`gateMetaItemDocument` serves without judging): **16 red.** These are all 14 non-holder / dashboard parity rows of the before-table plus the plural and unpublished-app controls. The object `/published` row stays green because it is the mask's. - Mask leg (`/published`'s `if (publishedMasker)` disabled): **1 red**, `GET /meta/object/invoice/published × non-holder`. - Restore leg, unmutated: **77/77 green.** The direction was the expected one: red. **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 70 commands for these 9 paths. All 70 exited 0 at `0fcb064a2`, and `--ran` reconciled them: 70 derived, 70 run, 0 NOT-MEASURED (a derived zero from recorded exit codes). Also at that head: - `pnpm lint`: full run, exit 0, 142 s. - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0. - These roster and wide-population gates, which read these directories, all exited 0: `check-changeset-fixed`, `check-published-list-mirrors`, `check:error-code-casing`, `check:route-ledger-census`, `check:published-readme-exports`, `check:meta-type-normalized`, `check:init-service-contract`, `check:startup-registry-verdict`, `check:wildcard-fallthrough`, `check:optional-error-sink`, `check:filter-alias-parity`, `check:console-injection`, `check:i18n-stale-fill`. `check:doc-authoring` first went red for a moved string. The nav-prune log line carried `[#7912]` into the new file. The id now sits in an adjacent `//` comment, and `scripts/doc-authoring-prose-id.baseline.json` shrinks by that one pinned pair (`--census-ledger`, shrink only). ## Patch round 1 (head `acb99baa9`) Contract review `5856229893` answered FAIL on `0fcb064a2`, and the seat's REWORK is `5856245736`. This round makes three changes and merges `origin/main` (`6d38c526f`, then `acb99baa9`). 1. **Liveness ledger re-anchored.** This fixes the `Test Core (1/6)` red: 「8 anchored citation(s) name a symbol the cited file does not contain」. Each pointer was re-measured against the site that now reads the key, and its entry is stamped `verifiedAt: 2026-09-27`. - `app.json` `navigation.requiredPermissions` and `navigation.requiresService` now point at `meta-item-read-gate.ts#filterNav`. - `book.json` `name` now points at `meta-item-read-gate.ts#deriveImplicitPackageBook` (the `bookNamed` lookup). - `doc.json` `name` now points at `meta-item-read-gate.ts#resolveDocAudiences` (`docReader`: `audiences.get(docName)`). `order` and `group` point at `meta-item-read-gate.ts#docCorpusOf`, the list-branch corpus projection. - `doc.json` `description` and `tags` point at `rest-server.ts#readableTree`. The tree route's `{ name, label, description, order, group, tags, packageId }` projection stayed in `rest-server.ts`, and the new file does not name `description` at all: the key-mention check refused that first repoint. So the pointer names the site that reads the key. `tags` also points at `meta-item-read-gate.ts#resolveBookTree`. - Also moved, although they still resolved as text (a docblock mention in `rest-server.ts` satisfied the anchor): - `app.json` `requiredPermissions`, `_unpublished` and the file's `_note` now point at `meta-item-read-gate.ts#filterAppForUserWithReason`; - `book.json` `audience` now points at `meta-item-read-gate.ts#audienceAllows` (`admitsBook`); - `dashboard.json` `widgets.requiresService` now points at `meta-item-read-gate.ts#filterDashboardForUser`. - Left alone: row 18 of `docs/adr/0056-permission-model-landing-verification.md` cites `rest-server.ts#filterAppForUser`. That is still a live declaration (the delegate REST's list route calls), and the file is a governed surface. - Results: `pnpm --filter @objectstack/spec run check:liveness` exit 0 (「758 pointer(s) written `path#symbol`, 758 naming a symbol the cited file contains」). `scripts/liveness/check-liveness.test.ts`: 64 passed, where 21 were red in CI. All `scripts/liveness/` tests: 252 passed (local project) and 81 passed (repo project). 2. **`Clause-②: yes`.** `@objectstack/rest`'s only export subpath gains `createMetaItemReadGate` and five types: `MetaItemReadGateSources`, `MetaItemReadVerdict`, `MetaItemReadRefusal`, `MetaReadGateCaller` and `MetaReadGatePolicy`. - They are public because the runtime dispatcher consumes this one gate, and `@objectstack/rest` cannot import the runtime. - Precedent: #17672 / PR #17815. - The changeset now carries the declaration line and names the exports. The bumps are unchanged. - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0. `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0. 3. **Composed-host pin, committed:** `packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts`, 7 cases. It boots a real `LiteKernel` and the real `createHonoApp`, and drives them with `app.request(...)`. - Non-holder: the doc, the doc's `/published` and the set-gated book answer `403`, with `error.code` `PERMISSION_DENIED`, `success: false` and no secret on the wire. The `crm` app answers `200`, pruned to `[nav_leads]`. - The holder control is served all four in full. - **Why this package and not `packages/adapters/hono`:** that package's `vitest.config.ts` aliases `@objectstack/runtime` to a stub (`src/__mocks__/runtime.ts`) for every test. `createHonoApp` imports `HttpDispatcher` from that specifier, so a host composed there would compose the stub. `http-conformance` is where the repo already boots the two for real (`hono-dispatcher-result-response.conformance.test.ts`): `@objectstack/hono` is aliased to source, and the runtime resolves through `dist/`, a ledgered pair in `check-test-source-alias`. - **Per-PR tier:** `@objectstack/http-conformance` is in `turbo ls --affected` for this diff, because it depends on `@objectstack/runtime` and `@objectstack/hono`. `Test Core`'s PR shards therefore collect it; they exclude only `@objectstack/dogfood`. - **Ablation (through `dist/`).** The mutate leg replaced the gate call `verdict = await judge(document);` with a serve-as-stored verdict carrying the marker `ablated20193`, then rebuilt the runtime. `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.cjs`. Result: **4 failed | 3 passed**, exactly the four non-holder rows red and the holder controls green. The restore leg put the file back (blob equals `HEAD`, `git diff HEAD` empty), rebuilt, and `--absent` found no marker with a clean tree. Result: **7/7**. Both legs ran at `b58d036ed` and again at `acb99baa9`. **Verification at `acb99baa9`:** - runtime local tests: 281 files, 4016 passed, 1 skipped; - rest local tests: 201 files, 3576 passed, 1 skipped; - `@objectstack/hono`: 5 files, 122 passed; `@objectstack/http-conformance`: 7 files, 96 passed; - both typechecks: exit 0; - `pnpm lint`: exit 0; - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0; - `dispatch-gates --commands` derived 77 commands, all ran, and `--ran` reconciled 77 of 77 with exit codes (0 NOT-MEASURED). ## Changeset `.changeset/20193-dispatcher-meta-read-gate.md`: - **`@objectstack/runtime`: `patch`.** A fix in a released package (Post-Task Checklist step 3). - **`@objectstack/rest`: `minor`.** It adds public exports (`createMetaItemReadGate` and five types), the same bump the changeset for `repeatedQueryParamMessage`'s publication took. REST's own behaviour is unchanged. `Clause-②: yes`: `@objectstack/rest`'s published export surface widens. Its only export subpath gains `createMetaItemReadGate` and five types, which are public because the runtime dispatcher consumes this one gate (precedent #17672 / PR #17815). The dispatcher's refusals themselves are not the widening: they pull a second transport back to the gate the contract already declares (ADR-0046 §6.7, ADR-0045 §3, ADR-0106, `apps.mdx`'s `requiredPermissions` row). No accept set widens, and nothing authorable moves. ADR anchor added: `scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json` (ADR-0045, ADR-0046). ## Acceptance notes - **Same family, outside this card's claimed surface: the dispatcher's `/meta/:type` LIST read is ungated too.** Measured through `dispatch()` for the same non-holder at head: - `GET /meta/doc?include=content` → 200, `crm_admin_runbook` **with its body**; - `GET /meta/book` → 200, `admin_guide` with its description; - `GET /meta/app` → 200, `payroll` (app-level `requiredPermissions`) and `crm` with `nav_finance_ledger`. `RestServer`'s list route answers `[crm_intro]`, `[]` and `[crm]` pruned. Fixing it needs `RestServer`'s LIST filters extracted the same way, which is a second seam and not mechanical, so it was filed separately as #20237 rather than folded in here. #20237 remains open. - The before-table was taken through `dispatch()`. Through a real `createHonoApp` host, the four rows are now a committed pin (`hono-meta-item-read-gate.conformance.test.ts`). Its ablation leg, with the gate call removed and the runtime rebuilt, is the Hono-level *before* reading: the four non-holder rows go red, and the holder rows stay green. - A host whose `protocol` has no `getMetaItems` now answers a doc or book item read with the fault (fail closed, ADR-0049) instead of the ungated body. `RestServer` has the same requirement. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14ae40b commit 2bcd5cf

14 files changed

Lines changed: 2427 additions & 1154 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/rest': minor
4+
---
5+
6+
fix(runtime): the dispatcher's `/meta` item reads ask the same per-caller read gate `RestServer` asks, and `@objectstack/rest` publishes it (#20193)
7+
8+
Clause-②: yes
9+
10+
`GET /meta/:type/:name` and `GET /meta/:type/:name/published` have two
11+
implementations: `RestServer`, and the runtime dispatcher's `/meta` domain. On a
12+
host that mounts only the `${prefix}/*` catch-all (`@objectstack/hono`'s
13+
`createHonoApp`, the documented embed shape, and any adapter built on the public
14+
`HttpDispatcher` API), the dispatcher is the only one that answers. Its item read
15+
and its `/published` read applied **no** per-caller read gate. An authenticated
16+
member who does not hold `crm_admin` got these answers through `dispatch()`:
17+
18+
```
19+
request before after (= RestServer)
20+
GET /meta/doc/crm_admin_runbook 200 + the gated body 403 PERMISSION_DENIED
21+
GET /meta/doc/crm_admin_runbook/published 200 + the gated body 403 PERMISSION_DENIED
22+
GET /meta/book/admin_guide (set-gated) 200 + the book 403 PERMISSION_DENIED
23+
GET /meta/app/crm (and /published) 200, every entry 200, pruned
24+
GET /meta/app/payroll (app-level perms) 200 403 PERMISSION_DENIED
25+
GET /meta/app/launchpad (unpublished) 200 404, the same body as a missing name
26+
GET /meta/dashboard/ops 200, every widget 200, minus the widget whose service is off
27+
GET /meta/object/invoice/published 200, every field 200, the ADR-0106 mask applied
28+
```
29+
30+
Holders are still served in full. Object reads through the plain item read are
31+
masked as before. An anonymous caller still gets `401 UNAUTHENTICATED`.
32+
33+
**One gate, not two.** `RestServer`'s gate moved unchanged into
34+
`packages/rest/src/meta-item-read-gate.ts`. That covers the ADR-0046 §6.7 docs
35+
audience, the app nav filter (`requiredPermissions`, the ADR-0045 §3 publish gate
36+
and the docs-audience entry arm), the ADR-0057 D10 service gates and the #7912
37+
servability gate. Both transports now call it. Each transport supplies only its
38+
own I/O (the caller, the protocol's list read, the security service and a
39+
service probe) and writes the gate's data verdict in its own envelope. There is
40+
no second audience resolver in `packages/runtime`. `RestServer` keeps its
41+
private helper names as delegates, so its own answers are byte-for-byte
42+
unchanged.
43+
44+
A gate input that cannot be read is answered as that fault, never as the
45+
document. This covers a books or doc-list read that throws, and a host whose
46+
protocol has no list read at all (fail closed, ADR-0049).
47+
48+
**`@objectstack/rest`'s published export surface widens**, and that is why this
49+
changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one value
50+
and five types:
51+
52+
- `createMetaItemReadGate(sources, metaType, name, documents, policy)`: the gate
53+
itself;
54+
- `MetaItemReadGateSources`: the I/O a caller supplies (the caller, a metadata
55+
list read, the security service, a service probe, a prune-log set);
56+
- `MetaItemReadVerdict` and `MetaItemReadRefusal`: the data verdict (`serve`, or
57+
`refuse` with `absent` / `app-permission` / `docs-audience`);
58+
- `MetaReadGateCaller`: the slice of the execution context the gate reads;
59+
- `MetaReadGatePolicy`: `arms` and `app`, how a door runs the gate.
60+
61+
They are public because the runtime dispatcher's `/meta` domain in
62+
`@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import
63+
the runtime, so the shared decision has to live here and travel as an export,
64+
the way `repeatedQueryParamMessage` does. A caller outside the platform does not
65+
need them.
66+
67+
Nothing is removed or renamed, and no authorable key moves. The dispatcher's
68+
refusals are not the widening: they pull a second transport back to the gate
69+
the contract already declares (ADR-0046 §6.7, ADR-0045 §3, `apps.mdx`), which is
70+
why `@objectstack/runtime` stays a `patch`.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20193] Through a REAL `createHonoApp` host, the dispatcher's `/meta` item
5+
* reads refuse and prune exactly as `RestServer` does — the composed-host pin.
6+
*
7+
* ## What this pins
8+
*
9+
* A host that mounts only `@objectstack/hono`'s `${prefix}/*` catch-all — the
10+
* documented edge / serverless embed shape — has no `RestServer` at all: every
11+
* `GET /meta/:type/:name` goes catch-all → `HttpDispatcher.dispatch()` → the
12+
* domain registry → `handleMetadataRequest`. That handler applied no per-caller
13+
* read gate, so a member who does not hold `crm_admin` read the
14+
* `{ permissionSet }`-gated doc body, its `/published` twin and the set-gated
15+
* book, and an app's `requiredPermissions`-gated nav entry reached every member.
16+
* It now asks the one gate `RestServer` asks (`createMetaItemReadGate`,
17+
* `@objectstack/rest`), and these rows hold the ANSWER ON THE WIRE — status,
18+
* `error.code`, and whether the withheld content is in the body — for a
19+
* non-holder, with a holder as the control that the same host still serves.
20+
*
21+
* The four rows are the card's: the doc, the doc's `/published`, the set-gated
22+
* book, and the `requiredPermissions`-gated app entry.
23+
*
24+
* ## ⚠️ Why this lives HERE and not in `packages/adapters/hono`
25+
*
26+
* `packages/adapters/hono/vitest.config.ts` aliases `@objectstack/runtime` to a
27+
* hand-written stub for its whole suite, and `createHonoApp` imports
28+
* `HttpDispatcher` from that specifier — so no test in that package can reach
29+
* the real dispatcher, and a "composed" host built there would compose the
30+
* stub. This package is where the repo already boots the two for real (see
31+
* `hono-dispatcher-result-response.conformance.test.ts`): `@objectstack/hono`
32+
* is aliased to this checkout's adapter SOURCE, and `@objectstack/runtime` is
33+
* the real package (through its `dist/`, a ledgered pair in
34+
* `scripts/check-test-source-alias.mjs`). So a runtime change reaches these
35+
* rows only once `@objectstack/runtime` is rebuilt — which is how CI builds
36+
* the closure before it tests.
37+
*
38+
* ## The one seam that is stubbed, and why
39+
*
40+
* WHO the caller is comes from the dispatcher's identity step
41+
* (`timedResolveExecutionContext`), whose real inputs are a better-auth session
42+
* and the authorization tables — neither of which this boot has. It is replaced
43+
* on the prototype (the instance `createHonoApp` constructs for itself is the
44+
* one that runs) by a lookup keyed on a request HEADER, read off the very Fetch
45+
* `Request` the catch-all hands to `dispatch()`. So the principal still arrives
46+
* on the wire, and everything after identity — the catch-all, the registry,
47+
* the handler, the gate, the adapter's rendering — is the real code.
48+
*/
49+
50+
import { describe, it, expect, vi, afterEach } from 'vitest';
51+
import { LiteKernel } from '@objectstack/core';
52+
import { HttpDispatcher } from '@objectstack/runtime';
53+
import { createHonoApp } from '@objectstack/hono';
54+
55+
const PREFIX = '/api/v1';
56+
const PRINCIPAL_HEADER = 'x-probe-principal';
57+
58+
// ── Fixtures (the #20156 door census's own, trimmed to the four rows) ─────────
59+
60+
const DOC_SECRET = 'Rotate the tenant signing keys before every release.';
61+
const BOOK_SECRET = 'Restricted spine: the incident playbooks.';
62+
63+
const STORE: Record<string, any[]> = {
64+
book: [
65+
{
66+
name: 'admin_guide', label: 'Admin Guide', description: BOOK_SECRET,
67+
audience: { permissionSet: 'crm_admin' }, _packageId: 'crm',
68+
groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }],
69+
},
70+
{
71+
name: 'help_center', label: 'Help Centre', audience: 'org', _packageId: 'crm',
72+
groups: [{ key: 'start', label: 'Start', include: 'crm_intro' }],
73+
},
74+
],
75+
doc: [
76+
{ name: 'crm_intro', label: 'Getting started', content: 'Welcome aboard.', _packageId: 'crm' },
77+
{ name: 'crm_admin_runbook', label: 'Admin runbook', content: DOC_SECRET, _packageId: 'crm' },
78+
],
79+
app: [{
80+
name: 'crm',
81+
label: 'CRM',
82+
navigation: [
83+
{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' },
84+
{ id: 'nav_finance_ledger', type: 'page', label: 'Ledger', pageName: 'ledger', requiredPermissions: ['finance.access'] },
85+
],
86+
}],
87+
object: [],
88+
};
89+
90+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
91+
const singular = (type: unknown): string => String(type ?? '').replace(/s$/, '');
92+
const find = (type: unknown, name: unknown): any =>
93+
(STORE[singular(type)] ?? []).find((i: any) => i.name === name);
94+
95+
// ── Principals ────────────────────────────────────────────────────────────────
96+
97+
interface Principal { ctx: Record<string, unknown>; holdings: string[] }
98+
const PRINCIPALS: Record<'holder' | 'non-holder', Principal> = {
99+
holder: {
100+
ctx: { userId: 'u_holder', isSystem: false, systemPermissions: ['finance.access'] },
101+
holdings: ['crm_admin'],
102+
},
103+
'non-holder': {
104+
ctx: { userId: 'u_member', isSystem: false, systemPermissions: [] },
105+
holdings: [],
106+
},
107+
};
108+
type PrincipalName = keyof typeof PRINCIPALS;
109+
const principalOf = (request: any): Principal | undefined => {
110+
const name = request?.headers?.get?.(PRINCIPAL_HEADER) as PrincipalName | null;
111+
return name ? PRINCIPALS[name] : undefined;
112+
};
113+
114+
// ── The composed host ─────────────────────────────────────────────────────────
115+
116+
/**
117+
* A real `LiteKernel` carrying the two services the `/meta` item read consults
118+
* (`protocol`, and `security` for permission-set holdings), and the REAL
119+
* `createHonoApp` over it.
120+
*/
121+
async function boot() {
122+
const getMetaItem = vi.fn(async ({ type, name }: any) => {
123+
const found = find(type, name);
124+
return { type: singular(type), name, item: found ? clone(found) : undefined };
125+
});
126+
const protocol = {
127+
getMetaItems: vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? [])),
128+
getMetaItem,
129+
getMetaItemLayered: vi.fn(async ({ type, name }: any) => {
130+
const found = find(type, name);
131+
const layer = () => (found ? clone(found) : null);
132+
return { type: singular(type), name, code: layer(), overlay: layer(), effective: layer() };
133+
}),
134+
};
135+
const security = {
136+
// Holdings are answered for the context the gate hands in — the
137+
// principal the identity step resolved for THIS request.
138+
resolvePermissionSetNames: async (ctx: any) =>
139+
Object.values(PRINCIPALS).find((p) => p.ctx.userId === ctx?.userId)?.holdings ?? [],
140+
};
141+
142+
const kernel = new LiteKernel();
143+
kernel.use({
144+
metadata: { name: 'test-meta-read-gate-services', version: '1.0.0' },
145+
init: (c: any) => {
146+
c.registerService('protocol', protocol);
147+
c.registerService('security', security);
148+
},
149+
} as any);
150+
await kernel.bootstrap();
151+
152+
vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext')
153+
.mockImplementation(async (opts: any) => clone(principalOf(opts?.request)?.ctx ?? { isSystem: false }));
154+
155+
const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX, cors: false });
156+
const read = async (as: PrincipalName, path: string) => {
157+
const res = await app.request(`http://localhost${PREFIX}${path}`, { headers: { [PRINCIPAL_HEADER]: as } });
158+
const text = await res.text();
159+
return { status: res.status, text, body: JSON.parse(text) };
160+
};
161+
return { read, getMetaItem };
162+
}
163+
164+
afterEach(() => { vi.restoreAllMocks(); });
165+
166+
const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id);
167+
168+
// ── The four rows, non-holder ─────────────────────────────────────────────────
169+
170+
describe('[#20193] a real createHonoApp host refuses the non-holder what RestServer refuses — the four rows', () => {
171+
const REFUSED: Array<{ row: string; path: string; secret: string }> = [
172+
{ row: 'the gated doc', path: '/meta/doc/crm_admin_runbook', secret: DOC_SECRET },
173+
{ row: 'the gated doc, /published', path: '/meta/doc/crm_admin_runbook/published', secret: DOC_SECRET },
174+
{ row: 'the set-gated book', path: '/meta/book/admin_guide', secret: BOOK_SECRET },
175+
];
176+
177+
for (const { row, path, secret } of REFUSED) {
178+
it(`${row}: 403 PERMISSION_DENIED, and none of it on the wire`, async () => {
179+
const { read } = await boot();
180+
const res = await read('non-holder', path);
181+
182+
// The ADR-0112 envelope — both halves, never a bare status.
183+
expect(res.status).toBe(403);
184+
expect(res.body?.success).toBe(false);
185+
expect(res.body?.error?.code).toBe('PERMISSION_DENIED');
186+
expect(res.text).not.toContain(secret);
187+
}, 30_000);
188+
}
189+
190+
it('the requiredPermissions-gated app entry: 200, the app PRUNED of the entry', async () => {
191+
const { read, getMetaItem } = await boot();
192+
const res = await read('non-holder', '/meta/app/crm');
193+
194+
expect(res.status).toBe(200);
195+
expect(res.body?.success).toBe(true);
196+
expect(navIds(res.body?.data?.item)).toEqual(['nav_leads']);
197+
expect(res.text).not.toContain('nav_finance_ledger');
198+
// Reached through the catch-all's `/meta` domain — the item read ran.
199+
expect(getMetaItem).toHaveBeenCalledTimes(1);
200+
}, 30_000);
201+
});
202+
203+
// ── The control: the same host still serves a holder ──────────────────────────
204+
205+
describe('[#20193] control — the same host serves a holder every row in full', () => {
206+
it('the gated doc and its /published twin: 200, the body', async () => {
207+
const { read } = await boot();
208+
for (const path of ['/meta/doc/crm_admin_runbook', '/meta/doc/crm_admin_runbook/published']) {
209+
const res = await read('holder', path);
210+
expect(res.status, path).toBe(200);
211+
expect(res.text, path).toContain(DOC_SECRET);
212+
}
213+
}, 30_000);
214+
215+
it('the set-gated book: 200, the book', async () => {
216+
const { read } = await boot();
217+
const res = await read('holder', '/meta/book/admin_guide');
218+
expect(res.status).toBe(200);
219+
expect(res.body?.data?.item?.description).toBe(BOOK_SECRET);
220+
}, 30_000);
221+
222+
it('the app: 200, every entry', async () => {
223+
const { read } = await boot();
224+
const res = await read('holder', '/meta/app/crm');
225+
expect(res.status).toBe(200);
226+
expect(navIds(res.body?.data?.item)).toEqual(['nav_leads', 'nav_finance_ledger']);
227+
}, 30_000);
228+
});

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

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,20 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
310310
// ---------------------------------------------------------------------------
311311

312312
describe('[#13160] §2 the consumer surface, counted from the tree', () => {
313-
it('70 invocation sites, 93 mentions — the thread\'s two control numbers hold', () => {
313+
it('68 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
314+
// [#20193] 70 → 68 sites / 93 → 92 mentions — a MOVE, not a new
315+
// consumer. `metaItemReadGate`'s body and the docs-audience helpers it
316+
// calls moved into `meta-item-read-gate.ts`, the one gate the runtime
317+
// dispatcher's `/meta` domain now asks too. Three same-line CAUGHT sites
318+
// left this file with them (the gate's app and dashboard arms, and
319+
// `resolveAudienceCaller`); ONE took their place — the caller port
320+
// `metaReadAudienceSources` hands the moved gate, still same-line
321+
// `.catch(rethrowAuthzStoreUnavailable)`, still not behind the shared
322+
// anonymous floor. Net −2 sites, all from the CAUGHT half (25 → 23, 17
323+
// → 15 on the invocation line); the bare half (45) is untouched. The
324+
// new port's docblock names the seam once, so mentions fall by one
325+
// fewer than sites (−2 invocations, +1 prose mention).
326+
//
314327
// [#20156] 69 → 70 sites / 92 → 93 mentions — one net site, and it is
315328
// a MOVE plus one. The plain read's app and dashboard gates each
316329
// resolved the context inline (two same-line CAUGHT sites); both moved
@@ -423,26 +436,26 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
423436
// naming the seam is the point of the sentence — and the sentence
424437
// moving only the mention count is this control working: a site was not
425438
// added, and the number that tracks sites did not move.
426-
expect(SITES.length).toBe(70);
427-
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(93);
439+
expect(SITES.length).toBe(68);
440+
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
428441
});
429442

430-
it('the split is 25 locally caught / 45 bare — NOT 17 / 45, which does not add to 70', () => {
431-
// 17 sites spell the catch on the invocation line; 8 more spell it on
432-
// the continuation line. A single-line grep sees 17 and the arithmetic
443+
it('the split is 23 locally caught / 45 bare — NOT 15 / 45, which does not add to 68', () => {
444+
// 15 sites spell the catch on the invocation line; 8 more spell it on
445+
// the continuation line. A single-line grep sees 15 and the arithmetic
433446
// silently loses eight sites.
434447
//
435448
// [#13214] The new site is BARE, and that is a decision the next case
436449
// enforces: a locally-caught site sitting behind the shared floor would
437450
// be the first of its kind and would break the structural claim below.
438451
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
439-
expect(sameLine.length).toBe(17);
440-
expect(CAUGHT.length).toBe(25);
452+
expect(sameLine.length).toBe(15);
453+
expect(CAUGHT.length).toBe(23);
441454
expect(BARE.length).toBe(45);
442455
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
443456
});
444457

445-
it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 25 caught ones is', () => {
458+
it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 23 caught ones is', () => {
446459
// This inverts the reason the thread gave for doing the bare sites
447460
// first ("no local signal that a fault becomes an anonymous subject").
448461
// The bare sites are bare BECAUSE the shared anonymous floor is the
@@ -709,7 +722,12 @@ describe('[#13279] §7 every caught resolveExecCtx site re-raises the outage', (
709722
// form would report "0 survivors" and read exactly like a clean pass —
710723
// which is the precise failure that let the four sites through.
711724
const args = catchArguments();
712-
expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(16);
725+
// [#20193] 16 → 14, the floor keeping its one site of slack: the inline
726+
// population fell 17 → 15 when three inline sites moved into
727+
// `meta-item-read-gate.ts` with the gate and one inline caller port
728+
// replaced them (§2 has the arithmetic) — the population shrank, the
729+
// reader did not go blind.
730+
expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(14);
713731
expect(args.filter((a) => a.layout === 'continuation').length).toBeGreaterThanOrEqual(4);
714732
expect(args.length).toBe(CAUGHT.length);
715733
});

0 commit comments

Comments
 (0)