From 3d09fd1b4950b5873444a3c078988f2602e42f03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:08:59 +0000 Subject: [PATCH 01/11] feat(spec): declare the app metadata bundle shape `AppMetadataBundleResponseSchema` (`@objectstack/spec/api`) is the answer of `GET /api/v1/meta/app/{appId}/bundle`: the app and the objects, views, pages and dashboards its navigation reaches, each member the envelope its own `GET /meta/{type}/{name}` answers. Generated artifacts regenerated. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- .changeset/27627-app-metadata-bundle.md | 19 ++ content/docs/references/api/app-bundle.mdx | 198 ++++++++++++++++++ content/docs/references/api/index.mdx | 3 +- content/docs/references/api/meta.json | 1 + content/docs/references/index.mdx | 9 +- .../api.md | 2 +- packages/spec/api-surface/api.json | 3 + packages/spec/authorable-surface/api.json | 5 + packages/spec/declaration-map/api.json | 2 + packages/spec/export-origins/api.json | 3 + packages/spec/json-schema.manifest/api.json | 1 + packages/spec/llms.txt | 4 +- packages/spec/src/api/app-bundle.test.ts | 41 ++++ packages/spec/src/api/app-bundle.zod.ts | 76 +++++++ packages/spec/src/api/index.ts | 1 + 15 files changed, 360 insertions(+), 8 deletions(-) create mode 100644 .changeset/27627-app-metadata-bundle.md create mode 100644 content/docs/references/api/app-bundle.mdx create mode 100644 packages/spec/src/api/app-bundle.test.ts create mode 100644 packages/spec/src/api/app-bundle.zod.ts diff --git a/.changeset/27627-app-metadata-bundle.md b/.changeset/27627-app-metadata-bundle.md new file mode 100644 index 00000000000..9101d0d6971 --- /dev/null +++ b/.changeset/27627-app-metadata-bundle.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/rest': minor +'@objectstack/client': minor +--- + +feat(rest): `GET /api/v1/meta/app/{appId}/bundle` answers an app's metadata in one request, per caller, with an ETag (#27627) + +Clause-②: yes (widening: a new route and its response shape; no existing route, key or answer changes) +Metadata-protocol: added + +A console working inside one app used to read the app, then one `GET /meta/object/{name}` per object it opened, then the views, pages and dashboards those draw, each as its own request. The new route answers them together (`AppMetadataBundleResponseSchema`, `@objectstack/spec/api`): + +- **What is in it**: the app with its navigation; every object its navigation reaches (top-level, area and nested entries) and every object with a lookup or master-detail field referencing one of those; the views the navigation names or that bind to a bundled object; the pages the navigation names or that bind to a bundled object; the dashboards the navigation names. +- **Every member is the item read it replaces, as the same caller**: the app is `GET /meta/app/{appId}`, and each entry is `GET /meta/{type}/{name}`, envelope included. Audience gates, list-view and dashboard permissions, the field-visibility mask, the translation to the request locale and `sortability` apply exactly as they apply to those reads. An item the caller may not read is absent from the bundle. An app the caller may not read refuses the whole request with the app read's own answer. +- **Cached per caller**: a strong `ETag` over the bytes served to this caller, `Cache-Control: private, no-cache`, `Vary: Accept-Language`, and `If-None-Match` answered `304`. Two callers share a validator only when they are served the same bytes. A member whose field visibility could not be determined serves the bundle `private, no-store` with no `ETag`. +- **Server-Timing**: the bundle's metadata reads are timed as a `meta` span beside `db` and `total`, under the existing gate (`OS_SERVER_TIMING=true`, or `X-OS-Debug-Timing: 1` from an admin). +- The query-parameter set is closed and empty. +- `@objectstack/client` gains `meta.getAppBundle(appId)`. diff --git a/content/docs/references/api/app-bundle.mdx b/content/docs/references/api/app-bundle.mdx new file mode 100644 index 00000000000..c89f54c8a39 --- /dev/null +++ b/content/docs/references/api/app-bundle.mdx @@ -0,0 +1,198 @@ +--- +title: App Bundle schema — API Protocol reference +navTitle: App Bundle +description: "App metadata bundle — GET /api/v1/meta/app/{appId}/bundle. Reference for AppMetadataBundleResponse: every property with its type and default." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +App metadata bundle — `GET /api/v1/meta/app/{appId}/bundle`. + +One request that answers the metadata a console asks for while it works +inside one app: the app with its navigation, every object that navigation +reaches and the objects that point at those through a relationship, and the +views, pages and dashboards they draw. A console loads it once per session and +revalidates it with `If-None-Match` after that. + +Every member is the answer of the item read it replaces, asked as the same +caller: the app is `GET /meta/app/{appId}`, and each entry of `objects`, +`views`, `pages` and `dashboards` is `GET /meta/{type}/{name}` for that +entry's own `type` and `name`, envelope included. Audience gates, list-view +and dashboard permissions, the field-visibility mask, the translation to the +request's locale and the per-caller `sortability` therefore apply exactly as +they apply to those reads. An item whose read refuses the caller is ABSENT: +the bundle carries no entry, no name and no placeholder for it. An app the +caller cannot read refuses the whole request with the app read's own answer, +so the bundle never discloses an app the caller could not open. + +Which items the bundle holds is derived on the server, from the app the +caller is served (navigation already pruned to that caller): + +- `objects` — every object an `object` navigation entry names (top-level + `navigation`, each area's `navigation`, and `children` at any depth), in + navigation order; then every object with a `lookup` or `master_detail` + field whose `reference` is one of those, in registry order. One hop: an + object related only to a related object is not included. +- `views` — every view a navigation entry names by `viewName`, and every view + bound to an object in `objects`. +- `pages` — every page a `page` navigation entry names, and every page bound + to an object in `objects` by its `object`. +- `dashboards` — every dashboard a `dashboard` navigation entry names. + +A name appears once per list, at its first position. Items outside the +bundle stay on their own `GET /meta/{type}/{name}` read. + +The response carries a strong `ETag` that identifies the bytes served to +THIS caller, under `Cache-Control: private, no-cache` (the item reads' own +cache posture), and `Vary: Accept-Language`; a request whose `If-None-Match` +names it is answered `304` with no body. Two callers are answered one +validator only when they are served the same bytes. When any member's field +visibility could not be determined, the bundle is served under +`Cache-Control: private, no-store` with no `ETag`, as that item's read is. + + +**Source:** `packages/spec/src/api/app-bundle.zod.ts` + + +## TypeScript Usage + +```typescript +import { AppMetadataBundleResponseSchema } from '@objectstack/spec/api'; +import type { AppMetadataBundleResponse } from '@objectstack/spec/api'; + +// Validate data +const result = AppMetadataBundleResponseSchema.parse(data); +``` + +--- + +## AppMetadataBundleResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **app** | `{ type: string; name: string; item: any; sortability?: object; … }` | ✅ | The app, as `GET /meta/app/{appId}` answers it to this caller (its navigation pruned to what the caller may open). | +| **objects** | `{ type: string; name: string; item: any; sortability?: object; … }[]` | ✅ | The objects the app's navigation reaches, then the objects whose relationship fields reference them, each as `GET /meta/object/{name}` answers it. An object the caller may not read is absent. | +| **views** | `{ type: string; name: string; item: any; sortability?: object; … }[]` | ✅ | The views the navigation names and the views bound to the bundled objects, each as `GET /meta/view/{name}` answers it. A view the caller may not read is absent. | +| **pages** | `{ type: string; name: string; item: any; sortability?: object; … }[]` | ✅ | The pages the navigation names and the pages bound to the bundled objects, each as `GET /meta/page/{name}` answers it. A page the caller may not read is absent. | +| **dashboards** | `{ type: string; name: string; item: any; sortability?: object; … }[]` | ✅ | The dashboards the navigation names, each as `GET /meta/dashboard/{name}` answers it. A dashboard the caller may not read is absent. | + +### Nested Shape: `AppMetadataBundleResponse.app` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Metadata type name | +| **name** | `string` | ✅ | Item name | +| **item** | `any` | ✅ | Metadata item definition | +| **sortability** | `{ fields: Record }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | +| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | +| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | +| **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | +| **provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Where the item came from (package \| org \| env-forced). Present only when the resolved item declares `_provenance`. | +| **packageId** | `string` | optional | Owning package machine id. Present only when the resolved item declares `_packageId`. | +| **packageVersion** | `string` | optional | Owning package version. Present only when the resolved item declares `_packageVersion`. | +| **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. | +| **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | +| **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | +| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | +| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | +| **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | + +### Nested Shape: `AppMetadataBundleResponse.objects[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Metadata type name | +| **name** | `string` | ✅ | Item name | +| **item** | `any` | ✅ | Metadata item definition | +| **sortability** | `{ fields: Record }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | +| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | +| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | +| **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | +| **provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Where the item came from (package \| org \| env-forced). Present only when the resolved item declares `_provenance`. | +| **packageId** | `string` | optional | Owning package machine id. Present only when the resolved item declares `_packageId`. | +| **packageVersion** | `string` | optional | Owning package version. Present only when the resolved item declares `_packageVersion`. | +| **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. | +| **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | +| **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | +| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | +| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | +| **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | + +### Nested Shape: `AppMetadataBundleResponse.views[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Metadata type name | +| **name** | `string` | ✅ | Item name | +| **item** | `any` | ✅ | Metadata item definition | +| **sortability** | `{ fields: Record }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | +| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | +| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | +| **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | +| **provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Where the item came from (package \| org \| env-forced). Present only when the resolved item declares `_provenance`. | +| **packageId** | `string` | optional | Owning package machine id. Present only when the resolved item declares `_packageId`. | +| **packageVersion** | `string` | optional | Owning package version. Present only when the resolved item declares `_packageVersion`. | +| **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. | +| **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | +| **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | +| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | +| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | +| **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | + +### Nested Shape: `AppMetadataBundleResponse.pages[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Metadata type name | +| **name** | `string` | ✅ | Item name | +| **item** | `any` | ✅ | Metadata item definition | +| **sortability** | `{ fields: Record }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | +| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | +| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | +| **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | +| **provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Where the item came from (package \| org \| env-forced). Present only when the resolved item declares `_provenance`. | +| **packageId** | `string` | optional | Owning package machine id. Present only when the resolved item declares `_packageId`. | +| **packageVersion** | `string` | optional | Owning package version. Present only when the resolved item declares `_packageVersion`. | +| **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. | +| **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | +| **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | +| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | +| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | +| **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | + +### Nested Shape: `AppMetadataBundleResponse.dashboards[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Metadata type name | +| **name** | `string` | ✅ | Item name | +| **item** | `any` | ✅ | Metadata item definition | +| **sortability** | `{ fields: Record }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | +| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | +| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | +| **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | +| **provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Where the item came from (package \| org \| env-forced). Present only when the resolved item declares `_provenance`. | +| **packageId** | `string` | optional | Owning package machine id. Present only when the resolved item declares `_packageId`. | +| **packageVersion** | `string` | optional | Owning package version. Present only when the resolved item declares `_packageVersion`. | +| **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. | +| **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | +| **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | +| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | +| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | +| **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | + + +--- + diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index 668533eddd1..ab2e5e47354 100644 --- a/content/docs/references/api/index.mdx +++ b/content/docs/references/api/index.mdx @@ -1,7 +1,7 @@ --- title: API Protocol — complete schema reference navTitle: API Protocol -description: "The ObjectStack API Protocol in 33 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack API Protocol in 34 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -10,6 +10,7 @@ This section contains all protocol schemas for the api layer of ObjectStack. + diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index a5e7903f7d4..c9836a69072 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -32,6 +32,7 @@ "plugin-rest-api", "storage", "---More---", + "app-bundle", "error-code-ledger", "misc", "package-api-assembled", diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index cab4ec45df8..3d605bdf30e 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1569 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1570 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 33 | 451 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 34 | 452 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **203** | **1569** | 14 protocol modules | +| **Total** | **204** | **1570** | 14 protocol modules | --- @@ -63,13 +63,14 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **33 pages, 451 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 452 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. | File | Schemas | | :--- | :--- | | [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` | +| [`app-bundle.zod.ts`](/docs/references/api/app-bundle) | `AppMetadataBundleResponse` | | [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` | | [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` | | [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowFailureDetails`, `FlowInnerRefusal`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 0012bb0eb42..0f34efe8cca 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 460 | +| `api/` | 461 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index c2aa5fdb219..d5104cd5366 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -75,6 +75,9 @@ "AppDefinitionResponse (type)", "AppDefinitionResponseParsed (type)", "AppDefinitionResponseSchema (const)", + "AppMetadataBundleResponse (type)", + "AppMetadataBundleResponseParsed (type)", + "AppMetadataBundleResponseSchema (const)", "ApproveAiPendingActionResponse (type)", "ApproveAiPendingActionResponseSchema (const)", "AuditMetaItemRequest (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 24602f5a806..bdace1a9b08 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -175,6 +175,11 @@ "api/AppDefinitionResponse:error", "api/AppDefinitionResponse:meta", "api/AppDefinitionResponse:success", + "api/AppMetadataBundleResponse:app", + "api/AppMetadataBundleResponse:dashboards", + "api/AppMetadataBundleResponse:objects", + "api/AppMetadataBundleResponse:pages", + "api/AppMetadataBundleResponse:views", "api/ApproveAiPendingActionResponse:error", "api/ApproveAiPendingActionResponse:result", "api/ApproveAiPendingActionResponse:status", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 787d2f41137..c90c0605468 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -57,6 +57,8 @@ "ApiTestRequestSchema": "api/ApiTestRequest", "AppDefinitionResponse": "api/AppDefinitionResponse", "AppDefinitionResponseSchema": "api/AppDefinitionResponse", + "AppMetadataBundleResponse": "api/AppMetadataBundleResponse", + "AppMetadataBundleResponseSchema": "api/AppMetadataBundleResponse", "ApproveAiPendingActionResponse": "api/ApproveAiPendingActionResponse", "ApproveAiPendingActionResponseSchema": "api/ApproveAiPendingActionResponse", "AssembledInstalledPackage": "api/AssembledInstalledPackage", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 4b86af0ac78..d37f42c14b8 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -71,6 +71,9 @@ "AppDefinitionResponse": "src/api/metadata.zod.ts#AppDefinitionResponse (type)", "AppDefinitionResponseParsed": "src/api/metadata.zod.ts#AppDefinitionResponseParsed (type)", "AppDefinitionResponseSchema": "src/api/metadata.zod.ts#AppDefinitionResponseSchema (const)", + "AppMetadataBundleResponse": "src/api/app-bundle.zod.ts#AppMetadataBundleResponse (type)", + "AppMetadataBundleResponseParsed": "src/api/app-bundle.zod.ts#AppMetadataBundleResponseParsed (type)", + "AppMetadataBundleResponseSchema": "src/api/app-bundle.zod.ts#AppMetadataBundleResponseSchema (const)", "ApproveAiPendingActionResponse": "src/api/protocol.zod.ts#ApproveAiPendingActionResponse (type)", "ApproveAiPendingActionResponseSchema": "src/api/protocol.zod.ts#ApproveAiPendingActionResponseSchema (const)", "AuditMetaItemRequest": "src/api/protocol.zod.ts#AuditMetaItemRequest (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index b5e02e95fee..f766597010d 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -30,6 +30,7 @@ "api/ApiTestCollection", "api/ApiTestRequest", "api/AppDefinitionResponse", + "api/AppMetadataBundleResponse", "api/ApproveAiPendingActionResponse", "api/AssembledInstalledPackage", "api/AuditMetaItemRequest", diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 5dbcbf952a3..11edee3ccf2 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (211 schemas) +## 3. Schema Inventory by Domain (212 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -88,7 +88,7 @@ here is verifiable from the installed package. | system | 37 | Auth, Cache, Demo Staff, Dev Login, Email / SMS Config, Encryption, Fate Supplement (organization ownership), HTTP Server, License, Logging, Metrics | | kernel | 30 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry | | data | 32 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube, Seed | -| api | 32 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page | +| api | 33 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page, App Metadata Bundle | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 15 | Flow, Approval, Assignment Rule, BPMN Interop, Control Flow, Webhook, Schedule Organization | | shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | diff --git a/packages/spec/src/api/app-bundle.test.ts b/packages/spec/src/api/app-bundle.test.ts new file mode 100644 index 00000000000..c7ad1a844ec --- /dev/null +++ b/packages/spec/src/api/app-bundle.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { AppMetadataBundleResponseSchema } from './app-bundle.zod'; +import { GetMetaItemResponseSchema } from './protocol.zod'; + +const entry = (type: string, name: string) => ({ type, name, item: { name } }); +const bundle = { + app: entry('app', 'crm'), + objects: [entry('object', 'account')], + views: [entry('view', 'all_accounts')], + pages: [entry('page', 'account_record')], + dashboards: [entry('dashboard', 'pipeline')], +}; + +describe('[#27627] the app metadata bundle carries each member as its own item read answers it', () => { + it('takes the app and the four member lists of item envelopes', () => { + expect(AppMetadataBundleResponseSchema.safeParse(bundle).success).toBe(true); + expect(AppMetadataBundleResponseSchema.safeParse({ ...bundle, objects: [], views: [], pages: [], dashboards: [] }).success).toBe(true); + }); + + it('declares every member list with the item read\'s own envelope', () => { + const shape = (AppMetadataBundleResponseSchema as any).shape; + expect(Object.keys(shape).sort()).toEqual(['app', 'dashboards', 'objects', 'pages', 'views']); + for (const key of ['objects', 'views', 'pages', 'dashboards']) { + expect(shape[key].element, key).toBe(GetMetaItemResponseSchema); + } + }); + + it('refuses a missing member list, a member without its envelope, and an undeclared key', () => { + const { dashboards: _dropped, ...withoutDashboards } = bundle; + for (const answer of [ + withoutDashboards, + { ...bundle, objects: [{ item: { name: 'account' } }] }, + { ...bundle, app: { name: 'crm', item: {} } }, + { ...bundle, reports: [] }, + ]) { + expect(AppMetadataBundleResponseSchema.safeParse(answer).success, JSON.stringify(answer)).toBe(false); + } + }); +}); diff --git a/packages/spec/src/api/app-bundle.zod.ts b/packages/spec/src/api/app-bundle.zod.ts new file mode 100644 index 00000000000..b8173ce385e --- /dev/null +++ b/packages/spec/src/api/app-bundle.zod.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { GetMetaItemResponseSchema } from './protocol.zod'; + +/** + * App metadata bundle — `GET /api/v1/meta/app/{appId}/bundle`. + * + * One request that answers the metadata a console asks for while it works + * inside one app: the app with its navigation, every object that navigation + * reaches and the objects that point at those through a relationship, and the + * views, pages and dashboards they draw. A console loads it once per session and + * revalidates it with `If-None-Match` after that. + * + * Every member is the answer of the item read it replaces, asked as the same + * caller: the app is `GET /meta/app/{appId}`, and each entry of `objects`, + * `views`, `pages` and `dashboards` is `GET /meta/{type}/{name}` for that + * entry's own `type` and `name`, envelope included. Audience gates, list-view + * and dashboard permissions, the field-visibility mask, the translation to the + * request's locale and the per-caller `sortability` therefore apply exactly as + * they apply to those reads. An item whose read refuses the caller is ABSENT: + * the bundle carries no entry, no name and no placeholder for it. An app the + * caller cannot read refuses the whole request with the app read's own answer, + * so the bundle never discloses an app the caller could not open. + * + * Which items the bundle holds is derived on the server, from the app the + * caller is served (navigation already pruned to that caller): + * + * - `objects` — every object an `object` navigation entry names (top-level + * `navigation`, each area's `navigation`, and `children` at any depth), in + * navigation order; then every object with a `lookup` or `master_detail` + * field whose `reference` is one of those, in registry order. One hop: an + * object related only to a related object is not included. + * - `views` — every view a navigation entry names by `viewName`, and every view + * bound to an object in `objects`. + * - `pages` — every page a `page` navigation entry names, and every page bound + * to an object in `objects` by its `object`. + * - `dashboards` — every dashboard a `dashboard` navigation entry names. + * + * A name appears once per list, at its first position. Items outside the + * bundle stay on their own `GET /meta/{type}/{name}` read. + * + * The response carries a strong `ETag` that identifies the bytes served to + * THIS caller, under `Cache-Control: private, no-cache` (the item reads' own + * cache posture), and `Vary: Accept-Language`; a request whose `If-None-Match` + * names it is answered `304` with no body. Two callers are answered one + * validator only when they are served the same bytes. When any member's field + * visibility could not be determined, the bundle is served under + * `Cache-Control: private, no-store` with no `ETag`, as that item's read is. + */ + +/** The answer of `GET /meta/app/{appId}/bundle` — bare, as the `/meta` item reads answer. */ +export const AppMetadataBundleResponseSchema = lazySchema(() => z.object({ + app: GetMetaItemResponseSchema.describe( + 'The app, as `GET /meta/app/{appId}` answers it to this caller (its navigation pruned to what the caller may open).', + ), + objects: z.array(GetMetaItemResponseSchema).describe( + 'The objects the app\'s navigation reaches, then the objects whose relationship fields reference them, each as ' + + '`GET /meta/object/{name}` answers it. An object the caller may not read is absent.', + ), + views: z.array(GetMetaItemResponseSchema).describe( + 'The views the navigation names and the views bound to the bundled objects, each as `GET /meta/view/{name}` ' + + 'answers it. A view the caller may not read is absent.', + ), + pages: z.array(GetMetaItemResponseSchema).describe( + 'The pages the navigation names and the pages bound to the bundled objects, each as `GET /meta/page/{name}` ' + + 'answers it. A page the caller may not read is absent.', + ), + dashboards: z.array(GetMetaItemResponseSchema).describe( + 'The dashboards the navigation names, each as `GET /meta/dashboard/{name}` answers it. A dashboard the caller ' + + 'may not read is absent.', + ), +}).strict()); +export type AppMetadataBundleResponse = z.input; +export type AppMetadataBundleResponseParsed = z.infer; diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index 2ddabca24d0..e25be91e0e9 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -52,6 +52,7 @@ export * from './router.zod'; export * from './odata.zod'; export * from './batch.zod'; export * from './record-page.zod'; +export * from './app-bundle.zod'; export * from './http-cache.zod'; export * from './errors.zod'; // The ADR-0114 D3 boundary mapper — the ONE implementation of Zod issue code → From 17cdfc4e79fda82106c7a1acf64884053fd007ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:13:08 +0000 Subject: [PATCH 02/11] wip(rest): app metadata bundle route Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- packages/client/src/index.ts | 17 + packages/rest/src/meta-app-bundle-routes.ts | 341 ++++++++++++++++++++ packages/rest/src/rest-route-ledger.ts | 3 + packages/rest/src/rest-server.ts | 25 +- 4 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 packages/rest/src/meta-app-bundle-routes.ts diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 85877466507..7fe4010c0f1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -7,6 +7,7 @@ import { GetRecordFieldAccessResponse, RecordPageRequest, RecordPageResponse, + AppMetadataBundleResponse, UpdateManyRequest, DeleteManyRequest, BatchOptions, @@ -2262,6 +2263,22 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, + /** + * [#27627] The app metadata bundle: the app and the objects, views, pages + * and dashboards its navigation reaches, each as its own + * `GET /meta/{type}/{name}` answers it to this caller, in one request + * (`GET /meta/app/{appId}/bundle`). An item the caller may not read is + * absent; an app the caller may not read rejects with the app read's own + * answer. The response carries a per-caller `ETag` under + * `Cache-Control: private, no-cache`, so the platform's HTTP cache + * revalidates it with `If-None-Match`. + */ + getAppBundle: async (appId: string): Promise => { + const route = this.getRoute('metadata'); + const res = await this.fetch(`${this.baseUrl}${route}/app/${encodeURIComponent(appId)}/bundle`); + return this.unwrapResponse(res); + }, + /** * ADR-0046 §6: resolve a book spine against the docs that exist now. * An unknown name is treated as a package id (implicit per-package book). diff --git a/packages/rest/src/meta-app-bundle-routes.ts b/packages/rest/src/meta-app-bundle-routes.ts new file mode 100644 index 00000000000..3609759915d --- /dev/null +++ b/packages/rest/src/meta-app-bundle-routes.ts @@ -0,0 +1,341 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27627] `GET {metaPath}/app/:appId/bundle` — the app metadata bundle + * (`AppMetadataBundleResponseSchema`, `@objectstack/spec/api`). + * + * One request answers the metadata reads a console made one by one while it + * worked inside an app: the app, each object it opened, and the views, pages + * and dashboards those draw. The spec module's header carries what each member + * list holds and the cache contract; this header carries how it is built. + * + * ## Every member is THE item answer, asked as the same caller + * + * Each member — the app included — is fetched the way the plain read's + * uncached arm fetches it (`getMetaItem({ type, name })`) and answered by + * `createMetaItemAnswer` in `./meta-item-read-gate.ts`, the ONE item chain + * `GET /meta/:type/:name` and the runtime dispatcher's item read both call, + * under the rendered read's policy (`{ arms: 'all', app: 'gate' }`) and the + * member's own field-visibility posture (`resolveObjectMasker`, resolved before + * the fetch). The per-caller gate, the doc locale collapse, the ADR-0106 mask, + * the translation, `sortability` and the public-form intake reason are + * therefore whatever that chain applies, by construction; ⛔ this module adds + * no judgement of its own and copies no gate. The uncached arm is the one + * chosen because it is the chain's own entry — the cached arm reaches the same + * gate and mask through the same functions for the two types it serves gated, + * and serves the published value under a validator this bundle replaces with + * its own (below). + * + * A member the chain refuses (`absent`, `app-permission`, + * `audience-permission`, `docs-audience`), or whose fetch throws the bare + * not-found the item read answers as absent, is left out. The app's own + * refusal is the whole answer, written by the item read's emitter + * (`sendMetaReadRefusal`) byte for byte. A mask fault, and any other thrown + * read, is the whole answer too: a member that could not be judged is never + * served as one that is absent. + * + * ## Which items are members + * + * Read from the app document the caller is SERVED — the gate has already + * pruned its navigation — so an entry the caller may not open reaches nothing. + * The relationship scan, the views bound to an object and the pages bound to + * one are found in the protocol's whole-type lists (`metaListSource`, the same + * source the read gate reads), which only NAME candidates: every candidate is + * then answered through the item chain above. A list read that throws is the + * whole answer — an enumeration that failed is never served as one that found + * nothing. + * + * ## The validator + * + * A strong `ETag` is the SHA-256 of the JSON this response sends, so it folds + * every per-caller dimension the item routes fold into theirs + * (`foldVisibilityFingerprintIntoEtag`: the mask, the runtime view, the intake + * reason, what the gate withheld, the switched-off actions) and the locale, + * because each of them changes those bytes: two callers are answered one + * validator only when they are sent the same body. `Cache-Control: private, + * no-cache` is the item routes' own posture (`getMetaItemCached`); a member + * served under an undetermined field visibility (ADR-0106 D6 tier 2) serves the + * whole bundle `private, no-store` with no validator, as that item's read is. + * + * The closed query-parameter set is empty (AGENTS.md, Route & surface + * ownership 5). The route registers through the per-item switch inside the + * `/meta` umbrella, so the anonymous deny and the type-level admissions apply + * to it as to every `/meta` route. + */ + +import { normalizeIfNoneMatch, ObjectSchemaMaskEvaluationError } from '@objectstack/metadata-core'; +import type { GetMetaItemRequest } from '@objectstack/spec/api'; +import { deriveViewContainerObject } from '@objectstack/spec/ui'; +import { measureServerTiming } from '@objectstack/observability'; +import { handleRouteError, sendFieldVisibilityFault, thrownAnswerIsBareNotFound } from './error-response.js'; +import type { MetaRouteScope } from './meta-collection-routes.js'; +import * as metaReadGate from './meta-item-read-gate.js'; +import type { MetaItemAnswer, MetaReadGatePolicy } from './meta-item-read-gate.js'; +import { refuseUnknownQueryParams } from './query-allowlist.js'; +import { sendMetaItemAbsent, type RestServer } from './rest-server.js'; + +/** The closed query-parameter set of `GET {metaPath}/app/:appId/bundle`: EMPTY. */ +export const META_APP_BUNDLE_PARAMS: readonly string[] = []; + +/** The relationship field types whose `reference` makes an object an inbound relation. */ +const RELATIONSHIP_TYPES = new Set(['lookup', 'master_detail']); + +/** The rendered read's gate policy — `GET /meta/:type/:name` without `?state=draft`. */ +const RENDERED_READ_POLICY: MetaReadGatePolicy = Object.freeze({ arms: 'all', app: 'gate' }); + +/** + * The `RestServer` members {@link registerMetaAppBundleRoute} reads — every + * one of them, and no other. Each is typed AS the class member, so a change to + * a member's signature on the class is a change here too. No route manager: + * the route registers through the scope's per-item registrar, which reads the + * umbrella's wrapping one at call time. + */ +export interface MetaAppBundleRouteHost { + resolveProtocol: RestServer['resolveProtocol']; + resolveObjectMasker: RestServer['resolveObjectMasker']; + metaItemAnswerSources: RestServer['metaItemAnswerSources']; + metaListSource: RestServer['metaListSource']; + sendMetaReadRefusal: (typeof RestServer)['sendMetaReadRefusal']; +} + +/** The names an app's navigation reaches, per member type, in navigation order. */ +export interface AppNavigationReach { + objects: string[]; + views: string[]; + pages: string[]; + dashboards: string[]; +} + +/** A non-empty string, or `undefined`. */ +function nameOf(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** Append `name` to `list` once. */ +function addOnce(list: string[], name: string | undefined): void { + if (name !== undefined && !list.includes(name)) list.push(name); +} + +/** + * What an app's navigation reaches: every `object` entry's `objectName` and + * `viewName`, every `page` entry's `pageName` and every `dashboard` entry's + * `dashboardName`, in the trees the read gate's navigation prune walks — + * top-level `navigation`, each area's `navigation`, and `children` at any + * depth. + */ +export function appNavigationReach(app: unknown): AppNavigationReach { + const reach: AppNavigationReach = { objects: [], views: [], pages: [], dashboards: [] }; + const walk = (entries: unknown): void => { + if (!Array.isArray(entries)) return; + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + if (e.type === 'object') { + addOnce(reach.objects, nameOf(e.objectName)); + addOnce(reach.views, nameOf(e.viewName)); + } else if (e.type === 'page') { + addOnce(reach.pages, nameOf(e.pageName)); + } else if (e.type === 'dashboard') { + addOnce(reach.dashboards, nameOf(e.dashboardName)); + } + walk(e.children); + } + }; + if (app && typeof app === 'object') { + const a = app as Record; + walk(a.navigation); + if (Array.isArray(a.areas)) { + for (const area of a.areas) walk((area as Record | null)?.navigation); + } + } + return reach; +} + +/** The fields of an object document, from either stored shape of `fields`. */ +function fieldsOf(item: unknown): any[] { + const fields = (item as { fields?: unknown } | null)?.fields; + if (Array.isArray(fields)) return fields; + if (fields && typeof fields === 'object') return Object.values(fields); + return []; +} + +/** + * The objects with a relationship field (`lookup` / `master_detail`) whose + * `reference` names one of `targets`, in list order, excluding the targets. + */ +export function inboundRelationObjects(objects: readonly unknown[], targets: readonly string[]): string[] { + const wanted = new Set(targets); + const found: string[] = []; + for (const item of objects) { + const name = nameOf((item as { name?: unknown } | null)?.name); + if (name === undefined || wanted.has(name)) continue; + const relates = fieldsOf(item).some((field) => + field && RELATIONSHIP_TYPES.has(String(field.type)) && wanted.has(String(field.reference))); + if (relates) addOnce(found, name); + } + return found; +} + +/** + * The bundle's validator: the SHA-256 of the body it sends, hex. `undefined` + * where the runtime has no Web Crypto digest, which serves the bundle with no + * validator rather than a weaker one. + */ +async function bundleValidator(json: string): Promise { + const subtle = (globalThis as { crypto?: { subtle?: any } }).crypto?.subtle; + if (!subtle || typeof subtle.digest !== 'function') return undefined; + const digest: ArrayBuffer = await subtle.digest('SHA-256', new TextEncoder().encode(json)); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** A member the chain could not judge: the whole request answers it. */ +class BundleMemberFault extends Error { + constructor(readonly object: string) { + super(`Field visibility for object '${object}' could not be evaluated.`); + } +} + +/** + * Register the app metadata bundle route — called by + * `RestServer#registerMetadataEndpointsInner` with a + * {@link MetaAppBundleRouteHost} as `this`, through the scope's per-item + * registrar. + */ +export function registerMetaAppBundleRoute(this: MetaAppBundleRouteHost, scope: MetaRouteScope): void { + const { metaPath, isScoped, registerPerItemRoute } = scope; + + registerPerItemRoute({ + method: 'GET', + path: `${metaPath}/app/:appId/bundle`, + handler: async (req: any, res: any) => { + const appId = String(req.params?.appId ?? ''); + try { + if (refuseUnknownQueryParams(req, res, META_APP_BUNDLE_PARAMS)) return; + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + + // One masker per type, resolved once per request, as the list + // read resolves its own. + const maskers = new Map Promise>>(); + const maskerFor = (type: string) => { + let masker = maskers.get(type); + if (!masker) { + masker = this.resolveObjectMasker(environmentId, req, type); + maskers.set(type, masker); + } + return masker; + }; + + /** + * THE item answer of `GET /meta/{type}/{name}`'s uncached arm + * for one member, or `undefined` when that read answers the + * bare not-found it serves as absent. + */ + const answer = async (type: string, name: string): Promise => { + let maskPosture; + try { + maskPosture = await (await maskerFor(type))(name); + } catch (maskError) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) throw new BundleMemberFault(name); + throw maskError; + } + let envelope: Record; + try { + envelope = await p.getMetaItem({ type, name } satisfies GetMetaItemRequest) as Record; + } catch (error) { + if (thrownAnswerIsBareNotFound(error)) return undefined; + throw error; + } + const answered = await metaReadGate.createMetaItemAnswer( + this.metaItemAnswerSources(environmentId, req, p, RENDERED_READ_POLICY, type), + { metaType: type, name, policy: RENDERED_READ_POLICY, maskPosture }, + )(envelope); + if (answered.kind === 'mask-fault') throw new BundleMemberFault(answered.object); + return answered; + }; + + /** The served members of one type, in `names` order. */ + const members = async (type: string, names: readonly string[]) => { + const answers = await Promise.all(names.map((name) => answer(type, name))); + return answers.filter((a): a is Extract => a?.kind === 'serve'); + }; + + const list = this.metaListSource(p, environmentId); + const listOf = async (type: string) => metaReadGate.metaItemsArray(await list.listMetaItems(type)); + + const built = await measureServerTiming('meta', async () => { + const app = await answer('app', appId); + if (!app || app.kind !== 'serve') return { refused: app }; + + const reach = appNavigationReach((app.envelope as { item?: unknown } | null)?.item); + const inbound = inboundRelationObjects(await listOf('object'), reach.objects); + const objects = await members('object', [...reach.objects, ...inbound]); + const served = new Set(objects.map((o) => String((o.envelope as { name?: unknown }).name))); + + const viewNames = [...reach.views]; + for (const view of await listOf('view')) { + const bound = deriveViewContainerObject(view); + if (bound !== undefined && served.has(bound)) addOnce(viewNames, nameOf(view?.name)); + } + const pageNames = [...reach.pages]; + for (const page of await listOf('page')) { + const bound = nameOf(page?.object); + if (bound !== undefined && served.has(bound)) addOnce(pageNames, nameOf(page?.name)); + } + const [views, pages, dashboards] = await Promise.all([ + members('view', viewNames), + members('page', pageNames), + members('dashboard', reach.dashboards), + ]); + return { app, objects, views, pages, dashboards }; + }, 'Metadata reads'); + + if (!('app' in built)) { + if (built.refused) this.sendMetaReadRefusal(res, built.refused.refusal); + else sendMetaItemAbsent(res); + return; + } + + const { app, objects, views, pages, dashboards } = built; + const body = { + app: app.envelope, + objects: objects.map((m) => m.envelope), + views: views.map((m) => m.envelope), + pages: pages.map((m) => m.envelope), + dashboards: dashboards.map((m) => m.envelope), + }; + res.header('Vary', 'Accept-Language'); + if ([app, ...objects, ...views, ...pages, ...dashboards].some((m) => m.cacheControl !== undefined)) { + // [ADR-0106 D6 tier 2] A member served unmasked: no shared validator. + res.header('Cache-Control', metaReadGate.META_UNDETERMINED_CACHE_CONTROL); + res.json(body); + return; + } + const validator = await bundleValidator(JSON.stringify(body)); + res.header('Cache-Control', 'private, no-cache'); + if (validator !== undefined) { + res.header('ETag', `"${validator}"`); + if (normalizeIfNoneMatch(req.headers?.['if-none-match']) === validator) { + res.status(304).send(); + return; + } + } + res.json(body); + } catch (error: any) { + if (error instanceof BundleMemberFault) { + sendFieldVisibilityFault(res, error.object); + return; + } + if (thrownAnswerIsBareNotFound(error)) { + sendMetaItemAbsent(res); + return; + } + handleRouteError(res, error); + } + }, + metadata: { + summary: 'App metadata bundle: the app and the objects, views, pages and dashboards its navigation reaches, each as its own item read answers it, in one request', + tags: ['metadata'], + }, + }); +} diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 32bd22b857f..4923a30143d 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -227,6 +227,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getBookTree', responseSchema: 'ResolvedBookSchema', note: 'REST-only route; payload answered BARE — `resolveBookTree()`\'s `ResolvedBook`, verbatim. The schema is declared beside its interfaces in spec `system/book.zod.ts` and re-exported into `/api`, never declared there a second time; conformance: spec `system/book.test.ts`' }, + { route: 'GET /api/v1/meta/app/:appId/bundle', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAppBundle', + responseSchema: 'AppMetadataBundleResponseSchema', + note: 'The app metadata bundle (#27627): the app and the objects, views, pages and dashboards its navigation reaches (objects: navigation, then one hop of inbound relationships), each member the item chain\'s answer of its own GET /meta/:type/:name as the same caller (createMetaItemAnswer, the uncached arm\'s fetch), a refused member absent, a refused app the whole answer. Answers BARE, so the named schema is the whole body; per-caller strong ETag over the served bytes, private, no-cache, 304 on If-None-Match. meta-app-bundle-routes.test.ts parses the body against the named schema on the mount' }, // [#5882] The three-layer diagnostic projection, promoted from the // `?layers=true` flag on the row below to a path of its own so that one path // answers one response shape. `responseSchema` is filled because this mount diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b78bc2119c3..979a9341e53 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -304,6 +304,8 @@ import { import { registerDataActionEndpoints, type DataActionRouteHost } from './data-action-routes.js'; // [#27627] The record page payload route — see its module header. import { registerRecordPageEndpoints, type RecordPageRouteHost } from './record-page-routes.js'; +// [#27627] The app metadata bundle route — see its module header. +import { registerMetaAppBundleRoute, type MetaAppBundleRouteHost } from './meta-app-bundle-routes.js'; // [#23050 slice 2] The batch routes' body moved to its own module — see its header. import { registerBatchEndpoints, type BatchRouteHost } from './batch-routes.js'; // [#23050 slice 3] The approval routes' body moved to its own module — see its header. @@ -4187,12 +4189,17 @@ export class RestServer { * exemption, as {@link metaItemReadGate} builds them), {@link extractLocale}, * and the body through {@link translateMetaEnvelope}, handed the RAW * segment exactly as the plain read always called it. + * + * [#27627] `type` is that segment, `req.params.type` unless the door + * names it: the app metadata bundle answers several types on one request + * whose path has no `:type`, and hands each member's own. */ private metaItemAnswerSources( environmentId: string | undefined, req: any, p: RestProtocol, policy: MetaReadGatePolicy, + type: string = req.params.type, ): metaReadGate.MetaItemAnswerSources { return { ...this.metaItemReadGateSources(environmentId, req, p, policy.app === 'author-exempt'), @@ -4201,8 +4208,8 @@ export class RestServer { // reason the cached arm states (`GET /meta/:type/:name`). translateEnvelope: async (envelope, document, runtimeDocument) => this.translateMetaEnvelope( - req, req.params.type, environmentId, envelope as Record, - RestServer.metaTypeSingular(req.params.type) === 'view' + req, type, environmentId, envelope as Record, + RestServer.metaTypeSingular(type) === 'view' ? stampAnonymousFormIntakeWarnings( document, await this.anonymousFormIntakeWarnings(environmentId, req, p, document), ) @@ -5432,6 +5439,20 @@ export class RestServer { }; registerMetaItemReadRoutes.call(itemReadHost, scope); + // [#27627] The app metadata bundle (`GET {metaPath}/app/:appId/bundle`), in + // `meta-app-bundle-routes.ts`: every member is the item read's own answer, + // so it registers beside the item reads, through the per-item registrar + // (their switch, inside this umbrella). A deeper literal under `:name` + // than any per-item sub-resource, so it shadows none of them. + const appBundleHost: MetaAppBundleRouteHost = { + resolveProtocol: (...a) => this.resolveProtocol(...a), + resolveObjectMasker: (...a) => this.resolveObjectMasker(...a), + metaItemAnswerSources: (...a) => this.metaItemAnswerSources(...a), + metaListSource: (...a) => this.metaListSource(...a), + sendMetaReadRefusal: (...a) => RestServer.sendMetaReadRefusal(...a), + }; + registerMetaAppBundleRoute.call(appBundleHost, scope); + // [#23050 slice 13c] The per-item writes (`PUT` and `DELETE {metaPath}/:type/:name`, // the item save and the reset) moved to `meta-item-write-routes.ts`, and are // registered HERE so they keep their place in the order: after the per-item reads, From c7bafd73787996886e1fe6d5262570a0bb74a11a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:19:49 +0000 Subject: [PATCH 03/11] wip(rest,client): bundle route tests, conditional client read Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- packages/client/src/index.ts | 34 +- .../rest/src/meta-app-bundle-routes.test.ts | 320 ++++++++++++++++++ packages/rest/src/meta-app-bundle-routes.ts | 31 +- 3 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 packages/rest/src/meta-app-bundle-routes.test.ts diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7fe4010c0f1..bc483750e29 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -253,6 +253,15 @@ export interface ClientConfig { */ export type DiscoveryResult = GetDiscoveryResponse; +/** + * [#27627] `meta.getAppBundle`'s answer: the bundle and its validator, or + * `notModified` when the `ifNoneMatch` sent still names what the server would + * serve this caller. + */ +export type AppBundleCacheResponse = + | { notModified: false; data: AppMetadataBundleResponse; etag?: { value: string; weak: boolean } } + | { notModified: true; etag?: { value: string; weak: boolean } }; + /** * @deprecated Use `data.query()` with standard QueryAST parameters instead. * This interface uses legacy parameter names (filter/sort/top/skip) that @@ -2273,10 +2282,21 @@ export class ObjectStackClient { * `Cache-Control: private, no-cache`, so the platform's HTTP cache * revalidates it with `If-None-Match`. */ - getAppBundle: async (appId: string): Promise => { + getAppBundle: async (appId: string, cacheOptions?: { ifNoneMatch?: string }): Promise => { + // The conditional spelling is `getCached`'s (`MetadataCacheRequest.ifNoneMatch` in, + // `notModified` + `etag: { value, weak }` out), with the 304 admitted by the transport + // instead of thrown as a failed request. const route = this.getRoute('metadata'); - const res = await this.fetch(`${this.baseUrl}${route}/app/${encodeURIComponent(appId)}/bundle`); - return this.unwrapResponse(res); + const res = await this.fetch( + `${this.baseUrl}${route}/app/${encodeURIComponent(appId)}/bundle`, + cacheOptions?.ifNoneMatch ? { headers: { 'If-None-Match': cacheOptions.ifNoneMatch } } : {}, + { admitNotModified: true }, + ); + const header = res.headers.get('ETag'); + const etag = header ? { value: header.replace(/^W\/|"/g, ''), weak: header.startsWith('W/') } : undefined; + if (res.status === 304) return { notModified: true, ...(etag ? { etag } : {}) }; + const data = await this.unwrapResponse(res); + return { notModified: false, data, ...(etag ? { etag } : {}) }; }, /** @@ -7616,7 +7636,11 @@ export class ObjectStackClient { this.token = token; } - private async fetch(url: string, options: RequestInit = {}): Promise { + /** + * `transport.admitNotModified` — [#27627] a conditional read's `304` is its + * answer, not a failure: returned to the caller instead of thrown. + */ + private async fetch(url: string, options: RequestInit = {}, transport: { admitNotModified?: boolean } = {}): Promise { this.logger.debug('HTTP request', { method: options.method || 'GET', url, @@ -7652,7 +7676,7 @@ export class ObjectStackClient { ok: res.ok }); - if (!res.ok) { + if (!res.ok && !(transport.admitNotModified && res.status === 304)) { let errorBody: any; try { errorBody = await res.json(); diff --git a/packages/rest/src/meta-app-bundle-routes.test.ts b/packages/rest/src/meta-app-bundle-routes.test.ts new file mode 100644 index 00000000000..cd95c0fd812 --- /dev/null +++ b/packages/rest/src/meta-app-bundle-routes.test.ts @@ -0,0 +1,320 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27627] `GET /meta/app/:appId/bundle` — one request for an app's metadata, + * each member the answer of its own `GET /meta/{type}/{name}`, as the same + * caller. + * + * Pinned on the mounted route of a real `RestServer`, against the item route of + * the SAME server for every member (the store answers the plain read's uncached + * arm, the item chain the bundle asks), so "the item read's answer" is measured + * rather than restated: what the bundle holds, a member the caller may not read + * left out, two callers whose gates differ answered two validators, an + * unchanged bundle revalidated to `304`, an app the caller may not read refused + * with the app read's own answer, and the closed query set. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AppMetadataBundleResponseSchema } from '@objectstack/spec/api'; +import { PerfTiming, runWithPerfTiming } from '@objectstack/observability'; +// Explicit `.js` extension: NodeNext resolution (see the sibling meta tests). +import { RestServer } from './rest-server.js'; +import { appNavigationReach, inboundRelationObjects, META_APP_BUNDLE_PARAMS } from './meta-app-bundle-routes.js'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const SALES_ADMIN = 'sales.admin'; +const LEGAL_CAP = 'legal.view'; + +const CRM_APP = { + name: 'crm', + label: 'CRM', + navigation: [ + { id: 'nav_accounts', type: 'object', objectName: 'account', viewName: 'account.all' }, + { + id: 'nav_sales', + type: 'group', + label: 'Sales', + children: [ + { id: 'nav_deals', type: 'object', objectName: 'secret_deal', requiredPermissions: [SALES_ADMIN] }, + { id: 'nav_home', type: 'page', pageName: 'home_page' }, + { id: 'nav_pipeline', type: 'dashboard', dashboardName: 'pipeline' }, + { id: 'nav_legal', type: 'dashboard', dashboardName: 'legal_board' }, + ], + }, + ], + areas: [ + { id: 'area_service', label: 'Service', navigation: [{ id: 'nav_contacts', type: 'object', objectName: 'contact' }] }, + ], +}; +const PAYROLL_APP = { name: 'payroll', label: 'Payroll', requiredPermissions: ['payroll.access'], navigation: [] }; + +const field = (type: string, reference?: string) => ({ type, label: type, ...(reference ? { reference } : {}) }); +const OBJECTS = [ + { name: 'account', label: 'Account', fields: { name: field('text') } }, + { name: 'contact', label: 'Contact', fields: { name: field('text'), account: field('lookup', 'account') } }, + { name: 'secret_deal', label: 'Deal', fields: { amount: field('number') } }, + // Inbound relations: `note` points at an account, `task` at a contact (array-shaped fields). + { name: 'note', label: 'Note', fields: { body: field('text'), account: field('lookup', 'account') } }, + { name: 'task', label: 'Task', fields: [{ name: 'contact', ...field('master_detail', 'contact') }] }, + // Related only to a nav-reached object the member may not reach, and to nothing reached. + { name: 'deal_line', label: 'Deal line', fields: { deal: field('master_detail', 'secret_deal') } }, + { name: 'invoice', label: 'Invoice', fields: { total: field('number') } }, +]; +const VIEWS = [ + { name: 'account.all', object: 'account', viewKind: 'list', order: 0, config: { type: 'grid', columns: ['name'] } }, + { name: 'account.legal', object: 'account', viewKind: 'list', order: 1, type: 'grid', requiredPermissions: [LEGAL_CAP] }, + { name: 'contact.mine', object: 'contact', viewKind: 'list', order: 0, config: { type: 'grid', columns: ['name'] } }, + { name: 'invoice.all', object: 'invoice', viewKind: 'list', order: 0, config: { type: 'grid', columns: ['total'] } }, +]; +const PAGES = [ + { name: 'home_page', label: 'Home', type: 'home' }, + { name: 'account_record', label: 'Account', type: 'record', object: 'account' }, + { name: 'invoice_record', label: 'Invoice', type: 'record', object: 'invoice' }, +]; +const DASHBOARDS = [ + { name: 'pipeline', label: 'Pipeline', widgets: [{ id: 'w_open', type: 'metric' }] }, + { name: 'legal_board', label: 'Legal', requiredPermissions: [LEGAL_CAP], widgets: [{ id: 'w_matters', type: 'metric' }] }, +]; + +function store(): Record { + return { app: [CRM_APP, PAYROLL_APP], object: OBJECTS, view: VIEWS, page: PAGES, dashboard: DASHBOARDS }; +} + +const CALLERS = { + admin: { userId: 'u_admin', systemPermissions: [SALES_ADMIN, LEGAL_CAP] }, + member: { userId: 'u_member', systemPermissions: [] as string[] }, +} as const; +type CallerName = keyof typeof CALLERS; + +// ── Harness ─────────────────────────────────────────────────────────────────── + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +const singular = (type: unknown): string => String(type ?? '').replace(/s$/, ''); + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record, sent: false }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.send = vi.fn(() => { res.sent = true; return res; }); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + return res; +} + +const META = '/api/v1/meta'; + +function setup(who: CallerName, data: Record = store()) { + const find = (type: unknown, name: unknown): any => (data[singular(type)] ?? []).find((i: any) => i.name === name); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue({ types: Object.keys(data) }), + getMetaItems: vi.fn(async ({ type }: any) => ({ type: singular(type), items: clone(data[singular(type)] ?? []) })), + getMetaItem: vi.fn(async ({ type, name }: any) => { + const found = find(type, name); + return { type: singular(type), name, item: found ? clone(found) : undefined, lock: 'none', editable: true, deletable: true, resettable: false }; + }), + }; + const rest: any = new RestServer(createMockServer() as any, protocol, {} as any); + rest.resolveExecCtx = async () => clone(CALLERS[who]); + rest.securityServiceProvider = async () => ({ resolvePermissionSetNames: async () => [] }); + rest.serviceExistsProvider = () => true; + rest.registerRoutes(); + const route = (path: string) => rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === path); + return { rest, protocol, route }; +} + +async function bundle( + who: CallerName, + appId = 'crm', + opts: { headers?: Record; query?: Record; data?: Record; patch?: (rest: any) => void } = {}, +) { + const { rest, protocol, route } = setup(who, opts.data); + opts.patch?.(rest); + const res = makeRes(); + await route(`${META}/app/:appId/bundle`).handler({ + method: 'GET', path: `${META}/app/${appId}/bundle`, params: { appId }, query: opts.query ?? {}, body: {}, headers: opts.headers ?? {}, + }, res); + return { res, protocol }; +} + +async function item(who: CallerName, type: string, name: string) { + const { route } = setup(who); + const res = makeRes(); + await route(`${META}/:type/:name`).handler({ + method: 'GET', path: `${META}/${type}/${name}`, params: { type, name }, query: {}, body: {}, headers: {}, + }, res); + return res; +} + +const namesOf = (entries: any[]): string[] => entries.map((e) => e.name); + +// ── The derivation ──────────────────────────────────────────────────────────── + +describe('[#27627] which items an app reaches', () => { + it('reads every navigation tree: top level, nested children and areas, in order, each name once', () => { + expect(appNavigationReach(CRM_APP)).toEqual({ + objects: ['account', 'secret_deal', 'contact'], + views: ['account.all'], + pages: ['home_page'], + dashboards: ['pipeline', 'legal_board'], + }); + expect(appNavigationReach(undefined)).toEqual({ objects: [], views: [], pages: [], dashboards: [] }); + }); + + it('takes one hop of inbound lookup and master-detail relationships, from either shape of `fields`', () => { + expect(inboundRelationObjects(OBJECTS, ['account', 'contact'])).toEqual(['note', 'task']); + expect(inboundRelationObjects(OBJECTS, ['secret_deal'])).toEqual(['deal_line']); + expect(inboundRelationObjects(OBJECTS, [])).toEqual([]); + }); + + it('declares an EMPTY closed query set', () => { + expect(META_APP_BUNDLE_PARAMS).toEqual([]); + }); +}); + +// ── The bundle ──────────────────────────────────────────────────────────────── + +describe('[#27627] one request answers the app, its reached objects, views, pages and dashboards', () => { + it('admin: every reached member, in derivation order, the body parsing against the declared shape', async () => { + const { res } = await bundle('admin'); + expect(res.statusCode).toBe(200); + expect(AppMetadataBundleResponseSchema.safeParse(res.body).success).toBe(true); + expect(res.body.app.name).toBe('crm'); + expect(namesOf(res.body.objects)).toEqual(['account', 'secret_deal', 'contact', 'note', 'task', 'deal_line']); + expect(namesOf(res.body.views)).toEqual(['account.all', 'account.legal', 'contact.mine']); + expect(namesOf(res.body.pages)).toEqual(['home_page', 'account_record']); + expect(namesOf(res.body.dashboards)).toEqual(['pipeline', 'legal_board']); + }); + + it('every member is byte-equal to its own GET /meta/{type}/{name} answer, for each caller', async () => { + for (const who of Object.keys(CALLERS) as CallerName[]) { + const { res } = await bundle(who); + const members: Array<[string, any]> = [ + ['app', res.body.app], + ...res.body.objects.map((m: any) => ['object', m] as [string, any]), + ...res.body.views.map((m: any) => ['view', m] as [string, any]), + ...res.body.pages.map((m: any) => ['page', m] as [string, any]), + ...res.body.dashboards.map((m: any) => ['dashboard', m] as [string, any]), + ]; + for (const [type, member] of members) { + const single = await item(who, type, member.name); + expect(single.statusCode, `${who} ${type}/${member.name}`).toBe(200); + expect(JSON.stringify(member), `${who} ${type}/${member.name}`).toBe(JSON.stringify(single.body)); + } + } + }); + + it('member: what the item reads withhold is absent — the gated nav entry reaches nothing, the gated view and dashboard are left out', async () => { + const { res } = await bundle('member'); + expect(res.statusCode).toBe(200); + expect(namesOf(res.body.objects)).toEqual(['account', 'contact', 'note', 'task']); + expect(namesOf(res.body.views)).toEqual(['account.all', 'contact.mine']); + expect(namesOf(res.body.dashboards)).toEqual(['pipeline']); + // The members carry none of it. (The app is its own read's answer: it + // still names the dashboard, whose entry no app gate prunes.) + const { app, ...members } = res.body; + const text = JSON.stringify(members); + for (const withheld of ['secret_deal', 'deal_line', 'account.legal', 'legal_board', 'w_matters']) { + expect(text, withheld).not.toContain(withheld); + } + expect(JSON.stringify(app)).not.toContain('secret_deal'); + // The controls: the same caller's own item reads refuse what the bundle left out. + expect((await item('member', 'view', 'account.legal')).statusCode).toBe(403); + expect((await item('member', 'dashboard', 'legal_board')).statusCode).toBe(403); + expect(JSON.stringify((await item('member', 'app', 'crm')).body)).not.toContain('secret_deal'); + }); + + it('times its metadata reads as one `meta` span', async () => { + const timing = new PerfTiming(); + await runWithPerfTiming(timing, () => bundle('admin')); + expect(timing.marks().map((m) => m.name)).toContain('meta'); + }); +}); + +// ── The validator ───────────────────────────────────────────────────────────── + +describe('[#27627] a per-caller validator: ETag, private, no-cache, and 304', () => { + it('sends a strong ETag under the item reads\' cache posture', async () => { + const { res } = await bundle('admin'); + expect(res.headers.ETag).toMatch(/^"[0-9a-f]{64}"$/); + expect(res.headers['Cache-Control']).toBe('private, no-cache'); + expect(res.headers.Vary).toBe('Accept-Language'); + }); + + it('two callers whose gates differ are answered two validators; one caller twice, one validator', async () => { + const admin = (await bundle('admin')).res.headers.ETag; + const member = (await bundle('member')).res.headers.ETag; + expect(admin).not.toBe(member); + expect((await bundle('admin')).res.headers.ETag).toBe(admin); + }); + + it('an unchanged bundle answers 304 with no body; another caller\'s validator, or a changed store, is answered in full', async () => { + const etag = (await bundle('member')).res.headers.ETag; + const again = (await bundle('member', 'crm', { headers: { 'if-none-match': etag } })).res; + expect(again.statusCode).toBe(304); + expect(again.body).toBeUndefined(); + expect(again.sent).toBe(true); + expect((await bundle('member', 'crm', { headers: { 'if-none-match': `W/${etag}` } })).res.statusCode).toBe(304); + + const admin = (await bundle('admin', 'crm', { headers: { 'if-none-match': etag } })).res; + expect(admin.statusCode).toBe(200); + expect(admin.headers.ETag).not.toBe(etag); + + const changed = store(); + changed.object = changed.object.map((o) => (o.name === 'account' ? { ...o, label: 'Customer' } : o)); + const edited = (await bundle('member', 'crm', { headers: { 'if-none-match': etag }, data: changed })).res; + expect(edited.statusCode).toBe(200); + expect(edited.headers.ETag).not.toBe(etag); + }); + + it('a member served under an undetermined field visibility serves the bundle no-store with no validator', async () => { + const { res } = await bundle('admin', 'crm', { + headers: { 'if-none-match': (await bundle('admin')).res.headers.ETag }, + patch: (rest) => { + rest.resolveObjectMasker = async (_env: unknown, _req: unknown, type: string) => async () => ( + type === 'object' ? { kind: 'undetermined' } : { kind: 'passthrough', reason: 'not-applicable' } + ); + }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['Cache-Control']).toBe('private, no-store'); + expect(res.headers.ETag).toBeUndefined(); + expect(namesOf(res.body.objects)).toEqual(['account', 'secret_deal', 'contact', 'note', 'task', 'deal_line']); + }); +}); + +// ── Refusals ────────────────────────────────────────────────────────────────── + +describe('[#27627] an app the caller cannot read refuses the whole request with the app read\'s own answer', () => { + it('an unknown app: the item read\'s absence, byte for byte, and no member is read', async () => { + const { res, protocol } = await bundle('admin', 'no_such_app'); + const single = await item('admin', 'app', 'no_such_app'); + expect(res.statusCode).toBe(single.statusCode); + expect(res.body).toEqual(single.body); + expect(res.headers.ETag).toBeUndefined(); + expect(protocol.getMetaItem.mock.calls.map((c: any[]) => c[0].type)).toEqual(['app']); + }); + + it('an app the caller may not open: the item read\'s 403, byte for byte', async () => { + const { res } = await bundle('member', 'payroll'); + const single = await item('member', 'app', 'payroll'); + expect(single.statusCode).toBe(403); + expect(res.statusCode).toBe(403); + expect(res.body).toEqual(single.body); + }); + + it('an unrecognised query parameter is a located 400 before anything is read', async () => { + const { res, protocol } = await bundle('admin', 'crm', { query: { package: 'x' } }); + expect(res.statusCode).toBe(400); + expect(res.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/meta-app-bundle-routes.ts b/packages/rest/src/meta-app-bundle-routes.ts index 3609759915d..d852f7ba6bc 100644 --- a/packages/rest/src/meta-app-bundle-routes.ts +++ b/packages/rest/src/meta-app-bundle-routes.ts @@ -64,7 +64,7 @@ */ import { normalizeIfNoneMatch, ObjectSchemaMaskEvaluationError } from '@objectstack/metadata-core'; -import type { GetMetaItemRequest } from '@objectstack/spec/api'; +import type { GetMetaItemRequest, GetMetaItemsRequest } from '@objectstack/spec/api'; import { deriveViewContainerObject } from '@objectstack/spec/ui'; import { measureServerTiming } from '@objectstack/observability'; import { handleRouteError, sendFieldVisibilityFault, thrownAnswerIsBareNotFound } from './error-response.js'; @@ -260,15 +260,28 @@ export function registerMetaAppBundleRoute(this: MetaAppBundleRouteHost, scope: return answers.filter((a): a is Extract => a?.kind === 'serve'); }; - const list = this.metaListSource(p, environmentId); - const listOf = async (type: string) => metaReadGate.metaItemsArray(await list.listMetaItems(type)); + // The whole-type lists that NAME candidates: the object list + // through the read gate's own source (its execution face, which + // carries every field), the view and page lists through the + // protocol's list read with the same env-scoped request. + const objectList = async () => metaReadGate.metaItemsArray( + await this.metaListSource(p, environmentId).listMetaItems('object'), + ); + const listOf = async (type: 'view' | 'page') => metaReadGate.metaItemsArray( + await p.getMetaItems?.({ type, ...(environmentId ? { environmentId } : {}) } as GetMetaItemsRequest), + ); - const built = await measureServerTiming('meta', async () => { + type Served = Extract; + type Built = + | { kind: 'refused'; app: Extract | undefined } + | { kind: 'bundle'; app: Served; objects: Served[]; views: Served[]; pages: Served[]; dashboards: Served[] }; + const built = await measureServerTiming('meta', async (): Promise => { const app = await answer('app', appId); - if (!app || app.kind !== 'serve') return { refused: app }; + if (app?.kind === 'refuse') return { kind: 'refused', app }; + if (!app || app.kind !== 'serve') return { kind: 'refused', app: undefined }; const reach = appNavigationReach((app.envelope as { item?: unknown } | null)?.item); - const inbound = inboundRelationObjects(await listOf('object'), reach.objects); + const inbound = inboundRelationObjects(await objectList(), reach.objects); const objects = await members('object', [...reach.objects, ...inbound]); const served = new Set(objects.map((o) => String((o.envelope as { name?: unknown }).name))); @@ -287,11 +300,11 @@ export function registerMetaAppBundleRoute(this: MetaAppBundleRouteHost, scope: members('page', pageNames), members('dashboard', reach.dashboards), ]); - return { app, objects, views, pages, dashboards }; + return { kind: 'bundle', app, objects, views, pages, dashboards }; }, 'Metadata reads'); - if (!('app' in built)) { - if (built.refused) this.sendMetaReadRefusal(res, built.refused.refusal); + if (built.kind === 'refused') { + if (built.app) this.sendMetaReadRefusal(res, built.app.refusal); else sendMetaItemAbsent(res); return; } From 2863e6d06edf9db1806bd86a12754d159ea4a0a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:29:11 +0000 Subject: [PATCH 04/11] wip(rest): census rows for the bundle route Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- packages/client/src/meta-app-bundle.test.ts | 58 +++++++++++++++++++ .../src/rest-config-mount-table.pin.test.ts | 5 ++ .../rest-server-canonical-query-ast.test.ts | 6 ++ .../src/rest-server-docblock-position.test.ts | 8 ++- .../rest/src/rest-server-registrar-files.ts | 7 +++ scripts/check-route-envelope.mjs | 15 +++++ 6 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 packages/client/src/meta-app-bundle.test.ts diff --git a/packages/client/src/meta-app-bundle.test.ts b/packages/client/src/meta-app-bundle.test.ts new file mode 100644 index 00000000000..43ab2e40572 --- /dev/null +++ b/packages/client/src/meta-app-bundle.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27627] `meta.getAppBundle` — the app metadata bundle read, conditional in + * `getCached`'s spelling: `ifNoneMatch` in; `notModified`, `data` and + * `etag: { value, weak }` out, with the server's `304` answered rather than + * thrown as a failed request. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackClient } from './index'; + +const BUNDLE = { + app: { type: 'app', name: 'crm', item: { name: 'crm' } }, + objects: [{ type: 'object', name: 'account', item: { name: 'account' } }], + views: [], + pages: [], + dashboards: [], +}; +const ETAG = `"${'a'.repeat(64)}"`; + +function clientAnswering(status: number, body: unknown, etag?: string) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : status === 304 ? 'Not Modified' : 'Error', + json: async () => body, + headers: new Headers(etag ? { ETag: etag } : {}), + }); + return { client: new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }), fetchMock }; +} + +describe('[#27627] meta.getAppBundle', () => { + it('reads GET /meta/app/{appId}/bundle and answers the bundle with its validator', async () => { + const { client, fetchMock } = clientAnswering(200, BUNDLE, ETAG); + const answer = await client.meta.getAppBundle('crm app'); + expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/meta/app/crm%20app/bundle'); + expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('If-None-Match')).toBeNull(); + expect(answer).toEqual({ notModified: false, data: BUNDLE, etag: { value: 'a'.repeat(64), weak: false } }); + }); + + it('sends ifNoneMatch, and a 304 is answered `notModified` instead of thrown', async () => { + const { client, fetchMock } = clientAnswering(304, undefined, ETAG); + const answer = await client.meta.getAppBundle('crm', { ifNoneMatch: ETAG }); + expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('If-None-Match')).toBe(ETAG); + expect(answer).toEqual({ notModified: true, etag: { value: 'a'.repeat(64), weak: false } }); + }); + + it('a refusal still rejects with the server\'s code and status', async () => { + const { client } = clientAnswering(403, { success: false, error: { code: 'PERMISSION_DENIED', message: 'No.' } }); + const err: any = await client.meta.getAppBundle('payroll').then( + () => { throw new Error('expected the read to reject'); }, + (e) => e, + ); + expect(err.httpStatus).toBe(403); + expect(err.code).toBe('PERMISSION_DENIED'); + }); +}); diff --git a/packages/rest/src/rest-config-mount-table.pin.test.ts b/packages/rest/src/rest-config-mount-table.pin.test.ts index 9f06ec39722..a13c88d3b94 100644 --- a/packages/rest/src/rest-config-mount-table.pin.test.ts +++ b/packages/rest/src/rest-config-mount-table.pin.test.ts @@ -202,6 +202,9 @@ const CASES: Array<{ path: string; removes: string[] }> = [ // the ruling it gated the four reads alone and the rest answered to // `api.enableMetadata`. // + // [#27627] Thirteen: the app metadata bundle registers through the per-item + // registrar too, since every member is the item read's own answer. + // // ⚠️ `GET {prefix}/object/:name/state/:field` is deliberately NOT here. // It is the object FSM read, addressed by object name rather than by // `:type/:name`, and no per-family switch gates it — see the @@ -217,6 +220,7 @@ const CASES: Array<{ path: string; removes: string[] }> = [ `GET ${META}/:type/:name/layers`, `GET ${META}/:type/:name/published`, `GET ${META}/:type/:name/references`, + `GET ${META}/app/:appId/bundle`, `GET ${META}/book/:name/tree`, `POST ${META}/:type/:name/publish`, `POST ${META}/:type/:name/rollback`, @@ -254,6 +258,7 @@ const CASES: Array<{ path: string; removes: string[] }> = [ `GET ${META}/:type/:name/published`, `GET ${META}/:type/:name/references`, `GET ${META}/_drafts`, + `GET ${META}/app/:appId/bundle`, `GET ${META}/book/:name/tree`, `GET ${META}/diagnostics`, `GET ${META}/object/:name/state/:field`, diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 383b7ad8251..5e20fd0ab57 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -202,6 +202,10 @@ interface CensusEntry { * [#27627] 5 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + 1: * the record page (`record-page-routes.ts`) builds one `query:` slot, the * related list read, so the new module's floor is 1. + * + * [#27627] 5, unchanged: the app metadata bundle (`meta-app-bundle-routes.ts`) + * reads metadata only and builds no `query:` slot, so the new module's floor + * is 0. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 0, @@ -223,6 +227,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // state-machine read nor the published read carries a slot). // [#26433] The list's length is no longer pinned: the list is the authority. // [#27627] The sum 4 → 5: the record page's related list read. + // [#27627] The sum unchanged by the app metadata bundle (no slot). expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index 5d3dab6b2c6..62a1460ce78 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -249,6 +249,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -301,8 +302,13 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // than a moved slice, carries ten (the module header, the closed // parameter set, the relationship types, the host interface and its // member, five module-level helpers' blocks and the function's). + // [#27627] 80 + 16 = 96: `meta-app-bundle-routes.ts`, a new `/meta` group, + // carries sixteen (the module header, the closed parameter set, the + // relationship types, the read policy, the host interface, the reach + // interface, seven module-level helpers' blocks, the function's, and the + // two inner helpers' inside it). expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(80); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(96); }); }); diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index b9cdb67130f..5b4227b86eb 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -72,6 +72,9 @@ export const REST_SERVER_REGISTRAR_FILES = [ // [#27627] `registerRecordPageEndpoints`' body, the record page payload. New, not a // moved slice: a registrar added beside the others joins the list in its own PR. 'record-page-routes.ts', + // [#27627] `registerMetaAppBundleRoute`' body, the app metadata bundle. New, not a + // moved slice, and a `/meta` group — see {@link REST_SERVER_META_ROUTE_FILES}. + 'meta-app-bundle-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ @@ -116,6 +119,10 @@ export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = // It is in no per-family switch and registers on the route manager itself, by value // from a host literal built inside the inner method. 'meta-object-state-routes.ts', + // [#27627] The app metadata bundle, `GET {metaPath}/app/:appId/bundle`. New, not a + // moved slice; like the per-item writes it goes through the inner method's per-item + // registrar, handed over in its scope. + 'meta-app-bundle-routes.ts', ]; /** diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 9615deee184..f49370f6b00 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -606,6 +606,21 @@ const MODULES = { ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', }, + // [#27627] The app metadata bundle (`GET {metaPath}/app/:appId/bundle`), + // declared in the PR that created it. The two write sites are the bundle body, + // under `no-store` and under its validator: answered BARE, the `/meta` item + // reads' own convention, because its members ARE those reads' envelopes. Every + // refusal goes through an existing emitter (the item read's own + // `sendMetaReadRefusal`, the shared absence and field-visibility emitters, + // `handleRouteError`), so `ok` / `err` are 0. It moves to the shared pair with + // the rest of the `/meta` family under #9559 option 1, never alone. + 'packages/rest/src/meta-app-bundle-routes.ts': { + responses: 2, + ok: 0, + err: 0, + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + }, + // [#23050 slice 1] `RestServer#registerDataActionEndpoints`' body — record // clone, the import doors and import-job reads, the export — moved out of // `rest-server.ts` whole. A MOVE, no wire answer changed, so it is held to From 4fc85bb8cc7443a67f94e75337915229e9c46684 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:38:03 +0000 Subject: [PATCH 05/11] wip(changeset): name the conditional getAppBundle Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- .changeset/27627-app-metadata-bundle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/27627-app-metadata-bundle.md b/.changeset/27627-app-metadata-bundle.md index 9101d0d6971..25c9edd3ec1 100644 --- a/.changeset/27627-app-metadata-bundle.md +++ b/.changeset/27627-app-metadata-bundle.md @@ -16,4 +16,4 @@ A console working inside one app used to read the app, then one `GET /meta/objec - **Cached per caller**: a strong `ETag` over the bytes served to this caller, `Cache-Control: private, no-cache`, `Vary: Accept-Language`, and `If-None-Match` answered `304`. Two callers share a validator only when they are served the same bytes. A member whose field visibility could not be determined serves the bundle `private, no-store` with no `ETag`. - **Server-Timing**: the bundle's metadata reads are timed as a `meta` span beside `db` and `total`, under the existing gate (`OS_SERVER_TIMING=true`, or `X-OS-Debug-Timing: 1` from an admin). - The query-parameter set is closed and empty. -- `@objectstack/client` gains `meta.getAppBundle(appId)`. +- `@objectstack/client` gains `meta.getAppBundle(appId, { ifNoneMatch })`, conditional in `meta.getCached`'s spelling: it answers `{ notModified: false, data, etag }` on a `200` and `{ notModified: true, etag }` on a `304`, and a `304` is not thrown as a failed request. From 39fb1d8542329aaaca24a0e3d7130c694072f5e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:44:35 +0000 Subject: [PATCH 06/11] chore(docs): regenerate the reference index after the main merge Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 3d605bdf30e..2215a7e8e78 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1570 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1571 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -25,7 +25,7 @@ counts are sums of the rows they head. Regenerate with | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | -| [Integration Protocol](/docs/references/integration) | 1 | 21 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | +| [Integration Protocol](/docs/references/integration) | 1 | 22 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **204** | **1570** | 14 protocol modules | +| **Total** | **204** | **1571** | 14 protocol modules | --- @@ -192,13 +192,13 @@ Users and accounts, organizations, positions, SCIM provisioning. ## Integration Protocol -**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 21 schemas** +**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 22 schemas** The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | File | Schemas | | :--- | :--- | -| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorAppCredentialVendor`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAppCredentialAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorInstanceOAuth2Auth`, `ConnectorInstanceOAuth2AuthorizationCodeAuth`, `ConnectorInstanceOAuth2ClientCredentialsAuth`, `ConnectorInstanceSignedAliyunAcs3Auth`, `ConnectorInstanceSignedAuth`, `ConnectorOAuth2Grant`, `ConnectorRetryStrategy`, `ConnectorSigningScheme`, `ConnectorType`, `DeclarativeConnectorEntry`, `RetryConfig` | +| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorAppCredentialVendor`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAppCredentialAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorInstanceOAuth2Auth`, `ConnectorInstanceOAuth2AuthorizationCodeAuth`, `ConnectorInstanceOAuth2ClientCredentialsAuth`, `ConnectorInstanceSignedAliyunAcs3Auth`, `ConnectorInstanceSignedAuth`, `ConnectorInstanceSignedTencentCloudTc3Auth`, `ConnectorOAuth2Grant`, `ConnectorRetryStrategy`, `ConnectorSigningScheme`, `ConnectorType`, `DeclarativeConnectorEntry`, `RetryConfig` | --- From 28ac68c20651a0ac549956e52c8e3c85de16ab7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:02:19 +0000 Subject: [PATCH 07/11] wip(rest): keep the tracker id out of the ledger note string Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- packages/rest/src/rest-route-ledger.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 4923a30143d..084c0f61c79 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -227,9 +227,10 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getBookTree', responseSchema: 'ResolvedBookSchema', note: 'REST-only route; payload answered BARE — `resolveBookTree()`\'s `ResolvedBook`, verbatim. The schema is declared beside its interfaces in spec `system/book.zod.ts` and re-exported into `/api`, never declared there a second time; conformance: spec `system/book.test.ts`' }, + // [#27627] The app metadata bundle — see meta-app-bundle-routes.ts. { route: 'GET /api/v1/meta/app/:appId/bundle', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAppBundle', responseSchema: 'AppMetadataBundleResponseSchema', - note: 'The app metadata bundle (#27627): the app and the objects, views, pages and dashboards its navigation reaches (objects: navigation, then one hop of inbound relationships), each member the item chain\'s answer of its own GET /meta/:type/:name as the same caller (createMetaItemAnswer, the uncached arm\'s fetch), a refused member absent, a refused app the whole answer. Answers BARE, so the named schema is the whole body; per-caller strong ETag over the served bytes, private, no-cache, 304 on If-None-Match. meta-app-bundle-routes.test.ts parses the body against the named schema on the mount' }, + note: 'The app metadata bundle: the app and the objects, views, pages and dashboards its navigation reaches (objects: navigation, then one hop of inbound relationships), each member the item chain\'s answer of its own GET /meta/:type/:name as the same caller (createMetaItemAnswer, the uncached arm\'s fetch), a refused member absent, a refused app the whole answer. Answers BARE, so the named schema is the whole body; per-caller strong ETag over the served bytes, private, no-cache, 304 on If-None-Match. meta-app-bundle-routes.test.ts parses the body against the named schema on the mount' }, // [#5882] The three-layer diagnostic projection, promoted from the // `?layers=true` flag on the row below to a path of its own so that one path // answers one response shape. `responseSchema` is filled because this mount From 42b1f2e58d65afd6d644ef1a27c668a77da65182 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:37:17 +0000 Subject: [PATCH 08/11] fix(client): meta.getCached answers its 304 instead of throwing it Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- packages/client/src/index.ts | 4 +++- packages/client/src/meta-app-bundle.test.ts | 22 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index bc483750e29..9a2dde7dff0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2102,9 +2102,11 @@ export class ObjectStackClient { headers['If-Modified-Since'] = cacheOptions.ifModifiedSince; } + // [#27627] The 304 is admitted by the transport; without it `fetch` threw on the + // non-ok status and the branch below was unreachable. const res = await this.fetch(`${this.baseUrl}${route}/object/${name}`, { headers - }); + }, { admitNotModified: true }); // Check for 304 Not Modified if (res.status === 304) { diff --git a/packages/client/src/meta-app-bundle.test.ts b/packages/client/src/meta-app-bundle.test.ts index 43ab2e40572..56088e55ed8 100644 --- a/packages/client/src/meta-app-bundle.test.ts +++ b/packages/client/src/meta-app-bundle.test.ts @@ -4,7 +4,8 @@ * [#27627] `meta.getAppBundle` — the app metadata bundle read, conditional in * `getCached`'s spelling: `ifNoneMatch` in; `notModified`, `data` and * `etag: { value, weak }` out, with the server's `304` answered rather than - * thrown as a failed request. + * thrown as a failed request. `getCached` itself rides the same opt-in: its `304` + * branch was unreachable while the transport threw on every non-ok status. */ import { describe, it, expect, vi } from 'vitest'; @@ -56,3 +57,22 @@ describe('[#27627] meta.getAppBundle', () => { expect(err.code).toBe('PERMISSION_DENIED'); }); }); + +describe('[#27627] meta.getCached answers a 304 instead of throwing it', () => { + it('sends ifNoneMatch and answers `notModified` with the validator it sent', async () => { + const { client, fetchMock } = clientAnswering(304, undefined); + const answer = await client.meta.getCached('account', { ifNoneMatch: ETAG }); + expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/meta/object/account'); + expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('If-None-Match')).toBe(ETAG); + expect(answer).toEqual({ notModified: true, etag: { value: 'a'.repeat(64), weak: false } }); + }); + + it('a refusal still rejects', async () => { + const { client } = clientAnswering(404, { success: false, error: { code: 'NOT_FOUND', message: 'No.' } }); + const err: any = await client.meta.getCached('ghost').then( + () => { throw new Error('expected the read to reject'); }, + (e) => e, + ); + expect(err.httpStatus).toBe(404); + }); +}); From 1e7a1b104ea5f7afccee6cb52f21b1c5fc01c501 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:40:55 +0000 Subject: [PATCH 09/11] wip(changeset): name the getCached 304 fix Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- .changeset/27627-app-metadata-bundle.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/27627-app-metadata-bundle.md b/.changeset/27627-app-metadata-bundle.md index 25c9edd3ec1..3b7a7dcee2b 100644 --- a/.changeset/27627-app-metadata-bundle.md +++ b/.changeset/27627-app-metadata-bundle.md @@ -17,3 +17,4 @@ A console working inside one app used to read the app, then one `GET /meta/objec - **Server-Timing**: the bundle's metadata reads are timed as a `meta` span beside `db` and `total`, under the existing gate (`OS_SERVER_TIMING=true`, or `X-OS-Debug-Timing: 1` from an admin). - The query-parameter set is closed and empty. - `@objectstack/client` gains `meta.getAppBundle(appId, { ifNoneMatch })`, conditional in `meta.getCached`'s spelling: it answers `{ notModified: false, data, etag }` on a `200` and `{ notModified: true, etag }` on a `304`, and a `304` is not thrown as a failed request. +- `meta.getCached` now answers a matching `If-None-Match` / `If-Modified-Since` with `{ notModified: true }` through the same opt-in. Before, the transport threw on the `304` before the method's own `304` branch could run. From ac4bdf070e9e02f67fe132020c14fcacdda71989 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:50:56 +0000 Subject: [PATCH 10/11] refactor(rest): the bundle sends through the /meta reads' shared validator Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- .changeset/27627-app-metadata-bundle.md | 2 +- .../rest/src/meta-app-bundle-routes.test.ts | 21 ++++++- packages/rest/src/meta-app-bundle-routes.ts | 61 +++++++------------ packages/rest/src/rest-route-ledger.ts | 2 +- .../src/rest-server-docblock-position.test.ts | 10 +-- packages/spec/src/api/app-bundle.zod.ts | 4 +- scripts/check-route-envelope.mjs | 20 +++--- 7 files changed, 55 insertions(+), 65 deletions(-) diff --git a/.changeset/27627-app-metadata-bundle.md b/.changeset/27627-app-metadata-bundle.md index 3b7a7dcee2b..d8d09fff901 100644 --- a/.changeset/27627-app-metadata-bundle.md +++ b/.changeset/27627-app-metadata-bundle.md @@ -13,7 +13,7 @@ A console working inside one app used to read the app, then one `GET /meta/objec - **What is in it**: the app with its navigation; every object its navigation reaches (top-level, area and nested entries) and every object with a lookup or master-detail field referencing one of those; the views the navigation names or that bind to a bundled object; the pages the navigation names or that bind to a bundled object; the dashboards the navigation names. - **Every member is the item read it replaces, as the same caller**: the app is `GET /meta/app/{appId}`, and each entry is `GET /meta/{type}/{name}`, envelope included. Audience gates, list-view and dashboard permissions, the field-visibility mask, the translation to the request locale and `sortability` apply exactly as they apply to those reads. An item the caller may not read is absent from the bundle. An app the caller may not read refuses the whole request with the app read's own answer. -- **Cached per caller**: a strong `ETag` over the bytes served to this caller, `Cache-Control: private, no-cache`, `Vary: Accept-Language`, and `If-None-Match` answered `304`. Two callers share a validator only when they are served the same bytes. A member whose field visibility could not be determined serves the bundle `private, no-store` with no `ETag`. +- **Cached per caller**: a weak `ETag` over the body served to this caller (the `/meta` reads' shared rule), `Cache-Control: private, no-cache`, `Vary: Accept-Language`, and `If-None-Match` answered `304`. Two callers share a validator only when they are served the same body. A member whose field visibility could not be determined serves the bundle `private, no-store` with no `ETag`. - **Server-Timing**: the bundle's metadata reads are timed as a `meta` span beside `db` and `total`, under the existing gate (`OS_SERVER_TIMING=true`, or `X-OS-Debug-Timing: 1` from an admin). - The query-parameter set is closed and empty. - `@objectstack/client` gains `meta.getAppBundle(appId, { ifNoneMatch })`, conditional in `meta.getCached`'s spelling: it answers `{ notModified: false, data, etag }` on a `200` and `{ notModified: true, etag }` on a `304`, and a `304` is not thrown as a failed request. diff --git a/packages/rest/src/meta-app-bundle-routes.test.ts b/packages/rest/src/meta-app-bundle-routes.test.ts index cd95c0fd812..1530c572720 100644 --- a/packages/rest/src/meta-app-bundle-routes.test.ts +++ b/packages/rest/src/meta-app-bundle-routes.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect, vi } from 'vitest'; import { AppMetadataBundleResponseSchema } from '@objectstack/spec/api'; import { PerfTiming, runWithPerfTiming } from '@objectstack/observability'; +import { representationEtag } from '@objectstack/types'; // Explicit `.js` extension: NodeNext resolution (see the sibling meta tests). import { RestServer } from './rest-server.js'; import { appNavigationReach, inboundRelationObjects, META_APP_BUNDLE_PARAMS } from './meta-app-bundle-routes.js'; @@ -242,9 +243,10 @@ describe('[#27627] one request answers the app, its reached objects, views, page // ── The validator ───────────────────────────────────────────────────────────── describe('[#27627] a per-caller validator: ETag, private, no-cache, and 304', () => { - it('sends a strong ETag under the item reads\' cache posture', async () => { + it('sends the /meta reads\' shared weak ETag of the served body, under their cache posture', async () => { const { res } = await bundle('admin'); - expect(res.headers.ETag).toMatch(/^"[0-9a-f]{64}"$/); + expect(res.headers.ETag).toMatch(/^W\/"[0-9a-f]{16}"$/); + expect(res.headers.ETag).toBe(representationEtag(res.body)); expect(res.headers['Cache-Control']).toBe('private, no-cache'); expect(res.headers.Vary).toBe('Accept-Language'); }); @@ -262,7 +264,8 @@ describe('[#27627] a per-caller validator: ETag, private, no-cache, and 304', () expect(again.statusCode).toBe(304); expect(again.body).toBeUndefined(); expect(again.sent).toBe(true); - expect((await bundle('member', 'crm', { headers: { 'if-none-match': `W/${etag}` } })).res.statusCode).toBe(304); + // Compared weakly (RFC 9110 §13.1.2): the opaque tag without its `W/` matches too. + expect((await bundle('member', 'crm', { headers: { 'if-none-match': etag.replace(/^W\//, '') } })).res.statusCode).toBe(304); const admin = (await bundle('admin', 'crm', { headers: { 'if-none-match': etag } })).res; expect(admin.statusCode).toBe(200); @@ -289,6 +292,18 @@ describe('[#27627] a per-caller validator: ETag, private, no-cache, and 304', () expect(res.headers.ETag).toBeUndefined(); expect(namesOf(res.body.objects)).toEqual(['account', 'secret_deal', 'contact', 'note', 'task', 'deal_line']); }); + + it('`metadata.enableCache: false` sends no validator and answers a matching If-None-Match in full, as the item reads do', async () => { + const etag = (await bundle('admin')).res.headers.ETag; + const { res } = await bundle('admin', 'crm', { + headers: { 'if-none-match': etag }, + patch: (rest) => { rest.config.metadata.enableCache = false; }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers.ETag).toBeUndefined(); + expect(res.headers['Cache-Control']).toBeUndefined(); + expect(namesOf(res.body.objects)).toEqual(['account', 'secret_deal', 'contact', 'note', 'task', 'deal_line']); + }); }); // ── Refusals ────────────────────────────────────────────────────────────────── diff --git a/packages/rest/src/meta-app-bundle-routes.ts b/packages/rest/src/meta-app-bundle-routes.ts index d852f7ba6bc..d0e2c5110f5 100644 --- a/packages/rest/src/meta-app-bundle-routes.ts +++ b/packages/rest/src/meta-app-bundle-routes.ts @@ -24,7 +24,7 @@ * chosen because it is the chain's own entry — the cached arm reaches the same * gate and mask through the same functions for the two types it serves gated, * and serves the published value under a validator this bundle replaces with - * its own (below). + * the bundle's own (below). * * A member the chain refuses (`absent`, `app-permission`, * `audience-permission`, `docs-audience`), or whose fetch throws the bare @@ -47,15 +47,18 @@ * * ## The validator * - * A strong `ETag` is the SHA-256 of the JSON this response sends, so it folds - * every per-caller dimension the item routes fold into theirs - * (`foldVisibilityFingerprintIntoEtag`: the mask, the runtime view, the intake - * reason, what the gate withheld, the switched-off actions) and the locale, - * because each of them changes those bytes: two callers are answered one - * validator only when they are sent the same body. `Cache-Control: private, - * no-cache` is the item routes' own posture (`getMetaItemCached`); a member - * served under an undetermined field visibility (ADR-0106 D6 tier 2) serves the - * whole bundle `private, no-store` with no validator, as that item's read is. + * The `/meta` reads' one rule, `metaRepresentationCaching` and + * `sendMetaRepresentation` (`./meta-representation-validator.ts`, over + * `representationEtag` in `@objectstack/types`): the weak `ETag` hashes the + * JSON this response sends, so it folds every per-caller dimension the members + * were judged under (the gate, the mask, the runtime view, the intake reason) + * and the locale, because each of them changes those bytes; two callers are + * answered one validator only when they are sent the same body. The posture is + * `private, no-cache`, and a matching `If-None-Match` is a bodiless `304`. A + * member served under an undetermined field visibility (ADR-0106 D6 tier 2) + * carries its `private, no-store` and no validator, and so does the whole + * bundle, as that item's read does. `metadata.enableCache: false` sends no + * validator, as it does for the item reads. * * The closed query-parameter set is empty (AGENTS.md, Route & surface * ownership 5). The route registers through the per-item switch inside the @@ -63,7 +66,7 @@ * to it as to every `/meta` route. */ -import { normalizeIfNoneMatch, ObjectSchemaMaskEvaluationError } from '@objectstack/metadata-core'; +import { ObjectSchemaMaskEvaluationError } from '@objectstack/metadata-core'; import type { GetMetaItemRequest, GetMetaItemsRequest } from '@objectstack/spec/api'; import { deriveViewContainerObject } from '@objectstack/spec/ui'; import { measureServerTiming } from '@objectstack/observability'; @@ -71,6 +74,7 @@ import { handleRouteError, sendFieldVisibilityFault, thrownAnswerIsBareNotFound import type { MetaRouteScope } from './meta-collection-routes.js'; import * as metaReadGate from './meta-item-read-gate.js'; import type { MetaItemAnswer, MetaReadGatePolicy } from './meta-item-read-gate.js'; +import { metaRepresentationCaching, sendMetaRepresentation } from './meta-representation-validator.js'; import { refuseUnknownQueryParams } from './query-allowlist.js'; import { sendMetaItemAbsent, type RestServer } from './rest-server.js'; @@ -176,18 +180,6 @@ export function inboundRelationObjects(objects: readonly unknown[], targets: rea return found; } -/** - * The bundle's validator: the SHA-256 of the body it sends, hex. `undefined` - * where the runtime has no Web Crypto digest, which serves the bundle with no - * validator rather than a weaker one. - */ -async function bundleValidator(json: string): Promise { - const subtle = (globalThis as { crypto?: { subtle?: any } }).crypto?.subtle; - if (!subtle || typeof subtle.digest !== 'function') return undefined; - const digest: ArrayBuffer = await subtle.digest('SHA-256', new TextEncoder().encode(json)); - return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); -} - /** A member the chain could not judge: the whole request answers it. */ class BundleMemberFault extends Error { constructor(readonly object: string) { @@ -202,7 +194,7 @@ class BundleMemberFault extends Error { * registrar. */ export function registerMetaAppBundleRoute(this: MetaAppBundleRouteHost, scope: MetaRouteScope): void { - const { metaPath, isScoped, registerPerItemRoute } = scope; + const { metadata, metaPath, isScoped, registerPerItemRoute } = scope; registerPerItemRoute({ method: 'GET', @@ -318,22 +310,11 @@ export function registerMetaAppBundleRoute(this: MetaAppBundleRouteHost, scope: dashboards: dashboards.map((m) => m.envelope), }; res.header('Vary', 'Accept-Language'); - if ([app, ...objects, ...views, ...pages, ...dashboards].some((m) => m.cacheControl !== undefined)) { - // [ADR-0106 D6 tier 2] A member served unmasked: no shared validator. - res.header('Cache-Control', metaReadGate.META_UNDETERMINED_CACHE_CONTROL); - res.json(body); - return; - } - const validator = await bundleValidator(JSON.stringify(body)); - res.header('Cache-Control', 'private, no-cache'); - if (validator !== undefined) { - res.header('ETag', `"${validator}"`); - if (normalizeIfNoneMatch(req.headers?.['if-none-match']) === validator) { - res.status(304).send(); - return; - } - } - res.json(body); + // [ADR-0106 D6 tier 2] A member served unmasked was issued no + // validator; its posture is the whole bundle's. + const undetermined = [app, ...objects, ...views, ...pages, ...dashboards] + .find((m) => m.etag === undefined)?.cacheControl; + sendMetaRepresentation(req, res, body, metaRepresentationCaching(body, undetermined), metadata.enableCache !== false); } catch (error: any) { if (error instanceof BundleMemberFault) { sendFieldVisibilityFault(res, error.object); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 084c0f61c79..8a7593b00ef 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -230,7 +230,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ // [#27627] The app metadata bundle — see meta-app-bundle-routes.ts. { route: 'GET /api/v1/meta/app/:appId/bundle', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAppBundle', responseSchema: 'AppMetadataBundleResponseSchema', - note: 'The app metadata bundle: the app and the objects, views, pages and dashboards its navigation reaches (objects: navigation, then one hop of inbound relationships), each member the item chain\'s answer of its own GET /meta/:type/:name as the same caller (createMetaItemAnswer, the uncached arm\'s fetch), a refused member absent, a refused app the whole answer. Answers BARE, so the named schema is the whole body; per-caller strong ETag over the served bytes, private, no-cache, 304 on If-None-Match. meta-app-bundle-routes.test.ts parses the body against the named schema on the mount' }, + note: 'The app metadata bundle: the app and the objects, views, pages and dashboards its navigation reaches (objects: navigation, then one hop of inbound relationships), each member the item chain\'s answer of its own GET /meta/:type/:name as the same caller (createMetaItemAnswer, the uncached arm\'s fetch), a refused member absent, a refused app the whole answer. Answers BARE, so the named schema is the whole body; per-caller weak ETag over the served body (the /meta reads\' shared validator), private, no-cache, 304 on If-None-Match. meta-app-bundle-routes.test.ts parses the body against the named schema on the mount' }, // [#5882] The three-layer diagnostic projection, promoted from the // `?layers=true` flag on the row below to a path of its own so that one path // answers one response shape. `responseSchema` is filled because this mount diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index 62a1460ce78..62311b8e0d1 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -249,7 +249,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -302,13 +302,13 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // than a moved slice, carries ten (the module header, the closed // parameter set, the relationship types, the host interface and its // member, five module-level helpers' blocks and the function's). - // [#27627] 80 + 16 = 96: `meta-app-bundle-routes.ts`, a new `/meta` group, - // carries sixteen (the module header, the closed parameter set, the + // [#27627] 80 + 15 = 95: `meta-app-bundle-routes.ts`, a new `/meta` group, + // carries fifteen (the module header, the closed parameter set, the // relationship types, the read policy, the host interface, the reach - // interface, seven module-level helpers' blocks, the function's, and the + // interface, six module-level helpers' blocks, the function's, and the // two inner helpers' inside it). expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(96); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(95); }); }); diff --git a/packages/spec/src/api/app-bundle.zod.ts b/packages/spec/src/api/app-bundle.zod.ts index b8173ce385e..7a9969441a3 100644 --- a/packages/spec/src/api/app-bundle.zod.ts +++ b/packages/spec/src/api/app-bundle.zod.ts @@ -41,11 +41,11 @@ import { GetMetaItemResponseSchema } from './protocol.zod'; * A name appears once per list, at its first position. Items outside the * bundle stay on their own `GET /meta/{type}/{name}` read. * - * The response carries a strong `ETag` that identifies the bytes served to + * The response carries a weak `ETag` that identifies the body served to * THIS caller, under `Cache-Control: private, no-cache` (the item reads' own * cache posture), and `Vary: Accept-Language`; a request whose `If-None-Match` * names it is answered `304` with no body. Two callers are answered one - * validator only when they are served the same bytes. When any member's field + * validator only when they are served the same body. When any member's field * visibility could not be determined, the bundle is served under * `Cache-Control: private, no-store` with no `ETag`, as that item's read is. */ diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index d3752fed23a..5bfbb6b4900 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -607,19 +607,13 @@ const MODULES = { }, // [#27627] The app metadata bundle (`GET {metaPath}/app/:appId/bundle`), - // declared in the PR that created it. The two write sites are the bundle body, - // under `no-store` and under its validator: answered BARE, the `/meta` item - // reads' own convention, because its members ARE those reads' envelopes. Every - // refusal goes through an existing emitter (the item read's own - // `sendMetaReadRefusal`, the shared absence and field-visibility emitters, - // `handleRouteError`), so `ok` / `err` are 0. It moves to the shared pair with - // the rest of the `/meta` family under #9559 option 1, never alone. - 'packages/rest/src/meta-app-bundle-routes.ts': { - responses: 2, - ok: 0, - err: 0, - ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', - }, + // declared in the PR that created it. It builds no body itself: the bundle is + // sent by the `/meta` reads' shared `sendMetaRepresentation` + // (`meta-representation-validator.ts`), answered BARE like the item reads whose + // envelopes are its members, and every refusal goes through an existing + // emitter (the item read's own `sendMetaReadRefusal`, the shared absence and + // field-visibility emitters, `handleRouteError`). + 'packages/rest/src/meta-app-bundle-routes.ts': { responses: 0, ok: 0, err: 0 }, // [#23050 slice 1] `RestServer#registerDataActionEndpoints`' body — record // clone, the import doors and import-job reads, the export — moved out of From 170b86ad19fb0e32fa0fc1462ece83ab32ed28d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:14:52 +0000 Subject: [PATCH 11/11] chore(docs): regenerate the bundle reference page Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwM9YxDuiRDQYmA1Yd --- content/docs/references/api/app-bundle.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/app-bundle.mdx b/content/docs/references/api/app-bundle.mdx index c89f54c8a39..0ddf82b90e3 100644 --- a/content/docs/references/api/app-bundle.mdx +++ b/content/docs/references/api/app-bundle.mdx @@ -42,11 +42,11 @@ caller is served (navigation already pruned to that caller): A name appears once per list, at its first position. Items outside the bundle stay on their own `GET /meta/{type}/{name}` read. -The response carries a strong `ETag` that identifies the bytes served to +The response carries a weak `ETag` that identifies the body served to THIS caller, under `Cache-Control: private, no-cache` (the item reads' own cache posture), and `Vary: Accept-Language`; a request whose `If-None-Match` names it is answered `304` with no body. Two callers are answered one -validator only when they are served the same bytes. When any member's field +validator only when they are served the same body. When any member's field visibility could not be determined, the bundle is served under `Cache-Control: private, no-store` with no `ETag`, as that item's read is.