Skip to content

Commit 95f729a

Browse files
fix(rest, runtime): the dispatcher's /meta reads answer what RestServer's answer (#20320) (#20404)
Fixes #20320 Clause-②: yes (widening) The runtime dispatcher's `/meta` reads now give the same answers as `RestServer`'s. A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, or any adapter on the public `HttpDispatcher` API) serves `/meta` through the dispatcher. Triage's direction was to extend the shared seam (AGENTS.md 〈Route & surface ownership〉 rule 1: one implementation, two transports). ADR-0076 item 9 keeps the catch-all as the fallback. ## What changed **Row A: one list chain.** Everything `RestServer`'s `GET /meta/:type` does to a list after the store read moved, unchanged, into `createMetaListAnswer` in `packages/rest/src/meta-item-read-gate.ts`, beside `createMetaListReadGate`. The steps, in `RestServer`'s order: 1. the #5224 `api` served-set face; 2. the per-caller list gate; 3. `?id=` for apps; 4. `?object=` for views; 5. the ADR-0046 doc locale collapse; 6. the doc content slim; 7. the transport's own ADR-0106 object mask (a port); 8. the translation. `RestServer`'s handler keeps its entry: the repeated-parameter refusal, the unknown-type refusal and the admitted `previewDrafts` declaration from #20338, still ONE declaration ahead of every read of it. It then calls the chain. Every exit of the dispatcher's list branch (protocol, `MetadataService`, ObjectQL registry) calls the same chain. The dispatcher's own `slimDocList` is gone. Its list answer now carries `Vary: Accept-Language`, as `RestServer`'s does. The chain needs the locale parse and the translation helpers, so those moved too. `RestServer`'s private methods `extractLocale`, `buildTranslationBundle`, `translateOptionsFor`, `packagedObjectBase`, `translateMetaItems` and the module-level `isTranslatableMetaType` are now one-line delegates to them. Nothing in `meta.ts` is a copy. **Row B: one public-audience predicate.** `isPublicAudienceRead` moved out of `RestServer`. It now takes a method, a route shape and the raw `:type`. `RestServer`'s static maps its registered path onto a shape and delegates. The dispatcher's anonymous gate at `handleMetadataRequest`'s entry asks the same predicate with its own shapes: `list` for one segment, `item` for two. The dispatcher has no `/book/:name/tree` route; that path falls to the located `ROUTE_NOT_FOUND` tail. So the dispatcher never names `book-tree`, and that path, like `/published`, keeps the deny. The legacy one-segment object-name exit answers the anonymous deny, so the exemption can never reach an object schema. **The `?state=draft` row.** The dispatcher's item read declares `?state=draft` next to `?preview=draft`, each with its `mayReadPendingDrafts` admission, above every branch. It parses the parameter as `RestServer` does. An admitted caller gets the protocol's draft read for any type, the object branch included: - `404 NO_DRAFT` answered as itself when nothing is pending; - otherwise the draft through the per-caller gate under `STORED_VERSION_DOOR_POLICY`. The constant moved to the shared module; `RestServer` keeps its static name as an alias. - `mayWriteItem` comes from the dispatcher's own save-door admission. That admission is now spelled once, as a local in `handleMetadataRequest`, and the `PUT` branch uses it too. A caller the predicate does not admit gets the plain read, byte for byte. **Add-on.** The cached arm's `[#9741] Typed request` comment now says the branch is unreachable for an ADMITTED switch. The query parameter itself still reaches it. **Gate ledger.** `scripts/check-route-envelope.mjs` declares `meta.ts` `handBuilt: 2` (was 1), with a note. The dispatcher's list answer must carry `Vary: Accept-Language`, and `deps.success` takes no headers. ## What a dispatcher-only host answered before (measured on `b1cbd9277`) The census drives `dispatch()` and `RestServer` over the same fixtures: - 18 type cells × 10 parameter probes × 4 callers = 720 list cells; - anonymous public book and doc reads, with controls; - the `?state=draft` cells. List cells, by cause, before the fix: | cause | cells | |:--|--:| | the same answer | 140 | | `Vary` header only | 374 | | items differ | 166 | | status differs (anonymous `401` vs `200`) | 40 | - `?id=crm` listed every visible app. `?object=lead` listed every view. - `/meta/docs` served bodies. Every doc list kept `translations`, with no locale collapse. - Translatable lists were untranslated. - `/meta/api` listed the unserved declaration. - Anonymous `public` book and doc lists and items answered `401`. - Admitted `?state=draft` answered the active item: 10 cells, builder and author. ## Evidence - **Red first.** The census file at the base: `198 failed | 13 passed (211)`. On the fix: `211 passed (211)`. - **REST unchanged.** `pnpm --filter @objectstack/rest test`: `211 passed` files, `3831 passed | 2 skipped` tests, with every existing REST test unedited. `test:repo`: `8 passed`. - **Runtime.** `pnpm --filter @objectstack/runtime test`: `283 passed` files, `4073 passed | 1 skipped` tests. `test:repo`: `280 passed`, the census included. - **Runtime pins that moved, and why:** - the list parity's anonymous test and the item parity's anonymous test: book and doc reads now reach the §6.7 gate, as on REST, and every other type keeps the deny; - the draft-door ledger gains the `?state=draft` site. - **Ablations.** Each was committed first, mutated through `scripts/ablation-replace.mjs`, and restored with the restore proven: blob equal to HEAD, empty `git diff HEAD`, zero markers left. The subject resolves through the runtime vitest alias to `packages/rest/src`, so no `dist` was involved. The suites run: the census and `meta-list-read-gate-parity.test.ts`, 251 tests. - **(A) The shared chain as a no-op: `19 failed`.** The direction was NOT the predicted one. The 720 parity cells stayed green, because both transports call the one chain and move together; that is what one implementation means. The reference pins went red: the reference-moves pin, 4 row-B list cells, and 14 #20237 reference and gate cells. The first attempt was refused by the tool (its replacement contained its anchor) and ran nothing; the second attempt is the measurement. - **(A2) The dispatcher bypassing the chain's query and translation: `27 failed`.** 20 census cells went red (`?id=` 4, `?object=` 4, `?include=content` 2, translation 10), plus 7 #20237 cells. This is the ablation that shows the parity cells can fail. - **(B) The dispatcher skipping `isPublicAudienceRead`: `49 failed`.** 40 anonymous book and doc census cells, 8 row-B cells and 1 #20237 cell went red. The `401` controls stayed green. ## Gates, all on head `64a05bb97` (after merging `origin/main` at `15bf186f5`) - **`pnpm --filter @objectstack/rest test`:** 213 files passed; `3886 passed | 26 skipped`. `test:repo`: 1 file, `8 passed`. - **`pnpm --filter @objectstack/runtime test`:** 283 files passed; `4073 passed | 1 skipped`. `test:repo`: 3 files, `280 passed`. - **`pnpm --filter @objectstack/rest typecheck` and `pnpm --filter @objectstack/runtime typecheck`:** exit 0, `check:test-typecheck` OK on both. - **`pnpm lint`:** exit 0, the whole repo, 133s. - **`node scripts/check-issue-citations.mjs --base origin/main`:** exit 0. - **`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`:** 81 commands derived. 80 were run on this head, each exit recorded, and all exit 0 except: - `pnpm check:dual-build-cjs-loads`, exit 3: **NOT MEASURED**, `PREREQUISITE NOT MET`, because 36 packages have no `dist/` in this worktree. - `pnpm check:pm-dispatch-gates`, exit 124: **NOT MEASURED**, because its self-test alone ran past a 580s foreground cap. The diff touches no `scripts/pm/` file; the family comes from the `scripts/` path of `check-route-envelope.mjs`. - `pnpm check:dts-closure` first answered 1 on tree state: `client`, `organizations` and `verify` had a `dist/` without `.d.ts`, and this diff touches none of them. After rebuilding those three it answers 0; that is the recorded reading. - **Reconciliation:** `dispatch-gates --ran` answers `81 derived famil(ies) accounted for — 79 run, 2 NOT-MEASURED` (the two named above), exit 0. - **`check-route-envelope.mjs` edited:** the script has no test file of its own, and no test executes it. Its `--self-test` passes inside `pnpm check:route-envelope`. ## Acceptance notes - **Out of scope; the census found these and they are reported, not fixed.** The same family: the dispatcher's `/meta` answering differently from `RestServer`. Each is measured through `dispatch()` on the fixtures above. 1. `GET /meta/totally_invented_type` answers `200 []` where `RestServer` answers `400 INVALID_REQUEST` (the #9488 refusal is REST-only). 2. `?preview=DRAFT` from a builder: the dispatcher compares case-sensitively and lists the published world, where `RestServer` overlays the drafts. 3. The item read does not translate: `GET /meta/app/crm` with `Accept-Language: zh-CN` answers `CRM` against `客户管理`. 4. The item read does not collapse a doc's locale: it keeps `translations`, where REST answers `入门`. 5. There is no `/meta/book/:name/tree` route: `404 ROUTE_NOT_FOUND` to an authenticated caller, where `RestServer` serves the tree. 6. The object branch ignores `?preview=draft`: a builder reads `Invoice`, where REST answers `Invoice (draft)`. - **Not measured; read at source only.** - The dispatcher's list and item reads thread `resolveActiveOrganizationId` for every type. `RestServer` threads `organizationIdForMetaRead` over the folded type, and only for org-overridable types. - The dispatcher's object mask sets no `Cache-Control: private, no-store` on an undetermined posture. `RestServer`'s list does. - **Repeated parameters.** `RestServer` refuses a repeated `?id=` with `400`. The Hono catch-all flattens the query to last-wins before `dispatch()`, so the dispatcher never sees a repeat. That is the adapter's seam, not this domain's. - **Docs drift, not filed.** `content/docs/permissions/system-context.mdx` row 49 lists `/layers`, `?layers=true` and `/diff` as the doors where a caller the save verdict admits reads an app's full stored version. It does not list `?state=draft`: not on REST since #20290, and not on the dispatcher after this PR. - **Public API.** `@objectstack/rest` gains the exports `createMetaListAnswer`, `translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, `STORED_VERSION_DOOR_POLICY` and their types. Nothing is removed. That is a widening of its published surface, so the changeset declares `@objectstack/rest` `minor` with `Clause-②: yes (widening)`; `@objectstack/runtime` stays `patch`. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d63088 commit 95f729a

11 files changed

Lines changed: 1693 additions & 527 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@objectstack/rest': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(rest, runtime): the runtime dispatcher's `/meta` reads answer what `RestServer`'s answer — one list chain, one `public`-audience predicate, and the `?state=draft` read (#20320)
7+
8+
Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains five value exports (`createMetaListAnswer`, `translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, `STORED_VERSION_DOOR_POLICY`) and six type exports (`MetaListAnswer`, `MetaListAnswerSources`, `MetaListRequest`, `MetaListTranslationSources`, `MetaPublicReadRoute`, `MetaRequestHttp`), so its published surface grows; nothing it exported before 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. Its reads now give the same answers as `RestServer`'s
13+
`GET /meta/:type` and `GET /meta/:type/:name`. Until now a dispatcher-only host
14+
answered:
15+
16+
- **`GET /meta/app?id=crm`** — every app the caller may see, not `[crm]`. An
17+
`?id=` that matches nothing listed every app instead of an empty list.
18+
- **`GET /meta/view?object=lead`** — every view, not the lead views sorted for
19+
the switcher.
20+
- **`GET /meta/docs`** (the plural spelling) — every doc WITH its body. The
21+
content slim compared the raw segment, so it ran only for `/meta/doc`.
22+
- **any doc list** — each doc with its `translations` map and in no locale.
23+
`RestServer` collapses each doc to the request's locale.
24+
- **every translatable list** (`app`, `view`, `object`, `page`, `dashboard`,
25+
`action`, `dataset`) — untranslated labels, whatever `Accept-Language` or
26+
`?locale=` asked for, and no `Vary: Accept-Language` header.
27+
- **`GET /meta/api`** — every stored `api` declaration, including ones the
28+
endpoint matcher does not serve (their routes answer 404).
29+
- **an anonymous `GET` of a `public` book or doc** (list or item) —
30+
`401 UNAUTHENTICATED`. `RestServer` serves it (ADR-0046 §6.7).
31+
- **`GET /meta/:type/:name?state=draft` from a caller who may read drafts** —
32+
the ACTIVE item. It should be the pending draft, whole for a caller who may
33+
save the app and pruned per caller for everyone else, or `404 NO_DRAFT` when
34+
nothing is pending. A caller who may not read drafts is still answered the
35+
plain read, byte for byte.
36+
37+
**What changed.** The list route's whole post-read chain moved out of
38+
`RestServer` into `createMetaListAnswer` in `@objectstack/rest`, unchanged. That
39+
chain is the `api` served-set face, the per-caller list gate, `?id=`,
40+
`?object=`, the doc locale collapse and content slim, the transport's own
41+
object mask, and the translation. Every exit of the dispatcher's list branch
42+
now hands its answer to that same function. The anonymous gates on both
43+
transports ask one exported predicate, `isPublicAudienceRead`. It admits only
44+
`GET` reads of book and doc, so every other type keeps the anonymous deny, and
45+
the §6.7 audience gate still refuses `org` and `{ permissionSet }` audiences.
46+
The dispatcher's `?state=draft` read runs the exported
47+
`STORED_VERSION_DOOR_POLICY`, the constant `RestServer`'s draft branch runs.
48+
49+
`RestServer`'s own answers are unchanged: the move is a refactor on that side,
50+
and every existing REST test passes unedited.
51+
52+
New exports from `@objectstack/rest`: `createMetaListAnswer`,
53+
`translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`,
54+
`STORED_VERSION_DOOR_POLICY` and their types. Nothing is removed or renamed.

‎packages/rest/src/index.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,34 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu
8686
// audience prunes, the app nav filter and the dashboard widget gate — over the
8787
// same ports. The judge answers the pruned ITEMS; each caller rewraps them in
8888
// its own list envelope.
89-
export { createMetaItemReadGate, createMetaListReadGate } from './meta-item-read-gate.js';
89+
//
90+
// [#20320] …and everything else the two transports' `/meta` reads must answer
91+
// alike: the list route's whole post-read chain (`createMetaListAnswer` — the
92+
// `api` served-set face, the list gate, `?id=`, `?object=`, the doc locale
93+
// collapse and slim, the transport's object mask, the translation
94+
// `translateMetaList`), the one locale parse it reads (`metaRequestLocale`),
95+
// the anonymous gates'
96+
// `public`-audience predicate (`isPublicAudienceRead`) and the stored-version
97+
// doors' policy (`STORED_VERSION_DOOR_POLICY`, which `?state=draft` runs).
98+
export {
99+
createMetaItemReadGate,
100+
createMetaListReadGate,
101+
createMetaListAnswer,
102+
isPublicAudienceRead,
103+
metaRequestLocale,
104+
STORED_VERSION_DOOR_POLICY,
105+
translateMetaList,
106+
} from './meta-item-read-gate.js';
90107
export type {
91108
MetaItemReadGateSources,
92109
MetaItemReadRefusal,
93110
MetaItemReadVerdict,
111+
MetaListAnswer,
112+
MetaListAnswerSources,
113+
MetaListRequest,
114+
MetaListTranslationSources,
115+
MetaPublicReadRoute,
94116
MetaReadGateCaller,
95117
MetaReadGatePolicy,
118+
MetaRequestHttp,
96119
} from './meta-item-read-gate.js';

0 commit comments

Comments
 (0)