Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/24332-nav-item-api-data-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': minor
---

feat(spec): a navigation entry may declare one API data source, which its `visible` reads as `data` (planned, not yet rendered)

Clause-②: yes (widening)

- **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'`.
- **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.
- **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.
- **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).
- **Who notices.** Nobody, by construction: every app that parsed before parses unchanged. Nothing narrows.
279 changes: 234 additions & 45 deletions content/docs/references/ui/app.mdx

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
"ui/ActionNavItem:actionDef",
"ui/ActionNavItem:badge",
"ui/ActionNavItem:badgeVariant",
"ui/ActionNavItem:dataSource",
"ui/ActionNavItem:icon",
"ui/ActionNavItem:id",
"ui/ActionNavItem:label",
Expand Down Expand Up @@ -296,6 +297,7 @@
"ui/ComponentNavItem:badge",
"ui/ComponentNavItem:badgeVariant",
"ui/ComponentNavItem:componentRef",
"ui/ComponentNavItem:dataSource",
"ui/ComponentNavItem:icon",
"ui/ComponentNavItem:id",
"ui/ComponentNavItem:label",
Expand Down Expand Up @@ -338,6 +340,7 @@
"ui/DashboardNavItem:badge",
"ui/DashboardNavItem:badgeVariant",
"ui/DashboardNavItem:dashboardName",
"ui/DashboardNavItem:dataSource",
"ui/DashboardNavItem:icon",
"ui/DashboardNavItem:id",
"ui/DashboardNavItem:label",
Expand Down Expand Up @@ -420,6 +423,7 @@
"ui/DocNavItem:badge",
"ui/DocNavItem:badgeVariant",
"ui/DocNavItem:book",
"ui/DocNavItem:dataSource",
"ui/DocNavItem:doc",
"ui/DocNavItem:icon",
"ui/DocNavItem:id",
Expand Down Expand Up @@ -644,6 +648,7 @@
"ui/GlobalFilterOptionsFrom:valueField",
"ui/GroupNavItem:badge",
"ui/GroupNavItem:badgeVariant",
"ui/GroupNavItem:dataSource",
"ui/GroupNavItem:expanded",
"ui/GroupNavItem:icon",
"ui/GroupNavItem:id",
Expand Down Expand Up @@ -1012,6 +1017,7 @@
"ui/ObjectMetricProps:variant",
"ui/ObjectNavItem:badge",
"ui/ObjectNavItem:badgeVariant",
"ui/ObjectNavItem:dataSource",
"ui/ObjectNavItem:filters",
"ui/ObjectNavItem:icon",
"ui/ObjectNavItem:id",
Expand Down Expand Up @@ -1114,6 +1120,7 @@
"ui/PageHeaderProps:title",
"ui/PageNavItem:badge",
"ui/PageNavItem:badgeVariant",
"ui/PageNavItem:dataSource",
"ui/PageNavItem:icon",
"ui/PageNavItem:id",
"ui/PageNavItem:label",
Expand Down Expand Up @@ -1289,6 +1296,7 @@
"ui/ReportChart:yAxis",
"ui/ReportNavItem:badge",
"ui/ReportNavItem:badgeVariant",
"ui/ReportNavItem:dataSource",
"ui/ReportNavItem:icon",
"ui/ReportNavItem:id",
"ui/ReportNavItem:label",
Expand Down Expand Up @@ -1326,6 +1334,7 @@
"ui/TreeConfig:parentField",
"ui/UrlNavItem:badge",
"ui/UrlNavItem:badgeVariant",
"ui/UrlNavItem:dataSource",
"ui/UrlNavItem:icon",
"ui/UrlNavItem:id",
"ui/UrlNavItem:label",
Expand Down
37 changes: 37 additions & 0 deletions packages/spec/liveness/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,43 @@
"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)",
"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."
},
"dataSource": {
"status": "planned",
"verifiedAt": "2026-10-19",
"evidenceScope": "cross-repo",
"authorWarn": true,
"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.",
"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.",
"children": {
"provider": {
"status": "planned",
"verifiedAt": "2026-10-19",
"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."
},
"read": {
"status": "planned",
"verifiedAt": "2026-10-19",
"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.",
"children": {
"url": {
"status": "planned",
"verifiedAt": "2026-10-19",
"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."
},
"method": {
"status": "planned",
"verifiedAt": "2026-10-19",
"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."
}
}
},
"path": {
"status": "planned",
"verifiedAt": "2026-10-19",
"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`."
}
}
},
"requiredPermissions": {
"status": "live",
"verifiedAt": "2026-09-27",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.

| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `app` | 51 | 0 | 0 | 8 | 0 | 59 |
| `app` | 51 | 0 | 0 | 8 | 4 | 63 |
156 changes: 156 additions & 0 deletions packages/spec/src/ui/app-nav-item-api-data-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24332] A navigation entry may declare ONE API data source — the page
* element's `ElementApiDataSourceSchema`, BY REFERENCE — and its `visible`
* reads the bound value as `data` (shape 1 of the card, the spec half of the
* objectstack-ai/cloud#3002 letter A sidebar row).
*
* What is pinned here is the SHAPE half:
*
* 1. the binding parses on every branch that spreads the shared nav base, and
* on a `group`'s child, beside a `visible` that reads `data.*`;
* 2. it is the page element's binding and not a restatement of it: the nav
* door refuses an unknown key, an off-origin `read.url`, a credential key
* and the object binding at the nav item's own path, with the very issue
* the page element's binding raises for the same value;
* 3. a separator refuses the key with its own prescription;
* 4. the key is PLANNED: its describe carries the marker and its liveness row
* is `planned`, never `live`, until the navigation renderer reads it.
*
* The refusal sentences are the binding's own and are deliberately not pinned
* as text: each refusal is compared with the page element's answer for the
* same input, which is the claim (one binding, one set of refusals).
*/
import fs from 'node:fs';

import { describe, it, expect } from 'vitest';

import { AppSchema, NavigationItemSchema, ObjectNavItemSchema } from './app.zod';
import { ElementApiDataSourceSchema } from './page.zod';

const API_BINDING = { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' } as const;

/** One entry per branch that spreads the shared nav base — every `type` but `separator`. */
const BRANCHES: ReadonlyArray<[string, Record<string, unknown>]> = [
['object', { type: 'object', objectName: 'sys_environment' }],
['dashboard', { type: 'dashboard', dashboardName: 'ops' }],
['page', { type: 'page', pageName: 'environment_home' }],
['url', { type: 'url', url: '/apps/cloud' }],
['report', { type: 'report', reportName: 'usage' }],
['action', { type: 'action', actionDef: { actionName: 'create_environment' } }],
['component', { type: 'component', componentRef: 'metadata:directory' }],
['doc', { type: 'doc', book: 'cloud_manual' }],
['group', { type: 'group', children: [] }],
];

const withNav = (navigation: unknown[]) => ({ name: 'cloud_console', label: 'Cloud', navigation });

/** The page element binding's own issues for `value`, re-rooted at `prefix`. */
const bindingIssues = (value: unknown, prefix: PropertyKey[]) => {
const r = ElementApiDataSourceSchema.safeParse(value);
expect(r.success, 'control: the page element binding must refuse this value too').toBe(false);
return r.error!.issues.map((i) => ({ code: i.code, path: [...prefix, ...i.path], message: i.message }));
};

const navIssues = (item: Record<string, unknown>) => {
const r = NavigationItemSchema.safeParse(item);
expect(r.success).toBe(false);
return r.error!.issues.map((i) => ({ code: i.code, path: i.path, message: i.message }));
};

describe('navigation item `dataSource` — accepted on every branch that spreads the base', () => {
it.each(BRANCHES)('a `%s` entry parses with the API binding and a `visible` reading `data`', (_name, target) => {
const item = { id: 'nav_environments', label: 'Environments', visible: 'data.total == 1', dataSource: API_BINDING, ...target };
const r = NavigationItemSchema.safeParse(item);
expect(r.success, JSON.stringify(r.error?.issues)).toBe(true);
expect((r.data as { dataSource?: unknown }).dataSource).toEqual(API_BINDING);
});

it('parses through the app door, on a top-level entry and on a group\'s child', () => {
const r = AppSchema.safeParse(withNav([
{ id: 'nav_environments', type: 'object', objectName: 'sys_environment', visible: 'data.total != 1', dataSource: API_BINDING },
{
id: 'grp_tenancy', type: 'group', label: 'Tenancy',
children: [{ id: 'nav_environment_home', type: 'page', pageName: 'environment_home', visible: 'data.total == 1', dataSource: API_BINDING }],
},
]));
expect(r.success, JSON.stringify(r.error?.issues)).toBe(true);
});

it('accepts the bare binding: `path` and `read.method` stay optional, and `GET` is the one method', () => {
for (const dataSource of [
{ provider: 'api', read: { url: '/api/v1/cloud/home' } },
{ provider: 'api', read: { url: '/api/v1/cloud/home', method: 'GET' } },
]) {
const r = ObjectNavItemSchema.safeParse({ id: 'nav_environments', type: 'object', objectName: 'sys_environment', dataSource });
expect(r.success, JSON.stringify(r.error?.issues)).toBe(true);
}
});
});

describe('navigation item `dataSource` — the page element binding\'s refusals, at the nav item\'s path', () => {
const entry = (dataSource: unknown) => ({ id: 'nav_environments', type: 'object', objectName: 'sys_environment', dataSource });

it('refuses an unknown key on the binding, not drops it', () => {
const value = { ...API_BINDING, refresh: 30 };
const issues = navIssues(entry(value));
expect(issues).toEqual(bindingIssues(value, ['dataSource']));
expect(issues[0]).toMatchObject({ code: 'unrecognized_keys', path: ['dataSource'] });
});

it('refuses an off-origin `read.url` at `dataSource.read.url`', () => {
for (const url of ['https://api.example.com/v1/home', '//evil.example/home', 'api/v1/home']) {
const value = { provider: 'api', read: { url } };
const issues = navIssues(entry(value));
expect(issues, url).toEqual(bindingIssues(value, ['dataSource']));
expect(issues[0], url).toMatchObject({ code: 'invalid_format', path: ['dataSource', 'read', 'url'] });
}
});

it('refuses a credential key inside the read request', () => {
const value = { provider: 'api', read: { url: '/api/v1/cloud/home', headers: { authorization: 'Bearer x' } } };
const issues = navIssues(entry(value));
expect(issues).toEqual(bindingIssues(value, ['dataSource']));
expect(issues[0]).toMatchObject({ code: 'unrecognized_keys', path: ['dataSource', 'read'] });
});

it('refuses a method other than `GET`', () => {
const value = { provider: 'api', read: { url: '/api/v1/cloud/home', method: 'POST' } };
const issues = navIssues(entry(value));
expect(issues).toEqual(bindingIssues(value, ['dataSource']));
expect(issues[0]).toMatchObject({ code: 'invalid_value', path: ['dataSource', 'read', 'method'] });
});

it('does not admit the page element\'s OBJECT binding — a nav entry binds an API answer only', () => {
const value = { object: 'sys_environment', limit: 1 };
const issues = navIssues(entry(value));
expect(issues).toEqual(bindingIssues(value, ['dataSource']));
expect(issues.every((i) => i.path[0] === 'dataSource'), JSON.stringify(issues)).toBe(true);
});
});

describe('a separator refuses `dataSource` with its own prescription', () => {
it('answers with the separator guidance, not a bare unknown key', () => {
const r = NavigationItemSchema.safeParse({ type: 'separator', id: 'sep_1', dataSource: API_BINDING });
expect(r.success).toBe(false);
const issue = r.error!.issues[0];
expect(issue).toMatchObject({ code: 'unrecognized_keys', path: [] });
expect(issue.message).toContain('`dataSource` is not a separator key');
});
});

describe('navigation item `dataSource` is PLANNED — declared, not yet rendered', () => {
it('its describe carries the planned marker', () => {
const shape = (ObjectNavItemSchema as unknown as { shape: Record<string, { description?: string }> }).shape;
expect(shape.dataSource.description).toMatch(/^\[PLANNED — not yet rendered\] /);
});

it('its liveness row is `planned` with an author warning, never `live`', () => {
const ledger = JSON.parse(fs.readFileSync(new URL('../../liveness/app.json', import.meta.url), 'utf8'));
const row = ledger.props.navigation.children.dataSource;
expect(row, 'liveness/app.json navigation.children.dataSource must exist').toBeDefined();
expect(row.status).toBe('planned');
expect(row.authorWarn).toBe(true);
});
});
Loading
Loading