Skip to content

Commit cc40033

Browse files
fix(runtime,rest): the dispatcher /meta list prunes what RestServer's list prunes, through one list gate (#20237) (#20319)
Fixes #20237 Clause-②: yes The runtime dispatcher's `/meta/:type` LIST now prunes what `RestServer`'s `GET /meta/:type` prunes, for the same caller. It is one function with two callers. `RestServer`'s per-caller list filters moved unchanged into `createMetaListReadGate`, beside the item gate in `packages/rest/src/meta-item-read-gate.ts`. `RestServer`'s list route and the dispatcher's one-segment list branch both call it. There is no second audience resolver in `packages/runtime` (ruling `5793362670` item 1). This is the route #20193's item half took in PR #20236. #20139 remains open. ## The mechanism assumptions, measured first ### 1. `RestServer`'s LIST filters, classified In handler order (`GET ${metaPath}/:type`, after `getMetaItems(listRequest)`): | filter | class | dispatcher before | now | |:--|:--|:--|:--| | `api` served-set face (`selectServedEndpoints`) | per-deployment (projection of what the endpoint matcher serves) | not run | not run (out of scope; see Acceptance notes) | | `app` nav filter (`filterAppForUser`: unpublished, `requiredPermissions`, docs-audience entry arm, plus the ADR-0057 D10 and #7912 servability arms) | **per-caller** (with per-deployment arms riding along) | not run | **the shared list gate** | | `app` `?id=` filter | projection (query narrowing, not a gate) | ignored | ignored (out of scope; see Acceptance notes) | | `dashboard` widget gate (ADR-0057 D10) | per-deployment | not run | **the shared list gate** (the card's direction names it) | | `view` `?object=` filter | projection | ignored | ignored (out of scope) | | `book` audience prune (ADR-0046 §6.7) | **per-caller** | not run | **the shared list gate** | | `doc` effective-audience prune (ADR-0046 §6.7) | **per-caller** | not run | **the shared list gate** | | `doc` locale collapse, translation | projection | not run | not run (out of scope) | | `doc` content slim (`?include=content`) | projection | run (`slimDocList`), singular spelling only | unchanged | | ADR-0106 object mask | **per-caller** | run on the protocol exit only | run on **every** list exit | The object mask stays on each transport's own exit, as the item gate documents: both already call the one mask in `@objectstack/metadata-core`. The projections withhold nothing from any caller, so they are not this card. ### 2. The dispatcher's list branch Confirmed at source on `2dccb7d4`: `handleMetadataRequest`, `parts.length === 1`, had no per-caller filter anywhere on its path. It has **three** list exits, not one: - the protocol's `getMetaItems`, masked for objects and slimmed for docs; - a fallback to the runtime metadata service's `list`, reached when the protocol does not answer the type. It was slimmed only: no gate, and **no object mask**; - a fallback to the ObjectQL registry's `listItems`. It had no gate, no mask and no slim; for `object` it answers `getAllObjects()`, unmasked. The mask ran inside the protocol exit's swallowing `try`. A mask fault other than the D6 tier-3 one therefore fell through to the unmasked metadata-service exit. ### 3. The rows, re-measured before the fix Driven through `dispatch()` (the `createHonoApp` catch-all's delegate) at base `2dccb7d4`, against the same fixtures and caller as `RestServer`'s list route. The caller is a member without `crm_admin`: | request | dispatcher before | `RestServer`, same caller | dispatcher now | |:--|:--|:--|:--| | `GET /meta/doc?include=content` | `crm_intro`, **`crm_admin_runbook` with its body** | `crm_intro` | `crm_intro` | | `GET /meta/book` | **`admin_guide`** (with its description), `help_center` | `help_center` | `help_center` | | `GET /meta/app` | **`crm` with 3 entries, `payroll`, `launchpad`** | `crm`, pruned to `nav_leads` | `crm`, pruned to `nav_leads` | | `GET /meta/dashboard` (any caller) | `ops`, **both widgets** | `ops`, `w_open_cases` | `ops`, `w_open_cases` | | `GET /meta/object` | `invoice`, masked to `amount` | the same | the same | | `GET /meta/docs?include=content`, `/meta/books`, `/meta/apps` | as their singular rows | as their singular rows | as their singular rows | - **Holder:** every row equals `RestServer` before and after. - **Anonymous:** the dispatcher answers `401 UNAUTHENTICATED` on every list, before any read, both before and after. For `doc` and `book` lists `RestServer` instead serves the `public` entries (none in this fixture). The dispatcher is stricter there; see Acceptance notes. ### 4. The #20193 lessons - **Liveness:** no gate symbol moved out of its file. The list filters were inline blocks, not named symbols, so no pointer broke. `pnpm --filter @objectstack/spec run check:liveness`: exit 0, 760 of 760 `path#symbol` pointers resolve, and no ledger file was edited. - **Pin location:** the composed-host pin sits beside #20193's, in `packages/qa/http-conformance/src/`. - **Exports:** one new value export, `createMetaListReadGate`. It takes the item gate's existing `MetaItemReadGateSources`, so no new type is exported. That keeps `Clause-②: yes` and makes `@objectstack/rest` `minor`; `@objectstack/runtime` is `patch`. ### 5. The exec-ctx census 68 → 66 sites and 92 → 90 mentions. The list route's app and dashboard blocks each resolved the context inline (two same-line CAUGHT sites). Both moved into the gate, which reads the caller through the port the item gate already uses, so nothing replaced them. The census now reads: CAUGHT 23 → 21, same-line 15 → 13, bare 45 unchanged, and the inline floor 14 → 12, keeping its one site of slack. The arithmetic is in the same hunks. ## How - **`createMetaListReadGate(sources, metaType)`** (`meta-item-read-gate.ts`) answers a judge from a list's items to the items this caller may be served. It is `RestServer`'s former inline list filters, in the same order and with the same inputs, and it is built from the functions already in the module (`filterAppForUser`, `filterDashboardForUser`, `resolveRegisteredServices`, `resolveNavServability`, `resolveNavDocAudience`, `resolveDocsAudience`, `fetchAudienceBooks`, `docCorpusOf`). It answers the same array when nothing applies, and a gate input that cannot be read rejects. - **`RestServer`'s list route** calls it once, at the position where the app filter always ran: before `?id=` narrows, and before the doc locale collapse. It rewraps the result only when the gate pruned something. Its list answers are unchanged: the package's 3,662 local tests pass, the list-route tests among them. - **The dispatcher's list branch** sends every list exit through `gateMetaListAnswer`, which runs the shared gate and then the ADR-0106 mask. The lookups keep their own `try`s, which classify a type the store does not know. The gate and the mask run outside them, so a fault is answered as itself (its own status, or `500` for a shapeless one) and never falls through to the next store. Each exit keeps its own projection (the doc slim) exactly as before. - **`RestServer` delegates.** Seven private delegates lost their only caller with the move (`filterAppForUser`, `filterDashboardForUser`, `resolveRegisteredServices`, `resolveNavServability`, `resolveNavDocAudience`, `fetchAudienceBooks`, `docCorpusOf`). They are deleted: `noUnusedLocals` reports each one as TS6133, and a delegate with no caller is a second place to read a rule that nothing runs. - 83 unit-test call sites in `rest.test.ts`, `meta-app-nav-servability-gate.test.ts` and `meta-app-nav-doc-audience.test.ts` reached those delegates through `any`. They now call the one implementation directly. Assertions are unchanged, and the `it()` counts are unchanged at 215, 16 and 18. - **`filterAppForUser` stays resolvable at `rest-server.ts`** as a named re-export, because row 18 of `docs/adr/0056-permission-model-landing-verification.md` cites `packages/rest/src/rest-server.ts#filterAppForUser`. Measured: deleting the declaration turns `check:adr-symbol-anchors` `unresolved-symbol` on that row, and that ADR is a governed surface this PR does not touch. The re-export is not on the package barrel; see Acceptance notes. ## Tests - **`packages/runtime/src/domains/meta-list-read-gate-parity.test.ts`**, 40 cases. It drives the same fixtures through `dispatch()` and through `RestServer`'s `GET /meta/:type`: - the reference answers `RestServer` gives the non-holder (11); - dispatcher equals `RestServer`, for holder and non-holder, on every row (22): the same status and code, the same items, and the same nav entries, widgets, fields and served bodies per item; - controls (4): the holder served in full; anonymous `401` before any read; `RestServer`'s anonymous doc and book lists serving no gated content; and a books-read fault answered as that fault; - the fallback exits (3): the metadata-service exit prunes doc, book and app lists and masks objects; the registry exit masks objects; the holder is served in full. - **`packages/qa/http-conformance/src/hono-meta-list-read-gate.conformance.test.ts`**, 6 cases, through a real `LiteKernel` behind the real `createHonoApp` (`app.request(...)`). For the non-holder, the card's three rows: the doc list with bodies lists `crm_intro` only, and the gated doc's name and body are nowhere on the wire; the book list lists `help_center` only; the app list lists `crm` pruned to `nav_leads`, with `payroll` absent. The holder control lists everything in full. Only identity is stubbed, and the runtime resolves through `dist/`. **Ablation** (the fix committed first at `34f2588dd`; `scripts/ablation-replace.mjs` in WRAP mode, plus a shell trap restoring from `HEAD`): - **Mutate leg.** `gateMetaListAnswer`'s gate call `const judged = await createMetaListReadGate(...)(list);` was replaced by a pass-through carrying the code marker `ablated20237`. The anchor count went 1 → 0 and the blob `e5cbe6ba` → `74221a11`. After a runtime rebuild, `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.cjs`. - Composed-host pin: **3 failed, 3 passed**. These are exactly the three non-holder rows; the holder rows stay green. - Parity pin: **11 failed, 29 passed**. Red: every non-holder doc, book and app row (both spellings), the dashboard rows for both callers (the gate is per-deployment), the books-fault control and the metadata-service exit. The object rows stay green, because the mask is not the gate. - **Restore leg.** The blob equals `HEAD` (`e5cbe6ba`) and `git diff HEAD` is empty. After a rebuild, `--absent` found no marker in any of the 6 built files, and the whole-tree `git status --porcelain` was empty. Result: **6 of 6** and **40 of 40**. - The direction was the predicted one: red. **Verification at head `a48f373a2`** (after merging `origin/main` at `6a6a17b62`, merge commit `8c972a5c4`): - `pnpm --filter @objectstack/rest exec vitest run --project local`: 202 files, 3662 passed, 1 skipped. `--project repo`: 1 file, 8 passed. - `pnpm --filter @objectstack/runtime exec vitest run --project local`: 282 files, 4057 passed, 1 skipped. `--project repo`: 2 files, 69 passed. Run at `8c972a5c4`; the only later commit, `a48f373a2`, touches two rest test files that no runtime suite reads. - `pnpm --filter @objectstack/http-conformance test`: 8 files, 102 passed. Run at `8c972a5c4`, for the same reason. - `typecheck` for rest (at `a48f373a2`), runtime and http-conformance (at `8c972a5c4`): exit 0 each. The test layers are OK: rest at 0 errors, and the runtime and http-conformance debt ledgers unchanged. - `pnpm --filter @objectstack/spec run check:liveness`: exit 0 (760 of 760). - `pnpm lint` (full, `eslint . --no-inline-config`): exit 0, 114 s on a shared box. - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0, board probed, 17 citations judged, all resolve. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 61 commands for these 11 paths. All 61 exited 0, and `--ran` reconciled them: 61 derived, 61 run, 0 NOT-MEASURED (a derived zero from recorded exit codes). - Near-diff gates, run beside the derived set, all exit 0: `check:liveness`, `check:adr-symbol-anchors`, `check:adr-anchors`, `check:scripts-symbol-anchors`, `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`. ## Changeset `.changeset/20237-dispatcher-meta-list-gate.md`: - **`@objectstack/rest`: `minor`.** One new public export, `createMetaListReadGate`, consumed by the runtime dispatcher's `/meta` list. `RestServer`'s answers are unchanged. - **`@objectstack/runtime`: `patch`.** The dispatcher's list is pulled back to the per-caller contract the platform already declares (ADR-0046 §6.7, ADR-0045 §3, `apps.mdx`'s `requiredPermissions` row: 「absent from the `/meta` body」). A permission pull-back is not a Clause-② widening. Who could read what before: on a host that mounts only the catch-all, any authenticated member could list a `{ permissionSet }`-gated doc with its body, a set-gated book's name and description, an app whose `requiredPermissions` they lack, and an ungated app's gated nav entries. On a host whose protocol does not answer a type, the fallback exits also served object schemas unmasked. ## Acceptance notes - **Out of scope, same family (the dispatcher's `/meta` list is a second implementation of `RestServer`'s), measured through `dispatch()` after the fix.** None of these serves anything a caller may not read, because the gate prunes first. They are reported for the seat, not fixed here: - `GET /meta/app?id=crm` ignores `?id=`: the holder is listed all three apps, where `RestServer` answers `[crm]`; - `GET /meta/view?object=nope` ignores `?object=` and lists every view, where `RestServer` answers `[]`; - `GET /meta/docs`, the plural spelling without `?include=content`, serves the doc bodies, because `slimDocList` compares the raw segment with `'doc'`. `RestServer` slims both spellings; - the doc locale collapse, translation and the `api` served-set face are not run. - **Anonymous `public` docs and books are unreachable on a catch-all-only host.** The dispatcher answers `401` to every anonymous `/meta` read. With a `public` book claiming `crm_intro`, `RestServer` lists `public_guide` and `crm_intro` to an anonymous caller, and the dispatcher answers `401 UNAUTHENTICATED`. That is fail-closed, so it is not this card; it is reported for the seat. - **ADR-0056 row 18** still cites `rest-server.ts#filterAppForUser`. The rule lives in `meta-item-read-gate.ts#filterAppForUser`, and `rest-server.ts` re-exports it by name so the pointer keeps landing one hop from it. Re-anchoring that row is a governed-surface edit for the maintainer; the re-export goes when the row moves. - **Fault paths, fail closed.** A gate or mask fault on the dispatcher's list is now answered as that fault, not by falling through to the next store. A host whose protocol has no `getMetaItems` answers a doc list with the fault rather than the unfiltered list, the same requirement the item gate and `RestServer` already have. - **Deviations from the suggested route**, each measured: - the object mask stays per transport, not inside the seam (the item gate's documented split); - seven `RestServer` delegates were deleted and their unit tests retargeted (the `noUnusedLocals` consequence of the move); - no liveness or ADR-anchor file moved. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7338efe commit cc40033

11 files changed

Lines changed: 1086 additions & 352 deletions
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/rest': minor
4+
---
5+
6+
fix(runtime): the dispatcher's `/meta/:type` list prunes what `RestServer`'s list prunes, through one list gate that `@objectstack/rest` publishes (#20237)
7+
8+
Clause-②: yes
9+
10+
`GET /meta/:type` has two implementations: `RestServer`, and the runtime
11+
dispatcher's `/meta` domain. On a host that mounts only the `${prefix}/*`
12+
catch-all (`@objectstack/hono`'s `createHonoApp`, the documented embed shape,
13+
and any adapter built on the public `HttpDispatcher` API), the dispatcher is the
14+
only one that answers. Its list branch applied **no** per-caller filter. An
15+
authenticated member who does not hold `crm_admin` got these answers through
16+
`dispatch()`:
17+
18+
```
19+
request before after (= RestServer)
20+
GET /meta/doc?include=content the set-gated doc listed WITH its body the doc left out
21+
GET /meta/book the set-gated book listed the book left out
22+
GET /meta/app an app whose requiredPermissions the that app left out; the other app
23+
member lacks, and an ungated app with pruned of its gated entry
24+
its requiredPermissions-gated entry
25+
GET /meta/dashboard (anyone) every widget minus the widget whose service is off
26+
```
27+
28+
The plural spellings (`/meta/docs`, `/meta/books`, `/meta/apps`) answer the
29+
same. Holders are still listed everything in full. Object lists are masked as
30+
before. An anonymous caller still gets `401 UNAUTHENTICATED`.
31+
32+
**One gate, not two.** `RestServer`'s list filters moved unchanged into
33+
`createMetaListReadGate`, beside the item gate in
34+
`packages/rest/src/meta-item-read-gate.ts`. It covers the ADR-0046 §6.7 doc
35+
and book audience prunes, the app nav filter (`requiredPermissions`, the
36+
ADR-0045 §3 publish gate and the docs-audience entry arm) and the ADR-0057
37+
D10 dashboard widget gate. `RestServer`'s list route and the dispatcher's list
38+
branch both call it, over the same ports the item gate takes, and each rewraps
39+
the pruned items in its own list envelope. There is no second audience
40+
resolver in `packages/runtime`. `RestServer`'s list answers are unchanged.
41+
42+
Every exit of the dispatcher's list branch now runs the gate and the ADR-0106
43+
object mask: the protocol list and the two fallbacks, the runtime metadata
44+
service's list and the ObjectQL registry. The fallbacks used to serve
45+
unmasked object schemas as well as ungated docs, books and apps. A gate input
46+
that cannot be read (a doc list's books read throws) is answered as that fault,
47+
never as the unfiltered list.
48+
49+
**`@objectstack/rest`'s published export surface widens**, and that is why this
50+
changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one
51+
value:
52+
53+
- `createMetaListReadGate(sources, metaType)`: the list gate. It takes the
54+
same `MetaItemReadGateSources` the item gate takes, and it answers a judge
55+
from a list's items to the items this caller may be served.
56+
57+
It is public because the runtime dispatcher's `/meta` domain in
58+
`@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import
59+
the runtime, so the shared decision has to live here and travel as an export,
60+
the way `createMetaItemReadGate` does. A caller outside the platform does not
61+
need it.
62+
63+
Nothing is removed or renamed from the package's exports, and no authorable key
64+
moves. The dispatcher's prunes are not the widening: they pull a second
65+
transport back to the rules the contract already declares (ADR-0046 §6.7,
66+
ADR-0045 §3, `apps.mdx`'s `requiredPermissions` row), which is why
67+
`@objectstack/runtime` stays a `patch`.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20237] Through a REAL `createHonoApp` host, the dispatcher's `/meta/:type`
5+
* LIST prunes exactly as `RestServer`'s 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` goes catch-all → `HttpDispatcher.dispatch()` → the domain
12+
* registry → `handleMetadataRequest`'s list branch. That branch applied no
13+
* per-caller gate, so a member who does not hold `crm_admin` listed the
14+
* `{ permissionSet }`-gated doc WITH its body (`?include=content`), the
15+
* set-gated book, an app whose `requiredPermissions` they lack, and an ungated
16+
* app with its `requiredPermissions`-gated nav entry. It now asks the one list
17+
* gate `RestServer`'s `GET /meta/:type` asks (`createMetaListReadGate`,
18+
* `@objectstack/rest`), and these rows hold the ANSWER ON THE WIRE — status,
19+
* the listed names, and whether the withheld content is in the body — for a
20+
* non-holder, with a holder as the control that the same host still serves.
21+
*
22+
* The three rows are the card's: the doc list with bodies, the book list, and
23+
* the app list. What `RestServer` answers the same caller is pinned beside the
24+
* dispatcher's in `meta-list-read-gate-parity.test.ts` (`@objectstack/runtime`).
25+
*
26+
* ## Where this lives, and the one stubbed seam
27+
*
28+
* Beside `hono-meta-item-read-gate.conformance.test.ts`, for its reasons:
29+
* `packages/adapters/hono/vitest.config.ts` aliases `@objectstack/runtime` to a
30+
* stub for its whole suite, so no test there can compose the real dispatcher;
31+
* here `@objectstack/hono` is this checkout's adapter SOURCE and
32+
* `@objectstack/runtime` is the real package through its `dist/` (a ledgered
33+
* pair in `scripts/check-test-source-alias.mjs`) — a runtime change reaches
34+
* these rows only once `@objectstack/runtime` is rebuilt. WHO the caller is
35+
* comes from the dispatcher's identity step (`timedResolveExecutionContext`),
36+
* replaced on the prototype by a lookup keyed on a request HEADER read off the
37+
* very Fetch `Request` the catch-all hands to `dispatch()`; everything after
38+
* identity — the catch-all, the registry, the handler, the gate, the adapter's
39+
* rendering — is the real code.
40+
*/
41+
42+
import { describe, it, expect, vi, afterEach } from 'vitest';
43+
import { LiteKernel } from '@objectstack/core';
44+
import { HttpDispatcher } from '@objectstack/runtime';
45+
import { createHonoApp } from '@objectstack/hono';
46+
47+
const PREFIX = '/api/v1';
48+
const PRINCIPAL_HEADER = 'x-probe-principal';
49+
50+
// ── Fixtures (the item pin's, as lists) ───────────────────────────────────────
51+
52+
const DOC_SECRET = 'Rotate the tenant signing keys before every release.';
53+
const BOOK_SECRET = 'Restricted spine: the incident playbooks.';
54+
55+
const STORE: Record<string, any[]> = {
56+
book: [
57+
{
58+
name: 'admin_guide', label: 'Admin Guide', description: BOOK_SECRET,
59+
audience: { permissionSet: 'crm_admin' }, _packageId: 'crm',
60+
groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }],
61+
},
62+
{
63+
name: 'help_center', label: 'Help Centre', audience: 'org', _packageId: 'crm',
64+
groups: [{ key: 'start', label: 'Start', include: 'crm_intro' }],
65+
},
66+
],
67+
doc: [
68+
{ name: 'crm_intro', label: 'Getting started', content: 'Welcome aboard.', _packageId: 'crm' },
69+
{ name: 'crm_admin_runbook', label: 'Admin runbook', content: DOC_SECRET, _packageId: 'crm' },
70+
],
71+
app: [
72+
{
73+
name: 'crm',
74+
label: 'CRM',
75+
navigation: [
76+
{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' },
77+
{ id: 'nav_finance_ledger', type: 'page', label: 'Ledger', pageName: 'ledger', requiredPermissions: ['finance.access'] },
78+
],
79+
},
80+
{
81+
name: 'payroll',
82+
label: 'Payroll',
83+
requiredPermissions: ['payroll.access'],
84+
navigation: [{ id: 'nav_payroll_runs', type: 'page', label: 'Runs', pageName: 'payroll_runs' }],
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+
93+
// ── Principals ────────────────────────────────────────────────────────────────
94+
95+
interface Principal { ctx: Record<string, unknown>; holdings: string[] }
96+
const PRINCIPALS: Record<'holder' | 'non-holder', Principal> = {
97+
holder: {
98+
ctx: { userId: 'u_holder', isSystem: false, systemPermissions: ['finance.access', 'payroll.access'] },
99+
holdings: ['crm_admin'],
100+
},
101+
'non-holder': {
102+
ctx: { userId: 'u_member', isSystem: false, systemPermissions: [] },
103+
holdings: [],
104+
},
105+
};
106+
type PrincipalName = keyof typeof PRINCIPALS;
107+
const principalOf = (request: any): Principal | undefined => {
108+
const name = request?.headers?.get?.(PRINCIPAL_HEADER) as PrincipalName | null;
109+
return name ? PRINCIPALS[name] : undefined;
110+
};
111+
112+
// ── The composed host ─────────────────────────────────────────────────────────
113+
114+
/**
115+
* A real `LiteKernel` carrying the two services the `/meta` list consults
116+
* (`protocol`, and `security` for permission-set holdings), and the REAL
117+
* `createHonoApp` over it.
118+
*/
119+
async function boot() {
120+
const getMetaItems = vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? []));
121+
const protocol = { getMetaItems };
122+
const security = {
123+
// Holdings are answered for the context the gate hands in — the
124+
// principal the identity step resolved for THIS request.
125+
resolvePermissionSetNames: async (ctx: any) =>
126+
Object.values(PRINCIPALS).find((p) => p.ctx.userId === ctx?.userId)?.holdings ?? [],
127+
};
128+
129+
const kernel = new LiteKernel();
130+
kernel.use({
131+
metadata: { name: 'test-meta-list-read-gate-services', version: '1.0.0' },
132+
init: (c: any) => {
133+
c.registerService('protocol', protocol);
134+
c.registerService('security', security);
135+
},
136+
} as any);
137+
await kernel.bootstrap();
138+
139+
vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext')
140+
.mockImplementation(async (opts: any) => clone(principalOf(opts?.request)?.ctx ?? { isSystem: false }));
141+
142+
const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX, cors: false });
143+
const read = async (as: PrincipalName, path: string) => {
144+
const res = await app.request(`http://localhost${PREFIX}${path}`, { headers: { [PRINCIPAL_HEADER]: as } });
145+
const text = await res.text();
146+
const body = JSON.parse(text);
147+
const data = body?.data;
148+
const items: any[] = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : [];
149+
return { status: res.status, text, body, items };
150+
};
151+
return { read, getMetaItems };
152+
}
153+
154+
afterEach(() => { vi.restoreAllMocks(); });
155+
156+
const names = (items: any[]): string[] => items.map((i: any) => i?.name);
157+
const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id);
158+
159+
// ── The three rows, non-holder ────────────────────────────────────────────────
160+
161+
describe('[#20237] a real createHonoApp host lists the non-holder what RestServer lists — the three rows', () => {
162+
it('the doc list with bodies: 200, the gated doc left out, and its body nowhere on the wire', async () => {
163+
const { read, getMetaItems } = await boot();
164+
const res = await read('non-holder', '/meta/doc?include=content');
165+
166+
expect(res.status).toBe(200);
167+
expect(res.body?.success).toBe(true);
168+
expect(names(res.items)).toEqual(['crm_intro']);
169+
// The opt-in still serves the bodies the caller may read.
170+
expect(res.items[0]?.content).toBe('Welcome aboard.');
171+
expect(res.text).not.toContain('crm_admin_runbook');
172+
expect(res.text).not.toContain(DOC_SECRET);
173+
// Reached through the catch-all's `/meta` domain — the list read ran.
174+
expect(getMetaItems).toHaveBeenCalledWith(expect.objectContaining({ type: 'doc' }));
175+
}, 30_000);
176+
177+
it('the book list: 200, the set-gated book left out', async () => {
178+
const { read } = await boot();
179+
const res = await read('non-holder', '/meta/book');
180+
181+
expect(res.status).toBe(200);
182+
expect(names(res.items)).toEqual(['help_center']);
183+
expect(res.text).not.toContain('admin_guide');
184+
expect(res.text).not.toContain(BOOK_SECRET);
185+
}, 30_000);
186+
187+
it('the app list: 200, the requiredPermissions-gated app left out and the ungated app PRUNED of its gated entry', async () => {
188+
const { read } = await boot();
189+
const res = await read('non-holder', '/meta/app');
190+
191+
expect(res.status).toBe(200);
192+
expect(names(res.items)).toEqual(['crm']);
193+
expect(navIds(res.items[0])).toEqual(['nav_leads']);
194+
expect(res.text).not.toContain('payroll');
195+
expect(res.text).not.toContain('nav_finance_ledger');
196+
}, 30_000);
197+
});
198+
199+
// ── The control: the same host still serves a holder ──────────────────────────
200+
201+
describe('[#20237] control — the same host lists a holder every row in full', () => {
202+
it('the doc list with bodies: both docs, the gated body included', async () => {
203+
const { read } = await boot();
204+
const res = await read('holder', '/meta/doc?include=content');
205+
expect(res.status).toBe(200);
206+
expect(names(res.items)).toEqual(['crm_intro', 'crm_admin_runbook']);
207+
expect(res.text).toContain(DOC_SECRET);
208+
}, 30_000);
209+
210+
it('the book list: both books', async () => {
211+
const { read } = await boot();
212+
const res = await read('holder', '/meta/book');
213+
expect(res.status).toBe(200);
214+
expect(names(res.items)).toEqual(['admin_guide', 'help_center']);
215+
expect(res.text).toContain(BOOK_SECRET);
216+
}, 30_000);
217+
218+
it('the app list: both apps, every entry', async () => {
219+
const { read } = await boot();
220+
const res = await read('holder', '/meta/app');
221+
expect(res.status).toBe(200);
222+
expect(names(res.items)).toEqual(['crm', 'payroll']);
223+
expect(navIds(res.items[0])).toEqual(['nav_leads', 'nav_finance_ledger']);
224+
}, 30_000);
225+
});

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

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,19 @@ 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('68 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
313+
it('66 invocation sites, 90 mentions — the thread\'s two control numbers hold', () => {
314+
// [#20237] 68 → 66 sites / 92 → 90 mentions — a MOVE, not a lost
315+
// consumer. `GET /meta/:type`'s app nav filter and dashboard widget
316+
// gate each resolved the context inline (two same-line CAUGHT sites);
317+
// both moved into `createMetaListReadGate` in `meta-item-read-gate.ts`,
318+
// the one list gate the runtime dispatcher's `/meta` list branch now
319+
// asks too, where they read the caller through the SAME port the item
320+
// gate uses (`metaReadAudienceSources`'s `resolveCaller`, already
321+
// counted, still same-line `.catch(rethrowAuthzStoreUnavailable)`). So
322+
// no site replaced them: −2 sites, both from the CAUGHT half (23 → 21,
323+
// 15 → 13 on the invocation line); the bare half (45) is untouched. No
324+
// prose mention was added, so the two numbers move together (−2 / −2).
325+
//
314326
// [#20193] 70 → 68 sites / 93 → 92 mentions — a MOVE, not a new
315327
// consumer. `metaItemReadGate`'s body and the docs-audience helpers it
316328
// calls moved into `meta-item-read-gate.ts`, the one gate the runtime
@@ -436,26 +448,27 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
436448
// naming the seam is the point of the sentence — and the sentence
437449
// moving only the mention count is this control working: a site was not
438450
// added, and the number that tracks sites did not move.
439-
expect(SITES.length).toBe(68);
440-
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
451+
expect(SITES.length).toBe(66);
452+
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(90);
441453
});
442454

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
446-
// silently loses eight sites.
455+
it('the split is 21 locally caught / 45 bare — NOT 13 / 45, which does not add to 66', () => {
456+
// 13 sites spell the catch on the invocation line; 8 more spell it on
457+
// the continuation line. A single-line grep sees 13 and the arithmetic
458+
// silently loses eight sites. [#20237] 15 → 13 and 23 → 21: the list
459+
// route's app and dashboard sites moved into the shared list gate (§2).
447460
//
448461
// [#13214] The new site is BARE, and that is a decision the next case
449462
// enforces: a locally-caught site sitting behind the shared floor would
450463
// be the first of its kind and would break the structural claim below.
451464
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
452-
expect(sameLine.length).toBe(15);
453-
expect(CAUGHT.length).toBe(23);
465+
expect(sameLine.length).toBe(13);
466+
expect(CAUGHT.length).toBe(21);
454467
expect(BARE.length).toBe(45);
455468
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
456469
});
457470

458-
it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 23 caught ones is', () => {
471+
it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 21 caught ones is', () => {
459472
// This inverts the reason the thread gave for doing the bare sites
460473
// first ("no local signal that a fault becomes an anonymous subject").
461474
// The bare sites are bare BECAUSE the shared anonymous floor is the
@@ -727,7 +740,12 @@ describe('[#13279] §7 every caught resolveExecCtx site re-raises the outage', (
727740
// `meta-item-read-gate.ts` with the gate and one inline caller port
728741
// replaced them (§2 has the arithmetic) — the population shrank, the
729742
// reader did not go blind.
730-
expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(14);
743+
//
744+
// [#20237] 14 → 12, the same one site of slack: the inline population
745+
// fell 15 → 13 when the list route's app and dashboard sites moved into
746+
// `createMetaListReadGate`, and no port replaced them (§2) — again the
747+
// population shrank, not the reader's sight.
748+
expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(12);
731749
expect(args.filter((a) => a.layout === 'continuation').length).toBeGreaterThanOrEqual(4);
732750
expect(args.length).toBe(CAUGHT.length);
733751
});

0 commit comments

Comments
 (0)