Repository navigation
Commit 8441a01
feat(rest): one request loads an app's metadata — GET /meta/app/{appId}/bundle, per caller, with an ETag (#28287)
domain:spec#1 · os-tesla · session_01L9qibwM9YxDuiRDQYmA1Yd
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoCx7hpZ43LGt375Y5Yd8iPe)_
Part of #27627
Claim: 6538108232
Clause-②: yes (widening: a new route, its response shape and a new
client method; no existing route, key or answer changes)
Metadata-protocol: added
Skills-docs: none edited. No skill teaches which metadata requests a
console sends while it works inside an app, and the API skill's spec
index (`skills/objectstack-api/references/_index.md`) names no `api/`
schema file, so the new `api/app-bundle.zod.ts` needs no row. Named, not
edited.
## 维护者速读
- **改了什么:** 新增 `GET
/api/v1/meta/app/{appId}/bundle`,一次请求返回控制台在一个应用里要用的元数据:应用本身和导航、导航能到达的对象及其一跳内的入向关联对象、这些对象的视图、页面,以及导航点名的仪表盘。响应带按调用者计算的弱
ETag(与其他 `/meta` 读取共用 #28320 的同一个验证器),带 `If-None-Match` 且内容没变就回
304;`@objectstack/client` 加 `meta.getAppBundle(appId, { ifNoneMatch
})`,并顺手修好 `meta.getCached` 收到 304 时抛错、走不到自己 304 分支的问题(席位要求)。
- **为什么改:** PR 1(#27713)把打开记录的数据请求合成了一个,但每打开一个对象仍要单独读一次元数据;云端每个请求本身就要
0.5–1.4 秒,元数据一次取齐、会话内只做 304 校验,打开记录才不再等元数据。
- **风险与回滚:** 包里每一项都以同一调用者身份走单项读取 `GET /meta/{类型}/{名称}` 的同一条链路
`createMetaItemAnswer`(同一个读取闸门、字段可见性屏蔽、语言翻译),没有第二份闸门;看不到的项不出现,看不到应用就按应用读取的原样拒绝整个请求。ETag
由共用验证器对发给这个调用者的响应体求哈希,两人只有拿到完全相同的内容才共用一个 ETag;字段可见性无法判定时改为 `private,
no-store` 且不发 ETag。原有路由不变,回滚 = revert 本 PR。
- **先看:** `packages/rest/src/meta-app-bundle-routes.ts`(成员推导、逐项过闸门、验证器与
304),然后 `packages/rest/src/meta-app-bundle-routes.test.ts`
里“每一项与它自己的单项读取逐字节相同”那条。
- **两分钟验证:** `cd packages/rest && npx vitest run
src/meta-app-bundle-routes.test.ts` → 15
个用例绿;把交给共用验证器的内容改成只有键名(与调用者无关)会让“ETag 等于响应体的哈希”“不同闸门得到不同
ETag”和“304/另一调用者的 ETag 回 200”三条变红。
- **需要你做的:** 无,席位落地。
## What changes
- **The shape** (`AppMetadataBundleResponseSchema`,
`@objectstack/spec/api`, in `packages/spec/src/api/app-bundle.zod.ts`
beside PR 1's `record-page.zod.ts`): `{ app, objects, views, pages,
dashboards }`, answered bare like the `/meta` item reads. `app` and
every list entry are `GetMetaItemResponseSchema`, the envelope the
member's own `GET /meta/{type}/{name}` answers. The module header states
what each list holds and in which order, the absence rule and the cache
contract.
- **The route** (`packages/rest/src/meta-app-bundle-routes.ts`,
registered inside the `/meta` umbrella on the per-item switch
`metadata.endpoints.item`, so the anonymous deny and the type read
admission wrap it like every `/meta` read):
- **Members.** The app is read first. Its navigation is walked at every
depth (top-level `navigation`, `areas[].navigation`, `children`):
`object` entries name objects and their `viewName`, `page` entries name
pages, `dashboard` entries name dashboards. Objects are the
navigation's, then one hop of inbound relations (an object with a
`lookup` or `master_detail` field whose `reference` is a reached
object). Views are the navigation's named views plus every view that
binds to a served object (`deriveViewContainerObject`); pages are the
navigation's plus every page whose `object` is a served object;
dashboards are the navigation's.
- **One gate.** Every member is `createMetaItemAnswer` over
`p.getMetaItem({ type, name })` with the rendered read's policy `{ arms:
'all', app: 'gate' }` and the member type's own field-visibility
posture: the same absence check, read gate, doc locale collapse, field
mask and translation the item read runs. A member the chain refuses or
finds absent is left out, with nothing in the body naming it. An app the
caller may not read, or an unknown app, answers exactly what `GET
/meta/app/{appId}` answers. A field-visibility evaluation fault answers
the item read's 503.
- **Validator.** The `/meta` reads' shared one from #28320: the body is
sent by `sendMetaRepresentation(req, res, body,
metaRepresentationCaching(body, undetermined), metadata.enableCache !==
false)`, so the `ETag` is the weak `representationEtag` of the served
body, computed after every gate and therefore per caller by
construction. `Cache-Control: private, no-cache`, `Vary:
Accept-Language`, and a matching `If-None-Match` (compared weakly)
answers `304` with no body. A member served under an undetermined field
visibility is one the item chain issued no etag; its `private, no-store`
becomes the whole bundle's, with no `ETag`. `metadata.enableCache:
false` sends no validator, as it does for the item reads. The route has
no validator rule of its own.
- **Server-Timing.** The member reads run inside
`measureServerTiming('meta', …, 'Metadata reads')`, so a `meta` span
sits beside `db` and `total` under the existing gate. Opening the header
to every caller is out of scope.
- **Closed query set.** `refuseUnknownQueryParams` with an empty set:
any query parameter is a 400 before anything is read.
- **`rest-server.ts`:** `metaItemAnswerSources` takes the member's type
as an optional fifth argument (defaulting to `req.params.type`, so the
item routes are unchanged), and the bundle route is registered after the
item reads.
- **The client** (`packages/client/src/index.ts`):
`meta.getAppBundle(appId, { ifNoneMatch })` answers
`AppBundleCacheResponse`, `{ notModified: false, data, etag }` on a
`200` and `{ notModified: true, etag }` on a `304`. It follows the
spelling of the existing conditional read `meta.getCached`
(`ifNoneMatch` in, `notModified` and `etag: { value, weak }` out). The
private `fetch` gains an opt-in `admitNotModified` so a `304` is
answered instead of thrown. `meta.getCached` passes it too, which fixes
its unreachable `304` branch (see Acceptance notes).
- **Ledger and censuses:** a `GET /api/v1/meta/app/:appId/bundle` row in
`rest-route-ledger.ts` (`sdk`, `meta.getAppBundle`,
`AppMetadataBundleResponseSchema`); the new module on
`REST_SERVER_REGISTRAR_FILES` and `REST_SERVER_META_ROUTE_FILES`; its
canonical-query (0) and docblock (16) floors; the mount-table pin's
`metadata.endpoints.item` and `api.enableMetadata` rows; a
`check-route-envelope` MODULES row, ratcheted on #9559 like
`record-page-routes.ts`.
- Generated: `api-surface/`, `authorable-surface/`, `declaration-map/`,
`export-origins/`, `json-schema.manifest/`,
`content/docs/references/api/app-bundle.mdx` and the reference indexes,
the strictness-ledger count, `llms.txt` counts.
- Changeset `.changeset/27627-app-metadata-bundle.md`, `minor` for
`@objectstack/spec`, `@objectstack/rest` and `@objectstack/client`.
## Tests
- `packages/spec/src/api/app-bundle.test.ts` (3): the shape takes the
app and four lists of item envelopes; each list's element is
`GetMetaItemResponseSchema` itself; a missing list, an entry without its
envelope and an undeclared key are refused.
- `packages/rest/src/meta-app-bundle-routes.test.ts` (15), on a real
`RestServer` mount with a CRM app (navigation with a gated object, a
page, two dashboards one gated, and an area) and a gated payroll app:
- the navigation walk and the inbound-relation hop, and the empty closed
query set;
- the admin's bundle holds every member in order and parses against the
named schema;
- **every member is byte-equal to that caller's own `GET
/meta/{type}/{name}`**, for both callers;
- **a caller without read gets a bundle without the item**: the member's
bundle has no gated object, no object reached only through it, no gated
view and no gated dashboard, and no member names them (the app's own
read still lists the gated dashboard in its navigation, as `GET
/meta/app/crm` does);
- a Server-Timing `meta` mark is recorded;
- the `ETag` is exactly `representationEtag` of the served body, weak,
under `private, no-cache`;
- **differing gates give different ETags**, and the same caller twice
gives the same one;
- **an unchanged bundle answers 304** (with the tag as sent, and with
its bare opaque form), while another caller's ETag or a changed store
answers 200 with a new ETag;
- `metadata.enableCache: false` sends no validator and answers a
matching `If-None-Match` in full;
- an undetermined field-visibility posture serves `private, no-store`
with no ETag;
- **an unknown app gets the same refusal as `GET /meta/app/{unknown}`**
(status and body), and only the app is read; a gated app gets the app
read's 403;
- an unknown query parameter is a 400 with nothing read.
- `packages/client/src/meta-app-bundle.test.ts` (3): the URL is encoded
and the 200 answers data and etag; `ifNoneMatch` is sent and **a 304
answers `notModified` instead of throwing**; a 403 still rejects with
the server's status and code; `meta.getCached` answers a 304 as `{
notModified: true }` with the validator it sent, and a 404 still
rejects. Ablation: dropping the opt-in from `getCached` turns that 304
test red, and the restore brings back HEAD's blob.
- **Ablation of the per-caller fold:** `node
scripts/ablation-replace.mjs` swapped `metaRepresentationCaching(body,
undetermined)` for `metaRepresentationCaching(Object.keys(body),
undetermined)`, a validator input that no longer depends on what this
caller was served. 3 of 15 went red (the `representationEtag` pin, "two
callers whose gates differ are answered two validators", and "an
unchanged bundle answers 304 … another caller's validator, or a changed
store, is answered in full"). The tool restored the blob to HEAD, and
the rerun is 15/15 green. (On the earlier head, before the shared
helper, the same ablation on the route's own validator turned 2 of 14
red.)
## Acceptance notes
- **Validator, a declared deviation from the order's "fold the same
dimensions":** the ETag hashes the served body after every gate instead
of folding the per-dimension fingerprints. Two callers share it exactly
when they are served the same body, which the fold could only
approximate. Spec seat 1 confirmed this rule. Since #28320 landed, the
bundle uses that PR's shared helper rather than its own copy of the
rule, so the `ETag` is weak `W/"…"` like every other `/meta` read's. The
members' bytes did not change.
- **Runtime dispatcher:** the bundle is served by the `RestServer` route
only. The runtime dispatcher's `/meta` catch-all does not serve it, the
same as PR 1's record-page route.
- **Dashboards** come from the navigation only. A dashboard has no
object binding to follow, so no second rule adds them.
- **Deviation, added at spec seat 1's request: `meta.getCached`'s `304`
is fixed here.** Its `304` branch in `packages/client` was unreachable,
because the private `fetch` threw on any non-`ok` status before the
branch ran, so a matching `If-None-Match` rejected instead of answering
`notModified`. It now passes the same `admitNotModified` opt-in as
`getAppBundle`. The one consumer, `useMetadata`'s cached arm in
`@objectstack/client-react`, already branches on `notModified`, and its
suite is 34/34.
- **Out of scope per the order:** the console consumer (ui seat 2, which
is bound to this shape), opening Server-Timing to every caller, and
cloud#3378.
## Verification
Head `170b86ad`, merged with main at `f49b4787` (which brings #28320).
- **Tests:** route 15/15; full `packages/rest` suite 361 files, 7148
passed; client suite 687/687 and `client-react` 34/34 at `1e7a1b10` (the
client is unchanged since); dogfood `authz-probe-blind-spot` and
`authz-conformance` 70/70 at `28ac68c2`.
- **Typecheck:** `tsc --noEmit` in `packages/rest`, `packages/client`
and `packages/spec`, all exit 0.
- **Generated artifacts:** the spec header's wording change regenerated
`content/docs/references/api/app-bundle.mdx` (`check:generated --fix`,
which rebuilt the spec dist first, then exit 0); `check:route-envelope`
and its self-test exit 0.
- **Derived gates on `170b86ad`:** `node scripts/pm/dispatch-gates.mjs
--commands` named the same 125 commands as before. Every one was run
with stdin closed and its exit recorded, and all 125 exit 0. The ones
that read built output, plus `check:docs` and `check:generated`, first
recorded exit 3, which means "not measured", not a finding. They were
rerun after the spec, client and client-react rebuilds, the page
regeneration, and a `pnpm install --frozen-lockfile` for the workspace
link the merged main added to `@objectstack/cloud-connection`. `--ran`
reconciliation: "125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN", exit 0.
The earlier run on `28ac68c2` was also 125 of 125. Its
`check:doc-authoring` caught a real `#27627` in the ledger row's `note:`
string, and that fix is in this head.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 8771726 commit 8441a01
26 files changed
Lines changed: 1216 additions & 14 deletions
File tree
- .changeset
- content/docs/references
- api
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- client/src
- rest/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src/api
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| 13 | + | |
13 | 14 | | |
14 | 15 | | |
15 | 16 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| 35 | + | |
35 | 36 | | |
36 | 37 | | |
37 | 38 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
66 | | - | |
| 66 | + | |
67 | 67 | | |
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| 73 | + | |
73 | 74 | | |
74 | 75 | | |
75 | 76 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
| |||
252 | 253 | | |
253 | 254 | | |
254 | 255 | | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
255 | 265 | | |
256 | 266 | | |
257 | 267 | | |
| |||
2092 | 2102 | | |
2093 | 2103 | | |
2094 | 2104 | | |
| 2105 | + | |
| 2106 | + | |
2095 | 2107 | | |
2096 | 2108 | | |
2097 | | - | |
| 2109 | + | |
2098 | 2110 | | |
2099 | 2111 | | |
2100 | 2112 | | |
| |||
2262 | 2274 | | |
2263 | 2275 | | |
2264 | 2276 | | |
| 2277 | + | |
| 2278 | + | |
| 2279 | + | |
| 2280 | + | |
| 2281 | + | |
| 2282 | + | |
| 2283 | + | |
| 2284 | + | |
| 2285 | + | |
| 2286 | + | |
| 2287 | + | |
| 2288 | + | |
| 2289 | + | |
| 2290 | + | |
| 2291 | + | |
| 2292 | + | |
| 2293 | + | |
| 2294 | + | |
| 2295 | + | |
| 2296 | + | |
| 2297 | + | |
| 2298 | + | |
| 2299 | + | |
| 2300 | + | |
| 2301 | + | |
| 2302 | + | |
| 2303 | + | |
2265 | 2304 | | |
2266 | 2305 | | |
2267 | 2306 | | |
| |||
7599 | 7638 | | |
7600 | 7639 | | |
7601 | 7640 | | |
7602 | | - | |
| 7641 | + | |
| 7642 | + | |
| 7643 | + | |
| 7644 | + | |
| 7645 | + | |
7603 | 7646 | | |
7604 | 7647 | | |
7605 | 7648 | | |
| |||
7635 | 7678 | | |
7636 | 7679 | | |
7637 | 7680 | | |
7638 | | - | |
| 7681 | + | |
7639 | 7682 | | |
7640 | 7683 | | |
7641 | 7684 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
0 commit comments