diff --git a/.changeset/28022-app-landing-per-audience.md b/.changeset/28022-app-landing-per-audience.md new file mode 100644 index 00000000000..65a7e46f703 --- /dev/null +++ b/.changeset/28022-app-landing-per-audience.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +feat(spec): an app can name a landing per audience with `landing` + +Clause-②: yes (widening) +Metadata-protocol: added + +An app opens on its first navigation item, one landing for every audience. `AppSchema` now declares `landing`, a list of `{ item, visible }` entries (`AppLandingEntrySchema`): the first entry whose `visible` (CEL, the dialect of a navigation entry's `visible`) holds for the reader names the navigation entry the app opens on. With no match, or no key, the app opens where it did before: its first navigation item by `order`. + +- **It cannot dangle.** Every app parse runs `checkAppLandingItems`: an `item` that names no entry of the app's own `navigation` or `areas[].navigation` (any depth), or names a `group`, `separator` or `action` entry, is refused at `landing.N.item`, and the message lists the entries that do exist. Items another package contributes through `navigationContributions` cannot be named. +- **It is per audience.** `visible` is required, and an empty list is refused. To change where everyone lands, put that entry first in `navigation`, as before. A `visible` that reads `data` is refused: a landing entry has no `dataSource`. +- **Lint.** `os validate` and `os build` check each landing predicate with the shared expression validator (CEL syntax, size, unknown functions). The metadata save door runs the parse refusals above on every app write. +- **Planned.** No console reads `landing` yet. The liveness ledger carries it as `planned` with an author warning, so `os validate` tells an author that every reader still opens on the first navigation item until the console landing redirect ships. + +Nothing to do for existing apps. The retired `homePageId` (and its `home` / `homepage` / `landingpage` spellings) still answers with its removal notice, which now also names `landing`. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index de4f82ee7ac..4a7960cc3ae 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -95,6 +95,7 @@ const result = AppDefinitionResponseSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | +| **landing** | `{ item: string; visible: string \| object }[]` | optional | [PLANNED — not yet rendered] Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. No console reads this list yet, so until the landing redirect ships every reader still opens on the first navigation item. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index e846ca90112..4a560a68e8b 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 — 1574 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1575 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/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [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 | 174 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **204** | **1574** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **204** | **1575** | 14 protocol modules | --- @@ -371,7 +371,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 174 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 175 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -379,7 +379,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | :--- | :--- | | [`action.zod.ts`](/docs/references/ui/action) | `Action`, `ActionAi`, `ActionLocation`, `ActionParam`, `ActionType`, `InlineAction` | | [`action-params.zod.ts`](/docs/references/ui/action-params) | `ActionSession` | -| [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `ComponentNavItem`, `DashboardNavItem`, `DocNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` | +| [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `AppLandingEntry`, `ComponentNavItem`, `DashboardNavItem`, `DocNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` | | [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` | | [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` | | [`component.zod.ts`](/docs/references/ui/component) | `ActionButtonProps`, `ActionGroupProps`, `ActionIconProps`, `ActionMenuProps`, `ElementButtonProps`, `ElementDefinitionListProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementRepeaterProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordLineItemsProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 94cd0aa4ba3..49a07b2f484 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -1,7 +1,7 @@ --- title: App schema — UI Protocol property reference navTitle: App -description: "App schemas of the ObjectStack UI Protocol: ActionNavItem, App, AppBranding and 12 more — each property with its type, default and a TypeScript example." +description: "App schemas of the ObjectStack UI Protocol: ActionNavItem, App, AppBranding and 13 more — each property with its type, default 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/. */} @@ -13,8 +13,8 @@ description: "App schemas of the ObjectStack UI Protocol: ActionNavItem, App, Ap ## TypeScript Usage ```typescript -import { ActionNavItemSchema, AppSchema, AppBrandingSchema, AppContextSelectorSchema, ComponentNavItemSchema, DashboardNavItemSchema, DocNavItemSchema, GroupNavItemSchema, NavigationAreaSchema, NavigationContributionSchema, NavigationItemSchema, ObjectNavItemSchema, PageNavItemSchema, ReportNavItemSchema, UrlNavItemSchema } from '@objectstack/spec/ui'; -import type { ActionNavItem, App, AppBranding, AppContextSelector, ComponentNavItem, DashboardNavItem, DocNavItem, GroupNavItem, NavigationArea, NavigationContribution, NavigationItem, ObjectNavItem, PageNavItem, ReportNavItem, UrlNavItem } from '@objectstack/spec/ui'; +import { ActionNavItemSchema, AppSchema, AppBrandingSchema, AppContextSelectorSchema, AppLandingEntrySchema, ComponentNavItemSchema, DashboardNavItemSchema, DocNavItemSchema, GroupNavItemSchema, NavigationAreaSchema, NavigationContributionSchema, NavigationItemSchema, ObjectNavItemSchema, PageNavItemSchema, ReportNavItemSchema, UrlNavItemSchema } from '@objectstack/spec/ui'; +import type { ActionNavItem, App, AppBranding, AppContextSelector, AppLandingEntry, ComponentNavItem, DashboardNavItem, DocNavItem, GroupNavItem, NavigationArea, NavigationContribution, NavigationItem, ObjectNavItem, PageNavItem, ReportNavItem, UrlNavItem } from '@objectstack/spec/ui'; // Validate data const result = ActionNavItemSchema.parse(data); @@ -78,6 +78,7 @@ const result = ActionNavItemSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | +| **landing** | `{ item: string; visible: string \| object }[]` | optional | [PLANNED — not yet rendered] Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. No console reads this list yet, so until the landing redirect ships every reader still opens on the first navigation item. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | @@ -302,6 +303,13 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **allValue** | `string` | optional (default: `""`) | Sentinel value meaning "no concrete selection yet" (empty string is almost always right) | | **persist** | `Enum<'query' \| 'session' \| 'none'>` | optional (default: `"query"`) | Persist selection via URL query, sessionStorage, or not at all | +### Nested Shape: `App.landing[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **item** | `string` | ✅ | Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator` or `action` entry, which open nothing. | +| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Audience predicate (CEL), the dialect of a navigation entry's `visible`, e.g. P`'service_agent' in current_user.positions`. Required. It reads the signed-in reader (`current_user`, `features`); there is no `data` here, and a `visible` that reads `data` is refused at parse. | + ### Nested Shape: `App.protection` | Property | Type | Required | Description | @@ -350,6 +358,18 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **filter** | `{ key: string; op?: Enum<'eq' \| 'ne' \| 'in' \| 'nin'>; value: string \| string[] }[]` | optional | Predicates (AND) each option row must satisfy | +--- + +## AppLandingEntry + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **item** | `string` | ✅ | Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator` or `action` entry, which open nothing. | +| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Audience predicate (CEL), the dialect of a navigation entry's `visible`, e.g. P`'service_agent' in current_user.positions`. Required. It reads the signed-in reader (`current_user`, `features`); there is no `data` here, and a `visible` that reads `data` is refused at parse. | + + --- ## ComponentNavItem diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 4f0a1205f60..a7851ab6853 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -49,6 +49,7 @@ const crmApp = defineApp({ | `navigation` | `NavigationItem[]` | optional | Navigation tree | | `areas` | `NavigationArea[]` | optional | Partition navigation by business domain — see [Areas](#areas) | | `contextSelectors` | `AppContextSelector[]` | optional | Sidebar scope dropdowns whose value is injected into navigation items — see [Context Selectors](#context-selectors) | +| `landing` | `AppLandingEntry[]` | optional | **Planned.** Per-audience landing: which navigation entry each audience opens on — see [Landing per audience](#landing-per-audience) | | `branding` | `AppBranding` | optional | Visual customization | | `requiredPermissions` | `string[]` | optional | Required permissions to access | | `defaultAgent` | `string` | optional | Platform agent bound to this app's ambient AI chat — see [Default Agent](#default-agent) | @@ -283,7 +284,8 @@ label — the only keys it accepts are `type`, an optional `id`, and an optional All navigation items **except `separator`** share these base properties. Every item **must** declare a unique `id` (lowercase `snake_case`) — it is required by the schema, and it is what `order` sorts and what the landing page resolves to -(the first item, since `homePageId` was removed in 17.0.0 — see below). +(the first item, since `homePageId` was removed in 17.0.0 — see below — unless a +[`landing`](#landing-per-audience) entry matches the reader). (On a `separator`, `id` is optional and the remaining properties below are rejected — a divider has nothing to label, gate, or badge.) @@ -714,6 +716,63 @@ const studioApp = defineApp({ }); ``` +## Landing per audience + +An app opens on its first navigation item (by `order`). When one app serves +several audiences, that is one landing for all of them: moving Sales above +Service to suit the sales team moves service staff's landing to Leads. +`landing` lets the app name, per audience, which of its own navigation entries +a reader opens on. + +```typescript +defineApp({ + name: 'crm', + label: 'CRM', + navigation: [ + { id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' }, + { id: 'grp_service', type: 'group', label: 'Service', children: [ + { id: 'nav_service_console', type: 'page', label: 'Service console', pageName: 'service_console' }, + ]}, + ], + landing: [ + { item: 'nav_service_console', visible: "'service_agent' in current_user.positions" }, + { item: 'nav_leads', visible: "'sales_rep' in current_user.positions" }, + ], +}); +``` + +The entries are asked **in order**, and the first whose `visible` holds for the +reader wins. When no entry matches, or the key is absent, the app opens where it +always has: its first navigation item. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `item` | `string` | ✅ | The `id` of a navigation entry of **this app** — its own `navigation` or any `areas[].navigation`, at any depth | +| `visible` | `string` (CEL) | ✅ | Who lands there — the same dialect and reader roots (`current_user`, `features`) as a navigation entry's `visible` | + +Refused at parse, at the entry's own path: + +- an `item` that names no navigation entry of this app (the error lists the ones + that exist). Items another package contributes through + `navigationContributions` are not part of the app being parsed and cannot be + named; +- an `item` that names a `group`, `separator` or `action` entry — none of them + opens a page; +- an entry without `visible`. A landing for everyone is still "put that entry + first in `navigation`"; `landing` is for different audiences; +- a `visible` that reads `data` — a landing entry has no `dataSource`. + +`landing` only decides where a reader **opens**. It grants nothing: the entry +keeps its own `visible` / `requiredPermissions` / `requiresObject` gates, and an +entry the reader cannot see is passed over. + + + **Planned — not yet applied.** The key is declared and validated, but no + console reads it yet, so every reader still opens on the app's first + navigation item until the console's landing redirect ships. `os validate` + says so for any app that declares it. + + ## Default Agent `defaultAgent` binds this app's **ambient AI chat** — the assistant the shell diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 725fcd426a4..c4e25d0f820 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 217 | 208 | 4 | 0 | 5 | +| `ui/` | 218 | 209 | 4 | 0 | 5 | ## `ui/` — sites @@ -33,7 +33,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit |---|---| | `action-params.zod.ts` | 1 | | `action.zod.ts` | 9 | -| `app.zod.ts` | 19 | +| `app.zod.ts` | 20 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | | `component.zod.ts` | 81 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **217** | +| **total** | **218** | ## `ui/` — open @@ -54,15 +54,15 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**5 strip of 217**, in 4 file(s). +**5 strip of 218**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | -| `app.zod.ts` | 1 | 19 | +| `app.zod.ts` | 1 | 20 | | `view.zod.ts` | 2 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **5** | **217** | +| **total** | **5** | **218** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 1a4c1cefc51..c0ac47ab84e 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -10,7 +10,7 @@ import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionInputSchema, ObjectStac import { FieldSchema, ObjectFieldGroupSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data'; import { EvalUserSchema } from '@objectstack/spec/identity'; import { SharingRuleSchema } from '@objectstack/spec/security'; -import { BulkActionDefSchema } from '@objectstack/spec/ui'; +import { AppLandingEntrySchema, AppSchema, BulkActionDefSchema } from '@objectstack/spec/ui'; // [#15137] The published refusal sentence a `value`-slot finding must lead // with — asserted from the spec's own export, never re-spelled in a test. import { @@ -945,6 +945,24 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { expect(ok).toHaveLength(0); }); + it('judges an app landing predicate with the data-independent verdicts and no record scope (#28022)', () => { + const nav = [{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' }]; + // `===` is not CEL: the predicate can never evaluate, so the landing could never match. + const bad = validateStackExpressions({ + apps: [{ name: 'crm', label: 'CRM', navigation: nav, landing: [{ item: 'nav_leads', visible: "current_user.positions === 'x'" }] }], + }); + const landingIssues = bad.filter(i => i.where === "app 'crm' · landing[0] ('nav_leads') visible"); + expect(landingIssues).toHaveLength(1); + expect(landingIssues[0].severity).toBe('error'); + + // A healthy reader predicate is clean, and the slot binds no record, so a + // bare identifier is not told to become `record.`. + const ok = validateStackExpressions({ + apps: [{ name: 'crm', label: 'CRM', navigation: nav, landing: [{ item: 'nav_leads', visible: "'sales_rep' in current_user.positions && features.crm != false" }] }], + }); + expect(ok).toHaveLength(0); + }); + it('never reads `disabledReason` as a predicate — it is display copy (#26619)', () => { // The same bare-field source that is an error on `disabled` above is a // plain sentence on `disabledReason`: nothing is checked, nothing flagged. @@ -3357,10 +3375,23 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s receiver: 'stack', // `views` joined with #24798: the bulk `confirmText` pass walks the views' // list containers. - expected: ['actions', 'flows', 'hooks', 'objects', 'sharingRules', 'views'], + // `apps` joined with #28022: the landing predicates. + expected: ['actions', 'apps', 'flows', 'hooks', 'objects', 'sharingRules', 'views'], declaredBy: 'ObjectStackSchema', keys: () => Object.keys(ObjectStackSchema.shape), }, + { + receiver: 'appDef', + expected: ['landing', 'name'], + declaredBy: 'AppSchema', + keys: () => Object.keys(AppSchema.shape), + }, + { + receiver: 'landingEntry', + expected: ['item', 'visible'], + declaredBy: 'AppLandingEntrySchema', + keys: () => Object.keys(AppLandingEntrySchema.shape), + }, { receiver: 'obj', // `validationRules` is absent — one of the five #5017 removed. diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 4752918c9c5..07fac05f121 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -3542,6 +3542,24 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression } } + // ── App landing predicates (`app.landing[].visible`) ─────────────── + // [#28022] An app's per-audience landing: the first entry whose predicate + // holds names the navigation entry the reader opens on. It reads the + // signed-in reader (`current_user`, `features`) and no record, so it gets + // the shared validator's data-independent verdicts (CEL syntax, size, an + // unknown function, a malformed `has()`) with no object and no bare-field + // judgement — a `record`-scoped call would prescribe `record.` on a slot + // that binds no record. The parse already refused a dangling `item` and a + // `data` read (`checkAppLandingItems`). Apps are not an object's body, so + // an object write reads an empty list here. + for (const appDef of objectWrite ? [] : recordsOf(stack.apps)) { + const appName = typeof appDef.name === 'string' ? appDef.name : '?'; + recordsOf(appDef.landing).forEach((landingEntry, index) => { + const item = typeof landingEntry.item === 'string' ? landingEntry.item : '?'; + check(`app '${appName}' · landing[${index}] ('${item}') visible`, landingEntry.visible); + }); + } + // ── Sharing-rule predicates (security-critical, record-scoped) ───── // A criteria sharing rule's `condition` decides which rows a principal sees. // It is evaluated against the record, so a bare ref silently changes access. diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 4e65d6aeed0..589130330d1 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -356,8 +356,9 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // face (the progress bar or the plain percentage). // [#27945] `field` 80 → 81: `lookupAutofill`, the fields a pick in a // lookup fills. + // [#28022] `app` 22 → 23: `landing`, the per-audience landing list. const CARD_PROPERTY_COUNTS: Record = { - agent: 24, app: 22, dashboard: 20, dataset: 16, field: 81, flow: 22, + agent: 24, app: 23, dashboard: 20, dataset: 16, field: 81, flow: 22, hook: 23, object: 49, page: 26, position: 13, report: 23, skill: 16, tool: 14, }; diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 25c9c9afa61..265e483c6a5 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -439,6 +439,16 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ covers: ['ui/action.zod.ts:actionObject.pressed'], note: 'Display only by contract — it never gates, greys or routes the action — so a fault refuses nothing: the predicate\'s effect (the pressed face) is dropped and the fault is logged, which is `fail-soft-log`, not `fail-closed`. The unpressed face is also the one that never claims the reader acted. Split from `cel-action-visible` / `cel-action-disabled` for the collision rule those two were split on: one row cannot state three fault faces. A menu item (the header overflow, the bar\'s override menu) draws no pressed state at all. Measured here: the ui half is in this checkout and its suites pin the TRUE / FALSE / fault faces on both surfaces.', }, + { + // The key arrived spec-first (card 28022); the console landing redirect that reads it is its own ui card. + id: 'cel-app-landing', + summary: "app per-audience landing predicate (AppLandingEntry.visible) — the first entry whose predicate holds names the navigation entry the app opens on", + dialect: 'cel', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated', + enforcement: + 'PARSE ONLY — `EvaluatedExpressionInputSchema` normalizes a bare string to `{dialect:"cel",source}` and refuses an `ast`-only envelope and a blank source, and `checkAppLandingItems` on `AppSchema` refuses a `data` read (a landing entry binds no `data`) and an `item` naming no openable navigation entry of the app. NO EVALUATOR FOUND: no console reads `app.landing` yet; the landing redirect that will ask these predicates is the follow-up ui card', + covers: ['ui/app.zod.ts:AppLandingEntrySchema.visible'], + note: 'EXPERIMENTAL, declared spec-first by ruling (letter A): the liveness ledger carries `app.landing` as `planned` with an author warning, so an author is told no console reads it yet. Re-classify when the console redirect lands: the predicate decides only where a reader opens, never what they may reach (each navigation entry keeps its own gates), so its fault face is expected to be `fail-soft-log` — a faulting entry is passed over and the app opens on its first navigation item.', + }, { id: 'cel-record-alert-visible', summary: '`record:alert` banner visibility (RecordAlertProps.visible) — the one record component whose PROPS carry a real predicate', diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 781699d71ee..1944a4120af 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -56,6 +56,9 @@ "AppContextSelector (type)", "AppContextSelectorParsed (type)", "AppContextSelectorSchema (const)", + "AppLandingEntry (type)", + "AppLandingEntryParsed (type)", + "AppLandingEntrySchema (const)", "AppParsed (type)", "AppSchema (const)", "AppearanceConfig (type)", @@ -522,6 +525,7 @@ "chartAggregateCategoryKey (function)", "chartAggregateResultKeys (function)", "chartAggregateValueKey (function)", + "checkAppLandingItems (function)", "checkDashboardWidgetChartMeasureArity (function)", "checkDashboardWidgetMetricMeasureArity (function)", "checkDashboardWidgetStageOrder (function)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index d503ecbc500..746f40c6710 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -182,6 +182,7 @@ "ui/App:icon", "ui/App:isDefault", "ui/App:label", + "ui/App:landing", "ui/App:name", "ui/App:navigation", "ui/App:protection", @@ -196,6 +197,8 @@ "ui/AppContextSelector:label", "ui/AppContextSelector:optionsSource", "ui/AppContextSelector:persist", + "ui/AppLandingEntry:item", + "ui/AppLandingEntry:visible", "ui/AppearanceConfig:allowedVisualizations", "ui/AppearanceConfig:showDescription", "ui/AriaProps:ariaDescribedBy", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index 18436b21268..ace570d90e9 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -30,6 +30,8 @@ "AppBrandingSchema": "ui/AppBranding", "AppContextSelector": "ui/AppContextSelector", "AppContextSelectorSchema": "ui/AppContextSelector", + "AppLandingEntry": "ui/AppLandingEntry", + "AppLandingEntrySchema": "ui/AppLandingEntry", "AppSchema": "ui/App", "AppearanceConfig": "ui/AppearanceConfig", "AppearanceConfigSchema": "ui/AppearanceConfig", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 99d392c3a96..44aba1ff614 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 246, - "droppedRefinementSites": 942, + "droppedRefinementSites": 948, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -62,6 +62,7 @@ }, "api/AppDefinitionResponse": { "sites": [ + "data", "data.navigation.element.lazy", "data.navigation.element.lazy.options[0]" ] @@ -75,6 +76,7 @@ "manifest.agents.element.memory", "manifest.agents.element.structuredOutput.schema", "manifest.analyticsCubes.element.measures.valueType", + "manifest.apps.element", "manifest.assignmentRules.element", "manifest.assignmentRules.element.entries.element", "manifest.assignmentRules.element.entries.element.assignTo", @@ -210,6 +212,7 @@ "data.options[1].manifest.agents.element.memory", "data.options[1].manifest.agents.element.structuredOutput.schema", "data.options[1].manifest.analyticsCubes.element.measures.valueType", + "data.options[1].manifest.apps.element", "data.options[1].manifest.assignmentRules.element", "data.options[1].manifest.assignmentRules.element.entries.element", "data.options[1].manifest.assignmentRules.element.entries.element.assignTo", @@ -342,6 +345,7 @@ "options[1].manifest.agents.element.memory", "options[1].manifest.agents.element.structuredOutput.schema", "options[1].manifest.analyticsCubes.element.measures.valueType", + "options[1].manifest.apps.element", "options[1].manifest.assignmentRules.element", "options[1].manifest.assignmentRules.element.entries.element", "options[1].manifest.assignmentRules.element.entries.element.assignTo", @@ -416,6 +420,7 @@ "data.packages.element.options[1].manifest.agents.element.memory", "data.packages.element.options[1].manifest.agents.element.structuredOutput.schema", "data.packages.element.options[1].manifest.analyticsCubes.element.measures.valueType", + "data.packages.element.options[1].manifest.apps.element", "data.packages.element.options[1].manifest.assignmentRules.element", "data.packages.element.options[1].manifest.assignmentRules.element.entries.element", "data.packages.element.options[1].manifest.assignmentRules.element.entries.element.assignTo", @@ -1523,6 +1528,7 @@ }, "ui/App": { "sites": [ + "", "navigation.element.lazy", "navigation.element.lazy.options[0]" ] diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index e5c34996f80..34da78d93a2 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -53,6 +53,9 @@ "AppContextSelector": "src/ui/app.zod.ts#AppContextSelector (type)", "AppContextSelectorParsed": "src/ui/app.zod.ts#AppContextSelectorParsed (type)", "AppContextSelectorSchema": "src/ui/app.zod.ts#AppContextSelectorSchema (const)", + "AppLandingEntry": "src/ui/app.zod.ts#AppLandingEntry (type)", + "AppLandingEntryParsed": "src/ui/app.zod.ts#AppLandingEntryParsed (type)", + "AppLandingEntrySchema": "src/ui/app.zod.ts#AppLandingEntrySchema (const)", "AppParsed": "src/ui/app.zod.ts#AppParsed (type)", "AppSchema": "src/ui/app.zod.ts#AppSchema (const)", "AppearanceConfig": "src/ui/view.zod.ts#AppearanceConfig (type)", @@ -505,6 +508,7 @@ "chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)", "chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)", "chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)", + "checkAppLandingItems": "src/ui/app.zod.ts#checkAppLandingItems (function)", "checkDashboardWidgetChartMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetChartMeasureArity (function)", "checkDashboardWidgetMetricMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetMetricMeasureArity (function)", "checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 400f5f41cab..f41c74c8d46 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -17,6 +17,7 @@ "ui/App", "ui/AppBranding", "ui/AppContextSelector", + "ui/AppLandingEntry", "ui/AppearanceConfig", "ui/AriaProps", "ui/BulkActionDef", diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index e5758b3eb9f..a891c9d0ed8 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -314,6 +314,27 @@ }, "note": "Drilled because includeAll/placement diverge (dead) from the live core." }, + "landing": { + "status": "planned", + "verifiedAt": "2026-10-31", + "evidenceScope": "in-repo", + "authorWarn": true, + "authorHint": "Keep it, but nothing reads it yet: no console applies `landing`, so every reader still opens on the app's first navigation item until the landing redirect ships.", + "evidence": "packages/spec/src/ui/app.zod.ts#checkAppLandingItems (the parse-time door, mounted on AppSchema: an `item` naming no entry of the app's own `navigation` / `areas[].navigation`, or a `group` / `separator` / `action` entry, is refused at `landing.N.item`, and a `visible` reading `data` at `landing.N.visible`); packages/spec/src/ui/app.zod.ts#AppLandingEntrySchema (strict `{ item, visible }`, `visible` required, the navigation entry's CEL dialect)", + "note": "PLANNED RECORD (card 28022, ruling record 6545223246, letter A; spec half by domain:spec seat 2): a per-audience landing list, first entry whose `visible` holds names the navigation entry the app opens on; no match or no key keeps today's rule (first navigation item by `order`). Declared spec-first by the maintainer's ruling, against the `homePageId` tombstone's enforce-first preference, which is why this row warns: measured at origin/main 5da3a8ce, nothing under ui/ reads `app.landing` (the console's landing resolution is ui/packages/app-shell/src/utils/homePath.ts and RootLandingRedirect, neither of which reads the key). The tombstone's other objection, a dangling id resolving silently to the first item, is closed at parse by the door in `evidence`. Flip to `live` when the console landing redirect (the follow-up domain:ui card) reads it, citing the read point, and drop `authorWarn` with the flip.", + "children": { + "item": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `landing` (see that row): the id of the navigation entry this audience opens on. Its referential refusal is live at parse (pinned in packages/spec/src/ui/app-landing.test.ts); what is planned is the redirect that opens it." + }, + "visible": { + "status": "planned", + "verifiedAt": "2026-10-31", + "note": "Planned with its container `landing` (see that row): the audience predicate, the navigation entry `visible` dialect and reader roots, required on every entry. Its shape refusals (blank, ast-only, a `data` read) are live at parse; nothing evaluates it until the console landing redirect ships." + } + } + }, "requiredPermissions": { "status": "live", "verifiedAt": "2026-09-27", diff --git a/packages/spec/liveness/state-counts/app.md b/packages/spec/liveness/state-counts/app.md index 80ea528a4fb..0e3986f5688 100644 --- a/packages/spec/liveness/state-counts/app.md +++ b/packages/spec/liveness/state-counts/app.md @@ -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 | 0 | 4 | 55 | +| `app` | 51 | 0 | 0 | 0 | 6 | 57 | diff --git a/packages/spec/src/shared/evaluated-slot-population.test.ts b/packages/spec/src/shared/evaluated-slot-population.test.ts index d84c71a2aa9..4d92653e3c0 100644 --- a/packages/spec/src/shared/evaluated-slot-population.test.ts +++ b/packages/spec/src/shared/evaluated-slot-population.test.ts @@ -53,7 +53,7 @@ import { CriteriaSharingRuleSchema } from '../security/sharing.zod.js'; import { PluginPermissionSchema } from '../kernel/plugin-security-advanced.zod.js'; import { MultiVersionSupportSchema } from '../kernel/plugin-versioning.zod.js'; import { ActionParamSchema, ActionSchema } from '../ui/action.zod.js'; -import { ObjectNavItemSchema } from '../ui/app.zod.js'; +import { AppLandingEntrySchema, ObjectNavItemSchema } from '../ui/app.zod.js'; import { BulkActionDefSchema } from '../ui/bulk-action.zod.js'; import { PageTabsProps, RecordAlertProps } from '../ui/component.zod.js'; import { PageComponentSchema } from '../ui/page.zod.js'; @@ -191,11 +191,14 @@ function element(schema: unknown, ...path: string[]): z.ZodType { } /** - * The 32 declaring positions, keyed the way the ADR-0058 D7 conformance ledger + * The 33 declaring positions, keyed the way the ADR-0058 D7 conformance ledger * keys them (`file:Schema.field`). Re-derived by identity on this tree, ⛔ not - * inherited from the card: 30 declaring source lines, two of which are + * inherited from the card: 31 declaring source lines, two of which are * file-local alias consts mounting two slots each. * + * [#28022] It was 32 over 30 lines. `ui/app.zod.ts:AppLandingEntrySchema.visible` + * joined: an app's per-audience landing predicate, a new evaluated position. + * * [#27946] It was 31 over 29 lines. `ui/action.zod.ts:actionObject.pressed` * joined: the action's per-reader pressed-state predicate, a new evaluated * position rather than a moved one. @@ -239,6 +242,8 @@ const POSITIONS: ReadonlyArray z.ZodType]> = [ // [#27946] Mounted directly, no boolean arm: the reader's pressed state. ['ui/action.zod.ts:actionObject.pressed', () => slot(ActionSchema, 'pressed')], ['ui/app.zod.ts:BaseNavItemSchema.visible', () => slot(ObjectNavItemSchema, 'visible')], + // [#28022] Mounted directly, required: the audience a landing entry is for. + ['ui/app.zod.ts:AppLandingEntrySchema.visible', () => slot(AppLandingEntrySchema, 'visible')], ['ui/bulk-action.zod.ts:BulkActionDefSchema.visible', () => slot(BulkActionDefSchema, 'visible')], ['ui/component.zod.ts:PageTabsProps.visibleWhen', () => slot(element(PageTabsProps, 'items'), 'visibleWhen')], ['ui/component.zod.ts:RecordAlertProps.visible', () => slot(RecordAlertProps, 'visible')], @@ -258,10 +263,10 @@ const POSITIONS: ReadonlyArray z.ZodType]> = [ ]; describe('every evaluated slot refuses the two shapes no engine can run', () => { - it('reaches exactly the 32 declaring positions the census enumerated', () => { + it('reaches exactly the 33 declaring positions the census enumerated', () => { // A position that stops being reachable must red here rather than fall out // of the table: that silent drop is the #17630 failure in another costume. - expect(POSITIONS.length).toBe(32); + expect(POSITIONS.length).toBe(33); for (const [key, get] of POSITIONS) { expect(() => get(), `unreachable: ${key}`).not.toThrow(); } @@ -302,7 +307,7 @@ describe('every evaluated slot refuses the two shapes no engine can run', () => }); it('CONTROL — the persistence contract is NOT narrowed and still accepts both shapes', () => { - // This is what makes the 32 `false`s above a reading. Ruling item 2: + // This is what makes the 33 `false`s above a reading. Ruling item 2: // `ExpressionSchema` / `ExpressionInputSchema` keep `source` OR `ast`. for (const schema of [ExpressionSchema, ExpressionInputSchema, PredicateInputSchema]) { expect(schema.safeParse(AST_ONLY).success).toBe(true); @@ -310,7 +315,7 @@ describe('every evaluated slot refuses the two shapes no engine can run', () => } }); - it('CONTROL — a healthy predicate still parses at every one of the 32 positions', () => { + it('CONTROL — a healthy predicate still parses at every one of the 33 positions', () => { // The narrowing removes accepted shapes and adds none. The settings-manifest // pair speaks its own closed non-CEL grammar (#7169), so it gets the // predicate that grammar accepts; every other slot gets CEL. diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index eb971505f84..1483a9da01f 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -354,6 +354,17 @@ const LEDGER: ReadonlyArray = [ key: 'record', why: "planned, not yet rendered — the binding's one-row query is validated at parse and refused on every page that is not `type: 'app'`, but no renderer mounts it as the page's `record` until the console's page-root binding lands. A form control offered today would let an author bind a row no predicate can read yet", }, + // #28022 — an app's per-audience landing list lands spec-first the same way: + // `planned` in the liveness ledger, read by no console until the landing + // redirect card. Offering it in the app form first would be the UI half of + // declared-not-enforced. + { + kind: 'omit', + type: 'app', + path: ROOT_PATH, + key: 'landing', + why: "planned, not yet rendered — each entry's `item` is refused at parse unless it names an openable entry of the app's own navigation, and its audience predicate is shape-checked, but no console opens an app on it until the landing redirect lands. A form control offered today would let an author set a landing nobody is sent to", + }, // #22228 — a field's cell formatting rules land spec-first the same way: // `planned` in the liveness ledger, read by no renderer until objectui's cell // card. Offering the key in the field form first would be the UI half of diff --git a/packages/spec/src/ui/app-landing.test.ts b/packages/spec/src/ui/app-landing.test.ts new file mode 100644 index 00000000000..d9bfedd5f22 --- /dev/null +++ b/packages/spec/src/ui/app-landing.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `app.landing` — the per-audience landing list (ruling record 6545223246, + * letter A). + * + * The retired `homePageId` was an id into `navigation` that a renderer + * resolved by falling back to the first item when it dangled. `landing` is an + * id into the same tree, so what these cases pin is the refusal that makes the + * difference: an `item` that names nothing in this app, or an entry that opens + * nothing, is refused at parse at its own path; and the predicate is required + * and reads no `data`. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; + +import { AppLandingEntrySchema, AppSchema, checkAppLandingItems } from './app.zod'; +import * as uiBarrel from './index'; + +const NAV = [ + { id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' }, + { + id: 'grp_service', type: 'group', label: 'Service', children: [ + { id: 'nav_service_console', type: 'page', label: 'Service console', pageName: 'service_console' }, + ], + }, + { id: 'nav_new_case', type: 'action', label: 'New case', actionDef: { actionName: 'new_case' } }, + { type: 'separator', id: 'sep_one' }, +]; + +const app = (extra: Record) => ({ name: 'crm', label: 'CRM', navigation: NAV, ...extra }); + +const issuesOf = (r: z.ZodSafeParseResult) => + r.success ? [] : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); + +describe('app.landing — accepted shapes', () => { + it('accepts entries naming top-level and nested navigation entries, first match wins by order', () => { + const r = AppSchema.safeParse(app({ + landing: [ + { item: 'nav_service_console', visible: "'service_agent' in current_user.positions" }, + { item: 'nav_leads', visible: "'sales_rep' in current_user.positions" }, + ], + })); + expect(issuesOf(r)).toEqual([]); + // The bare string is normalised to the CEL envelope by the slot's own schema. + expect(r.success && r.data.landing?.[0].visible).toEqual({ + dialect: 'cel', + source: "'service_agent' in current_user.positions", + }); + }); + + it('accepts an entry naming an item that only an area declares', () => { + const r = AppSchema.safeParse({ + name: 'crm', + label: 'CRM', + areas: [{ id: 'area_sales', label: 'Sales', navigation: [{ id: 'nav_pipeline', type: 'object', label: 'Pipeline', objectName: 'opportunity' }] }], + landing: [{ item: 'nav_pipeline', visible: 'true' }], + }); + expect(issuesOf(r)).toEqual([]); + }); + + it('CONTROL — an app without the key parses exactly as before', () => { + const r = AppSchema.safeParse(app({})); + expect(r.success).toBe(true); + expect(r.success && 'landing' in r.data).toBe(false); + }); +}); + +describe('app.landing — refused at parse, at the entry\'s own path', () => { + it('refuses an item that names no navigation entry of this app, listing the ones that exist', () => { + const issues = issuesOf(AppSchema.safeParse(app({ landing: [{ item: 'nav_missing', visible: 'true' }] }))); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: 'landing.0.item' }); + expect(issues[0].message).toContain('`nav_missing`'); + // Openable entries are named; the group, the action and the separator are not. + expect(issues[0].message).toContain('`nav_leads`, `nav_service_console`'); + expect(issues[0].message).not.toContain('`grp_service`'); + }); + + it.each([ + ['group', 'grp_service'], + ['action', 'nav_new_case'], + ['separator', 'sep_one'], + ])('refuses an item naming a `%s` entry, which opens no page', (type, item) => { + const issues = issuesOf(AppSchema.safeParse(app({ landing: [{ item, visible: 'true' }] }))); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: 'landing.0.item' }); + expect(issues[0].message).toContain(`a \`${type}\` navigation entry`); + }); + + it('refuses a `visible` that reads `data`, which a landing entry never binds', () => { + const issues = issuesOf(AppSchema.safeParse(app({ landing: [{ item: 'nav_leads', visible: 'data.total == 1' }] }))); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: 'landing.0.visible' }); + }); + + it('refuses an entry without `visible`: a predicate-less landing is `homePageId` again', () => { + const issues = issuesOf(AppSchema.safeParse(app({ landing: [{ item: 'nav_leads' }] }))); + expect(issues.map((i) => i.path)).toContain('landing.0.visible'); + }); + + it('refuses an empty list', () => { + const issues = issuesOf(AppSchema.safeParse(app({ landing: [] }))); + expect(issues.map((i) => i.path)).toEqual(['landing']); + }); + + it('refuses an unknown key on an entry and routes near-miss spellings to the declared keys', () => { + const r = AppLandingEntrySchema.safeParse({ navigationId: 'nav_leads', visibleWhen: 'true' }); + expect(r.success).toBe(false); + const text = issuesOf(r).map((i) => i.message).join('\n'); + expect(text).toContain('`navigationId` → `item`'); + expect(text).toContain('`visibleWhen` → `visible`'); + }); + + it('routes `landings` on the app to `landing`', () => { + const text = issuesOf(AppSchema.safeParse(app({ landings: [] }))).map((i) => i.message).join('\n'); + expect(text).toContain('`landings` → `landing`'); + }); +}); + +describe('checkAppLandingItems — the exported refinement', () => { + it('is reachable from the ui barrel by identity', () => { + expect((uiBarrel as Record).checkAppLandingItems).toBe(checkAppLandingItems); + expect((uiBarrel as Record).AppLandingEntrySchema).toBe(AppLandingEntrySchema); + }); + + it('judges the authored bare-string `visible` the same as the parsed envelope', () => { + const issues: string[] = []; + const ctx = { addIssue: (i: { path?: unknown[] }) => issues.push((i.path ?? []).join('.')) } as unknown as z.RefinementCtx; + checkAppLandingItems({ navigation: NAV, landing: [{ item: 'nav_leads', visible: 'data.x' }] }, ctx); + checkAppLandingItems({ navigation: NAV, landing: [{ item: 'nav_leads', visible: { dialect: 'cel', source: 'data.x' } }] }, ctx); + expect(issues).toEqual(['landing.0.visible', 'landing.0.visible']); + }); +}); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 657505d6a5a..38db77c59d7 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1573,6 +1573,174 @@ export type AppContextSelector = z.input; /** Post-parse shape of {@link AppContextSelector} — defaults applied, transforms run (ADR-0122). */ export type AppContextSelectorParsed = z.infer; +/** + * One entry of an app's per-audience landing list, {@link AppSchema}`.landing` + * (#28022, ruling record 6545223246, letter A). + * + * An app shared by several audiences opens on ONE page for all of them: its + * first navigation item by `order`. Moving Sales above Service to suit the + * sales team moves service staff's landing to Leads. `landing` lets the app + * say, per audience, which of its own navigation entries it opens on: the + * console asks the entries in order and opens the first whose `visible` + * holds for the reader. + * + * ## Why an id into `navigation`, and why it cannot dangle + * + * The retired `homePageId` (see `HOME_PAGE_ID_RETIRED` below) was condemned + * for its SHAPE: an id cross-reference into `navigation` with no referential + * integrity, which a renderer resolved by falling back to the first item when + * the id dangled — one fact with two sources, the wrong one failing quietly. + * `item` is an id into the same tree, and the difference is the refusal: + * {@link checkAppLandingItems} runs on every app parse, so an `item` that + * names no entry of this app's own `navigation` or `areas[].navigation`, or + * names an entry that opens nothing (a `group`, a `separator`, an `action`), + * is refused at its own path before the app is stored. The landing is a + * sidebar entry, so the sidebar highlights what the reader landed on; carrying + * the target inline instead would have made a second copy of a navigation + * target that the sidebar knows nothing about. + * + * ## Why `visible` is required + * + * The key exists for "each audience its own landing". An entry with no + * predicate would land EVERY reader there, which is `homePageId` again under + * a new name; "open somewhere other than the first item for everyone" is + * still `navigation` order. A catch-all is unnecessary: when no entry + * matches, the reader lands where the app lands today. + */ +export const AppLandingEntrySchema = lazySchema(() => strictObject( + { + surface: 'this landing entry', + aliases: { + navigationid: 'item', + navitem: 'item', + itemid: 'item', + target: 'item', + page: 'item', + visiblewhen: 'visible', + visibleon: 'visible', + when: 'visible', + condition: 'visible', + }, + history: + 'A new key: there is no earlier shape whose keys were dropped silently. Strict from its first release so a ' + + 'mis-spelled entry is refused, not shipped as an entry that lands nobody.', + }, + { + /** + * The navigation entry this audience lands on, by its `id`. Must name an + * entry of this app's own `navigation` tree or of one of its + * `areas[].navigation` trees (any depth); an item a package contributes + * through `navigationContributions` is not part of the app being parsed + * and cannot be named here. + */ + item: SnakeCaseIdentifierSchema + .describe("Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator` or `action` entry, which open nothing.") + .meta({ title: 'Navigation item' }), + + /** + * Who lands on `item` (CEL), the same predicate dialect and the same reader + * roots as a navigation entry's own `visible`. Required: see the schema's + * docblock. + */ + visible: EvaluatedExpressionInputSchema + .describe("Audience predicate (CEL), the dialect of a navigation entry's `visible`, e.g. P`'service_agent' in current_user.positions`. Required. It reads the signed-in reader (`current_user`, `features`); there is no `data` here, and a `visible` that reads `data` is refused at parse.") + .meta({ title: 'Visible' }), +})); + +export type AppLandingEntry = z.input; +/** Post-parse shape of {@link AppLandingEntry} — defaults applied, transforms run (ADR-0122). */ +export type AppLandingEntryParsed = z.infer; + +/** Navigation entry types a landing may not name: none of them opens a page. */ +const LANDING_TARGET_REFUSED_TYPES: ReadonlySet = new Set(['group', 'separator', 'action']); + +/** Every navigation entry of one tree, at any depth, by `id`, first declaration wins. */ +function collectNavEntriesById(items: unknown, into: Map): void { + if (!Array.isArray(items)) return; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const entry = item as { id?: unknown; type?: unknown; children?: unknown }; + if (typeof entry.id === 'string' && !into.has(entry.id)) { + into.set(entry.id, typeof entry.type === 'string' ? entry.type : ''); + } + collectNavEntriesById(entry.children, into); + } +} + +/** + * The referential half of {@link AppSchema}`.landing`: every entry's `item` + * names a navigation entry of THIS app that opens something, and no entry's + * `visible` reads `data` (#28022). + * + * This is the refusal the `homePageId` tombstone asked of any future landing + * key: a dangling target is refused at its own path + * (`landing.N.item`), with the ids that do exist, instead of resolving to the + * first navigation item without a word. It reads the app's own `navigation` + * and every `areas[].navigation`, at any depth through `children`. + * + * A landing entry has no `dataSource`, so nothing binds `data` in its + * predicate; the same lexer that refuses a navigation entry's unbound `data` + * read ({@link navItemVisibleDataNeedsDataSource}) refuses it here. + * + * Mounted once, on {@link AppSchema}. EXPORTED for the same reason as + * {@link navItemVisibleDataNeedsDataSource}: a hand-written mirror of the app + * attaches this function rather than restating the rule. It accepts the + * authored bare-string `visible` as well as the parsed envelope. + */ +export function checkAppLandingItems(app: unknown, ctx: z.RefinementCtx): void { + if (!app || typeof app !== 'object') return; + const { landing, navigation, areas } = app as { landing?: unknown; navigation?: unknown; areas?: unknown }; + if (!Array.isArray(landing) || landing.length === 0) return; + const entries = new Map(); + collectNavEntriesById(navigation, entries); + if (Array.isArray(areas)) { + for (const area of areas) { + if (area && typeof area === 'object') collectNavEntriesById((area as { navigation?: unknown }).navigation, entries); + } + } + const openable = [...entries].filter(([, type]) => !LANDING_TARGET_REFUSED_TYPES.has(type)).map(([id]) => `\`${id}\``); + landing.forEach((raw, index) => { + if (!raw || typeof raw !== 'object') return; + const entry = raw as { item?: unknown; visible?: unknown }; + if (typeof entry.item === 'string') { + const type = entries.get(entry.item); + if (type === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['landing', index, 'item'], + message: + `\`landing[${index}].item\` names \`${entry.item}\`, but this app declares no navigation entry with that id ` + + '(its own `navigation` and `areas[].navigation`, at any depth). A landing must be an entry of this app: ' + + (openable.length > 0 + ? `name one of ${openable.join(', ')}, or add the entry to \`navigation\` first.` + : 'add the entry to `navigation` first.'), + }); + } else if (LANDING_TARGET_REFUSED_TYPES.has(type)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['landing', index, 'item'], + message: + `\`landing[${index}].item\` names \`${entry.item}\`, a \`${type}\` navigation entry, which opens no page, ` + + 'so a reader could not land on it. Name an entry that opens something (an object, page, dashboard, report, ' + + 'component, doc or url entry), e.g. one of the entries inside that group.', + }); + } + } + const visible = (typeof entry.visible === 'string' + ? authoredVisibleEnvelope(entry.visible) + : entry.visible) as { dialect?: unknown; source?: unknown } | undefined; + if (visible && visible.dialect === 'cel' && typeof visible.source === 'string' && celReadsRootData(visible.source)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['landing', index, 'visible'], + message: + `\`landing[${index}].visible\` reads \`data\`, but a landing entry binds no \`data\`: it has no \`dataSource\`, ` + + 'and its predicate is asked of the signed-in reader alone. Gate on `current_user` or `features` instead.', + }); + } + }); +} + /** * Schema for Applications (Apps). * A logical container for business functionality (e.g., "Sales CRM", "HR Portal"). @@ -1640,6 +1808,16 @@ export type AppContextSelectorParsed = z.infer; * `isDefault` routing (objectui's `RootLandingRedirect`, which was always * correct here). Retiring the key left a dead `if (homePageId)` branch in * objectui, tracked for removal in objectstack-ai/objectui#3264. + * + * Landing per AUDIENCE came back as `app.landing` (#28022, ruling record + * 6545223246, letter A), and it answers both objections above. It cannot + * dangle: {@link checkAppLandingItems} refuses, at parse, an `item` that + * names no entry of the app's own navigation, so the id is checked rather + * than trusted. And it is not a second "where everyone lands": every entry + * carries a required audience predicate, so "everyone" is still the first + * navigation item. The maintainer ruled it spec-first rather than + * enforce-first, so it lands `planned` in the liveness ledger, which warns an + * author that no console reads it until the landing redirect ships. */ const HOME_PAGE_ID_RETIRED = '`app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). ' @@ -1649,7 +1827,8 @@ const HOME_PAGE_ID_RETIRED = + 'app\'s landing page IS its first navigation item (by `order`), and the root landing ' + 'follows `isDefault` routing. Delete the key; to change where an app opens, ' + 'reorder `navigation` so the intended entry is first, and set `isDefault` on the app that ' - + 'should own the root landing. ' + + 'should own the root landing. To open a different entry for each audience, declare ' + + '`landing: [{ item, visible }]`, whose `item` must name an entry of the app\'s own navigation. ' + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** @@ -1698,6 +1877,8 @@ export const AppSchema = lazySchema(() => strictObject( enabled: 'active', default: 'isDefault', selectors: 'contextSelectors', + landings: 'landing', + landingpages: 'landing', }, guidance: { pages: @@ -1844,7 +2025,32 @@ export const AppSchema = lazySchema(() => strictObject( */ contextSelectors: z.array(AppContextSelectorSchema).optional() .describe('App-level scope dropdowns whose value is injected into nav items as {} template vars'), - + + /** + * Per-audience landing (#28022, ruling record 6545223246, letter A): which + * of this app's own navigation entries a reader opens on, by audience. The + * console asks the entries in order and opens the first whose `visible` + * holds for the reader and whose `item` the reader can see (an entry the + * reader's own navigation gates hide is passed over). When none matches, or + * the key is absent, the app opens where it always has: its first + * navigation item by `order`. Every `item` must name an entry of this app, + * refused at parse otherwise ({@link checkAppLandingItems}). + * + * PLANNED: declared spec-first; the console's landing redirect that reads it + * is its own card. Until it lands, an app opens on its first navigation item + * whatever this list says, and the liveness ledger tells the author so. + * + * @example Sales staff open on Leads, service staff on the service console + * ```ts + * landing: [ + * { item: 'nav_service_console', visible: "'service_agent' in current_user.positions" }, + * { item: 'nav_leads', visible: "'sales_rep' in current_user.positions" }, + * ] + * ``` + */ + landing: z.array(AppLandingEntrySchema).min(1).optional() + .describe('[PLANNED — not yet rendered] Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app\'s own `navigation` or `areas[].navigation`) the app opens on. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. No console reads this list yet, so until the landing redirect ships every reader still opens on the first navigation item.'), + /** * Access Control * List of permissions required to access this app. @@ -1902,7 +2108,7 @@ export const AppSchema = lazySchema(() => strictObject( // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, -})); +}).superRefine(checkAppLandingItems)); /** * App Factory Helper diff --git a/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index 3b2cc5da3a4..2a56b156084 100644 --- a/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -315,7 +315,15 @@ describe('objectui#12971 — the Page `record` binding placement refusal reaches */ const SITES = [ { site: 'NavigationAreaSchema (app.zod.ts)', spec: SpecNavigationAreaSchema, attached: [], notAttachable: [] }, - { site: 'SpecAppFields → AppComponentSchema (app.zod.ts)', spec: SpecAppSchema, attached: [], notAttachable: [] }, + { + site: 'SpecAppFields → AppComponentSchema (app.zod.ts)', + spec: SpecAppSchema, + attached: [], + // `checkAppLandingItems` joined the spec object with `landing` (objectstack#28022, spec half): + // a landing `item` must name an openable entry of the app's own navigation. OWED TO the + // follow-up domain:ui landing card, which attaches it here together with the console redirect. + notAttachable: ['checkAppLandingItems'], + }, { site: 'DashboardWidgetSchema (complex.zod.ts)', spec: SpecDashboardWidgetSchema,