diff --git a/.changeset/28450-app-landing-console.md b/.changeset/28450-app-landing-console.md new file mode 100644 index 00000000000..34c81a19611 --- /dev/null +++ b/.changeset/28450-app-landing-console.md @@ -0,0 +1,25 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/types': minor +'@objectstack/spec': minor +--- + +feat(console)!: an app opens on the first `landing` entry whose `visible` holds for the reader + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing in two places: `@objectstack/spec` refuses a `landing` entry that names a `url` navigation entry, and objectui's `app` node (`@object-ui/types` `AppComponentSchema`, and the published `objectui validate` door on it) runs the spec's landing check, so it refuses what the spec refuses. + +- **The console reads `landing`.** On the app root (`/apps/:app`), the console asks the app's `landing` entries in order and opens the navigation entry named by the first one whose `visible` holds for the signed-in member. Before, every reader opened on the app's first navigation item whatever the list said. No match, or no `landing` key, keeps that rule: the first navigation item by `order`. +- **The predicate's scope.** `visible` is asked with the evaluator the app's navigation `visible` uses, over the same roots: `current_user` (and its `user`, `ctx.user` and `os.user` aliases) and `features`. No `data` is bound. A predicate naming any other root (`record`, `app`) faults. +- **A fault passes the entry over.** A predicate the evaluator cannot answer is reported once in the browser console (the navigation gate's `withholding` line) and its entry is skipped; the next entry is asked. The landing only decides where a reader opens: every navigation entry keeps its own `visible`, `requiredPermissions` and `requiresObject` gates. +- **Only an entry the member is shown.** The `item` is looked up, at any depth, in the app's `navigation` and then each `areas[].navigation`, as the member is shown them. An entry the member's navigation gates hide is passed over. A `doc` entry lands: it opens the in-app docs portal, and which docs a reader may open stays the server's audience gate on the doc. +- **objectui refuses what the spec refuses.** `AppComponentSchema` now runs `checkAppLandingItems`: a `landing` entry whose `item` names no entry of the app's own navigation, or names a `group`, `separator`, `action` or `url` entry, is refused at `landing.N.item`, and a `visible` that reads `data` at `landing.N.visible`, with the spec's own message. To fix such a document, name an entry the app declares that opens a page, and gate on `current_user` or `features`. +- **Deriving from `AppComponentSchema`.** It now carries a refinement, and zod refuses `.extend()`, `.omit()` and `.pick()` on an object that carries one. Derive from it with `AppComponentSchema.shape` or `.safeExtend()`. +- **`@objectstack/spec` refuses a `url` landing.** A `url` navigation entry opens an external address, and the app-root landing is an in-app redirect, so a `url` landing would land nobody. `checkAppLandingItems` now refuses it at `landing.N.item` with the `group` / `separator` / `action` refusal, and the message no longer lists `url` among the entries that open something. + + FROM → TO: `landing: [{ item: 'nav_help_site', visible: … }]` naming a `url` entry → `landing: [{ item: 'nav_help', visible: … }]` naming an in-app entry the app declares (an `object`, `page`, `dashboard`, `report`, `component` or `doc` entry). `landing` arrived after `@objectstack/spec` 18.2.0, so only an app written against an unreleased build carries one; `os migrate meta` lists the change (`app-landing-url-item-refused`), with no conversion. +- **The liveness row flips.** `app.landing` (with `item` and `visible`) is `live` in `@objectstack/spec`'s liveness ledger, and its author warning is dropped. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 0270278747e..74a98a27656 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -95,7 +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. | +| **landing** | `{ item: string; visible: string \| object }[]` | optional | 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 entry whose `item` the reader's own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` / `url` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. | | **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/ui/app.mdx b/content/docs/references/ui/app.mdx index 31eca3fb74a..ff4b4a37885 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -78,7 +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. | +| **landing** | `{ item: string; visible: string \| object }[]` | optional | 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 entry whose `item` the reader's own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` / `url` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. | | **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. | @@ -308,7 +308,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | 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. | +| **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`, `action` or `url` entry, which open nothing inside the app. | | **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` @@ -374,7 +374,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | 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. | +| **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`, `action` or `url` entry, which open nothing inside the app. | | **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. | diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index a7851ab6853..c77c167cecc 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -49,7 +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) | +| `landing` | `AppLandingEntry[]` | optional | 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) | @@ -756,8 +756,8 @@ Refused at parse, at the entry's own path: 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 `item` that names a `group`, `separator`, `action` or `url` entry — none of + them opens a page inside the app (a `url` entry leaves it); - 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`. @@ -766,12 +766,10 @@ Refused at parse, at the entry's own path: 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. - +A `visible` that faults (one that names a root other than `current_user` or +`features`, such as `record` or `app`, or that the evaluator cannot answer) is +reported once in the browser console and its entry is passed over: the next +entry is asked, and with none left the app opens on its first navigation item. ## Default Agent diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 497ed3b953b..d565753eacd 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -440,14 +440,14 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 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. + // The key arrived spec-first (card 28022); the console landing redirect reads it (card 28450). 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', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', 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', + 'EVALUATOR: the console (objectui) app-root landing, `resolveDeclaredLandingRoute` called from `AppRootLanding`, which asks each entry in order with the navigation `visible` evaluator `useNavMemberGates` builds (`evaluateNavigationVisibility` over the shell scope `buildExpressionScope` binds: `current_user` and its `user` / `ctx.user` / `os.user` aliases, and `features`; no `data`). A predicate that faults, a root outside that scope included, is reported once and its entry passed over; the next entry is asked, and with none left the app opens on its first navigation item by `order`. PARSE: `EvaluatedExpressionInputSchema` normalizes a bare string and refuses an `ast`-only envelope and a blank source, and `checkAppLandingItems` (on `AppSchema` and on objectui\'s `AppComponentSchema`) refuses a `data` read and an `item` naming no openable navigation entry of the app', 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.', + note: 'Re-classified from `experimental` / `unevaluated` when the console redirect landed (the domain:ui half of the letter-A ruling); the liveness row `app.landing` flipped to `live` with it. `fail-soft-log` because the predicate decides only where a reader opens, never what they may reach (each navigation entry keeps its own gates): a fault passes the entry over, loud once in the console, and the landing falls back as it would with no match. ⛔ NOT MEASURED HERE: the renderer, which is under ui/ and not run by this suite; its pin is `ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx`.', }, { id: 'cel-record-alert-visible', diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index e169867110a..b8d3a4ea82c 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -315,23 +315,21 @@ "note": "Drilled because includeAll/placement diverge (dead) from the live core." }, "landing": { - "status": "planned", + "status": "live", "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.", + "evidence": "ui/packages/app-shell/src/console/appDeclaredLanding.ts#resolveDeclaredLandingRoute (the read point: the entries in order, each `visible` asked with the navigation's own evaluator, the first that holds opens the entry its `item` names when the member is shown it; a faulting predicate is passed over); ui/packages/app-shell/src/console/AppContent.tsx#AppRootLanding (the app root's redirect calls it over the trees the member is shown, through `useNavMemberGates`, and keeps the first navigation item by `order` when no entry lands); packages/spec/src/ui/app.zod.ts#checkAppLandingItems (the parse-time door, mounted on AppSchema and on objectui's `AppComponentSchema`: an `item` naming no entry of the app's own `navigation` / `areas[].navigation`, or a `group` / `separator` / `action` / `url` entry, is refused at `landing.N.item`, and a `visible` reading `data` at `landing.N.visible`)", + "note": "FLIPPED planned → live 2026-10-31 (card 28450, the domain:ui half of card 28022, ruling record 6545223246, letter A): the console's app-root landing reads the list, and `authorWarn` is dropped with the flip. Scope: the predicate is asked in the scope the app's navigation `visible` is asked in, the roots `buildExpressionScope` binds for the shell (`current_user` with its `user` / `ctx.user` / `os.user` aliases, and `features`); no `data` is bound, and a root outside that scope (`record`, `app`) faults. Fault face: a faulting entry is passed over and reported once by the navigation evaluator (`withholding`), the next entry is asked, and with none left the app opens on its first navigation item. An entry whose item the member's navigation gates hide is passed over too; a `url` item, which opens no in-app route, is refused at parse by `checkAppLandingItems`. Pinned in ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx.", "children": { "item": { - "status": "planned", + "status": "live", "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." + "note": "Live with its container `landing` (see that row): the id of the navigation entry this audience opens on, looked up at any depth in the trees the member is shown. Its referential refusal is live at parse (packages/spec/src/ui/app-landing.test.ts)." }, "visible": { - "status": "planned", + "status": "live", "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." + "note": "Live with its container `landing` (see that row): the audience predicate, asked with the navigation entry `visible` evaluator and its roots; a faulting predicate passes its entry over. Its shape refusals (blank, ast-only, a `data` read) are live at parse." } } }, diff --git a/packages/spec/liveness/state-counts/app.md b/packages/spec/liveness/state-counts/app.md index 40afbeaa9d2..65913cfcd46 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 | 8 | 59 | +| `app` | 53 | 0 | 0 | 0 | 6 | 59 | diff --git a/packages/spec/src/migrations/entries/semantic/18.app-landing-url-item-refused.ts b/packages/spec/src/migrations/entries/semantic/18.app-landing-url-item-refused.ts new file mode 100644 index 00000000000..2524027b9b9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.app-landing-url-item-refused.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #28450 — an app's `landing` entry may not name a `url` navigation entry. +// The console's app-root landing is an in-app redirect and a `url` entry opens +// an external address, so such an entry landed nobody and the reader opened on +// the first navigation item with no word said. The spec now refuses it at +// parse beside `group`, `separator` and `action`. Semantic only: which in-app +// entry that audience should open on is the author's choice, so no conversion +// maps it. `landing` arrived after `@objectstack/spec` 18.2.0, so only an app +// written against an unreleased build can carry one. +export const entry: SemanticMigration = { + id: 'app-landing-url-item-refused', + surface: 'an app\'s `landing[N].item` naming a navigation entry of `type: \'url\'` (in the app\'s own ' + + '`navigation` or any `areas[].navigation`, at any depth)', + replacement: 'name an in-app entry the app declares instead: an `object`, `page`, `dashboard`, `report`, ' + + '`component` or `doc` entry, e.g. `{ item: \'nav_help_site\', visible: … }` naming a `url` entry becomes ' + + '`{ item: \'nav_help\', visible: … }` naming a `page` entry that carries the same help; or drop the ' + + 'landing entry, and that audience opens on the first navigation item by `order`.', + reason: 'A landing decides where a reader opens inside the app, and the console\'s app-root landing is an ' + + 'in-app redirect: a `url` entry opens an external address, so the console found no in-app route for it and ' + + 'passed the entry over in silence, which is the silent fallback `landing` was designed to refuse. ' + + '`checkAppLandingItems` now refuses a `url` target at `landing.N.item`, on `AppSchema` and on objectui\'s ' + + '`app` node, and its message no longer lists `url` among the entries that open something. No conversion is ' + + 'registered: which in-app entry the audience should open on is the author\'s choice.', + acceptanceCriteria: 'Run `objectstack validate`: it reports no finding at any app\'s `landing.N.item`. Sign in ' + + 'as a member of each audience the landing names, open the app root, and confirm each opens on its entry.', +}; diff --git a/packages/spec/src/ui/app-landing.test.ts b/packages/spec/src/ui/app-landing.test.ts index d9bfedd5f22..ecdba01b09f 100644 --- a/packages/spec/src/ui/app-landing.test.ts +++ b/packages/spec/src/ui/app-landing.test.ts @@ -27,6 +27,7 @@ const NAV = [ }, { id: 'nav_new_case', type: 'action', label: 'New case', actionDef: { actionName: 'new_case' } }, { type: 'separator', id: 'sep_one' }, + { id: 'nav_help_site', type: 'url', label: 'Help site', url: 'https://help.example.com' }, ]; const app = (extra: Record) => ({ name: 'crm', label: 'CRM', navigation: NAV, ...extra }); @@ -73,16 +74,20 @@ describe('app.landing — refused at parse, at the entry\'s own path', () => { 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. + // Openable entries are named; the group, the action, the separator and the url entry are not. expect(issues[0].message).toContain('`nav_leads`, `nav_service_console`'); expect(issues[0].message).not.toContain('`grp_service`'); + expect(issues[0].message).not.toContain('`nav_help_site`'); }); 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) => { + // A `url` entry leaves the app; the app-root landing is an in-app redirect, + // so a `url` landing would land nobody (#28450). + ['url', 'nav_help_site'], + ])('refuses an item naming a `%s` entry, which opens no page inside the app', (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' }); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 3aace1a5f5c..e2714eaa286 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1594,8 +1594,8 @@ export type AppContextSelectorParsed = z.infer; * `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 + * names an entry that opens nothing inside the app (a `group`, a `separator`, + * an `action`, a `url`), 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. @@ -1635,7 +1635,7 @@ export const AppLandingEntrySchema = lazySchema(() => strictObject( * 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.") + .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`, `action` or `url` entry, which open nothing inside the app.") .meta({ title: 'Navigation item' }), /** @@ -1652,8 +1652,12 @@ 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']); +/** + * Navigation entry types a landing may not name: none of them opens a page + * inside the app. A `url` entry opens an external address, and the app-root + * landing is an in-app redirect, so a `url` landing would land nobody (#28450). + */ +const LANDING_TARGET_REFUSED_TYPES: ReadonlySet = new Set(['group', 'separator', 'action', 'url']); /** Every navigation entry of one tree, at any depth, by `id`, first declaration wins. */ function collectNavEntriesById(items: unknown, into: Map): void { @@ -1722,8 +1726,8 @@ export function checkAppLandingItems(app: unknown, ctx: z.RefinementCtx): void { 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.', + + 'so a reader could not land on it. Name an entry that opens something inside the app (an object, page, dashboard, ' + + 'report, component or doc entry), e.g. one of the entries inside that group.', }); } } @@ -1817,8 +1821,8 @@ export function checkAppLandingItems(app: unknown, ctx: z.RefinementCtx): void { * 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. + * enforce-first; the console's landing redirect followed (#28450), and the + * liveness ledger carries it `live`. */ const HOME_PAGE_ID_RETIRED = '`app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). ' @@ -2037,9 +2041,13 @@ export const AppSchema = lazySchema(() => strictObject( * 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. + * The console's app-root landing reads it (#28450): each `visible` is asked + * with the evaluator a navigation entry's `visible` is asked with, over the + * same reader roots (`current_user` and `features`; no `data`). A predicate + * that faults, a root outside those included, passes its entry over, and the + * next entry is asked. A `doc` entry is a landing like any other: it opens + * the in-app docs portal, and which docs a reader may open is the server's + * audience gate on the doc (objectstack#19790), not the landing's. * * @example Sales staff open on Leads, service staff on the service console * ```ts @@ -2050,7 +2058,7 @@ export const AppSchema = lazySchema(() => strictObject( * ``` */ 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.'), + .describe('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 entry whose `item` the reader\'s own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` / `url` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before.'), /** * Access Control diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index c3a63c9b732..91d2e974444 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -59,6 +59,7 @@ import { RouteFader } from '../chrome/RouteFader.js'; import { NavigationSyncEffect } from '../hooks/useNavigationSync.js'; import { AppNoAccessEmptyState } from './AppNoAccessEmptyState.js'; import { appNamesOnlyUnreadableObjects, appServesNoNavigation } from './appServesNoNavigation.js'; +import { resolveDeclaredLandingRoute, type LandingVisibilityEvaluator } from './appDeclaredLanding.js'; import { useNavMemberGates, navEntriesShownToMember, @@ -1714,25 +1715,40 @@ function buildItemRoute(item: any, ctx?: NavTemplateContext): string { /** * Resolves the route to navigate to when the user lands on the bare - * `/console/apps/:appName` URL: the app's landing page is its FIRST reachable - * navigation item, in `order`, among the entries the member is shown - * (`navigation` arrives already gated, objectui#12426) — `findFirstRoute` walks groups and lands on an - * `object`, `page`, `dashboard`, `report` or `component` entry (objectui#12113 - * added `component`). It skips the item types that address nothing inside - * the app (`url`, `separator`, `action`), and a `doc` entry is never the - * landing (objectui#11197). Which app the ROOT lands on is a separate - * question, decided by `isDefault`. + * `/console/apps/:appName` URL. * - * This used to honour an explicit `app.homePageId`. Spec 17.0.0 retired that - * key (objectstack#4667, premise corrected in objectstack-ai/objectstack#4709 / objectui#3287): it was - * an ID cross-reference with no referential integrity, so a dangling id fell - * back to the first item *silently*. If the capability returns, the correct - * encoding is a flag on the navigation item itself (`navigation[].landing`), - * which cannot dangle — hence this function is kept as the seam even though it - * now has a single expression. + * The app's declared per-audience landing comes first (`AppSchema.landing`, + * objectstack-ai/objectstack#28022, ruling record 6545223246, letter A): the + * first entry whose `visible` holds for this member, asked with the + * navigation's own `visible` evaluator, opens the navigation entry its `item` + * names, when the member is shown that entry. The rules, the predicate's scope + * and what a fault does are `resolveDeclaredLandingRoute`'s + * (`appDeclaredLanding.ts`). + * + * With no declared landing, or none that lands, the app's landing page is its + * FIRST reachable navigation item, in `order`, among the entries the member is + * shown (`navigation` arrives already gated, objectui#12426) — `findFirstRoute` + * walks groups and lands on an `object`, `page`, `dashboard`, `report` or + * `component` entry (objectui#12113 added `component`). It skips the item + * types that address nothing inside the app (`url`, `separator`, `action`), + * and a `doc` entry is never that fallback landing (objectui#11197). Which app + * the ROOT lands on is a separate question, decided by `isDefault`. + * + * The retired `app.homePageId` (spec 17.0.0, objectstack#4667) is not read: it + * was an id cross-reference that fell back to the first item in silence when + * it dangled. A `landing` entry's `item` is refused at parse when it names no + * entry of the app (`checkAppLandingItems`), so it cannot dangle. */ -function resolveLandingRoute(navigation: NavigationItem[], ctx?: NavTemplateContext): string { - return findFirstRoute(navigation, ctx); +function resolveLandingRoute( + app: { landing?: unknown } | null | undefined, + shownTrees: readonly NavigationItem[][], + evaluateVisibility: LandingVisibilityEvaluator, + ctx?: NavTemplateContext, +): string { + const declared = resolveDeclaredLandingRoute(app?.landing, shownTrees, evaluateVisibility, (item) => + buildItemRoute(item, ctx), + ); + return declared || findFirstRoute(shownTrees[0] ?? [], ctx); } /** @@ -1812,12 +1828,19 @@ function AppRootLanding({ return ; } const navigation = navEntriesShownToMember(app?.navigation || [], gates); - const landing = resolveLandingRoute(navigation, ctx); const areas = (app?.areas || []) as Array<{ navigation?: NavigationItem[] } | null | undefined>; const shownApp = { navigation, areas: areas.map((area) => ({ navigation: navEntriesShownToMember(area?.navigation || [], gates) })), }; + // objectstack-ai/objectstack#28022 — a declared landing entry may name an + // entry of any tree the member is shown, `navigation` first. + const landing = resolveLandingRoute( + app, + [navigation, ...shownApp.areas.map((area) => area.navigation)], + gates.evaluateVisibility, + ctx, + ); // objectui#13097 — the same questions, as if every pending read showed its // entry. A different answer means a pending read is still deciding it. const undecided = navEntriesShownOncePendingReadsShow(app?.navigation || [], gates); @@ -1825,8 +1848,14 @@ function AppRootLanding({ navigation: undecided, areas: areas.map((area) => ({ navigation: navEntriesShownOncePendingReadsShow(area?.navigation || [], gates) })), }; + const undecidedLanding = resolveLandingRoute( + app, + [undecided, ...undecidedApp.areas.map((area) => area.navigation)], + gates.evaluateVisibility, + ctx, + ); if ( - resolveLandingRoute(undecided, ctx) !== landing || + undecidedLanding !== landing || (!landing && appServesNoNavigation(shownApp) !== appServesNoNavigation(undecidedApp)) ) { return ( diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx new file mode 100644 index 00000000000..56eea698a88 --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An app opens on its declared per-audience landing: the first + * `AppSchema.landing` entry whose `visible` holds for the member + * (objectstack-ai/objectstack#28450; the key is objectstack-ai/objectstack#28022, + * ruling record 6545223246, letter A). + * + * What is pinned here, through the real `AppContent`, the real + * `ExpressionProvider` it mounts and the real `MePermissionsProvider`: + * - two entries, the first predicate false and the second true: the app opens + * on the second entry's item, which is not the first navigation item; + * - no entry holds: the first navigation item by `order`, today's rule; + * - a predicate that faults is passed over, and reported once with the + * navigation gate's `withholding` face; + * - the roots: the predicate reads the scope navigation `visible` reads + * (`current_user` and its `user` / `ctx.user` / `os.user` aliases); a root + * outside it (`record`, `app`) faults and is passed over; + * - an entry whose item the member's own navigation gates hide is passed over; + * - an item inside a group, and an item of `areas[].navigation`, are landed on. + * + * NOTE ON SCOPE: routing only. `ConsoleLayout` and the lazily imported pages + * are stubbed, as in `AppContent.landingMemberGates-12426.test.tsx`. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +// AGENTS.md §测试纪律 — the lazy page modules are stubbed so no unbounded +// `import()` races a bounded `findBy` window. +vi.mock('../../views/metadata-admin', () => ({ + MetadataDirectoryPage: () =>
, + MetadataResourceListPage: () =>
, + MetadataResourceEditPage: () =>
, + MetadataResourceHistoryPage: () =>
, + MetadataDiagnosticsPage: () =>
, +})); + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () =>
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => ( +
+ {children} +
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ + ObjectView: () =>
, +})); +vi.mock('../../views/DashboardView', () => ({ + DashboardView: () =>
, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => + String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) => String(options?.[name] ?? '')), + }), + useObjectLabel: () => ({ + objectLabel: ({ label }: { label?: string }) => label, + objectPluralLabel: ({ label }: { label?: string }) => label, + appLabel: ({ name, label }: { name: string; label?: string }) => label ?? name, + }), +})); + +/** The viewer under test: a `member` by default; positions name the audience. */ +let viewerRole = 'member'; +let viewerPositions: string[] = []; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u_1', name: 'Service Agent', email: 'agent@example.com', role: viewerRole, positions: viewerPositions }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: { id: 'org_1', name: 'Org' }, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +/** The app list as the server hands it to this session. */ +let metadataApps: unknown[] = []; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ + apps: metadataApps, + objects: [], + loading: false, + ensureType: undefined, + error: null, + refresh: vi.fn(async () => {}), + }), +})); + +const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }), + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + +/** The member's `/me/permissions` answer: every object readable unless denied. */ +function permissionsDenying(...unreadable: string[]): MePermissionsResponse { + return { + authenticated: true, + userId: 'u_1', + tenantId: null, + roles: [], + permissionSets: [], + objects: { + '*': { allowRead: true }, + ...Object.fromEntries(unreadable.map((name) => [name, { allowRead: false }])), + }, + fields: {}, + } as MePermissionsResponse; +} + +function renderConsoleAt(initialUrl: string, permissions: Partial>) { + return render( + } {...permissions}> + + + + } /> + + + , + ); +} + +const pathname = () => screen.getByTestId('pathname').textContent; + +/** HotCRM's shape: a dashboard first, then the object entries. */ +function crmApp(extra: Record) { + return { + name: 'crm', + label: 'CRM', + navigation: [ + { id: 'nav_home', type: 'dashboard', dashboardName: 'executive_dashboard', label: 'Home' }, + { id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads' }, + { + id: 'grp_service', + type: 'group', + label: 'Service', + children: [{ id: 'nav_cases', type: 'object', objectName: 'crm_case', label: 'Cases' }], + }, + ], + ...extra, + }; +} + +const agents = "'service_agent' in current_user.positions"; +const reps = "'sales_rep' in current_user.positions"; + +describe('AppContent — an app opens on its declared per-audience landing (objectstack#28450)', () => { + let warn: MockInstance; + beforeEach(() => { + viewerRole = 'member'; + viewerPositions = []; + metadataApps = []; + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + return () => warn.mockRestore(); + }); + + it('the first entry false and the second true: opens on the second entry\'s item', async () => { + viewerPositions = ['sales_rep']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: agents }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_lead'); + }); + + it('the first entry that holds wins, and an item inside a group is landed on', async () => { + viewerPositions = ['service_agent', 'sales_rep']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: agents }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_case'); + }); + + it('no entry holds: the first navigation item by `order`, today\'s rule', async () => { + viewerPositions = ['finance']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: agents }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('dashboard-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/dashboard/executive_dashboard'); + }); + + it('a predicate that faults is passed over and reported once, with the navigation gate\'s withholding face', async () => { + viewerPositions = ['sales_rep']; + const faulting = 'current_user.no_such_member.deeper == 1'; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: faulting }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_lead'); + const reports = warn.mock.calls.map((call) => String(call[0])).filter((line) => line.includes(faulting)); + expect(reports.length).toBeGreaterThanOrEqual(1); + expect(new Set(reports).size).toBe(1); + expect(reports[0]).toMatch(/withh/i); + }); + + it.each([ + ['`record`, the page row', "record.owner == 'u_1'"], + ['`app`, which no app-shell scope binds', "app.name == 'crm'"], + ])('a predicate naming a root outside the navigation scope (%s) faults and is passed over', async (_label, outside) => { + viewerPositions = ['sales_rep']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: outside }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_lead'); + expect(warn.mock.calls.some((call) => String(call[0]).includes(outside))).toBe(true); + }); + + it.each([ + ['`user`', "user.role == 'member'"], + ['`ctx.user`', "ctx.user.role == 'member'"], + ['`os.user`', "os.user.role == 'member'"], + ])('the identity alias %s reads the same member as `current_user`', async (_label, alias) => { + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: alias }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_case'); + }); + + it('an entry whose item the member\'s own navigation gates hide is passed over', async () => { + viewerPositions = ['service_agent', 'sales_rep']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: agents }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying('crm_case') }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_lead'); + }); + + it('an item of `areas[].navigation` is landed on', async () => { + viewerPositions = ['service_agent']; + metadataApps = [ + crmApp({ + areas: [ + { + id: 'area_service', + label: 'Service', + navigation: [{ id: 'nav_contracts', type: 'object', objectName: 'crm_contract', label: 'Contracts' }], + }, + ], + landing: [{ item: 'nav_contracts', visible: agents }], + }), + ]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_contract'); + }); + + it('control: an app that declares no landing opens on its first navigation item, as before', async () => { + viewerPositions = ['service_agent']; + metadataApps = [crmApp({})]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('dashboard-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/dashboard/executive_dashboard'); + }); +}); diff --git a/ui/packages/app-shell/src/console/appDeclaredLanding.ts b/ui/packages/app-shell/src/console/appDeclaredLanding.ts new file mode 100644 index 00000000000..683876e84ec --- /dev/null +++ b/ui/packages/app-shell/src/console/appDeclaredLanding.ts @@ -0,0 +1,106 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The app's declared per-audience landing, `@objectstack/spec`'s + * `AppSchema.landing` (objectstack-ai/objectstack#28022, ruling record + * 6545223246, letter A): `[{ item, visible }]`, asked in order, and the first + * entry whose `visible` holds for the reader names the navigation entry the app + * opens on. + * + * ## The scope a landing predicate is asked in + * + * The evaluator is the one the app's navigation `visible` is asked with: the + * `evaluateVisibility` of `useNavMemberGates`, called with no navigation entry, + * so the predicate sees exactly the roots `buildExpressionScope` + * (`providers/ExpressionProvider.tsx`) binds for the shell, and nothing else. + * A landing entry declares no `dataSource`, so no `data` is bound for it (the + * spec refuses a `data` read at parse, `checkAppLandingItems`). A predicate + * that names a root that scope does not bind faults in the engine, and so does + * any other predicate the engine cannot answer. + * + * ## What a fault does + * + * A faulting predicate is passed over, and reported once, by the navigation + * evaluator's own fault report (`evaluateNavigationVisibility`, which + * withholds on a fault). The landing only decides where a reader opens, never + * what they may reach: every navigation entry keeps its own gates. + * + * ## Which entries can be landed on + * + * The entry `item` names is looked up, by `id` and at any depth, in the trees + * as this member is SHOWN them (the app's `navigation` first, then each of its + * `areas[].navigation`): an entry the member's own navigation gates hide is + * passed over, as the spec's `AppSchema.landing` docblock states. An entry + * whose route the console cannot open inside the app (`routeOf` answers `''`) + * is passed over too: the spec refuses such a target at parse (a `group`, + * `separator`, `action` or `url` entry), so for a parsed app this pass-over is + * defence only. A `doc` entry lands: it opens the in-app docs portal, and which + * docs a reader may open is the server's audience gate (objectstack#19790). + * When no entry lands, the caller keeps today's rule: the first navigation + * entry by `order`. + * + * Internal module: not re-exported by the package entry. + */ + +import type { NavigationItem } from '@object-ui/types'; + +/** The `visible` evaluator of `useNavMemberGates`, asked here with no entry. */ +export type LandingVisibilityEvaluator = (expression: NavigationItem['visible']) => boolean; + +/** One entry of `AppSchema.landing`, as the served app carries it. */ +interface LandingEntry { + item: string; + visible: NavigationItem['visible']; +} + +/** The first entry with `id` in `items`, at any depth through `children`. */ +function findEntryById(items: readonly NavigationItem[], id: string): NavigationItem | undefined { + for (const item of items) { + if (item.id === id) return item; + const children = (item as { children?: unknown }).children; + if (Array.isArray(children)) { + const found = findEntryById(children as NavigationItem[], id); + if (found) return found; + } + } + return undefined; +} + +function isLandingEntry(value: unknown): value is LandingEntry { + return !!value && typeof value === 'object' && typeof (value as { item?: unknown }).item === 'string'; +} + +/** + * The route of the first declared landing entry that holds for this member, + * or `''` when the app declares none, or none of its entries lands. + * + * `shownTrees` are the app's navigation trees as the member is shown them, + * `navigation` first; `routeOf` is the in-app route of one entry, `''` when it + * opens nothing inside the app. + */ +export function resolveDeclaredLandingRoute( + landing: unknown, + shownTrees: readonly (readonly NavigationItem[])[], + evaluateVisibility: LandingVisibilityEvaluator, + routeOf: (item: NavigationItem) => string, +): string { + if (!Array.isArray(landing)) return ''; + for (const entry of landing) { + if (!isLandingEntry(entry)) continue; + if (!evaluateVisibility(entry.visible)) continue; + for (const tree of shownTrees) { + const target = findEntryById(tree, entry.item); + if (!target) continue; + const route = routeOf(target); + if (route) return route; + break; + } + } + return ''; +} diff --git a/ui/packages/app-shell/src/hooks/useNavMemberGates.ts b/ui/packages/app-shell/src/hooks/useNavMemberGates.ts index 41c3a8f1fb5..6b9e663337b 100644 --- a/ui/packages/app-shell/src/hooks/useNavMemberGates.ts +++ b/ui/packages/app-shell/src/hooks/useNavMemberGates.ts @@ -412,7 +412,10 @@ export function useNavMemberGates(forApp?: Record): NavMemberGa * console's sidebar always wires an action dispatcher, so it draws one. A `doc` * entry is kept as served: the sidebar's doc-readability answer is defence in * depth behind the server, which already drops an unreadable one - * (objectstack#19790), and no consumer of this function lands on a `doc`. + * (objectstack#19790). The console's app-root landing reads these trees and + * does land on a `doc` entry an app's `landing` names (objectstack#28450): it + * opens the in-app docs portal, under the server's audience gate on the doc, + * without the sidebar's doc-readability answer. */ export function navEntriesShownToMember(items: NavigationItem[], gates: NavMemberGates): NavigationItem[] { const guards: NavigationVisibilityOptions = { diff --git a/ui/packages/types/src/__tests__/app-actions-retired-7469.test.ts b/ui/packages/types/src/__tests__/app-actions-retired-7469.test.ts index 8d005c36ba8..31004eff64e 100644 --- a/ui/packages/types/src/__tests__/app-actions-retired-7469.test.ts +++ b/ui/packages/types/src/__tests__/app-actions-retired-7469.test.ts @@ -47,6 +47,7 @@ */ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { AppSchema as SpecAppSchema } from '@objectstack/spec/ui'; import type { AppComponentSchema } from '../app'; // @ts-expect-error — `AppAction` is retired (objectui#7469); re-exporting it turns this directive unused @@ -144,7 +145,10 @@ describe('objectui#7469 (c) — the platform\'s `AppSchema` refuses the key too' describe('objectui#7469 (d) — CONTROL: a deleted arm would KEEP the array under `.passthrough()`', () => { it('the mirror with the arm omitted parses the retired array green and keeps it — what a deletion would have left', () => { const value = RETIRED_VALUES[0]![1]; - const r = AppMirror.omit({ actions: true }).safeParse(app({ actions: value })); + // Rebuilt from the mirror's own shape: zod refuses `.omit()` on an object that + // carries refinements, and the mirror chains the spec's `checkAppLandingItems`. + const { actions: _retired, ...withoutArm } = AppMirror.shape; + const r = z.object(withoutArm).passthrough().safeParse(app({ actions: value })); expect(r.success).toBe(true); expect((r.data as Record).actions).toEqual(value); }); diff --git a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts index 77a6ad1c69a..77ae155039c 100644 --- a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -149,6 +149,7 @@ import { checkPageDefaultPlacement, checkPageMainRegionWidth, checkPageRecordBindingPlacement, + checkAppLandingItems, VIEW_METADATA_MEMBERS as SpecViewMetadataMembers, // objectui#12736 — the form option's spec shape: the form-view option. FormSelectOptionSchema as SpecFormSelectOptionSchema, @@ -745,6 +746,9 @@ describe('the import boundary strips every imported default (objectui#8317)', () // `ListViewSchema`, attached on the authored page `list-view` node's bag // (`ListViewPropsBag`), whose `type` is the spec's view kind. ['checkListViewChartBinding', checkListViewChartBinding], + // objectstack#28450: the spec's `AppSchema.landing` check, re-attached on + // `AppComponentSchema` under the objectui#7715 ruling. + ['checkAppLandingItems', checkAppLandingItems], ]); /** 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 2a56b156084..0d465fa4126 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 @@ -41,7 +41,7 @@ import { NavigationAreaSchema as SpecNavigationAreaSchema, PageSchema as SpecPageSchema, } from '@objectstack/spec/ui'; -import { ListViewSchema, PageNodeSchema, safeValidateSchema } from '../zod/index.zod'; +import { AppComponentSchema, ListViewSchema, PageNodeSchema, safeValidateSchema } from '../zod/index.zod'; import { asPageListView, bagPath } from './list-view-page-node'; import { DashboardWidgetSchema, GlobalFilterSchema } from '../zod/complex.zod'; @@ -318,11 +318,11 @@ const SITES = [ { 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'], + // `checkAppLandingItems` joined the spec object with `landing` (objectstack#28022, spec half) + // and was attached by objectstack#28450 with the console's landing redirect: a landing `item` + // must name an openable entry of the app's own navigation, and its `visible` reads no `data`. + attached: ['checkAppLandingItems'], + notAttachable: [], }, { site: 'DashboardWidgetSchema (complex.zod.ts)', @@ -551,3 +551,40 @@ describe('objectui#11073 — the DashboardWidget checks reach objectui\'s door w }); }); +describe('objectstack#28450 — the App `landing` refusals reach objectui\'s app node with the spec\'s own issue', () => { + const navigation = [ + { id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads' }, + { id: 'grp_service', type: 'group', label: 'Service', children: [{ id: 'nav_cases', type: 'object', objectName: 'crm_case', label: 'Cases' }] }, + ]; + const specApp = (landing: unknown[]) => ({ name: 'crm', label: 'CRM', navigation, landing }); + const appNode = (landing: unknown[]) => ({ type: 'app', name: 'crm', navigation, landing }); + const agents = "'service_agent' in current_user.positions"; + + it.each([ + ['an `item` that names no entry of the app', [{ item: 'nav_missing', visible: agents }], 'landing.0.item'], + ['an `item` that names a `group`', [{ item: 'grp_service', visible: agents }], 'landing.0.item'], + ['a `visible` that reads `data`', [{ item: 'nav_cases', visible: 'data.total > 0' }], 'landing.0.visible'], + ])('%s: the spec refuses it, and objectui refuses with the spec\'s own issue, mirror and published door', (_label, landing, at) => { + const spec = SpecAppSchema.safeParse(specApp(landing)); + expect(spec.success).toBe(false); + const specIssue = spec.error!.issues.find((i) => i.path.join('.') === at); + expect(specIssue?.code).toBe('custom'); + + for (const r of [AppComponentSchema.safeParse(appNode(landing)), safeValidateSchema(appNode(landing))]) { + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message }))) + .toContainEqual({ code: 'custom', path: at, message: specIssue!.message }); + } + }); + + it('controls: a landing on an entry inside a group, and an app with no landing, pass on the spec and both doors', () => { + for (const landing of [[{ item: 'nav_cases', visible: agents }], undefined]) { + const spec = landing ? specApp(landing) : { name: 'crm', label: 'CRM', navigation }; + const node = landing ? appNode(landing) : { type: 'app', name: 'crm', navigation }; + expect(SpecAppSchema.safeParse(spec).success).toBe(true); + const mirror = AppComponentSchema.safeParse(node); + expect(mirror.success, JSON.stringify(mirror.error?.issues)).toBe(true); + expect(safeValidateSchema(node).success).toBe(true); + } + }); +}); diff --git a/ui/packages/types/src/zod/app.zod.ts b/ui/packages/types/src/zod/app.zod.ts index 989b5093af3..5057ed3b09c 100644 --- a/ui/packages/types/src/zod/app.zod.ts +++ b/ui/packages/types/src/zod/app.zod.ts @@ -33,6 +33,9 @@ import { // RAW for the same reason: the spec's own refinement function, chained on // every navigation arm below (objectui#13076). navItemVisibleDataNeedsDataSource, + // RAW for the same reason: the spec's own `AppSchema.landing` check, chained + // on `AppComponentSchema` below (objectstack-ai/objectstack#28450). + checkAppLandingItems, } from '@objectstack/spec/ui'; import { EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema, @@ -803,7 +806,14 @@ export const AppComponentSchema = BaseSchema.extend(SpecAppFields.shape).extend( 'No renderer read app-level ARIA attributes. Declare `aria` on the page component that renders the DOM node ' + '(`page.components[].aria`; `page.aria` and the list view\'s `aria` are live too). Delete the key.', ), -}); +}) + // The spec's own object-level check for `landing` (objectstack-ai/objectstack#28450, + // objectui#7715's rule): a landing `item` must name an entry of this app's own + // `navigation` or `areas[].navigation` that opens something, and its `visible` + // reads no `data`. `landing` flows in by reference through `SpecAppFields`; this + // node's `navigation` / `areas` are its own, and carry the same `id` / `type` the + // check reads. `__tests__/spec-object-refinements-7715.test.ts` re-derives the count. + .superRefine(checkAppLandingItems); /** * objectui#11440 — ONE refusal string for both content channels of