Skip to content

Commit 5c7aa46

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(rest, runtime): the dispatcher's /meta doors scope to the vetted organization, and its item read, book tree and list answer what RestServer answers (#20408) (#20473)
Fixes #20408 Clause-②: yes The runtime dispatcher's `/meta` doors now answer what `RestServer`'s answer, for the item read, the book-tree route and the list, and they scope a caller to the same organization. A host that mounts only the `${prefix}/*` catch-all serves `/meta` through the dispatcher: `createHonoApp`, or any adapter written on the public `HttpDispatcher` API. ADR-0076 item 9 keeps that catch-all as the fallback. Triage's direction was to extend the shared seam (AGENTS.md 〈Route & surface ownership〉 rule 1: one implementation, two transports). This PR extends the seam PR #20404 built in `packages/rest/src/meta-item-read-gate.ts`, and builds no second one. ## First: the organization source was a cross-organization data-scope defect (H0, measured) The card's first read-at-source item. Triage said a cross-org difference outranks the six rows, and it does differ. - **The dispatcher read the session claim as stored.** Every dispatcher `/meta` door took its organization from `deps.resolveActiveOrganizationId`, which returns the auth session's `activeOrganizationId` unchanged. - **`RestServer` reads the vetted value.** It reads `ctx.tenantId` off the execution context. `resolveAuthzContext` vets that value: under a wall-enforcing posture, it DROPS a claim naming an organization the caller no longer belongs to. - **So a removed member kept the left organization's partition on the dispatcher**, for the rest of the session. Measured through `dispatch()` against `RestServer`. Both run the REAL identity resolution (`resolveExecutionContext` / `computeExecCtx` → `resolveAuthzContext`) under an `isolated` posture. The subject is `u_exmember`: the session is stamped `org_alpha`, and the only `sys_member` row is `org_beta`. Both principals hold one shared permission set, so only the organization claim separates the arms. On `b28550818`: | door (as the ex-member) | dispatcher | `RestServer` | |:--|:--|:--| | `GET /meta/view/lead_all` | `Alpha pipeline` (org_alpha's overlay) | `All leads` (env-wide) | | `GET /meta/view` | `Alpha pipeline` | `All leads` | | `GET /meta/view/lead_all?preview=draft` | `Alpha pipeline` | `All leads` | | `GET /meta/view/lead_all/published` | `Alpha pipeline` | `All leads` | | `GET /meta/view/lead_all?state=draft` | `200`, org_alpha's pending draft | `404 NO_DRAFT` | | `GET /meta/_drafts` | `['alpha_board']` | `['env_board']` | | `PUT /meta/view/lead_all` (manage_metadata) | write lands in `org_alpha` | write lands env-wide | The last row is a WRITE into the left organization's partition. - **Controls, green on both transports:** a current member reads its own organization, the double gates a non-overridable type's phantom row, and the ex-member switched to `org_beta` reads `org_beta`. - **The fix, in the seam:** - `metaCallerOrganizationId(caller)` answers the vetted `tenantId`. - `metaReadOrganizationId(type, caller)` answers `organizationIdForMetaRead` over the folded type and that value. - `RestServer`'s list and item reads ask the second, and so does every dispatcher `/meta` read. - The dispatcher's `PUT`, `_drafts` and `/published` take the first. That is what `RestServer`'s twins hand down (`ctx.tenantId`). - `meta.ts` no longer calls `deps.resolveActiveOrganizationId`. - **Pinned** in `packages/runtime/src/domains/meta-read-org-scope-parity.test.ts`: 11 tests, 7 red at the base and all green on the fix. - **The write door is a bounded in-place fix.** The read doors are the card's scope; the `PUT` row goes beyond it, and all four conditions hold: - the same defect class: the same source, the same file; - a mechanical, pinned shape; - `meta.ts` is this claim's file; - the same gate families. The pin's `PUT` row is its evidence: `['org_alpha']` against `[undefined]` at the base, equal on the fix. ## The six rows, and what the census found beside them Each row was re-measured first, and every one still reproduced on `b28550818`. The census in `meta-list-projection-parity.test.ts` now has item, book-tree and cache-posture blocks. Like the list block, each is derived from `RestServer`'s handler: the query parameters it reads and the type literals it keys on, plus the shared functions it calls. | # | row | before, on the census fixtures | |:--|:--|:--| | 1 | unknown type (`GET /meta/totally_invented_type`) | 33 list cells: `200 []` against `400 INVALID_REQUEST` | | 2 | `?preview=DRAFT` | 6 list cells, plus 2 item cells (`404` against `200`) and 4 item body cells | | 3 | item translation | 48 item cells | | 4 | doc item locale | 84 item cells (the `translations` map kept, no collapse) | | 5 | `GET /meta/book/:name/tree` | 64 of 80 tree cells (`404 ROUTE_NOT_FOUND` against `200`/`403`; `401` against `200` for an anonymous reader of the `public` book) | | 6 | object `?preview=draft` | 8 item cells (the active schema) | | H1 | `Cache-Control` on an undetermined posture | 20 list cells, 16 item cells | | new | item `Vary: Accept-Language` | 400 item cells (header only) | | new | object `sortability` | 64 item cells | The last two rows are same-family divergences the item census found that the card does not list. The dispatcher's item answer carried no `Vary`, and an object schema came with no `sortability` (#10235). The item chain closes both by construction. H1 was measured, and it differed: the list, the item read, `/published` and the legacy one-segment object read all served an undetermined posture's unmasked schema with no `Cache-Control`. ## What changed - **The item read is one chain, `createMetaItemAnswer`.** Everything `RestServer`'s `GET /meta/:type/:name` does after the store read moved there, unchanged: 1. absence (#18066, before the gate); 2. THE item gate under the door's policy; 3. the ADR-0046 doc locale collapse; 4. the ADR-0106 mask, under the posture resolved before the fetch; 5. the body, `translateMetaEnvelope`: the translation, and `sortability` beside an object schema. `RestServer`'s uncached arm calls the chain. So does every exit of the dispatcher's item read: the object branch, the generic branch, the `MetadataService` fallback and the `?state=draft` read. `RestServer.translateMetaItem` and `translateMetaEnvelope` delegate to the new `translateMetaDocument` and `translateMetaEnvelope`. The cached arm keeps calling them. - **Row 6:** an admitted `?preview=draft` makes the dispatcher's object branch ask the protocol first, as a scoped kernel always did. That protocol read now carries the request `RestServer` sends: `?package=` and the switch. - **Row 2:** both `?preview=` declarations in `meta.ts` parse the value case-insensitively, as `RestServer`'s do. The draft-door ledger in `meta-draft-read-builder-gate.test.ts` now names the new spelling. - **Row 1:** `refuseUnknownMetaListType` moved into the seam, unchanged, docblock included. `RestServer`'s private method is a one-line delegate. The dispatcher's list branch asks it before any listing work. It fails open when the live type listing cannot be read, so a host with no `getMetaTypes` keeps the legacy one-segment object read. - **Row 5:** the tree route's whole handler moved into `createMetaBookTreeAnswer`: the reads, THE `DocsAudience`, the §6.7 gate, the locale collapse and the narrowed tree. The dispatcher serves `GET /meta/book/:name/tree`, with the type segment literal as on `RestServer`, and `metaReadRouteOf` names it `book-tree` for the shared `isPublicAudienceRead`. `RestServer`'s two tree-only delegates (`audienceBooksOf`, `resolveDocsAudience`) went with it. - **H1:** the list chain applies the object mask itself. - `MetaListAnswerSources.maskObjects` became `resolveObjectMasker`. That port is new in this same release, with `createMetaListAnswer`. - The shared `projectMetaObjectSchema` projects each schema, so both transports make one decision. - `MetaListAnswer` reports `cacheControl`, and `RestServer`'s list writes it from the answer; its port used to write it. - The dispatcher's other mask exits (`/published`, the legacy read) use the same projection. One helper in `meta.ts` (`successWithHeaders`) carries the headers, so `check:route-envelope`'s `handBuilt: 2` is unchanged. `RestServer`'s answers are unchanged: every existing REST test passes unedited. **Runtime pins that moved, and why:** - **The census control's unrouted book path.** It was `/meta/book/public_guide/tree`, now `/meta/books/public_guide/tree`: the singular spelling is a route here now. - **The draft-door ledger:** the `?preview=` site spelling. - **`meta-write-org-scope.test.ts` and `meta-save-capability-gate.test.ts`.** Their execution context carried no `tenantId` while their auth session named an organization. That pairing is exactly the "dropped claim" state, which only the old raw-claim source read as org-scoped. Each now hands the organization on the execution context, as the real resolver does with no wall. 11 cases went red, and none of their assertions changed. ## Evidence - **Red first.** The census and pins at `410141ec34`, on base sources: census `259 failed | 247 passed (506)`, H0 `7 failed | 4 passed (11)`. - **Reverse verification.** The final tests, run against the BASE sources (the four source files restored from `b28550818` into the tree only): `267 failed | 263 passed (530)` across the census, H0 and ledger files. Restored with `git checkout HEAD --`, with proof: each blob equals HEAD's, and `git diff HEAD` is empty. - **Ablations** at `7903048a75` go through `scripts/ablation-replace.mjs`: the anchor hit once, and each mutation landed and was restored with blob == HEAD `3519d199a54c` and an empty `git diff HEAD`. The subject resolves through relative imports and the runtime vitest alias to `packages/rest/src`, so no `dist` was involved. Suite: the census plus the H0 pins, 517 tests. | ablation | predicted | measured | |:--|:--|:--| | (A) the dispatcher skips `refuseUnknownMetaListType` | exactly the row-1 cells | `11 failed \| 506 passed`: the 11 `totally_invented_type` tests, nothing else | | (B) the dispatcher's item chain bypasses `translateMetaEnvelope` | the translation and `sortability` cells | `40 failed \| 477 passed`: `object`/`objects` invoice 16 each, and the zh-CN cells of `app/crm`, `apps/crm`, `app/helpdesk`, `page/home` | | (C) the dispatcher's item read threads the raw claim again | the H0 item-read cells | `2 failed \| 515 passed`: the item read and its `?preview=draft` row | ## Gates, all on head `cf85a44ad5` (after merging `origin/main` at `e956924e1`) - **`pnpm --filter @objectstack/rest test`:** 216 files passed; `3912 passed | 26 skipped`. `test:repo`: 1 file, `8 passed`. - **`pnpm --filter @objectstack/runtime test`:** 284 files passed; `4084 passed | 1 skipped`. `test:repo`: 3 files, `575 passed`, the census included. - **`pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck`:** exit 0, and `check:test-typecheck` is OK on both. - **`pnpm lint`** (`eslint . --no-inline-config`, the whole repo): exit 0. - **`node scripts/check-issue-citations.mjs --base origin/main`:** exit 0. The first read was unauthenticated and answered `403` / exit 3 (PREREQUISITE NOT MET), so it was re-run with an authenticated read. - **`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`:** 62 commands derived, every one run on this head, all exit 0. `check:dual-build-cjs-loads` first needed 8 missing `dist/`s built. `--ran` answers `62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero …)`. - **Consumers the dispatcher's wire change reaches:** `@objectstack/hono` 5 files / 122 tests; `@objectstack/http-conformance` 8 files / 102; `@objectstack/client` `client.hono`, `client-url-conformance` and `meta-delete-item-carriers`, 27; and six `/meta` dogfood files, 72 (`meta-published-and-state-routes`, `route-ledger-live-mount-parity`, `showcase-anonymous-deny-surfaces`, `showcase-object-extension-meta-read`, `dashboard-designer-roundtrip`, `meta-types-create-seed`). All green. **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 turbo run build (the runtime closure, the rest package, the consumer closures), pnpm --filter @objectstack/rest test / test:repo, pnpm --filter @objectstack/runtime test / test:repo, pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck, pnpm lint, pnpm --filter @objectstack/hono --filter @objectstack/http-conformance test, the client and dogfood file runs ## Acceptance notes - **Out of scope, measured, reported (class a): `?layers=` on the dispatcher's item read.** `GET /meta/app/crm?layers=true` through `dispatch()` answers `200 {type, name, item}`, the plain read. `RestServer` answers the three-layer `{type, name, code, overlay, effective}` with `Deprecation: true`. The dispatcher serves no layered view at all: `/meta/app/crm/layers` answers a located `404 ROUTE_NOT_FOUND`, which is loud. The flag, though, is silently a different representation. The item census names `layers` as its one declared exclusion (`ITEM_PARAMS_NOT_SERVED_HERE`), so every other new parameter still reddens it. - **Out of scope, read at source (possible data leak): the same raw-claim source elsewhere in the dispatcher.** `domains/packages.ts` calls `deps.resolveActiveOrganizationId` at 9 sites (publish-drafts, commits, uninstall, revert, duplicate-adopt, the export sweep). Not measured here. Reading `executionContext.tenantId` in `HttpDispatcher.resolveActiveOrganizationId` itself would close the class for every domain. That is `http-dispatcher.ts`, outside this claim. - **Not measured.** The object branch's protocol read now threads `?package=` as `RestServer`'s does. The census double does not discriminate packages on item reads, so no cell moves on it. - **A stale note, not a count.** `scripts/check-route-envelope.mjs`'s `meta.ts` ledger note still describes the second hand-built site as "the /meta/:type list answer". It is now `successWithHeaders`, which every `/meta` read answer that owes a header goes through. The count (2) holds, and the gate is green. The script is not in this claim; its next editor carries it. - **Repeated query parameters are unchanged here and not measured by this PR:** `RestServer`'s item, list and tree handlers refuse a repeated single-valued parameter (`refuseRepeatedQueryParams`), and the dispatcher's `/meta` domain has no such gate. PR #20404 recorded the list half. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9801da1 commit 5c7aa46

11 files changed

Lines changed: 1870 additions & 639 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
'@objectstack/rest': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(rest, runtime): the runtime dispatcher's `/meta` doors scope a caller to the organization `RestServer` scopes them to, and its item read, book tree and list answer what `RestServer`'s answer (#20408)
7+
8+
Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains seven value exports (`createMetaItemAnswer`, `createMetaBookTreeAnswer`, `metaCallerOrganizationId`, `metaReadOrganizationId`, `projectMetaObjectSchema`, `refuseUnknownMetaListType`, `translateMetaEnvelope`) and five type exports (`MetaItemAnswer`, `MetaItemAnswerSources`, `MetaItemRequest`, `MetaBookTreeAnswer`, `MetaBookTreeSources`), and `MetaListAnswer` gains an optional `cacheControl`. `MetaListAnswerSources`, new in this same release with `createMetaListAnswer`, takes the transport's object-schema masker (`resolveObjectMasker`) instead of a whole-mask port, so the chain decides the cache posture for both transports. Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`.
9+
10+
A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any
11+
adapter written on the public `HttpDispatcher` API) serves `/meta` through the
12+
runtime dispatcher. Until now, on such a host:
13+
14+
- **A member removed from an organization kept its metadata partition.** The
15+
dispatcher's `/meta` doors took the organization from the session's
16+
`activeOrganizationId` as stored. Under a wall-enforcing tenancy posture the
17+
identity resolver DROPS a claim naming an organization the caller no longer
18+
belongs to, and `RestServer` reads that vetted value. The dispatcher did not,
19+
so for the rest of the session the removed member was served that
20+
organization's org-scoped overlays (`view`, `dashboard`, `report`,
21+
`translation`, `email_template`) by the item read, the list, `/published` and
22+
`?state=draft`, listed its pending drafts on `GET /meta/_drafts`, and had a
23+
`PUT /meta/:type/:name` land in its partition. Every `/meta` door here now reads
24+
the vetted organization on the execution context, the value `RestServer` reads.
25+
- **`GET /meta/:type/:name` answered a different body.** Nothing was translated
26+
whatever `Accept-Language` or `?locale=` asked for. A doc kept its whole
27+
`translations` map, in no locale. An object schema came with no
28+
`sortability`. The answer had no `Vary: Accept-Language`.
29+
- **`?preview=DRAFT`** (any casing but lower) from a builder read the published
30+
world on the item read and the list. `RestServer` compares it
31+
case-insensitively.
32+
- **`GET /meta/object/:name?preview=draft`** from a builder answered the ACTIVE
33+
schema, never the pending draft.
34+
- **`GET /meta/totally_invented_type`** answered `200 {"items": []}`. `RestServer`
35+
refuses a segment that names no metadata type with `400 INVALID_REQUEST`.
36+
- **`GET /meta/book/:name/tree`** was no route: `404 ROUTE_NOT_FOUND` to a signed-in
37+
reader and `401` to an anonymous reader of a `public` book (ADR-0046 §6.7).
38+
- **An object schema served under an undetermined field visibility** (ADR-0106
39+
D6 tier 2: served unmasked) carried no `Cache-Control`. `RestServer` answers
40+
`private, no-store`. This was true of the list, the item read, `/published` and
41+
the legacy one-segment object read.
42+
43+
**What changed.** Everything `RestServer`'s `GET /meta/:type/:name` does after
44+
the store read moved, unchanged, into `createMetaItemAnswer`: absence, the item
45+
gate, the doc locale collapse, the object mask and its cache posture, and the
46+
body (the translation and `sortability`, `translateMetaEnvelope`). The book-tree
47+
route's whole answer moved into `createMetaBookTreeAnswer`, and the list's
48+
unknown-type refusal into `refuseUnknownMetaListType`. The list chain now
49+
applies the object mask itself (`projectMetaObjectSchema`) and reports the cache
50+
posture. The dispatcher's `/meta` domain calls each of these, and takes its
51+
organization from `metaCallerOrganizationId` / `metaReadOrganizationId`, which
52+
`RestServer`'s list and item reads ask too.
53+
54+
`RestServer`'s own answers are unchanged: the move is a refactor on that side,
55+
and every existing REST test passes unedited.

‎packages/rest/src/index.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,24 +90,47 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu
9090
// [#20320] …and everything else the two transports' `/meta` reads must answer
9191
// alike: the list route's whole post-read chain (`createMetaListAnswer` — the
9292
// `api` served-set face, the list gate, `?id=`, `?object=`, the doc locale
93-
// collapse and slim, the transport's object mask, the translation
93+
// collapse and slim, the object mask over the transport's masker, the translation
9494
// `translateMetaList`), the one locale parse it reads (`metaRequestLocale`),
9595
// the anonymous gates'
9696
// `public`-audience predicate (`isPublicAudienceRead`) and the stored-version
9797
// doors' policy (`STORED_VERSION_DOOR_POLICY`, which `?state=draft` runs).
98+
//
99+
// [#20408] …and the item read's and the book tree's: the item route's whole
100+
// post-read chain (`createMetaItemAnswer` — absence, the item gate, the doc
101+
// locale collapse, the object mask and its `private, no-store`, and the body
102+
// `translateMetaEnvelope` builds: the translation and `sortability`), the one
103+
// projection every object-schema exit applies (`projectMetaObjectSchema`), the
104+
// `GET /meta/book/:name/tree` answer (`createMetaBookTreeAnswer`), the list's
105+
// unknown-type refusal (`refuseUnknownMetaListType`) and the organization a
106+
// caller's `/meta` request is scoped to — the VETTED one on its execution
107+
// context (`metaCallerOrganizationId`, and `metaReadOrganizationId` for a read
108+
// of one type).
98109
export {
110+
createMetaBookTreeAnswer,
111+
createMetaItemAnswer,
99112
createMetaItemReadGate,
100113
createMetaListReadGate,
101114
createMetaListAnswer,
102115
isPublicAudienceRead,
116+
metaCallerOrganizationId,
117+
metaReadOrganizationId,
103118
metaRequestLocale,
119+
projectMetaObjectSchema,
120+
refuseUnknownMetaListType,
104121
STORED_VERSION_DOOR_POLICY,
122+
translateMetaEnvelope,
105123
translateMetaList,
106124
} from './meta-item-read-gate.js';
107125
export type {
126+
MetaBookTreeAnswer,
127+
MetaBookTreeSources,
128+
MetaItemAnswer,
129+
MetaItemAnswerSources,
108130
MetaItemReadGateSources,
109131
MetaItemReadRefusal,
110132
MetaItemReadVerdict,
133+
MetaItemRequest,
111134
MetaListAnswer,
112135
MetaListAnswerSources,
113136
MetaListRequest,

0 commit comments

Comments
 (0)