Skip to content

Commit 26501ae

Browse files
feat(spec): a navigation entry may declare one API data source, which its visible reads as data (#24427)
Part of #24332 (the `visible` half of shape 1 lands here; the `label` and record-target halves stay open on the card as two forks, below). Clause-②: yes (widening) ## What this does A navigation entry may declare ONE API data source, the page element's `ElementApiDataSourceSchema` BY REFERENCE (`packages/spec/src/ui/page.zod.ts`), and its `visible` reads the bound value as `data`: ```ts { id: 'nav_environments', type: 'object', objectName: 'sys_environment', dataSource: { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' }, visible: 'data.total == 1' } ``` - `packages/spec/src/ui/app.zod.ts`: `BaseNavItemSchema` gains `dataSource: ElementApiDataSourceSchema.optional()`, so the nine branches that spread the base carry it (`object`, `dashboard`, `page`, `url`, `report`, `action`, `component`, `doc`, `group`). A `separator` spreads nothing and refuses it with its own prescription (the key joins `SEPARATOR_NAV_ITEM_GUIDANCE`). The `visible` describe now says an entry with a `dataSource` reads it as `data`, and an entry without one has no `data`. - The describe opens with `[PLANNED — not yet rendered]` and claims no reader. Only `visible` reads `data`: the describe says `label` and the target keys (`recordId`, `filters`, `params`) take no `data` read, and a group's binding is not published to its `children`. - `packages/spec/liveness/app.json`: row `navigation.children.dataSource`, graded `planned`, `authorWarn: true` with an `authorHint`, and drilled `provider` / `read.url` / `read.method` / `path`. `check:liveness` refuses an undeclared container inheritance, so the four child keys are drilled. The carrier is the objectui renderer half, which the seat files when this lands. The precedent for `planned` + `authorWarn` in this same file is `navigation.runAction` before objectui#5216. - Generated by their generators only: `authorable-surface/ui.json` (+9 rows, one per branch), `content/docs/references/ui/app.mdx`, `liveness/state-counts/app.md`. - `.changeset/24332-nav-item-api-data-source.md`: `@objectstack/spec` minor. ## The premise, measured before building (ruling item 2) | Hypothesis | Reading | |:--|:--| | H1: no import cycle | Holds. `page.zod.ts`'s transitive import closure is 52 files and does not contain `app.zod.ts`. Nothing moved. | | H2: the `data` root | objectui at the `.objectui-sha` pin `0707b4ae37` and at main `749ed76` gives the same reading. `objectui: packages/react/src/SchemaRenderer.tsx` reads a node's API binding (`useElementApiDataSource`) and puts the answer in that node's evaluation scope as `data` (pin `:1157`/`:1231`, main `:1174`/`:1248`). That one `ExpressionEvaluator` serves the node's `${…}` text holes, inline locale-map members included (objectui#12936), and its `visibleWhen` (CEL envelope via `evaluateCondition`). The binding is RENDERED at the pin, so the spec's `[PLANNED]` text on `ElementApiDataSourceSchema` and the "no `data` root" text on `visibleWhen` are stale. That is #24300's surface, held off here. | | H3a: `visible` | Holds. `objectui: packages/app-shell/src/hooks/useNavMemberGates.ts` evaluates a nav `visible` through `evaluateNavigationVisibility` with the same `ExpressionEvaluator.evaluateCondition`, so a per-entry `data` in scope is the same dialect and the same root name. **Built.** | | H3b: `label` | The dialect holds (`${…}` in a string or an inline-map member), but the label has a layer a page element does not: `translateApp` (`packages/spec/src/system/i18n-resolver.ts`) REPLACES a present label with the bundle entry keyed by the entry id, and `walkNavigation` in `packages/cli/src/utils/i18n-extract.ts` seeds every present label into the bundle as a translatable literal. The ruled row's own entry (`nav_environments`) has a zh-CN bundle entry, so a data label there would show the static noun in zh-CN. How a data-bearing label meets translation is a contract decision. **Not built**, see open question 1. | | H3c: `recordId` | Fails. `objectui: packages/layout/src/NavigationRenderer.tsx` `resolveHref` substitutes `recordId` through `applyNavTemplate`, whose vocabulary is `{current_user_id}`, `{current_org_id}` and a context selector's own `{id}` (single brace, no dots). A `${data.id}` there is taken as written. Reading data would add a second template dialect to the field, or a second spelling of a data read. **Not built**, see open question 2. | | H4: which types | Every branch that spreads the base. objectui's item guard (`passesNavItemGuards`) evaluates `visible` on every type, `group` included, so a group can honour its own binding. | ## Tests (all at `98e3434b0e`, the PR head, unless noted) - New `packages/spec/src/ui/app-nav-item-api-data-source.test.ts`: 19 tests. Every branch parses with the binding and a `data.*` `visible`; the app door accepts it on a top-level entry and on a group's child; and the bare binding parses. Five refusals are each compared with the page element binding's own issues for the same value (`code` + `path`, not prose): unknown key, off-origin `read.url` (3 shapes), credential key, `POST`, object binding. A separator refuses the key. The describe marker and the `planned` liveness row are pinned. - Ablation, once (the diff adds the accept behaviour these tests pin): `node scripts/ablation-replace.mjs` renamed the key to `dataSourceAblated`, with anchor x1 → x0 and blob `791b2943` → `c37dfcd0`. Result: **17 failed / 2 passed** (the separator refusal and the ledger row stay green, as they should). Restored, blob == HEAD, `git diff HEAD` empty. The tests import `./app.zod` from source, so no dist leg applies. - `pnpm --filter @objectstack/spec test`: 697 files, 21270 passed (at `82e723d6e5`; the merge after it brought no `packages/spec` change). `typecheck` passed, and `check:test-typecheck` OK. - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date after a rebuild at `98e3434b0e`. - Consumers (the accept set widens downstream; filter direction: these packages consume `@objectstack/spec`): - `@objectstack/lint` full suite: 145 files, 6864 passed. It includes `validate-nav-*`, `validate-translation-references` and `lint-liveness-properties`. - `@objectstack/metadata-protocol` full suite: 246 files passed (3 skipped), 20655 tests. - `@objectstack/cli` unit tier, NARROWED: the full unit tier ran past a 540 s bound (exit 124, NOT MEASURED, and declared to CI). The run covered `nav-contribution-groups*` and 23 `test/i18n-*` files: 25 files, 298 passed. - A liveness lint probe on an app with the key on a top-level entry and on a group child returns one `liveness-planned-property` finding. The lint fans out over the top-level entries only, as the ledger README states. ## Gates The derived list is `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff: 111 families, plus `check:dev-prereqs` (local-only, touches the spec build). All 112 were run locally on `98e3434b0e`, and `--ran` reconciles 111 of 111. Every one exits 0 except: - `pnpm check:platform-checklist`: **exit 1, red on `origin/main` as well**. The finding is `areas/access-security.json: ABSENT SYMBOL packages/core/src/security/resolve-authz-context.ts#legacyGrantAnchorRetired`. This diff touches neither file. On `origin/main` `71fa166e96`, `legacyGrant` has 0 hits in that file, against a control of `export` with 13 hits. Reported to the seat. Six families first answered exit 3 (PREREQUISITE NOT MET: no lint / client / objectql dist). After the consumer closure build (turbo, 62 tasks, all cache hits), all six re-ran with exit 0: `check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples`, `check:docs-transcript-drift`, `check:lean-entry-closure`, `check:dual-build-cjs-loads`. ## Open questions for the seat (also in the report on #24332) 1. **Label fork:** a data-bearing label and the bundle rung that replaces a present label by entry id. 2. **Target fork:** `recordId` has its own `{…}` template dialect. 3. **The narrowing arm, not shipped:** refusing `data.*` in a `visible` on an entry with no `dataSource`. It would be BREAKING and need an ADR-0087 disposition. Measured reach: 0 `data.` reads among the nav `visible` predicates in this repo's 38 navigation-declaring files. The control is the same files' `current_user` predicates, e.g. `packages/spec/src/conversions/registry.ts:2091`. 4. **Refusal wording:** the reused binding's refusals name a page element ("`read.url` of a page element's `api` data source …") on the navigation door too. That text is `page.zod.ts`'s, a hold-off for this card. ## Acceptance notes - objectui's own nav mirror (`objectui: packages/types/src/zod/app.zod.ts`, `NavigationItemObject`) is a non-strict `z.object` and strips `dataSource`. This is renderer-half work for the carrier card. - Whether an unbound `data.*` read in a nav `visible` FAULTS (and so withholds the entry under `evaluateNavigationVisibility`) or reads `undefined` is NOT MEASURED: objectui is not installed in this container. The `authorHint` says only that there is no value to read. --- _Generated by [Claude Code](https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 725a5b1 commit 26501ae

7 files changed

Lines changed: 512 additions & 48 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a navigation entry may declare one API data source, which its `visible` reads as `data` (planned, not yet rendered)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What changed.** Every navigation entry that spreads the shared nav base (`object`, `dashboard`, `page`, `url`, `report`, `action`, `component`, `doc`, `group`) accepts an optional `dataSource`. It is the page element's API binding, `ElementApiDataSourceSchema`, by reference: `{ provider: 'api', read: { url, method? }, path? }` reads one same-origin endpoint with the console's signed-in fetch, and the entry's `visible` reads the value `path` selects as `data`, for example `visible: 'data.total == 1'`.
10+
- **The refusals are the binding's own.** A `read.url` that is not a same-origin API path, a `method` other than `GET`, a credential key (`headers`, `token`, …), the object binding `{ object, … }` and an unknown key are all refused at parse, at the entry's `dataSource` path. A `separator` refuses `dataSource` with its own prescription.
11+
- **What does not read it.** Only `visible` reads `data`. `label` and the target keys (`recordId`, `filters`, `params`) take no `data` read, and a group's binding is not published to its `children`: each entry declares its own.
12+
- **Planned, not yet rendered.** No console reads the key yet, so a `visible` that reads `data` has no value to read until the navigation renderer ships. The describe opens with `[PLANNED — not yet rendered]`, and the liveness row `app` → `navigation.dataSource` is `planned` with an author warning (`os lint` / `os validate` report it on a top-level entry).
13+
- **Who notices.** Nobody, by construction: every app that parsed before parses unchanged. Nothing narrows.

‎content/docs/references/ui/app.mdx‎

Lines changed: 234 additions & 45 deletions
Large diffs are not rendered by default.

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
"ui/ActionNavItem:actionDef",
129129
"ui/ActionNavItem:badge",
130130
"ui/ActionNavItem:badgeVariant",
131+
"ui/ActionNavItem:dataSource",
131132
"ui/ActionNavItem:icon",
132133
"ui/ActionNavItem:id",
133134
"ui/ActionNavItem:label",
@@ -296,6 +297,7 @@
296297
"ui/ComponentNavItem:badge",
297298
"ui/ComponentNavItem:badgeVariant",
298299
"ui/ComponentNavItem:componentRef",
300+
"ui/ComponentNavItem:dataSource",
299301
"ui/ComponentNavItem:icon",
300302
"ui/ComponentNavItem:id",
301303
"ui/ComponentNavItem:label",
@@ -338,6 +340,7 @@
338340
"ui/DashboardNavItem:badge",
339341
"ui/DashboardNavItem:badgeVariant",
340342
"ui/DashboardNavItem:dashboardName",
343+
"ui/DashboardNavItem:dataSource",
341344
"ui/DashboardNavItem:icon",
342345
"ui/DashboardNavItem:id",
343346
"ui/DashboardNavItem:label",
@@ -420,6 +423,7 @@
420423
"ui/DocNavItem:badge",
421424
"ui/DocNavItem:badgeVariant",
422425
"ui/DocNavItem:book",
426+
"ui/DocNavItem:dataSource",
423427
"ui/DocNavItem:doc",
424428
"ui/DocNavItem:icon",
425429
"ui/DocNavItem:id",
@@ -644,6 +648,7 @@
644648
"ui/GlobalFilterOptionsFrom:valueField",
645649
"ui/GroupNavItem:badge",
646650
"ui/GroupNavItem:badgeVariant",
651+
"ui/GroupNavItem:dataSource",
647652
"ui/GroupNavItem:expanded",
648653
"ui/GroupNavItem:icon",
649654
"ui/GroupNavItem:id",
@@ -1012,6 +1017,7 @@
10121017
"ui/ObjectMetricProps:variant",
10131018
"ui/ObjectNavItem:badge",
10141019
"ui/ObjectNavItem:badgeVariant",
1020+
"ui/ObjectNavItem:dataSource",
10151021
"ui/ObjectNavItem:filters",
10161022
"ui/ObjectNavItem:icon",
10171023
"ui/ObjectNavItem:id",
@@ -1114,6 +1120,7 @@
11141120
"ui/PageHeaderProps:title",
11151121
"ui/PageNavItem:badge",
11161122
"ui/PageNavItem:badgeVariant",
1123+
"ui/PageNavItem:dataSource",
11171124
"ui/PageNavItem:icon",
11181125
"ui/PageNavItem:id",
11191126
"ui/PageNavItem:label",
@@ -1289,6 +1296,7 @@
12891296
"ui/ReportChart:yAxis",
12901297
"ui/ReportNavItem:badge",
12911298
"ui/ReportNavItem:badgeVariant",
1299+
"ui/ReportNavItem:dataSource",
12921300
"ui/ReportNavItem:icon",
12931301
"ui/ReportNavItem:id",
12941302
"ui/ReportNavItem:label",
@@ -1326,6 +1334,7 @@
13261334
"ui/TreeConfig:parentField",
13271335
"ui/UrlNavItem:badge",
13281336
"ui/UrlNavItem:badgeVariant",
1337+
"ui/UrlNavItem:dataSource",
13291338
"ui/UrlNavItem:icon",
13301339
"ui/UrlNavItem:id",
13311340
"ui/UrlNavItem:label",

‎packages/spec/liveness/app.json‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,43 @@
128128
"evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:891 (item gate); objectui @f8a9d0fb: packages/app-shell/src/layout/UnifiedSidebar.tsx#UnifiedSidebar (`evalVis`, the CEL evaluation through the ExpressionProvider's `evaluateVisibility`, handed to `NavigationRenderer` and to `hasVisibleNavigationItems`); objectui @f8a9d0fb: packages/app-shell/src/views/nav-menu-renderer.tsx#NavMenuRenderer (the same `evalVis`, and each row returns nothing when `evalVis(item.visible)` is false)",
129129
"note": "the CEL visibility gate — enforced per item. This is the layer the retired `areas[].visible` (#4651) prescribes moving to: same CEL dialect, one level down, and actually evaluated. CLIENT-side only, at both levels, and deliberately so: #4722 made the server enforce `requiredPermissions` / `requiresService` inside `areas[]` but left CEL where it was, because evaluating it server-side needs a bound `user` context the REST read layer does not have. Anything that must never reach the browser belongs in `requiredPermissions`, not `visible` — the asymmetry is pinned in packages/rest/src/rest.test.ts. 2026-09-27 (#20146): REPOINTED off objectui's `AppSidebar.tsx` — deprecated, never mounted (the console renders `UnifiedSidebar`), and removed from objectui main by objectui PR #10617 — to the mounted reader(s) above, each re-read at the `.objectui-sha` pin f8a9d0fb and unchanged at objectui main fb91ac9b0. The `NavigationRenderer` item-gate leg is unchanged by this re-point."
130130
},
131+
"dataSource": {
132+
"status": "planned",
133+
"verifiedAt": "2026-10-19",
134+
"evidenceScope": "cross-repo",
135+
"authorWarn": true,
136+
"authorHint": "Keep it, but nothing reads it yet: no console renders a navigation entry's `dataSource`, so a `visible` that reads `data` has no value to read until the navigation renderer ships.",
137+
"note": "PLANNED RECORD (#24332, 2026-10-19, shape 1 by the domain:spec seat's claim 6257260075; the spec half of the objectstack-ai/cloud#3002 letter A sidebar row): one API data source on a navigation entry, the page element's `ElementApiDataSourceSchema` BY REFERENCE (`{ provider: 'api', read: { url, method? }, path? }`, `read.url` a same-origin API path refused at parse otherwise, no credential key), declared on the shared nav base so the nine branches that spread it carry it (a separator refuses it). Its one declared reader is the same entry's `visible`, which reads the selected value as `data`; `label` and the target keys take no `data` read, and the binding is not published to `children`. PLANNED, not live: measured at the `.objectui-sha` pin 0707b4ae37 and unchanged at objectui main 749ed76, the shell evaluates every entry's `visible` through one shared evaluator built from the ambient scope (objectui: packages/app-shell/src/hooks/useNavMemberGates.ts#useNavMemberGates, which hands `evaluateNavigationVisibility(expr, evaluator)` to the item guard; objectui: packages/layout/src/NavigationRenderer.tsx#passesNavItemGuards, which calls `evaluateVisibility(item.visible)` with no per-entry scope), and objectui's own nav mirror (objectui: packages/types/src/zod/app.zod.ts) strips the key. No objectui source reads it. Carrier: the objectui renderer half, filed by the seat when this lands (read points `useNavMemberGates`, and `resolveNavItemLabel` / `resolveHref` in `NavigationRenderer.tsx` if the label and target forks are ruled). `authorWarn` is set because an entry authored with this key and a `data.*` gate today has no value to read; the lint fans `navigation.dataSource` over the top-level entries only (it reads a row and its direct children, no deeper), so an entry nested under a group is not warned. Flip to `live` on the commit that cites the console reader at the `.objectui-sha` pin, and drop `authorWarn` / `authorHint` in that same edit.",
138+
"children": {
139+
"provider": {
140+
"status": "planned",
141+
"verifiedAt": "2026-10-19",
142+
"note": "Planned with its container `navigation.dataSource` (see that row): the page element binding's own key, reused by reference, and read by no console on a navigation entry yet. `provider: 'api'` is the binding's discriminator; it is the only provider a navigation entry admits."
143+
},
144+
"read": {
145+
"status": "planned",
146+
"verifiedAt": "2026-10-19",
147+
"note": "Planned with its container `navigation.dataSource` (see that row): the page element binding's own key, reused by reference, and read by no console on a navigation entry yet. The read request: the one same-origin endpoint the entry binds.",
148+
"children": {
149+
"url": {
150+
"status": "planned",
151+
"verifiedAt": "2026-10-19",
152+
"note": "Planned with its container `navigation.dataSource` (see that row): the page element binding's own key, reused by reference, and read by no console on a navigation entry yet. Its same-origin refusal is live at parse, on this door as on the page element's (pinned in packages/spec/src/ui/app-nav-item-api-data-source.test.ts); what is planned is the READ."
153+
},
154+
"method": {
155+
"status": "planned",
156+
"verifiedAt": "2026-10-19",
157+
"note": "Planned with its container `navigation.dataSource` (see that row): the page element binding's own key, reused by reference, and read by no console on a navigation entry yet. `GET` alone, refused otherwise at parse."
158+
}
159+
}
160+
},
161+
"path": {
162+
"status": "planned",
163+
"verifiedAt": "2026-10-19",
164+
"note": "Planned with its container `navigation.dataSource` (see that row): the page element binding's own key, reused by reference, and read by no console on a navigation entry yet. The dotted path selecting the value the entry's `visible` reads as `data`."
165+
}
166+
}
167+
},
131168
"requiredPermissions": {
132169
"status": "live",
133170
"verifiedAt": "2026-09-27",

‎packages/spec/liveness/state-counts/app.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `app` | 51 | 0 | 0 | 8 | 0 | 59 |
15+
| `app` | 51 | 0 | 0 | 8 | 4 | 63 |
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24332] A navigation entry may declare ONE API data source — the page
5+
* element's `ElementApiDataSourceSchema`, BY REFERENCE — and its `visible`
6+
* reads the bound value as `data` (shape 1 of the card, the spec half of the
7+
* objectstack-ai/cloud#3002 letter A sidebar row).
8+
*
9+
* What is pinned here is the SHAPE half:
10+
*
11+
* 1. the binding parses on every branch that spreads the shared nav base, and
12+
* on a `group`'s child, beside a `visible` that reads `data.*`;
13+
* 2. it is the page element's binding and not a restatement of it: the nav
14+
* door refuses an unknown key, an off-origin `read.url`, a credential key
15+
* and the object binding at the nav item's own path, with the very issue
16+
* the page element's binding raises for the same value;
17+
* 3. a separator refuses the key with its own prescription;
18+
* 4. the key is PLANNED: its describe carries the marker and its liveness row
19+
* is `planned`, never `live`, until the navigation renderer reads it.
20+
*
21+
* The refusal sentences are the binding's own and are deliberately not pinned
22+
* as text: each refusal is compared with the page element's answer for the
23+
* same input, which is the claim (one binding, one set of refusals).
24+
*/
25+
import fs from 'node:fs';
26+
27+
import { describe, it, expect } from 'vitest';
28+
29+
import { AppSchema, NavigationItemSchema, ObjectNavItemSchema } from './app.zod';
30+
import { ElementApiDataSourceSchema } from './page.zod';
31+
32+
const API_BINDING = { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' } as const;
33+
34+
/** One entry per branch that spreads the shared nav base — every `type` but `separator`. */
35+
const BRANCHES: ReadonlyArray<[string, Record<string, unknown>]> = [
36+
['object', { type: 'object', objectName: 'sys_environment' }],
37+
['dashboard', { type: 'dashboard', dashboardName: 'ops' }],
38+
['page', { type: 'page', pageName: 'environment_home' }],
39+
['url', { type: 'url', url: '/apps/cloud' }],
40+
['report', { type: 'report', reportName: 'usage' }],
41+
['action', { type: 'action', actionDef: { actionName: 'create_environment' } }],
42+
['component', { type: 'component', componentRef: 'metadata:directory' }],
43+
['doc', { type: 'doc', book: 'cloud_manual' }],
44+
['group', { type: 'group', children: [] }],
45+
];
46+
47+
const withNav = (navigation: unknown[]) => ({ name: 'cloud_console', label: 'Cloud', navigation });
48+
49+
/** The page element binding's own issues for `value`, re-rooted at `prefix`. */
50+
const bindingIssues = (value: unknown, prefix: PropertyKey[]) => {
51+
const r = ElementApiDataSourceSchema.safeParse(value);
52+
expect(r.success, 'control: the page element binding must refuse this value too').toBe(false);
53+
return r.error!.issues.map((i) => ({ code: i.code, path: [...prefix, ...i.path], message: i.message }));
54+
};
55+
56+
const navIssues = (item: Record<string, unknown>) => {
57+
const r = NavigationItemSchema.safeParse(item);
58+
expect(r.success).toBe(false);
59+
return r.error!.issues.map((i) => ({ code: i.code, path: i.path, message: i.message }));
60+
};
61+
62+
describe('navigation item `dataSource` — accepted on every branch that spreads the base', () => {
63+
it.each(BRANCHES)('a `%s` entry parses with the API binding and a `visible` reading `data`', (_name, target) => {
64+
const item = { id: 'nav_environments', label: 'Environments', visible: 'data.total == 1', dataSource: API_BINDING, ...target };
65+
const r = NavigationItemSchema.safeParse(item);
66+
expect(r.success, JSON.stringify(r.error?.issues)).toBe(true);
67+
expect((r.data as { dataSource?: unknown }).dataSource).toEqual(API_BINDING);
68+
});
69+
70+
it('parses through the app door, on a top-level entry and on a group\'s child', () => {
71+
const r = AppSchema.safeParse(withNav([
72+
{ id: 'nav_environments', type: 'object', objectName: 'sys_environment', visible: 'data.total != 1', dataSource: API_BINDING },
73+
{
74+
id: 'grp_tenancy', type: 'group', label: 'Tenancy',
75+
children: [{ id: 'nav_environment_home', type: 'page', pageName: 'environment_home', visible: 'data.total == 1', dataSource: API_BINDING }],
76+
},
77+
]));
78+
expect(r.success, JSON.stringify(r.error?.issues)).toBe(true);
79+
});
80+
81+
it('accepts the bare binding: `path` and `read.method` stay optional, and `GET` is the one method', () => {
82+
for (const dataSource of [
83+
{ provider: 'api', read: { url: '/api/v1/cloud/home' } },
84+
{ provider: 'api', read: { url: '/api/v1/cloud/home', method: 'GET' } },
85+
]) {
86+
const r = ObjectNavItemSchema.safeParse({ id: 'nav_environments', type: 'object', objectName: 'sys_environment', dataSource });
87+
expect(r.success, JSON.stringify(r.error?.issues)).toBe(true);
88+
}
89+
});
90+
});
91+
92+
describe('navigation item `dataSource` — the page element binding\'s refusals, at the nav item\'s path', () => {
93+
const entry = (dataSource: unknown) => ({ id: 'nav_environments', type: 'object', objectName: 'sys_environment', dataSource });
94+
95+
it('refuses an unknown key on the binding, not drops it', () => {
96+
const value = { ...API_BINDING, refresh: 30 };
97+
const issues = navIssues(entry(value));
98+
expect(issues).toEqual(bindingIssues(value, ['dataSource']));
99+
expect(issues[0]).toMatchObject({ code: 'unrecognized_keys', path: ['dataSource'] });
100+
});
101+
102+
it('refuses an off-origin `read.url` at `dataSource.read.url`', () => {
103+
for (const url of ['https://api.example.com/v1/home', '//evil.example/home', 'api/v1/home']) {
104+
const value = { provider: 'api', read: { url } };
105+
const issues = navIssues(entry(value));
106+
expect(issues, url).toEqual(bindingIssues(value, ['dataSource']));
107+
expect(issues[0], url).toMatchObject({ code: 'invalid_format', path: ['dataSource', 'read', 'url'] });
108+
}
109+
});
110+
111+
it('refuses a credential key inside the read request', () => {
112+
const value = { provider: 'api', read: { url: '/api/v1/cloud/home', headers: { authorization: 'Bearer x' } } };
113+
const issues = navIssues(entry(value));
114+
expect(issues).toEqual(bindingIssues(value, ['dataSource']));
115+
expect(issues[0]).toMatchObject({ code: 'unrecognized_keys', path: ['dataSource', 'read'] });
116+
});
117+
118+
it('refuses a method other than `GET`', () => {
119+
const value = { provider: 'api', read: { url: '/api/v1/cloud/home', method: 'POST' } };
120+
const issues = navIssues(entry(value));
121+
expect(issues).toEqual(bindingIssues(value, ['dataSource']));
122+
expect(issues[0]).toMatchObject({ code: 'invalid_value', path: ['dataSource', 'read', 'method'] });
123+
});
124+
125+
it('does not admit the page element\'s OBJECT binding — a nav entry binds an API answer only', () => {
126+
const value = { object: 'sys_environment', limit: 1 };
127+
const issues = navIssues(entry(value));
128+
expect(issues).toEqual(bindingIssues(value, ['dataSource']));
129+
expect(issues.every((i) => i.path[0] === 'dataSource'), JSON.stringify(issues)).toBe(true);
130+
});
131+
});
132+
133+
describe('a separator refuses `dataSource` with its own prescription', () => {
134+
it('answers with the separator guidance, not a bare unknown key', () => {
135+
const r = NavigationItemSchema.safeParse({ type: 'separator', id: 'sep_1', dataSource: API_BINDING });
136+
expect(r.success).toBe(false);
137+
const issue = r.error!.issues[0];
138+
expect(issue).toMatchObject({ code: 'unrecognized_keys', path: [] });
139+
expect(issue.message).toContain('`dataSource` is not a separator key');
140+
});
141+
});
142+
143+
describe('navigation item `dataSource` is PLANNED — declared, not yet rendered', () => {
144+
it('its describe carries the planned marker', () => {
145+
const shape = (ObjectNavItemSchema as unknown as { shape: Record<string, { description?: string }> }).shape;
146+
expect(shape.dataSource.description).toMatch(/^\[PLANNED — not yet rendered\] /);
147+
});
148+
149+
it('its liveness row is `planned` with an author warning, never `live`', () => {
150+
const ledger = JSON.parse(fs.readFileSync(new URL('../../liveness/app.json', import.meta.url), 'utf8'));
151+
const row = ledger.props.navigation.children.dataSource;
152+
expect(row, 'liveness/app.json navigation.children.dataSource must exist').toBeDefined();
153+
expect(row.status).toBe('planned');
154+
expect(row.authorWarn).toBe(true);
155+
});
156+
});

0 commit comments

Comments
 (0)