From f30e13a4f245dd723eb32030ce66349452866bc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 16:53:54 +0000 Subject: [PATCH 1/3] feat(deps): track @objectstack/spec 17.0.0-rc.2 and fix every break it lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the workspace pin from ^17.0.0-rc.1 to ^17.0.0-rc.2 (32 declarations across 30 package.json), plus the five sibling @objectstack/* declarations (client / formula / lint). Those siblings pin @objectstack/spec EXACTLY, so leaving them behind kept a second rc.1 copy of the spec in the tree and had @objectstack/lint — an app-shell dependency — validating against schemas that still accept the keys this change retires. pnpm-lock.yaml now resolves a single spec version and carries zero rc.1 references. Bumping the pin and fixing the fallout cannot be split: the pin alone reddens CI, the code alone targets a shape that is not in effect yet. Adjudicated retirements: - app.homePageId (#3287, #3264): resolveLandingRoute() collapses to findFirstRoute(navigation, ctx) and findNavItemById() goes with it, having lost its only caller. The function name and call site stay as the seam for a future navigation[].landing flag, which is the encoding upstream recorded as correct — not implemented here, it goes enforce-first. AppPreview now DERIVES the landing entry the same way the shell does, so the preview shows the entry the runtime will actually open. - AppContextSelector.includeAll / placement (#3208): removed from the hand written ContextSelectorDef and from the parity assertions. Neither ever did anything here — context selectors are mandatory-scope, so no "All" row was rendered, and placement: 'topbar' put nothing in the topbar. - HttpMethod (#3235): aliased to the spec's HttpMethodType from ./ui. Shape is verbatim identical and the public surface is unchanged. Deliberately NOT ./shared, which is the 7-value enum. Breaks the dispatch list did not cover, found by typecheck + build + tests: - NavigationArea.visible / order / requiredPermissions retired at area level. AppSchemaRenderer's area switcher DID gate on them; the gating moves down to NavigationRenderer, where the spec put it. Premise mismatch: objectui#3311. - ui's Notification / NotificationConfig deleted with no successor, taking @object-ui/core's NotificationProtocol with them. It had zero callers here; the live implementation is @object-ui/react's NotificationContext. Premise mismatch: objectui#3310. - MetadataSaveOptions moved ./kernel -> ./system. - SchemaDiffEntryKind gained default_mismatch; the exhaustive Record failed the build exactly as designed, and now carries a label. - ActionSchema gained the package-lock envelope; SPEC_ACTION_KEYS re-derived. Vacuous passes that rc.2 turned into real failures: - The email_template and view client validators pointed at schemas the metadata kinds do not resolve to, and the lenient container silently stripped every key it was handed — so neither type was ever really validated. email_template now uses EmailTemplateDefinitionSchema; view dispatches on its own discriminant so each shape is checked against its own schema (objectui#3312). - Four console preview samples were off-spec and being stripped rather than refused: view.list.object, job.concurrency / job.timeoutMs, email_template.from / .to, datasource.capabilities / .healthCheck. These are the drafts an author — or a model generating metadata — copies. - Test fixtures pinning managedBy: 'system', a value protocol 17 retired, had stopped asserting anything: it now resolves to the default-writable fallback. Switched to engine-owned. Also updates the mobile_fullscreen JSDoc to list RichTextField alongside TextAreaField (objectui#3301). #3173 stays out: rc.2's DashboardWidgetSchema.responsive is byte-identical to rc.1, so the documented `any` is untouched. --- .changeset/spec-17-rc2-pin-bump.md | 90 +++++++++ apps/console/README.md | 1 - apps/console/package.json | 4 +- .../preview-samples-spec-valid.test.ts | 32 ++- .../src/components/RootLandingRedirect.tsx | 6 +- apps/console/src/preview-samples.ts | 39 ++-- apps/site/package.json | 2 +- package.json | 2 +- packages/app-shell/package.json | 6 +- packages/app-shell/src/console/AppContent.tsx | 48 ++--- .../app-shell/src/layout/ContextSelectors.tsx | 10 +- .../src/utils/crudAffordances.test.ts | 5 +- .../views/metadata-admin/clientValidation.ts | 55 ++++- .../clientValidation.viewShapes.test.ts | 81 ++++++++ .../external/ValidationPanel.tsx | 3 + .../previews/AppPreview.test.tsx | 39 ++-- .../metadata-admin/previews/AppPreview.tsx | 54 ++--- packages/auth/package.json | 2 +- packages/collaboration/package.json | 2 +- packages/components/package.json | 2 +- packages/core/package.json | 4 +- packages/core/src/actions/actionKeys.ts | 11 + .../src/protocols/NotificationProtocol.ts | 150 -------------- packages/core/src/protocols/index.ts | 9 +- packages/core/src/utils/managedBy.test.ts | 18 +- packages/data-objectstack/package.json | 4 +- .../src/spec-symbol-batch6.test.ts | 5 +- packages/fields/package.json | 2 +- packages/layout/package.json | 2 +- packages/layout/src/AppSchemaRenderer.tsx | 30 +-- .../src/__tests__/AppSchemaRenderer.test.tsx | 61 ++++-- packages/mobile/package.json | 2 +- packages/plugin-charts/package.json | 2 +- packages/plugin-chatbot/package.json | 2 +- packages/plugin-dashboard/package.json | 2 +- packages/plugin-detail/package.json | 2 +- packages/plugin-form/package.json | 2 +- .../src/ObjectForm.effectiveOps.test.tsx | 6 +- .../src/ObjectForm.managedEdit.test.tsx | 7 +- packages/plugin-gantt/package.json | 2 +- packages/plugin-grid/package.json | 4 +- .../src/__tests__/rowCrudAffordances.test.ts | 6 +- .../__tests__/rowCrudEffectiveOps.test.tsx | 7 +- packages/plugin-list/package.json | 4 +- packages/plugin-map/package.json | 2 +- packages/plugin-report/package.json | 2 +- packages/plugin-timeline/package.json | 2 +- packages/plugin-tree/package.json | 2 +- packages/plugin-view/package.json | 2 +- packages/providers/package.json | 2 +- packages/react/package.json | 2 +- packages/runner/package.json | 2 +- packages/sdui-parser/package.json | 2 +- packages/types/package.json | 2 +- .../src/__tests__/navigation-model.test.ts | 14 +- .../__tests__/navigation-spec-parity.test.ts | 7 +- .../page-app-dashboard-spec-parity.test.ts | 20 +- .../page-nav-misc-spec-parity.test.ts | 85 ++++++-- .../report-chart-query-spec-parity.test.ts | 7 +- packages/types/src/app.ts | 31 +-- packages/types/src/field-types.ts | 5 +- packages/types/src/index.ts | 19 +- packages/types/src/objectql.ts | 10 +- packages/types/src/zod/app.zod.ts | 35 ++-- pnpm-lock.yaml | 191 +++++++++--------- 65 files changed, 767 insertions(+), 504 deletions(-) create mode 100644 .changeset/spec-17-rc2-pin-bump.md create mode 100644 packages/app-shell/src/views/metadata-admin/clientValidation.viewShapes.test.ts delete mode 100644 packages/core/src/protocols/NotificationProtocol.ts diff --git a/.changeset/spec-17-rc2-pin-bump.md b/.changeset/spec-17-rc2-pin-bump.md new file mode 100644 index 0000000000..8b8557e3cb --- /dev/null +++ b/.changeset/spec-17-rc2-pin-bump.md @@ -0,0 +1,90 @@ +--- +"@object-ui/types": minor +"@object-ui/core": minor +"@object-ui/layout": minor +"@object-ui/app-shell": minor +"@object-ui/data-objectstack": minor +"@object-ui/console": minor +--- + +Track `@objectstack/spec` 17.0.0-rc.2 (objectui#3235, #3208, #3287, #3264). + +The pin moves from `^17.0.0-rc.1` to `^17.0.0-rc.2` across the workspace, and +the sibling `@objectstack/*` packages (`client` / `core` / `formula` / `lint`) +move with it — they pin `@objectstack/spec` **exactly**, so leaving them behind +kept a second copy of the spec in the tree and would have had `@objectstack/lint` +validating against rc.1 schemas that still accept keys rc.2 retires. + +Breaking semantics, in FROM → TO form: + +- **`app.homePageId` is retired — an app's landing page is now its first + navigation item.** An app that pinned a landing page with `homePageId` will + open on the first reachable navigation entry (by `order`) instead; the root + landing still follows `isDefault`. To restore a specific landing page, reorder + `navigation` so the intended entry comes first. Stored metadata is migrated by + `os migrate meta --from 16`. The key is a hard error now, not a stripped one: + the spec ships a tombstone that names the migration. + Upstream retired it because of its SHAPE, not its usage — it was an ID + cross-reference with no referential integrity, so a `homePageId` that pointed + at nothing silently fell back to the first navigation item anyway + (objectstack#4667, premise corrected in #4709). If the capability returns, it + returns as a flag on the navigation item itself, which cannot dangle. +- **`@object-ui/types`' `HttpMethod` now resolves to the spec's + `HttpMethodType`.** Shape is verbatim identical — the same 5-value UI subset — + and `@object-ui/types` still exports it as `HttpMethod`, so no consumer + changes. The spec renamed its `./ui` export because `HttpMethod` named two + different types depending on the import path (`./shared` / `./api` carry a + 7-value enum including `HEAD` / `OPTIONS`); objectui deliberately keeps the + 5-value one (objectstack#4691). +- **`AppContextSelector.includeAll` / `placement` are gone.** Neither ever did + anything in this renderer: context selectors are mandatory-scope, so no "All" + row was ever rendered, and `placement: 'topbar'` put nothing in the topbar. + Both carried schema defaults, which is why the liveness lint structurally + could not flag them — removal was the only channel that reaches an author + (framework#4509). +- **`NavigationArea.visible` / `order` / `requiredPermissions` are gone.** An + area is a layout grouping, not an access boundary. Gating moved down to the + navigation ITEM, where `visible` and `requiredPermissions` are unchanged and + still enforced. `AppSchemaRenderer`'s area switcher no longer hides an area, so + an area whose items are all gated away renders as visible-but-empty rather + than disappearing. +- **`@object-ui/core` no longer exports `NotificationProtocol`** + (`resolveNotificationConfig`, `specNotificationToToast`, `mapSeverityToVariant`, + `mapPosition`, `ToastNotification`). It bridged `@objectstack/spec/ui`'s + `Notification` / `NotificationConfig`, which objectstack#4610 deleted with no + successor. Use `resolveNotificationConfig` from `@object-ui/react` + (`NotificationContext`), which owns the live `NotificationSystemConfig` and is + what every notification surface already read. Note that the spec's *other* + `Notification` — `@objectstack/spec/api` — is the REST inbox row, a different + contract, and is deliberately NOT aliased in as a replacement. +- **The `email_template` client-side validator now uses + `EmailTemplateDefinitionSchema`.** It was pointing at the removed + `EmailTemplateSchema`, so authored templates were being checked against the + wrong contract: the live one is keyed `name` + `locale` (not `id`) and splits + the body into `bodyHtml` / `bodyText` (not `body` + `bodyType`) + (objectstack#4616 / #4807). + +Fixes that are not breaking, but were only found because rc.2 stopped being +lenient — each had been passing vacuously: + +- **`view` drafts are actually validated now.** The client validator named the + aggregated container schema while this admin authors first-class `ViewItem`s, + and the container used to strip `viewKind` / `config` in silence — so no view + draft ever had one of its own keys checked. It now validates each shape + against its own schema (objectui#3312). +- **The console's worked examples were wrong**, and being stripped rather than + refused: `view.list.object` (the container root already declares it), + `job.concurrency` / `job.timeoutMs` (no such keys; the spelling is `timeout`, + already in ms), `email_template.from` / `.to` (a template is not a send — + the sender override is `fromOverride`, an object), and + `datasource.capabilities` / `.healthCheck` (objectstack#4583 removed the + former; the latter was never a datasource key). These are the drafts an + author — or a model generating metadata — copies. +- Action key inventory re-derived: `ActionSchema` gained the package-lock + envelope (`_lock*` / `_package*` / `_provenance`), so a packaged action no + longer reports them as unknown keys. +- The schema-diff panel labels the new `default_mismatch` finding. +- Test fixtures pinning the retired `managedBy: 'system'` bucket now use + `engine-owned`. Protocol 17 split that value (objectstack#3355), so it + resolved to the default-writable fallback and a batch of "stays locked" + assertions had quietly stopped asserting anything. diff --git a/apps/console/README.md b/apps/console/README.md index 9dc437fd9a..e17f8e9424 100644 --- a/apps/console/README.md +++ b/apps/console/README.md @@ -79,7 +79,6 @@ Additional backend requirements for cross-origin deployments: ### AppSchema Support - ✅ `name`, `label`, `icon` — Basic app metadata - ✅ `description`, `version` — Optional app information -- ✅ `homePageId` — Custom landing page configuration - ✅ `requiredPermissions` — Permission-based access control - ✅ `branding.logo`, `branding.primaryColor`, `branding.favicon` — App branding diff --git a/apps/console/package.json b/apps/console/package.json index d644cbe689..726cd4a0c7 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -89,8 +89,8 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/client": "^17.0.0-rc.1", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/client": "^17.0.0-rc.2", + "@objectstack/spec": "^17.0.0-rc.2", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "^0.5.20", "@testing-library/jest-dom": "^7.0.0", diff --git a/apps/console/src/__tests__/preview-samples-spec-valid.test.ts b/apps/console/src/__tests__/preview-samples-spec-valid.test.ts index b439333210..e634b10d2b 100644 --- a/apps/console/src/__tests__/preview-samples-spec-valid.test.ts +++ b/apps/console/src/__tests__/preview-samples-spec-valid.test.ts @@ -42,16 +42,21 @@ * asks precisely the question that matters: would this sample survive being * published in a real stack? * - * LIMIT — worth knowing before trusting a pass. Only some element schemas are - * `.strict()` (`tools`, `apps`, `flows`, `permissions`, `positions`, - * `datasources` are; `views`, `jobs`, `emailTemplates` are not). For the - * non-strict ones an unknown or retired key is stripped rather than rejected, - * so a PASS there proves the sample is structurally sound, NOT that it is free - * of retired keys. The guard is exactly as strict as the spec is. `RETIRED_KEYS` - * below covers the named retirements regardless, which is the only reason the - * `tool` / `report` rows mean anything: `report` reached SPEC_CLEAN by binding a - * dataset, and its stripped pre-9.0 `object` / `groupBy` keys would not have - * failed anything on their own. + * LIMIT — worth knowing before trusting a pass. This used to read "only some + * element schemas are `.strict()`", and it named `views` / `jobs` / + * `emailTemplates` as the lenient ones. Spec 17.0.0 closed that gap: those + * three now reject unknown keys by name too, which is how the 17.0.0-rc.2 pin + * bump caught this file's `view.list.object`, `job.concurrency` / + * `job.timeoutMs` and `email_template.from` / `.to` — four stale samples that + * had been passing precisely because the key was stripped instead of refused. + * + * Some element schemas are still non-strict, and for those a PASS proves the + * sample is structurally sound, NOT that it is free of retired keys — the guard + * is exactly as strict as the spec is. `RETIRED_KEYS` below covers the named + * retirements regardless, which is the only reason the `tool` / `report` rows + * mean anything: `report` reached SPEC_CLEAN by binding a dataset, and its + * stripped pre-9.0 `object` / `groupBy` keys would not have failed anything on + * their own. */ import { describe, it, expect } from 'vitest'; @@ -159,7 +164,12 @@ const RETIRED_KEYS: Array<[type: string, key: string, adjudication: string]> = [ ['agent', 'knowledge', 'objectstack#3896'], ['skill', 'triggerPhrases', 'objectstack#3896'], ['flow', 'onTimeout', 'objectstack#4158'], - ['app', 'landing', 'objectstack#4001 — use `homePageId`'], + // `landing` was replaced by `homePageId` in objectstack#4001, and + // `homePageId` was itself retired in objectstack#4667 / #4709. There is no + // authorable landing key any more: the landing page IS the first navigation + // item (by `order`), and the root landing follows `isDefault`. + ['app', 'landing', 'objectstack#4001 — landing is now the first nav item'], + ['app', 'homePageId', 'objectstack#4667 / #4709 — landing is now the first nav item'], ]; /** Every key name appearing anywhere in `value`, at any depth. */ diff --git a/apps/console/src/components/RootLandingRedirect.tsx b/apps/console/src/components/RootLandingRedirect.tsx index 57967185c3..f239673a3c 100644 --- a/apps/console/src/components/RootLandingRedirect.tsx +++ b/apps/console/src/components/RootLandingRedirect.tsx @@ -12,8 +12,10 @@ * The landing is a build/dev-time PRODUCT decision, declared in metadata — not a * runtime, per-tenant, Settings-UI preference. Resolution order (see * {@link resolveLandingPath}): - * 1. the App marked `isDefault: true` → `/apps/` — and that App's own - * `homePageId` then selects the landing page within it; + * 1. the App marked `isDefault: true` → `/apps/` — and the landing page + * WITHIN that app is its first navigation item (by `order`); it used to + * be selectable with `homePageId`, retired in spec 17.0.0 + * (objectstack#4667 / #4709); * 2. else the single visible App (`active !== false && hidden !== true`) * → `/apps/` (a one-app deployment shouldn't show a one-tile launcher); * 3. else `/home` — the multi-app workspace launcher (the legacy default). diff --git a/apps/console/src/preview-samples.ts b/apps/console/src/preview-samples.ts index 1ca0982252..a756f688cf 100644 --- a/apps/console/src/preview-samples.ts +++ b/apps/console/src/preview-samples.ts @@ -42,7 +42,9 @@ export const SAMPLES: Record> = { object: 'sales_order', list: { type: 'grid', - object: 'sales_order', + // No `object` here: the view root above declares it. The list view is + // `.strict()` since spec 17.0.0, so repeating it one level down is a + // hard error rather than a silently dropped key. columns: ['name', 'amount', 'status', 'close_date'], }, }, @@ -77,13 +79,16 @@ export const SAMPLES: Record> = { // `.strict()`: an app does not route by hand-written `path`, it names the // metadata record to open (`objectName` / `pageName` / `dashboardName` / // `url`) and the shell builds the route. `id` is required so a nav entry can - // be addressed — by a patch, and by `homePageId`, which replaced the removed - // top-level `landing` route (objectstack#4001). + // be addressed by a patch. + // + // There is no landing-page key: `landing` was removed in objectstack#4001, + // its replacement `homePageId` was retired in objectstack#4667 / #4709, and + // the app now opens on the FIRST navigation item that yields a route — here + // `home`. To change where an app opens, reorder `navigation`. app: { name: 'crm', label: 'CRM', icon: 'briefcase', - homePageId: 'home', navigation: [ { id: 'home', type: 'page', label: 'Home', pageName: 'crm_welcome' }, { id: 'accounts', type: 'object', label: 'Accounts', objectName: 'account' }, @@ -237,9 +242,11 @@ export const SAMPLES: Record> = { enabled: true, schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'UTC' }, handler: 'syncOrders', - concurrency: 1, retryPolicy: { maxRetries: 3 }, - timeoutMs: 600000, + // `timeout`, not `timeoutMs` — the unit is already milliseconds. There is + // no `concurrency` key on a job; `JobSchema` is `.strict()` since spec + // 17.0.0, so both spellings are now rejected by name instead of dropped. + timeout: 600000, }, agent: { @@ -320,14 +327,16 @@ export const SAMPLES: Record> = { // declared at stack level via `datasourceMapping`. Both were rejected. driver: 'postgres', active: true, - // `ssl` and `capabilities` are config OBJECTS, not a boolean / token list. + // `ssl` is a config OBJECT, not a boolean. ssl: { enabled: true, rejectUnauthorized: true }, config: { host: 'db.internal', port: 5432, database: 'analytics' }, pool: { min: 2, max: 10 }, - capabilities: { readOnly: true, queryAggregations: true }, - // `interval` → `intervalMs`, and the unit is MILLISECONDS: the old `60` - // was read as 60ms once the key was spelled correctly, not 60 seconds. - healthCheck: { enabled: true, intervalMs: 60000 }, + // No `capabilities` block: spec 17.0.0 removed it (objectstack#4583, + // ADR-0049). All eleven flags were declared, strict-guarded and read by + // nobody — pushdown is decided by the runtime driver's own `supports.*`, + // never by datasource metadata, so `readOnly: true` here made nothing + // read-only. `healthCheck` is likewise not a datasource key. + // `os migrate meta --from 16` rewrites stored copies. }, // `condition` is the FAILURE predicate, not the invariant: `ScriptValidationSchema` @@ -366,8 +375,12 @@ export const SAMPLES: Record> = { name: 'order_confirmation', label: 'Order Confirmation', subject: 'Your order ${order.name} is confirmed', - from: 'sales@example.com', - to: '${contact.email}', + // An email TEMPLATE is not a send: there is no `to` — the recipient is + // supplied when the template is rendered and dispatched. The sender + // override is `fromOverride`, and it is an OBJECT (`name` + `address`), + // not a bare address string. Both `from` and `to` are rejected by name now + // that the authorable record is `.strict()` (spec 17.0.0). + fromOverride: { name: 'Sales Team', address: 'sales@example.com' }, bodyHtml: '

Thanks for your order!

Hi ${contact.name},

Your order ${order.name} for ${order.amount} is confirmed.

— The Sales Team

', }, diff --git a/apps/site/package.json b/apps/site/package.json index 1620386017..650eaf65e4 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -32,7 +32,7 @@ "@object-ui/plugin-view": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "fumadocs-core": "16.13.0", "fumadocs-mdx": "15.2.1", "fumadocs-ui": "16.14.0", diff --git a/package.json b/package.json index 785ffd9e67..754853abd7 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@changesets/cli": "^2.31.1", "@eslint/js": "^10.0.1", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@playwright/test": "^1.62.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 0457e8b1c7..be05163623 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -49,9 +49,9 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/formula": "^17.0.0-rc.1", - "@objectstack/lint": "^17.0.0-rc.1", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/formula": "^17.0.0-rc.2", + "@objectstack/lint": "^17.0.0-rc.2", + "@objectstack/spec": "^17.0.0-rc.2", "@sentry/react": "^10.69.0", "jsonc-parser": "^3.3.1", "lucide-react": "^1.25.0", diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 8618023914..9b4973690a 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -836,11 +836,11 @@ function findFirstRoute(items: any[], ctx?: NavTemplateContext): string { return ''; } -// Build the per-item route segment without recursing through groups — -// used when `homePageId` resolved to an exact match and we just need to -// know how to address it. Delegates to the layout package's resolveHref() -// so `recordId`/`recordMode`/`componentRef` semantics stay consistent -// with the sidebar. +// Build the per-item route segment without recursing through groups — the +// caller has already picked the item and just needs to know how to address +// it. Delegates to the layout package's resolveHref() so +// `recordId`/`recordMode`/`componentRef` semantics stay consistent with the +// sidebar. function buildItemRoute(item: any, ctx?: NavTemplateContext): string { if (!item) return ''; if (item.type === 'url' || item.type === 'action' || item.type === 'separator' || item.type === 'group') return ''; @@ -852,35 +852,23 @@ function buildItemRoute(item: any, ctx?: NavTemplateContext): string { return href.replace(/^\//, ''); } -function findNavItemById(items: any[], id: string): any | undefined { - if (!items) return undefined; - for (const item of items) { - if (item.id === id) return item; - if (item.type === 'group' && item.children) { - const hit = findNavItemById(item.children, id); - if (hit) return hit; - } - } - return undefined; -} - /** * Resolves the route to navigate to when the user lands on the bare - * `/console/apps/:appName` URL. Honors the app's explicit - * `homePageId` (Salesforce-style "Default Landing"); falls back to the - * first reachable nav item only when no homePageId is set or it points - * at something that doesn't yield a route. This is what lets the CRM - * example open on the Sales Dashboard instead of the Lead list. + * `/console/apps/:appName` URL: the app's landing page is its FIRST reachable + * navigation item, in `order` — `findFirstRoute` walks groups and skips the + * item types that address nothing (`url`, `separator`, `action`). Which app + * the ROOT lands on is a separate question, decided by `isDefault`. + * + * This used to honour an explicit `app.homePageId`. Spec 17.0.0 retired that + * key (objectstack#4667, premise corrected in #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. */ function resolveLandingRoute(activeApp: any, ctx?: NavTemplateContext): string { - const homePageId: string | undefined = activeApp?.homePageId; - const navigation = activeApp?.navigation || []; - if (homePageId) { - const item = findNavItemById(navigation, homePageId); - const route = buildItemRoute(item, ctx); - if (route) return route; - } - return findFirstRoute(navigation, ctx); + return findFirstRoute(activeApp?.navigation || [], ctx); } /** diff --git a/packages/app-shell/src/layout/ContextSelectors.tsx b/packages/app-shell/src/layout/ContextSelectors.tsx index 8c8369ac5a..47d631dc90 100644 --- a/packages/app-shell/src/layout/ContextSelectors.tsx +++ b/packages/app-shell/src/layout/ContextSelectors.tsx @@ -38,10 +38,8 @@ export interface ContextSelectorDef { label?: unknown; icon?: string; optionsSource: { endpoint: string; valueKey?: string; labelKey?: string; filter?: ContextSelectorFilter[] }; - includeAll?: boolean; allValue?: string; persist?: 'query' | 'session' | 'none'; - placement?: 'sidebar_header' | 'topbar'; } interface Option { value: string; label: string } @@ -240,8 +238,12 @@ function SelectorControl({ // Context selectors are *mandatory scope* selectors: a concrete option must // always be active. Allowing an "All" choice would unscope the surface and, - // for Studio's package filter, leak system metadata. We therefore ignore - // `includeAll`, never render an "All" row, and auto-select the first option + // for Studio's package filter, leak system metadata. There is no key to + // ignore any more — `includeAll` and `placement` no longer exist (removed + // from `AppContextSelectorSchema` in spec 17.0.0, framework#4509 / + // objectui#3208; this renderer never rendered an "All" row, so + // `includeAll: false` hardened nothing and `includeAll: true` unlocked + // nothing). We never render an "All" row, and auto-select the first option // as soon as the list resolves when nothing concrete is selected yet. const hasConcrete = !!value && value !== (def.allValue ?? ''); React.useEffect(() => { diff --git a/packages/app-shell/src/utils/crudAffordances.test.ts b/packages/app-shell/src/utils/crudAffordances.test.ts index e1bca7c391..565c130d92 100644 --- a/packages/app-shell/src/utils/crudAffordances.test.ts +++ b/packages/app-shell/src/utils/crudAffordances.test.ts @@ -34,7 +34,10 @@ describe('resolveEffectiveCrudAffordances (app-shell re-export)', () => { }); it('boolean userActions override the bucket default per flag', () => { - const aff = resolveEffectiveCrudAffordances({ managedBy: 'system', userActions: { edit: true } }); + // `engine-owned`, not the retired `'system'` (protocol 17 split it — + // objectstack#3355). The point is that `edit: true` opens ONLY edit, which + // needs a bucket that denies both by default. + const aff = resolveEffectiveCrudAffordances({ managedBy: 'engine-owned', userActions: { edit: true } }); expect(aff.edit).toBe(true); expect(aff.delete).toBe(false); }); diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.ts index 565709b6e4..cf61855842 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.ts @@ -33,6 +33,43 @@ type ZodLikeSchema = { type SchemaLoader = () => Promise; +/** + * The `view` metadata type has TWO spec-declared shapes, and the backend serves + * both (framework `objectql/engine.ts` registration; see the header of + * `MetadataProvider.mergeViewsIntoObjects`): + * + * - **ViewItem** (`ViewItemSchema`) — the first-class per-view record, + * `{ name: '.', object, viewKind, label, config }` (ADR-0017, + * "object has-many view"). This is what this admin AUTHORS: see `anchors.ts` + * `createBuildBody` and the `view-create-body.test.ts` guard. + * - **Container** (`ViewSchema`) — the aggregated + * `{ name, label, object, list, form, listViews, formViews }`, still served + * for records that were never expanded into ViewItems. + * + * This validator used to name only the container. That looked harmless because + * the container was non-strict: a ViewItem's `viewKind` / `config` were silently + * STRIPPED, so every draft this admin creates "passed" without one of its own + * keys ever being checked. Spec 17.0.0 made the container strict, turning that + * vacuous pass into a loud rejection — which is how `createConformance.test.ts` + * surfaced it during the 17.0.0-rc.2 uptake. + * + * So dispatch on the record's own discriminant rather than guessing: `viewKind` + * is what makes a record a ViewItem, and it is the same test + * `MetadataProvider.isViewItem()` applies on the read side. This is NOT a + * tolerant fallback — neither shape is coerced or waved through, each is checked + * strictly against its own schema. Which of the two should be the single + * authorable shape is a real open question, tracked in objectui#3312. + */ +function viewSchemaForDraft(item: ZodLikeSchema, container: ZodLikeSchema): ZodLikeSchema { + return { + safeParse: (value: unknown) => { + const isViewItem = + !!value && typeof value === 'object' && 'viewKind' in (value as object); + return (isViewItem ? item : container).safeParse(value); + }, + }; +} + // Map metadata-type name → loader for that type's root Zod schema. // Each loader pulls only one spec subpath so we don't drag the whole // 2MB schema bundle into the studio bundle. @@ -61,7 +98,13 @@ const LOADERS: Record = { analytics_cube: async () => (await import('@objectstack/spec/data')).CubeSchema as unknown as ZodLikeSchema, // ui - view: async () => (await import('@objectstack/spec/ui')).ViewSchema as unknown as ZodLikeSchema, + view: async () => { + const { ViewItemSchema, ViewSchema } = await import('@objectstack/spec/ui'); + return viewSchemaForDraft( + ViewItemSchema as unknown as ZodLikeSchema, + ViewSchema as unknown as ZodLikeSchema, + ); + }, page: async () => (await import('@objectstack/spec/ui')).PageSchema as unknown as ZodLikeSchema, app: async () => (await import('@objectstack/spec/ui')).AppSchema as unknown as ZodLikeSchema, dashboard: async () => (await import('@objectstack/spec/ui')).DashboardSchema as unknown as ZodLikeSchema, @@ -86,7 +129,15 @@ const LOADERS: Record = { skill: async () => (await import('@objectstack/spec/ai')).SkillSchema as unknown as ZodLikeSchema, // system - email_template: async () => (await import('@objectstack/spec/system')).EmailTemplateSchema as unknown as ZodLikeSchema, + // NOTE: `EmailTemplateDefinitionSchema`, NOT the removed `EmailTemplateSchema`. + // The `email_template` metadata kind has resolved to the Definition schema + // since spec 7.1.0 (`BUILTIN_METADATA_TYPE_SCHEMAS` in + // `kernel/metadata-type-schemas.ts` is the authority); `EmailTemplateSchema` + // survived only as an inline sub-shape of the old `Notification` holder and + // was deleted with it in objectstack#4610 / #4616. So this validator was + // checking authored templates against the WRONG contract — `name` + `locale` + // and `bodyHtml` / `bodyText`, not `id` and `body` + `bodyType`. + email_template: async () => (await import('@objectstack/spec/system')).EmailTemplateDefinitionSchema as unknown as ZodLikeSchema, job: async () => (await import('@objectstack/spec/system')).JobSchema as unknown as ZodLikeSchema, // security diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.viewShapes.test.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.viewShapes.test.ts new file mode 100644 index 0000000000..e497326124 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.viewShapes.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `view` draft validation across the type's TWO spec-declared shapes + * (objectui#3312, surfaced by the `@objectstack/spec` 17.0.0-rc.2 uptake). + * + * `@objectstack/spec/ui` exports both `ViewItemSchema` (the first-class + * per-view record this admin authors — `{ name, object, viewKind, config }`, + * ADR-0017) and `ViewSchema` (the aggregated container — `{ list, form, + * listViews, formViews }`), and the backend serves both. + * + * The validator used to name only the container. That looked fine because the + * container was non-strict: a ViewItem's `viewKind` / `config` were silently + * stripped and the draft "passed" without one of its own keys ever being + * checked — a vacuous pass, for every view this admin has ever created. Spec + * 17.0.0 made the container strict and turned it into a loud rejection. + * + * These pins state the property that matters and that a single-schema mapping + * cannot have: BOTH shapes validate, each against its OWN schema, and neither + * is waved through. The last two are the load-bearing ones — they fail if the + * dispatch ever degrades into "try one, shrug on failure". + */ + +import { describe, it, expect } from 'vitest'; +import { validateMetadataDraft } from './clientValidation'; + +/** What `anchors.ts`'s `createBuildBody` emits for a default list view. */ +const VIEW_ITEM = { + name: 'crm_lead.all_leads', + object: 'crm_lead', + viewKind: 'list', + label: 'All Leads', + config: { + type: 'grid', + columns: [], + data: { provider: 'object', object: 'crm_lead' }, + }, +}; + +/** A record that was never expanded into ViewItems. */ +const CONTAINER = { + name: 'crm_lead', + label: 'Lead views', + object: 'crm_lead', + list: { type: 'grid', columns: ['name'] }, +}; + +describe('validateMetadataDraft("view") — both spec shapes (objectui#3312)', () => { + it('accepts the ViewItem this admin authors', async () => { + const res = await validateMetadataDraft('view', VIEW_ITEM); + expect(res.issues, JSON.stringify(res.issues)).toEqual([]); + expect(res.ok).toBe(true); + }); + + it('accepts the aggregated container the backend still serves', async () => { + const res = await validateMetadataDraft('view', CONTAINER); + expect(res.issues, JSON.stringify(res.issues)).toEqual([]); + expect(res.ok).toBe(true); + }); + + it('still REJECTS a ViewItem whose own body is wrong', async () => { + // The whole point of dispatching: a ViewItem is now checked against + // `ViewItemSchema`, so a bad `config` is caught instead of stripped. Under + // the old container-only mapping this passed vacuously. + const res = await validateMetadataDraft('view', { + ...VIEW_ITEM, + config: { type: 'not_a_real_layout', columns: [] }, + }); + expect(res.ok).toBe(false); + expect(res.issues.length).toBeGreaterThan(0); + }); + + it('still REJECTS a container that carries an unknown key', async () => { + const res = await validateMetadataDraft('view', { + ...CONTAINER, + notAContainerKey: true, + }); + expect(res.ok).toBe(false); + expect(res.issues.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx b/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx index 40cff783b9..d4e876e8ce 100644 --- a/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx +++ b/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx @@ -37,6 +37,8 @@ type RunState = 'idle' | 'running' | 'done' | 'error' | 'unavailable'; * cell. `index_mismatch` and `unmapped_index` (framework#3728) were exactly * that: already emitted by the validate route, absent from the local copy of * the union, and therefore silently unlabelled here (objectstack#4115). + * `default_mismatch` (spec 17.0.0-rc.2) is the mechanism working as intended — + * it arrived with the pin bump and failed this build until labelled. */ const DIFF_LABEL: Record = { missing_table: 'Missing table', @@ -47,6 +49,7 @@ const DIFF_LABEL: Record = { pk_mismatch: 'Primary-key mismatch', index_mismatch: 'Index mismatch', unmapped_index: 'Unmapped index', + default_mismatch: 'Column default mismatch', }; export function ValidationPanel({ datasource }: ValidationPanelProps) { diff --git a/packages/app-shell/src/views/metadata-admin/previews/AppPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/AppPreview.test.tsx index b8ee6161b6..c67c290fda 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/AppPreview.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/AppPreview.test.tsx @@ -23,8 +23,10 @@ * the preview is therefore the link the runtime will follow — the preview * cannot drift from the shell because it is no longer guessing separately. * - * `homePageId` is a nav item's **id**, not a route, so it is rendered as an id - * resolved against the tree — never as a path. + * The landing entry is DERIVED (first navigation item that yields a route), + * never authored: spec 17.0.0 retired `homePageId` (objectstack#4667 / #4709), + * as it retired `landing` before it (objectstack#4001). Reading either back + * would show the author a landing page the runtime will not honour. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -38,7 +40,6 @@ const VALID_DRAFT = { name: 'crm', label: 'CRM', icon: 'briefcase', - homePageId: 'home', navigation: [ { id: 'home', type: 'page', label: 'Home', pageName: 'crm_welcome' }, { id: 'accounts', type: 'object', label: 'Accounts', objectName: 'account' }, @@ -126,31 +127,32 @@ describe('AppPreview reads the navigation discriminated union', () => { }); }); -describe('AppPreview renders homePageId as a nav item id', () => { - it('shows the id and the entry it selects', () => { +describe('AppPreview derives the landing entry from the navigation order', () => { + it('names the first navigation item that yields a route', () => { renderPreview(VALID_DRAFT); - const home = screen.getByText('home'); - expect(home).toBeTruthy(); + expect(screen.getByText(/opens the first navigation item/i)).toBeTruthy(); // Resolved to the entry's label — NOT rendered as a route. expect(screen.getByText('→ Home')).toBeTruthy(); }); - it('never renders homePageId as a path', () => { + it('never renders the landing entry as a path', () => { const { container } = renderPreview(VALID_DRAFT); expect(container.textContent).not.toContain('Landing: /home'); expect(container.textContent).not.toContain('/apps/crm/home'); }); - it('says so when the id matches no navigation item', () => { - renderPreview({ ...VALID_DRAFT, homePageId: 'nope_missing' }); - expect(screen.getByText(/no navigation item with this id/i)).toBeTruthy(); + it('ignores a retired `homePageId` instead of honouring it', () => { + // The runtime rejects the key outright (spec 17.0.0). A stored app that + // still carries it must NOT be previewed as landing there — that would + // promise a landing page the shell will not deliver. + renderPreview({ ...VALID_DRAFT, homePageId: 'orders' }); + expect(screen.getByText('→ Home')).toBeTruthy(); + expect(screen.queryByText('→ Sales Orders')).toBeNull(); }); - it('describes the real default when homePageId is absent', () => { - const noHome: Record = { ...VALID_DRAFT }; - delete noHome.homePageId; - renderPreview(noHome); - expect(screen.getByText(/opens the first navigation item/i)).toBeTruthy(); + it('says so when no navigation item yields a route', () => { + renderPreview({ name: 'crm', label: 'CRM', navigation: [] }); + expect(screen.getByText(/no navigation item yields a route/i)).toBeTruthy(); }); }); @@ -158,7 +160,10 @@ describe('AppPreview renders nothing from keys AppSchema rejects', () => { it('does not render the removed `landing` as a route', () => { renderPreview(STALE_DRAFT); expect(screen.queryByText('/apps/crm/home')).toBeNull(); - expect(screen.getByText(/opens the first navigation item/i)).toBeTruthy(); + // Every entry in STALE_DRAFT lacks `type`, so none of them yields a route + // and there is no landing entry to name — the point being that the stale + // `landing` value is not what fills the gap. + expect(screen.getByText(/no navigation item yields a route/i)).toBeTruthy(); }); it('does not infer a kind from `object` / `dashboard`', () => { diff --git a/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx index 0da0817b3e..0a5f18cf55 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx @@ -6,7 +6,7 @@ * admin would be confusing (nav-within-nav). * * Shows: - * • App label/icon + the landing nav item (`homePageId`) + * • App label/icon + the landing nav item (the first reachable entry) * • Top-level navigation items as a clickable list — each link opens * the runtime app in a new tab so authors can test the configured * nav without leaving the editor. @@ -169,14 +169,21 @@ function kindIcon(kind?: NavKind) { } } -/** Depth-first lookup of a nav item by `id` — how `homePageId` addresses one. */ -function findNavItem(items: NavItem[], id: string): NavItem | undefined { +/** + * The entry the app opens on: depth-first, the first item that addresses + * something inside the app. Mirrors `findFirstRoute` / `resolveLandingRoute` + * in `console/AppContent.tsx` — `group` recurses, and `url` / `separator` / + * `action` are skipped because none of them yields an in-app route. + */ +function findFirstLanding(items: NavItem[]): NavItem | undefined { for (const it of items) { - if (it.id === id) return it; - if (it.children) { - const hit = findNavItem(it.children, id); + if (it.kind === 'group') { + const hit = it.children ? findFirstLanding(it.children) : undefined; if (hit) return hit; + continue; } + if (it.kind === 'url' || it.kind === 'separator' || it.kind === 'action') continue; + if (it.href) return it; } return undefined; } @@ -184,12 +191,13 @@ function findNavItem(items: NavItem[], id: string): NavItem | undefined { export function AppPreview({ name, draft, editing, selection, onSelectionChange, onPatch }: MetadataPreviewProps) { const appName = String((draft as any).name ?? name ?? ''); const label = (draft as any).label ?? appName; - // `homePageId` is a nav item's **id**, not a route: it names which entry - // the app opens on (the shell resolves that entry's URL via resolveHref). - // The old `landingRoute ?? landing ?? defaultRoute ?? '/'` read three keys - // AppSchema has never declared — `landing` was removed outright in - // objectstack#4001 — so a valid app always showed the invented `Landing: /`. - const homePageId = typeof (draft as any).homePageId === 'string' ? (draft as any).homePageId : undefined; + // The landing page is DERIVED, never authored: it is the first navigation + // item that actually addresses something. The app used to be able to pin it + // with `homePageId`, but spec 17.0.0 retired that key (objectstack#4667 / + // #4709) — an ID cross-reference with no referential integrity, which fell + // back to the first item silently when it dangled. Before that it was + // `landing`, removed in objectstack#4001. Reading either one back here would + // show the author a landing page the runtime will not honour. const { rootKey, navItems } = React.useMemo<{ rootKey: string | null; navItems: NavItem[] }>(() => { const candidates: Array<[string, unknown]> = [ ['nav', (draft as any).nav], @@ -204,9 +212,11 @@ export function AppPreview({ name, draft, editing, selection, onSelectionChange, return { rootKey: null, navItems: [] }; }, [draft, appName]); - // Resolve the landing id against the tree so the author sees WHICH entry - // it selects — and, when it matches nothing, that it selects none. - const homeItem = homePageId ? findNavItem(navItems, homePageId) : undefined; + // Resolve the landing entry the same way `resolveLandingRoute` does in the + // console shell, so the author sees WHICH entry the app will open on: + // depth-first, first item that yields a route (`group` recurses; `url`, + // `separator` and `action` address nothing inside the app). + const homeItem = React.useMemo(() => findFirstLanding(navItems), [navItems]); // For Add we need a root key even when empty — default to `navigation`, // the only root key the spec (AppSchema) actually accepts; `nav` / @@ -245,19 +255,13 @@ export function AppPreview({ name, draft, editing, selection, onSelectionChange,
{String(label)}
{appName}
- {homePageId ? ( + {homeItem ? ( <> - Home: {homePageId} - {homeItem ? ( - → {homeItem.label} - ) : ( - - — no navigation item with this id - - )} + Home: opens the first navigation item + → {homeItem.label} ) : ( - <>Home: opens the first navigation item + <>Home: no navigation item yields a route yet )}
diff --git a/packages/auth/package.json b/packages/auth/package.json index 5d6cd37bd2..a71b902e5c 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "better-auth": "^1.6.25" }, "devDependencies": { diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json index f02ceab262..c8b31410fe 100644 --- a/packages/collaboration/package.json +++ b/packages/collaboration/package.json @@ -40,7 +40,7 @@ "@object-ui/types": "workspace:*" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@types/react": "19.2.18", "react": "19.2.8", "typescript": "^6.0.3", diff --git a/packages/components/package.json b/packages/components/package.json index 0ade0e5127..88cdada349 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -40,7 +40,7 @@ "@object-ui/react-runtime": "workspace:*", "@object-ui/sdui-parser": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-aspect-ratio": "^1.1.15", diff --git a/packages/core/package.json b/packages/core/package.json index ec1220e613..17d81fe0d4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,8 +32,8 @@ }, "dependencies": { "@object-ui/types": "workspace:*", - "@objectstack/formula": "^17.0.0-rc.1", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/formula": "^17.0.0-rc.2", + "@objectstack/spec": "^17.0.0-rc.2", "lodash": "^4.18.1", "zod": "^4.4.3" }, diff --git a/packages/core/src/actions/actionKeys.ts b/packages/core/src/actions/actionKeys.ts index 3522c3dfb4..968ed7460c 100644 --- a/packages/core/src/actions/actionKeys.ts +++ b/packages/core/src/actions/actionKeys.ts @@ -153,6 +153,17 @@ export const ACTION_DEF_KEYS = [ * `ActionComponent`. The pin test does the walk and fails the day this drifts. */ export const SPEC_ACTION_KEYS = [ + // The package-lock / provenance envelope, added to `ActionSchema` in spec + // 17.0.0-rc.2. Not authorable action vocabulary — it is stamped on a record + // that came from an installed package — but it IS declared by the schema, so + // an action carrying it must not be reported as having unknown keys. + '_lock', + '_lockDocsUrl', + '_lockReason', + '_lockSource', + '_packageId', + '_packageVersion', + '_provenance', 'ai', 'aria', 'body', diff --git a/packages/core/src/protocols/NotificationProtocol.ts b/packages/core/src/protocols/NotificationProtocol.ts deleted file mode 100644 index cb66247a17..0000000000 --- a/packages/core/src/protocols/NotificationProtocol.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * 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. - */ - -/** - * @object-ui/core - Notification Protocol Bridge - * - * Converts spec-aligned Notification schemas into toast-compatible - * objects that UI layers can render. Maps severity to variant, - * position names, and resolves default notification configs. - * - * @module protocols/NotificationProtocol - * @packageDocumentation - */ - -import type { - Notification as SpecNotification, - NotificationAction, - NotificationConfig, - NotificationPosition, - NotificationSeverity, -} from '@object-ui/types'; - -// ============================================================================ -// Resolved Types -// ============================================================================ - -/** Fully resolved notification configuration. */ -export interface ResolvedNotificationConfig { - defaultPosition: NotificationPosition; - defaultDuration: number; - maxVisible: number; - stackDirection: 'up' | 'down'; - pauseOnHover: boolean; -} - -/** Toast-compatible representation of a spec Notification. */ -export interface ToastNotification { - title?: string; - description: string; - variant: string; - position: string; - duration: number; - dismissible: boolean; - actions: ToastAction[]; -} - -/** Toast-compatible action button. */ -export interface ToastAction { - label: string; - action: string; - variant: 'primary' | 'secondary' | 'link'; -} - -// ============================================================================ -// Severity → Variant Mapping -// ============================================================================ - -const SEVERITY_TO_VARIANT: Record = { - info: 'default', - success: 'success', - warning: 'warning', - error: 'destructive', -}; - -/** - * Map a spec notification severity to a toast variant string. - * - * @param severity - Spec severity (info, success, warning, error) - * @returns Toast variant (default, success, warning, destructive) - */ -export function mapSeverityToVariant(severity: string): string { - return SEVERITY_TO_VARIANT[severity] ?? 'default'; -} - -// ============================================================================ -// Position Mapping -// ============================================================================ - -const POSITION_MAP: Record = { - top_left: 'top-left', - top_center: 'top-center', - top_right: 'top-right', - bottom_left: 'bottom-left', - bottom_center: 'bottom-center', - bottom_right: 'bottom-right', -}; - -/** - * Map a spec notification position (underscore-separated) to a - * toast position string (hyphen-separated). - * - * @param position - Spec position (e.g. "top_right") - * @returns Toast position (e.g. "top-right") - */ -export function mapPosition(position: string): string { - return POSITION_MAP[position] ?? 'top-right'; -} - -// ============================================================================ -// Notification Config Resolution -// ============================================================================ - -/** - * Resolve a notification configuration by applying spec defaults. - * - * @param config - NotificationConfig from the spec - * @returns Fully resolved notification configuration - */ -export function resolveNotificationConfig(config: NotificationConfig): ResolvedNotificationConfig { - return { - defaultPosition: config.defaultPosition ?? 'top_right', - defaultDuration: config.defaultDuration ?? 5000, - maxVisible: config.maxVisible ?? 5, - stackDirection: config.stackDirection ?? 'down', - pauseOnHover: config.pauseOnHover ?? true, - }; -} - -// ============================================================================ -// Spec Notification → Toast -// ============================================================================ - -/** - * Convert a spec Notification to a toast-compatible object. - * - * @param notification - Spec Notification - * @returns Toast-compatible notification with title, description, variant, etc. - */ -export function specNotificationToToast(notification: SpecNotification): ToastNotification { - const actions: ToastAction[] = (notification.actions ?? []).map((a: NotificationAction) => ({ - label: a.label, - action: a.action, - variant: a.variant ?? 'primary', - })); - - return { - title: notification.title, - description: notification.message ?? '', - variant: mapSeverityToVariant(notification.severity ?? 'info'), - position: mapPosition(notification.position ?? 'top_right'), - duration: notification.duration ?? 5000, - dismissible: notification.dismissible ?? true, - actions, - }; -} diff --git a/packages/core/src/protocols/index.ts b/packages/core/src/protocols/index.ts index 061bc8503c..86b2596b26 100644 --- a/packages/core/src/protocols/index.ts +++ b/packages/core/src/protocols/index.ts @@ -8,6 +8,13 @@ export * from './DndProtocol.js'; export * from './KeyboardProtocol.js'; -export * from './NotificationProtocol.js'; +// `NotificationProtocol` removed in the 17.0.0-rc.2 uptake: it bridged +// `@objectstack/spec/ui`'s `Notification` / `NotificationConfig`, which +// objectstack#4610 deleted with no successor. The bridge had zero consumers in +// this repo — the live notification config resolution is +// `resolveNotificationConfig` in `@object-ui/react`'s `NotificationContext`, +// which declares its own `NotificationSystemConfig` and is what every surface +// reads. Re-typing the bridge would have meant re-declaring a vocabulary the +// spec had just retired (AGENTS.md #0.1). export * from './ResponsiveProtocol.js'; export * from './SharingProtocol.js'; diff --git a/packages/core/src/utils/managedBy.test.ts b/packages/core/src/utils/managedBy.test.ts index 7d83578475..bf9c607a0f 100644 --- a/packages/core/src/utils/managedBy.test.ts +++ b/packages/core/src/utils/managedBy.test.ts @@ -31,8 +31,13 @@ describe('resolveEffectiveCrudAffordances — bucket half, delegated to the spec } }); - it('userActions overrides the bucket default (ADR-0103 writable system)', () => { - const aff = resolveEffectiveCrudAffordances({ managedBy: 'system', userActions: { create: true, edit: true, delete: true } }); + it('userActions overrides the bucket default (ADR-0103 opened-up locked bucket)', () => { + // Was `managedBy: 'system'`. Protocol 17 split that bucket + // (objectstack#3355) and `'system'` is now simply an unknown value, which + // falls back to the writable platform default — so it pinned nothing and + // `import: false` was the assertion that noticed. `engine-owned` is the + // locked bucket this was always about. + const aff = resolveEffectiveCrudAffordances({ managedBy: 'engine-owned', userActions: { create: true, edit: true, delete: true } }); expect(aff).toMatchObject({ create: true, edit: true, delete: true, import: false }); }); @@ -200,10 +205,10 @@ describe('resolveEffectiveCrudAffordances — effective API operations (#3391)', describe('isObjectInlineEditable — effective API operations (#3546)', () => { it('undefined effective set → bucket affordance decides (backward-compatible)', () => { - // platform is inline-editable by default; system is not. + // platform is inline-editable by default; engine-owned is not. expect(isObjectInlineEditable({ managedBy: 'platform' })).toBe(true); expect(isObjectInlineEditable({ managedBy: 'platform' }, undefined)).toBe(true); - expect(isObjectInlineEditable({ managedBy: 'system' })).toBe(false); + expect(isObjectInlineEditable({ managedBy: 'engine-owned' })).toBe(false); }); it('effective set WITHOUT `update` closes inline-edit even on an editable bucket', () => { @@ -216,8 +221,9 @@ describe('isObjectInlineEditable — effective API operations (#3546)', () => { }); it('effective set never re-opens inline-edit the bucket already denied (intersection)', () => { - // system resolves edit=false; even a server `update` grant can't re-open it. - expect(isObjectInlineEditable({ managedBy: 'system' }, ['get', 'update'])).toBe(false); + // engine-owned resolves edit=false; even a server `update` grant can't + // re-open it. (Was `'system'`, retired in protocol 17 — objectstack#3355.) + expect(isObjectInlineEditable({ managedBy: 'engine-owned' }, ['get', 'update'])).toBe(false); }); it('empty effective set → not inline-editable (deny-all)', () => { diff --git a/packages/data-objectstack/package.json b/packages/data-objectstack/package.json index d27baebe7e..12a64af949 100644 --- a/packages/data-objectstack/package.json +++ b/packages/data-objectstack/package.json @@ -32,8 +32,8 @@ "dependencies": { "@object-ui/core": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/client": "^17.0.0-rc.1", - "@objectstack/spec": "^17.0.0-rc.1" + "@objectstack/client": "^17.0.0-rc.2", + "@objectstack/spec": "^17.0.0-rc.2" }, "devDependencies": { "tsup": "^8.5.1", diff --git a/packages/data-objectstack/src/spec-symbol-batch6.test.ts b/packages/data-objectstack/src/spec-symbol-batch6.test.ts index d31ed51ae0..ffb9a1ef14 100644 --- a/packages/data-objectstack/src/spec-symbol-batch6.test.ts +++ b/packages/data-objectstack/src/spec-symbol-batch6.test.ts @@ -57,10 +57,13 @@ import type { DroppedFieldsEvent } from './index'; import type { DroppedFieldsEvent as SpecDroppedFieldsEvent } from '@objectstack/spec/data'; import type { CacheStats as SpecCacheStats } from '@objectstack/spec/contracts'; import type { - MetadataSaveOptions as SpecMetadataSaveOptions, SecurityPolicy as SpecSecurityPolicy, ValidationError as SpecValidationError, } from '@objectstack/spec/kernel'; +// `MetadataSaveOptions` moved `./kernel` → `./system` in spec 17.0.0-rc.2. +// The name still belongs to the spec and still means the FILE writer, so the +// rename this batch recorded stands — only the entry point moved. +import type { MetadataSaveOptions as SpecMetadataSaveOptions } from '@objectstack/spec/system'; const PROBE_OPTIONS: ts.CompilerOptions = { noEmit: true, diff --git a/packages/fields/package.json b/packages/fields/package.json index 6082dac153..ced8985146 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -37,7 +37,7 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "clsx": "^2.1.1", "lucide-react": "^1.25.0", "react-markdown": "^10.1.0", diff --git a/packages/layout/package.json b/packages/layout/package.json index 9b9a9711ef..eda964fb72 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -38,7 +38,7 @@ "react-router-dom": "^6.0.0 || ^7.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@vitejs/plugin-react": "^6.0.5", "react-router-dom": "^7.18.2", "vite": "^8.2.0", diff --git a/packages/layout/src/AppSchemaRenderer.tsx b/packages/layout/src/AppSchemaRenderer.tsx index 79eb8eb94c..416c0f6619 100644 --- a/packages/layout/src/AppSchemaRenderer.tsx +++ b/packages/layout/src/AppSchemaRenderer.tsx @@ -120,27 +120,29 @@ export interface AppSchemaRendererProps { // AreaSwitcher // --------------------------------------------------------------------------- +/** + * Areas are NOT gated here any more. `@objectstack/spec` 17.0.0 retired + * `visible` and `requiredPermissions` at area level + * (`AREA_VISIBLE_RETIRED` / `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a + * layout grouping, not an access boundary, so gating belongs on the navigation + * ITEM — which `NavigationRenderer` still enforces, via the same `evalVis` / + * `checkPerm` this component used to apply one level up. The spec's area object + * is `.strict()`, so no v17-valid app can carry the retired keys and this + * filter had become unreachable for every app the platform accepts. + * + * Consequence worth knowing: an area whose items are all gated away now renders + * as a visible-but-empty area rather than disappearing from the switcher. + */ function AreaSwitcher({ areas, activeAreaId, onAreaChange, - evalVis, - checkPerm, }: { areas: NavigationArea[]; activeAreaId: string; onAreaChange: (id: string) => void; - evalVis: VisibilityEvaluator; - checkPerm: PermissionChecker; }) { - // Filter areas by visibility & permissions - const visibleAreas = areas.filter((a) => { - if (!evalVis(a.visible)) return false; - if (a.requiredPermissions?.length && !checkPerm(a.requiredPermissions)) return false; - return true; - }); - - if (visibleAreas.length <= 1) return null; + if (areas.length <= 1) return null; return ( @@ -150,7 +152,7 @@ function AreaSwitcher({ - {visibleAreas.map((area) => { + {areas.map((area) => { const AreaIcon = resolveIcon(area.icon); return ( @@ -353,8 +355,6 @@ function InternalSidebar({ areas={areas} activeAreaId={activeAreaId} onAreaChange={setActiveAreaId} - evalVis={evalVis} - checkPerm={checkPerm} /> )} diff --git a/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx b/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx index aed3a73f8c..2c28af144e 100644 --- a/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx +++ b/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx @@ -184,42 +184,63 @@ describe('AppSchemaRenderer', () => { expect(screen.getByText('Cases')).toBeTruthy(); }); - // --- Area visibility and permissions --- - - it('hides areas that fail visibility check', () => { - const schemaWithHiddenArea: AppComponentSchema = { + // --- Area gating moved to the navigation ITEM (spec 17.0.0) --- + // + // `visible` / `requiredPermissions` were retired at AREA level + // (`AREA_VISIBLE_RETIRED` / `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a + // layout grouping, not an access boundary. These two tests used to assert the + // area itself was hidden; they now assert the capability still exists one + // level down, which is where the spec moved it. Losing the gate entirely — + // rather than relocating it — is the regression worth catching, so the + // item-level assertions below are deliberately the same scenarios. + + // NB: the gated item must live in the FIRST area — that is the one the + // switcher activates by default, so it is the only area whose navigation is + // actually rendered. Gating an item in a non-active area asserts nothing: + // it is absent either way. + + it('gates the navigation ITEM by visibility, not the area', () => { + const schema: AppComponentSchema = { type: 'app', name: 'crm', title: 'CRM', areas: [ - salesArea, - { ...serviceArea, visible: false }, + { + ...salesArea, + navigation: [{ ...salesArea.navigation[0], visible: false }], + }, + serviceArea, ], }; - renderApp(schemaWithHiddenArea, { - evaluateVisibility: (expr) => { - if (expr === false) return false; - return true; - }, + renderApp(schema, { + evaluateVisibility: (expr) => expr !== false, }); - // Area switcher should not show (only 1 visible area) - expect(screen.queryByText('Service')).toBeNull(); + // The area still appears in the switcher… + expect(screen.getByText('Sales')).toBeTruthy(); + // …but the item it gates does not render. + expect(screen.queryByText('Opportunities')).toBeNull(); }); - it('hides areas that fail permission check', () => { - const schemaWithPermArea: AppComponentSchema = { + it('gates the navigation ITEM by permission, not the area', () => { + const schema: AppComponentSchema = { type: 'app', name: 'crm', title: 'CRM', areas: [ - salesArea, - { ...serviceArea, requiredPermissions: ['service:admin'] }, + { + ...salesArea, + navigation: [ + { ...salesArea.navigation[0], requiredPermissions: ['sales:admin'] }, + ], + }, + serviceArea, ], }; - renderApp(schemaWithPermArea, { - checkPermission: (perms) => !perms.includes('service:admin'), + renderApp(schema, { + checkPermission: (perms) => !perms.includes('sales:admin'), }); - expect(screen.queryByText('Service')).toBeNull(); + expect(screen.getByText('Sales')).toBeTruthy(); + expect(screen.queryByText('Opportunities')).toBeNull(); }); // --- Mobile bottom_nav mode --- diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 6bd424e3ce..496926bd55 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -40,7 +40,7 @@ "@object-ui/types": "workspace:*" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@types/react": "19.2.18", "react": "19.2.8", "typescript": "^6.0.3", diff --git a/packages/plugin-charts/package.json b/packages/plugin-charts/package.json index a8ff347ab8..5f96568390 100644 --- a/packages/plugin-charts/package.json +++ b/packages/plugin-charts/package.json @@ -44,7 +44,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-chatbot/package.json b/packages/plugin-chatbot/package.json index ba66517208..ecdf7fd22e 100644 --- a/packages/plugin-chatbot/package.json +++ b/packages/plugin-chatbot/package.json @@ -38,7 +38,7 @@ "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", "@radix-ui/react-slot": "^1.3.3", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@radix-ui/react-use-controllable-state": "^1.2.6", "ai": "^7.0.44", "class-variance-authority": "^0.7.1", diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json index 424763d574..83686110f9 100644 --- a/packages/plugin-dashboard/package.json +++ b/packages/plugin-dashboard/package.json @@ -42,7 +42,7 @@ "react-grid-layout": "^2.2.0 || ^1.4.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@types/react-grid-layout": "^2.1.0", "@vitejs/plugin-react": "^6.0.5", "react-grid-layout": "^2.2.4", diff --git a/packages/plugin-detail/package.json b/packages/plugin-detail/package.json index f5e8b0ddf9..8e622d139c 100644 --- a/packages/plugin-detail/package.json +++ b/packages/plugin-detail/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@object-ui/i18n": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "lucide-react": "^1.25.0" }, "peerDependencies": { diff --git a/packages/plugin-form/package.json b/packages/plugin-form/package.json index 960add3478..f7c065cf1f 100644 --- a/packages/plugin-form/package.json +++ b/packages/plugin-form/package.json @@ -28,7 +28,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "lucide-react": "^1.25.0" }, "peerDependencies": { diff --git a/packages/plugin-form/src/ObjectForm.effectiveOps.test.tsx b/packages/plugin-form/src/ObjectForm.effectiveOps.test.tsx index 04535e2f4b..fd06aa501b 100644 --- a/packages/plugin-form/src/ObjectForm.effectiveOps.test.tsx +++ b/packages/plugin-form/src/ObjectForm.effectiveOps.test.tsx @@ -136,8 +136,10 @@ describe('ObjectForm — blanket lock vs effective API operations (#3546)', () = }); it('bucket-locked object stays locked even when the server allows `update`', async () => { - // Intersection, never union. - const engineOwned = { name: 'sys_automation_run', managedBy: 'system', fields: textField }; + // Intersection, never union. The bucket is `engine-owned`: protocol 17 + // split the old `'system'` (objectstack#3355), which now resolves to the + // default-writable fallback and would lock nothing. + const engineOwned = { name: 'sys_automation_run', managedBy: 'engine-owned', fields: textField }; state.effectiveOps = ['get', 'list', 'create', 'update', 'delete']; expect(await nameInputDisabled(engineOwned, 'edit')).toBe(true); }); diff --git a/packages/plugin-form/src/ObjectForm.managedEdit.test.tsx b/packages/plugin-form/src/ObjectForm.managedEdit.test.tsx index 86500b72a5..84b0a94f6b 100644 --- a/packages/plugin-form/src/ObjectForm.managedEdit.test.tsx +++ b/packages/plugin-form/src/ObjectForm.managedEdit.test.tsx @@ -106,10 +106,13 @@ describe('ObjectForm — managed-object edit affordance (ADR-0092 D4)', () => { // engine-owned system object stays locked, while one that opened // `userActions.create` (e.g. Notification Preferences) unlocks. it('create mode: engine-owned system locked, userActions.create unlocks', async () => { - const locked = renderCreate({ name: 'sys_automation_run', managedBy: 'system', fields: { name: { type: 'text', label: 'Name', readonly: false } } }); + // `engine-owned`, not the residual `'system'`: protocol 17 split that + // bucket (objectstack#3355), so `'system'` now falls through to the + // default-writable branch and pins nothing. + const locked = renderCreate({ name: 'sys_automation_run', managedBy: 'engine-owned', fields: { name: { type: 'text', label: 'Name', readonly: false } } }); expect((await inputByName(locked.container, 'name')).disabled).toBe(true); - const open = renderCreate({ name: 'sys_notification_preference', managedBy: 'system', userActions: { create: true }, fields: { name: { type: 'text', label: 'Name', readonly: false } } }); + const open = renderCreate({ name: 'sys_notification_preference', managedBy: 'engine-owned', userActions: { create: true }, fields: { name: { type: 'text', label: 'Name', readonly: false } } }); expect((await inputByName(open.container, 'name')).disabled).toBe(false); }); }); diff --git a/packages/plugin-gantt/package.json b/packages/plugin-gantt/package.json index 6ef2b719e7..c3e88e0324 100644 --- a/packages/plugin-gantt/package.json +++ b/packages/plugin-gantt/package.json @@ -38,7 +38,7 @@ "@object-ui/plugin-detail": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "lucide-react": "^1.25.0", "sonner": "^2.0.7" }, diff --git a/packages/plugin-grid/package.json b/packages/plugin-grid/package.json index 39a890c66b..b102aa9081 100644 --- a/packages/plugin-grid/package.json +++ b/packages/plugin-grid/package.json @@ -29,7 +29,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@tanstack/react-virtual": "^3.14.9", "exceljs": "^4.4.0", "lucide-react": "^1.25.0" @@ -40,7 +40,7 @@ }, "devDependencies": { "@object-ui/data-objectstack": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@vitejs/plugin-react": "^6.0.5", "msw": "^2.15.0", "typescript": "^6.0.3", diff --git a/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts b/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts index ee7fa07777..6bb671b4c1 100644 --- a/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts +++ b/packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts @@ -160,7 +160,11 @@ describe('resolveRowCrudAffordances', () => { .toEqual({ canEdit: true, canDelete: true }); }); - it.each(['system', 'engine-owned', 'append-only', 'better-auth'])( + // `'system'` is gone from this list: protocol 17 split that bucket + // (objectstack#3355) into `engine-owned` (locked) and `system-data` + // (writable), so the old value now resolves to the default-writable + // fallback and asserting it is locked pins nothing. + it.each(['engine-owned', 'append-only', 'better-auth'])( 'engine-owned bucket %s hides generic edit/delete', (managedBy) => { expect(rowGate({ ...wired, managedBy })).toEqual({ canEdit: false, canDelete: false }); diff --git a/packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx b/packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx index e5968ea86b..498a8528e6 100644 --- a/packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx +++ b/packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx @@ -190,7 +190,12 @@ describe('ObjectGrid row CRUD vs the effective API operation set (#3720)', () => describe('ObjectGrid row CRUD vs the ADR-0103 bucket lock (#3720)', () => { it('engine-owned buckets no longer leak generic row Edit/Delete', async () => { - expect(await rowKebab({ managedBy: 'system' })).toEqual({ edit: false, delete: false }); + // `engine-owned`, NOT the residual `'system'` this used to pass. Protocol + // 17 split that bucket (objectstack#3355): the engine-owned objects moved + // to `engine-owned` and the admin/user-writable half became `system-data`, + // so `'system'` is no longer a bucket at all and now resolves to the + // default-writable fallback. The lock this pins lives on `engine-owned`. + expect(await rowKebab({ managedBy: 'engine-owned' })).toEqual({ edit: false, delete: false }); cleanup(); expect(await rowKebab({ managedBy: 'append-only' })).toEqual({ edit: false, delete: false }); }); diff --git a/packages/plugin-list/package.json b/packages/plugin-list/package.json index 5a74a0c6da..bd52f84c91 100644 --- a/packages/plugin-list/package.json +++ b/packages/plugin-list/package.json @@ -42,7 +42,7 @@ "@object-ui/permissions": "workspace:^", "@object-ui/react": "workspace:^", "@object-ui/types": "workspace:^", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, @@ -55,7 +55,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-map/package.json b/packages/plugin-map/package.json index 2aeaec221e..1964c9ffd7 100644 --- a/packages/plugin-map/package.json +++ b/packages/plugin-map/package.json @@ -35,7 +35,7 @@ "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "lucide-react": "^1.25.0", "maplibre-gl": "^6.1.0", "react-map-gl": "^8.1.2", diff --git a/packages/plugin-report/package.json b/packages/plugin-report/package.json index c20f14a75c..c6c3288b6f 100644 --- a/packages/plugin-report/package.json +++ b/packages/plugin-report/package.json @@ -43,7 +43,7 @@ "react-dom": "^18.0.0" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@types/node": "^26.1.2", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", diff --git a/packages/plugin-timeline/package.json b/packages/plugin-timeline/package.json index 8bced11a49..f3c6e0ea82 100644 --- a/packages/plugin-timeline/package.json +++ b/packages/plugin-timeline/package.json @@ -37,7 +37,7 @@ "@object-ui/mobile": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "class-variance-authority": "^0.7.1", "zod": "^4.4.3" }, diff --git a/packages/plugin-tree/package.json b/packages/plugin-tree/package.json index e5509561d5..ff3b4080b9 100644 --- a/packages/plugin-tree/package.json +++ b/packages/plugin-tree/package.json @@ -35,7 +35,7 @@ "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "lucide-react": "^1.25.0" }, "peerDependencies": { diff --git a/packages/plugin-view/package.json b/packages/plugin-view/package.json index cbd5ae3c27..6ffbd4866d 100644 --- a/packages/plugin-view/package.json +++ b/packages/plugin-view/package.json @@ -31,7 +31,7 @@ "@object-ui/plugin-grid": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "class-variance-authority": "^0.7.1", "lucide-react": "^1.25.0" }, diff --git a/packages/providers/package.json b/packages/providers/package.json index d8b389e0ab..c1b287079d 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1" + "@objectstack/spec": "^17.0.0-rc.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/react/package.json b/packages/react/package.json index 6c25f30315..1fee3a1266 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,7 +34,7 @@ "@object-ui/data-objectstack": "workspace:*", "@object-ui/i18n": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "react-hook-form": "^7.83.0" }, "peerDependencies": { diff --git a/packages/runner/package.json b/packages/runner/package.json index 50c8ff6e37..2c1a48e750 100644 --- a/packages/runner/package.json +++ b/packages/runner/package.json @@ -37,7 +37,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "@tailwindcss/postcss": "^4.3.3", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index ad64541c05..7220c54017 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -33,6 +33,6 @@ "devDependencies": { "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", - "@objectstack/spec": "^17.0.0-rc.1" + "@objectstack/spec": "^17.0.0-rc.2" } } diff --git a/packages/types/package.json b/packages/types/package.json index fe69f4e7a3..4f0205e895 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -90,7 +90,7 @@ "directory": "packages/types" }, "dependencies": { - "@objectstack/spec": "^17.0.0-rc.1", + "@objectstack/spec": "^17.0.0-rc.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/types/src/__tests__/navigation-model.test.ts b/packages/types/src/__tests__/navigation-model.test.ts index f3e6518a7b..9411656575 100644 --- a/packages/types/src/__tests__/navigation-model.test.ts +++ b/packages/types/src/__tests__/navigation-model.test.ts @@ -413,10 +413,18 @@ describe('Type exports', () => { label: 'Sales', icon: 'DollarSign', navigation: [ - { id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' }, + // Gating moved to the ITEM in spec 17.0.0: `visible` and + // `requiredPermissions` were retired at AREA level, since an area is a + // layout grouping and not an access boundary. + { + id: 'nav_leads', + type: 'object', + label: 'Leads', + objectName: 'lead', + visible: true, + requiredPermissions: ['sales:access'], + }, ], - visible: true, - requiredPermissions: ['sales:access'], }; expect(area.id).toBe('sales'); expect(area.navigation).toHaveLength(1); diff --git a/packages/types/src/__tests__/navigation-spec-parity.test.ts b/packages/types/src/__tests__/navigation-spec-parity.test.ts index c581fa21ad..e8f344d7e2 100644 --- a/packages/types/src/__tests__/navigation-spec-parity.test.ts +++ b/packages/types/src/__tests__/navigation-spec-parity.test.ts @@ -100,16 +100,17 @@ describe('NavigationItemSchema keeps the spec vocabulary it used to drop', () => }); describe('NavigationAreaSchema keeps the spec fields it used to drop', () => { - it('keeps order and description', () => { + it('keeps description', () => { + // `order` was asserted here too until spec 17.0.0 retired it at AREA level + // (with `visible` and `requiredPermissions`); `description` is the one of + // the pair from objectui#3088 that is still a spec key. const r = NavigationAreaSchema.safeParse({ id: 'sales', label: 'Sales', - order: 2, description: 'Pipeline and accounts', navigation: [], }); expect(r.success).toBe(true); - expect(r.success && r.data.order).toBe(2); expect(r.success && r.data.description).toBe('Pipeline and accounts'); }); }); diff --git a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts index 2989191600..370c41ffe7 100644 --- a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts @@ -136,11 +136,29 @@ describe('spec-only keys are now VALIDATED, not passed through (objectstack#4115 it('App declares the package-lock and access keys that used to ride through', () => { const keys = Object.keys(shapeOf(OuiAppSchema)); - for (const key of ['branding', 'sharing', 'embed', 'objects', 'apis', 'requiredPermissions', 'homePageId', '_packageId']) { + // `homePageId` was in this list until spec 17.0.0 retired it; it is now a + // tombstone rather than an authorable key, so it is pinned separately + // below — listing it here would read as "still authorable". + for (const key of ['branding', 'sharing', 'embed', 'objects', 'apis', 'requiredPermissions', '_packageId']) { expect(keys, `'${key}' is still undeclared`).toContain(key); } }); + it('App REJECTS the retired `homePageId` rather than stripping it', () => { + // objectstack#4667 / #4709 retired the key: it was an ID cross-reference + // with no referential integrity, so a dangling id silently fell back to the + // first nav item. The landing page is now the first reachable nav item (by + // `order`), and the root landing follows `isDefault`. + // + // Rejection, not stripping, is the whole point — objectui's derivation is + // strip-mode, so if the tombstone ever stopped flowing in by reference an + // author would get their landing-page intent silently deleted instead of an + // error naming the migration. That is the failure this pins. + const rejected = OuiAppSchema.safeParse({ type: 'app', name: 'app_x', homePageId: 'home' }); + expect(rejected.success, '`homePageId` is being accepted again').toBe(false); + expect(OuiAppSchema.safeParse({ type: 'app', name: 'app_x' }).success).toBe(true); + }); + it('App validates branding instead of accepting any shape', () => { expect(OuiAppSchema.safeParse({ type: 'app', branding: { primaryColor: '#fff' } }).success).toBe(true); // A wrong-typed branding used to be indistinguishable from a correct one. diff --git a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts index 4caf0a6bb0..d899a602a3 100644 --- a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts @@ -88,7 +88,9 @@ describe('NavigationAreaSchema derives from the spec', () => { // One `.unwrap()` deep: `specFieldsExcept` ends in `.partial()`, which wraps // each carried field in a fresh `ZodOptional` around the spec's own object. // That wrapper is the only new allocation — what it holds must be identical. - for (const key of ['icon', 'order', 'description', 'requiredPermissions'] as const) { + // `order` and `requiredPermissions` were in this list until spec 17.0.0 + // retired them at AREA level (see the retirement block below). + for (const key of ['icon', 'description'] as const) { const local = shapeOf(NavigationAreaSchema)[key] as { unwrap(): unknown }; expect( local.unwrap(), @@ -97,17 +99,17 @@ describe('NavigationAreaSchema derives from the spec', () => { } }); - it('keeps `order` and `description`, the two keys the hand copy dropped', () => { + it('keeps `description`, one of the two keys the hand copy dropped', () => { // objectui#3088: an area authored with a sort weight or a description lost - // both — `objectui validate` is strip-mode, so the loss was silent. + // both — `objectui validate` is strip-mode, so the loss was silent. `order` + // was the other one; spec 17.0.0 retired it at area level, so only + // `description` is still a spec key this can be asserted about. const parsed = NavigationAreaSchema.parse({ id: 'area_sales', label: 'Sales', - order: 1, description: 'Pipeline and quotes', navigation: [{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' }], }); - expect(parsed.order).toBe(1); expect(parsed.description).toBe('Pipeline and quotes'); }); @@ -120,18 +122,50 @@ describe('NavigationAreaSchema derives from the spec', () => { expect(NavigationAreaSchema.safeParse({ id: 'area_a', navigation: [] }).success).toBe(false); }); - it('keeps the bare-predicate `visible` wire contract (input AND output)', () => { - // The spec pipes `visible` through ExpressionInput and emits an - // `{ dialect, source }` envelope. objectui's renderers read the bare - // predicate, so comparing `_output` alone would miss the divergence that - // matters here — the guard header's `_input` rule. - const bool = NavigationAreaSchema.parse({ id: 'area_a', label: 'A', navigation: [], visible: true }); - expect(bool.visible).toBe(true); - const cel = NavigationAreaSchema.parse({ - id: 'area_a', label: 'A', navigation: [], visible: 'user.isAdmin', + it('follows the AREA-level retirement of `visible` / `order` / `requiredPermissions`', () => { + // Spec 17.0.0 (`AREA_VISIBLE_RETIRED` / `AREA_REQUIRED_PERMISSIONS_RETIRED`) + // removed all three from `NavigationAreaSchema`: an area is a layout + // grouping, not an access boundary, so gating belongs on the navigation + // ITEM (`visible` / `requiredPermissions` — both still there) or on the app. + // + // This block replaces the old "keeps the bare-predicate `visible` wire + // contract" pin. That pin existed because objectui re-typed the spec's + // `visible`; with the spec key gone there is nothing to re-type, and + // re-adding it locally would be a FORK — objectui would accept areas the + // platform's `.strict()` schema rejects. So the assertion is inverted: the + // three names must be absent from both sides, and a local re-add fails here. + for (const key of ['visible', 'order', 'requiredPermissions'] as const) { + expect( + Object.keys(shapeOf(SpecNavigationAreaSchema)), + `spec re-added area-level '${key}' — re-check the retirement`, + ).not.toContain(key); + expect( + Object.keys(shapeOf(NavigationAreaSchema)), + `'${key}' was re-added locally; gate the nav ITEM or the app instead`, + ).not.toContain(key); + } + }); + + it('gates on the navigation ITEM, which is where the spec kept the keys', () => { + // The other half of the retirement: what moved is the LAYER, not the + // capability. If these ever stop parsing, the retirement lost its migration + // path and areas have no gating story at all. + const parsed = NavigationAreaSchema.parse({ + id: 'area_a', + label: 'A', + navigation: [ + { + id: 'nav_forecast', + type: 'object', + label: 'Forecast', + objectName: 'forecast', + visible: 'user.isAdmin', + requiredPermissions: ['sales.admin'], + }, + ], }); - expect(cel.visible).toBe('user.isAdmin'); - expect(typeof cel.visible).toBe('string'); + expect(parsed.navigation[0].visible).toBe('user.isAdmin'); + expect(parsed.navigation[0].requiredPermissions).toEqual(['sales.admin']); }); it('validates navigation items, which inheriting the spec key would not', () => { @@ -153,16 +187,29 @@ describe('NavigationAreaSchema derives from the spec', () => { describe('NavigationArea derives from the spec', () => { it('inherits the spec keys', () => { + // `order` / `requiredPermissions` / `visible` were here until spec 17.0.0 + // retired them at area level; the zod block above pins their absence. const area: NavigationArea = { id: 'area_sales', label: 'Sales', icon: 'briefcase', - order: 1, description: 'Pipeline and quotes', - requiredPermissions: ['sales.read'], navigation: [], }; - expect(area.order).toBe(1); + expect(area.description).toBe('Pipeline and quotes'); + }); + + it('does NOT re-add the retired area-level keys on the TS side either', () => { + const area: NavigationArea = { id: 'area_sales', label: 'Sales', navigation: [] }; + // @ts-expect-error `visible` was retired at AREA level in spec 17.0.0 — + // gate the navigation item or the app instead. + area.visible = true; + // @ts-expect-error `order` was retired at AREA level in spec 17.0.0. + area.order = 1; + // @ts-expect-error `requiredPermissions` was retired at AREA level in + // spec 17.0.0 — an area is a layout grouping, not an access boundary. + area.requiredPermissions = ['sales.read']; + expect(area.id).toBe('area_sales'); }); it('holds objectui navigation items, which the spec type cannot express', () => { diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index d3dbd2b34f..b00e1ecdf1 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -73,9 +73,12 @@ describe('AppContextSelectorSchema derives from the spec', () => { }); expect(parsed.optionsSource.valueKey).toBe('id'); expect(parsed.optionsSource.labelKey).toBe('name'); - expect(parsed.includeAll).toBe(true); expect(parsed.persist).toBe('query'); - expect(parsed.placement).toBe('sidebar_header'); + // `includeAll` and `placement` were asserted here until spec 17.0.0 + // removed them (framework#4509 / objectui#3208). Both carried schema + // defaults, which is exactly why the liveness lint could not flag them: + // a materialised default is indistinguishable from an authored value. + // Removal was the only channel that reaches the author. }); }); diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 04beccf12b..569c6f82d8 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -287,12 +287,17 @@ export interface NavigationItem { * Sales, Service, Marketing). * * DERIVED from `@objectstack/spec/ui` (objectstack#4115): `id`, `label`, - * `icon`, `order`, `description` and `requiredPermissions` flow in **by - * reference**, so a key the spec adds or retypes cannot silently diverge here. - * The hand copy this replaces had already lost `order` and `description` once - * (objectui#3088). + * `icon` and `description` flow in **by reference**, so a key the spec adds or + * retypes cannot silently diverge here. The hand copy this replaces had already + * lost `order` and `description` once (objectui#3088). * - * Two keys are pinned locally, each for a reason that outlives a spec release: + * `order`, `visible` and `requiredPermissions` were area-level keys until spec + * 17.0.0 retired them (`AREA_VISIBLE_RETIRED` / + * `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a layout grouping, not an + * access boundary — gate the navigation ITEM or the app instead. No objectui + * renderer ever read them at area level, so dropping them costs no behaviour. + * + * One key is pinned locally, for a reason that outlives a spec release: * * - `navigation` — objectui's own {@link NavigationItem}, not the spec's. * Spec 17.0.0-rc.1 gave the spec's item a real type, so this is no longer @@ -303,20 +308,12 @@ export interface NavigationItem { * header, and the umbrella verdict lives with the element type in * `__tests__/spec-derived-unions.test.ts`, which is where the blockers are * pinned one by one. - * - `visible` — objectui's wire contract is the bare predicate - * (`boolean | string`), while the spec's parsed shape is the - * `ExpressionInput` envelope (`{ dialect, source }`). Same divergence, and - * the same reason, as `SelectOption.visibleWhen` (objectui#3090). * * Drift guard: `__tests__/page-nav-misc-spec-parity.test.ts`. */ -export interface NavigationArea - extends Omit { +export interface NavigationArea extends Omit { /** Navigation items within this area (see the `navigation` note above). */ navigation: NavigationItem[]; - - /** Visibility expression (see the `visible` note above). */ - visible?: boolean | string; } // ============================================================================ @@ -408,12 +405,6 @@ export interface AppComponentSchema extends BaseSchema { */ actions?: AppAction[]; - /** - * Home page ID (ObjectStack Spec v2.0.1) - * Default page to navigate to after login - */ - homePageId?: string; - /** * Required permissions (ObjectStack Spec v2.0.1) * Permissions required to access this application diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 968ea26513..d4a8f7867e 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -80,8 +80,9 @@ export interface BaseFieldMetadata { * Writing it on an object's field definition is meaningless — nothing * publishes it and nothing else produces it. * - * **Consumer**: `TextAreaField` (`@object-ui/fields`), which reads it off - * `field` and nowhere else — `field` being the single metadata carrier since + * **Consumers**: `TextAreaField` and `RichTextField` (`@object-ui/fields`, + * the latter since objectui#3301 / PR #3302), which read it off `field` and + * nowhere else — `field` being the single metadata carrier since * objectui#3233. It is declared here, on the type * `FieldWidgetComponentProps.field` resolves to, so that the one legal * location for the flag is a typed one rather than an untyped pun: before diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1f10562d1b..e0ef445882 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1020,9 +1020,24 @@ export type { // ============================================================================ // v2.0.7 Spec UI Types — Notifications // ============================================================================ +/** + * The PRESENTATION vocabulary only. `@objectstack/spec/ui` dropped the two + * composite notification types in 17.0.0 (objectstack#4610): `Notification` + * (a toast/banner instance shape) and `NotificationConfig` (a toaster global + * config). Neither has a successor anywhere in the spec. + * + * Do NOT re-point `Notification` at `@objectstack/spec/api`. That name now has + * exactly one owner, and it is a DIFFERENT contract — the REST inbox row + * (`id` / `type` / `title` / `body` / `read` / `data` / `actionUrl` / + * `createdAt`), with none of `severity` / `duration` / `dismissible` / + * `actions` / `position`. Same name, different shape: aliasing it here would + * re-create the very dual-source trap #4610 closed. + * + * The live objectui equivalent of the removed config is + * `NotificationSystemConfig` in `@object-ui/react`'s `NotificationContext`, + * which is declared locally and is what every surface actually reads. + */ export type { - Notification, - NotificationConfig, NotificationAction, NotificationPosition, NotificationSeverity, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 479d39f391..03d4ba4aad 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -31,8 +31,16 @@ import type { ListViewInferred } from './zod/objectql.zod.js'; /** * HTTP Method for API requests * Canonical definition from @objectstack/spec/ui. + * + * The spec renamed this export to `HttpMethodType` in 17.0.0 (objectstack#4691): + * `HttpMethod` used to name two DIFFERENT types depending on the entry point — + * the 7-value enum on `./shared` / `./api` (which adds `HEAD` / `OPTIONS`) and + * the 5-value UI subset on `./ui`. We alias the `./ui` one back to `HttpMethod` + * so `@object-ui/types`' public surface is unchanged and the shape stays + * verbatim identical. Do NOT re-point this at `./shared`: that is the 7-value + * enum, and `ApiDataSource` means the 5-value one. */ -export type { HttpMethod } from '@objectstack/spec/ui'; +export type { HttpMethodType as HttpMethod } from '@objectstack/spec/ui'; /** * HTTP Request Configuration for API Provider diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index e64f1aebd2..5922c29462 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -112,20 +112,28 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => z.object({ * Navigation Area Schema — business-domain partition of navigation, DERIVED * from `@objectstack/spec/ui` (objectstack#4115). * - * `icon`, `order`, `description` and `requiredPermissions` flow in **by - * reference** through {@link specFieldsExcept}; `id` and `label` are taken from - * the spec's own shape too, re-stated only to keep them required (the helper - * `.partial()`s what it carries, deliberately, so a future spec field cannot - * become required and invalidate stored objectui apps). `objectui validate` - * silently dropped `order` and `description` until objectui#3088 — restating - * this shape by hand is what let that happen. + * `icon` and `description` flow in **by reference** through + * {@link specFieldsExcept}; `id` and `label` are taken from the spec's own + * shape too, re-stated only to keep them required (the helper `.partial()`s + * what it carries, deliberately, so a future spec field cannot become required + * and invalidate stored objectui apps). `objectui validate` silently dropped + * `order` and `description` until objectui#3088 — restating this shape by hand + * is what let that happen. * - * Two keys are pinned locally, matching the TS twin in `app.ts`: + * One key is pinned locally, matching the TS twin in `app.ts`: * - `navigation` — objectui's `NavigationItemSchema`, because the spec's is * `z.ZodType` (objectstack#4171 / objectui#3162) and would validate - * nothing; - * - `visible` — objectui's bare-predicate wire contract (`boolean | string`) - * rather than the spec's `ExpressionInput` envelope pipe. + * nothing. + * + * `order`, `visible` and `requiredPermissions` were AREA-level keys until + * `@objectstack/spec` 17.0.0 retired them (`AREA_VISIBLE_RETIRED` / + * `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a layout grouping, not an + * access boundary, so gating belongs on the navigation ITEM (`visible` / + * `requiredPermissions`, both still there) or on the app. No objectui renderer + * ever read the area-level trio — `AppSchemaRenderer`, `AppSidebar`, + * `UnifiedSidebar` and `AppHeader` read only `id` / `label` / `navigation` — + * so following the spec here drops no behaviour. The spec object is `.strict()`, + * so keeping them locally would have meant accepting areas the platform rejects. * * Drift guard: `__tests__/page-nav-misc-spec-parity.test.ts`. */ @@ -133,12 +141,10 @@ export const NavigationAreaSchema = specFieldsExcept(SpecNavigationAreaSchema.sh 'id', 'label', 'navigation', - 'visible', ] as const).extend({ id: SpecNavigationAreaSchema.shape.id.describe('Unique identifier'), label: SpecNavigationAreaSchema.shape.label.describe('Display label'), navigation: z.array(NavigationItemSchema).describe('Navigation items within area'), - visible: z.union([z.boolean(), z.string()]).optional().describe('Visibility expression'), }); // ============================================================================ @@ -217,7 +223,8 @@ export const AppContextSelectorSchema = SpecAppContextSelectorSchema.extend({ * `BaseSchema` is `.passthrough()` while the spec's `AppSchema` is strict, so * before this derivation 23 spec-only keys rode through objectui completely * unvalidated — `branding`, `sharing`, `embed`, `objects`, `apis`, - * `requiredPermissions`, `homePageId`, `protection` and the whole + * `requiredPermissions`, `homePageId` (itself retired in spec 17.0.0), + * `protection` and the whole * `_lock*`/`_package*`/`_provenance` package-lock envelope. A typo in any of * them (`brading: {…}`) was invisible, and a packaged app round-tripped * through this schema lost nothing only by luck. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d792d2342..b1e2894b9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,8 +35,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -267,11 +267,11 @@ importers: specifier: workspace:* version: link:../../packages/types '@objectstack/client': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -402,8 +402,8 @@ importers: specifier: workspace:* version: link:../../packages/types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) fumadocs-core: specifier: 16.13.0 version: 16.13.0(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.25.0(react@19.2.8))(next@16.2.12(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3) @@ -691,14 +691,14 @@ importers: specifier: workspace:* version: link:../types '@objectstack/formula': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@objectstack/lint': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@sentry/react': specifier: ^10.69.0 version: 10.69.0(react@19.2.8) @@ -788,8 +788,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) better-auth: specifier: ^1.6.25 version: 1.6.25(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0)(next@16.2.12(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -872,8 +872,8 @@ importers: version: link:../types devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -908,8 +908,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1092,11 +1092,11 @@ importers: specifier: workspace:* version: link:../types '@objectstack/formula': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -1154,11 +1154,11 @@ importers: specifier: workspace:* version: link:../types '@objectstack/client': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) devDependencies: tsup: specifier: ^8.5.1 @@ -1191,8 +1191,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1299,8 +1299,8 @@ importers: version: 3.6.0 devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -1324,8 +1324,8 @@ importers: version: link:../types devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1499,8 +1499,8 @@ importers: version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.6)(react@19.2.8)(redux@5.0.1) devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1541,8 +1541,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1657,8 +1657,8 @@ importers: version: 3.6.0 devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1766,8 +1766,8 @@ importers: specifier: workspace:* version: link:../i18n '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) lucide-react: specifier: ^1.25.0 version: 1.25.0(react@19.2.8) @@ -1885,8 +1885,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) lucide-react: specifier: ^1.25.0 version: 1.25.0(react@19.2.8) @@ -1940,8 +1940,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) lucide-react: specifier: ^1.25.0 version: 1.25.0(react@19.2.8) @@ -2001,8 +2001,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2142,8 +2142,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2181,8 +2181,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) lucide-react: specifier: ^1.25.0 version: 1.25.0(react@19.2.8) @@ -2331,8 +2331,8 @@ importers: version: 3.6.0 devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@types/node': specifier: ^26.1.2 version: 26.1.2 @@ -2376,8 +2376,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2428,8 +2428,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) lucide-react: specifier: ^1.25.0 version: 1.25.0(react@19.2.8) @@ -2492,8 +2492,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2526,8 +2526,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2560,8 +2560,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2634,8 +2634,8 @@ importers: version: 1.0.7(tailwindcss@4.3.3) devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2673,14 +2673,14 @@ importers: specifier: workspace:* version: link:../react '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) packages/types: dependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.1 - version: 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + specifier: ^17.0.0-rc.2 + version: 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -4027,26 +4027,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.0.0-rc.1': - resolution: {integrity: sha512-3ZRNyx+lOx8C+LBBChhXZc22rWcMa5wufVRrGHc5Dfd42OZqn5AsbKjjhSZrHEfyoCvmeeENo9JGlej0R8RJ9Q==} + '@objectstack/client@17.0.0-rc.2': + resolution: {integrity: sha512-tM4jm2R6UnR2+ymA94BSefco9DbrgeZFytL6b/mTVaUv7uy/joSZKWWrcEG5FiBzcyxJCevDvQixRHuiYzcHHg==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.0.0-rc.1': - resolution: {integrity: sha512-3snd0dJhd+MPEDek+YRapZ02siBnbxYNNl8GoyfnlSeEuKXdcY6bDBf/zueAwoTqyV32zNG5epA85cZh1zLQPw==} + '@objectstack/core@17.0.0-rc.2': + resolution: {integrity: sha512-5hAtKmjBbzEWA7qWXCN8WB12+BhGZ8jZYg7Y5r8PHYH+jOf5ZLX4KkD3bQw6oTUBpBhWclLVREKSlah9truGRA==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.0.0-rc.1': - resolution: {integrity: sha512-oAJENCl3QQcJA0484ScNdOU6EX5MEZ14pRF7sLITnoBkXGhgh/eMKboG+q6zLGJsZwS4ThPEu6sF72GVDlEmuw==} + '@objectstack/formula@17.0.0-rc.2': + resolution: {integrity: sha512-+4H3nESU3fH+pjjV4b1P8llvSPff/vQF0Th5/FdbZrYz0fKxoNNL+MtLBAzpu+ME3ZLFMwwsKRH7obPyCS9wmQ==} - '@objectstack/lint@17.0.0-rc.1': - resolution: {integrity: sha512-qIXylf5FsFhWAii0m+jQp49kZXlTnGXNz/cyZTrvqwJItI78k5afo3zKxZjmDreGRnAFIKXJLGLEDvZWjHgdFg==} + '@objectstack/lint@17.0.0-rc.2': + resolution: {integrity: sha512-S571+BtsXhyBpfZSNh9Ic0ljH8cVKyhgaVbQjqPc0ICgzvwh9/b4PEB7ElbgWUvTNje2KOriLgCjgNGGnTGfuw==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.0.0-rc.1': - resolution: {integrity: sha512-K96OHHU/HtBkumyKhNz+Sxnhvvy1wAtzQOjuTdwTDpUYLxJu3J3xqxoXPBQUh+FuYrTlxJq99CkhsSCjDrSLjA==} + '@objectstack/sdui-parser@17.0.0-rc.2': + resolution: {integrity: sha512-Xc2ftxDFaDWyKmPWh+nAhgrMXmakKEXACdU/0fTrWertmfv9vRSrKomdCgFSvLksMxS/JcDT2O9EkMxGMQbj2g==} - '@objectstack/spec@17.0.0-rc.1': - resolution: {integrity: sha512-1SIDQHwxaV8GLD8YqLxCJ0fxTag6eYvvWntihil+lBIxNSjYLI3U384I4l03esBvkytrts3L5W0WCZJkquJiXw==} + '@objectstack/spec@17.0.0-rc.2': + resolution: {integrity: sha512-gvybztXuGTaqAlUzSOP4xeTpjl44ty2RZ67JQfiCma/engMG8pKqfv4/18nYitFktiWUVLsr2U6AquZAidmcRQ==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -12439,40 +12439,41 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.0.0-rc.1(ai@7.0.44(zod@4.4.3))': + '@objectstack/client@17.0.0-rc.2(ai@7.0.44(zod@4.4.3))': dependencies: - '@objectstack/core': 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) - '@objectstack/spec': 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + '@objectstack/core': 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) + '@objectstack/spec': 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/core@17.0.0-rc.1(ai@7.0.44(zod@4.4.3))': + '@objectstack/core@17.0.0-rc.2(ai@7.0.44(zod@4.4.3))': dependencies: - '@objectstack/spec': 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + '@objectstack/spec': 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) zod: 4.4.3 transitivePeerDependencies: - ai - '@objectstack/formula@17.0.0-rc.1(ai@7.0.44(zod@4.4.3))': + '@objectstack/formula@17.0.0-rc.2(ai@7.0.44(zod@4.4.3))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + '@objectstack/spec': 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/lint@17.0.0-rc.1(ai@7.0.44(zod@4.4.3))': + '@objectstack/lint@17.0.0-rc.2(ai@7.0.44(zod@4.4.3))': dependencies: - '@objectstack/formula': 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) - '@objectstack/sdui-parser': 17.0.0-rc.1 - '@objectstack/spec': 17.0.0-rc.1(ai@7.0.44(zod@4.4.3)) + '@marcbachmann/cel-js': 8.0.0 + '@objectstack/formula': 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) + '@objectstack/sdui-parser': 17.0.0-rc.2 + '@objectstack/spec': 17.0.0-rc.2(ai@7.0.44(zod@4.4.3)) sucrase: 3.35.1 typescript: 6.0.3 transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.0.0-rc.1': {} + '@objectstack/sdui-parser@17.0.0-rc.2': {} - '@objectstack/spec@17.0.0-rc.1(ai@7.0.44(zod@4.4.3))': + '@objectstack/spec@17.0.0-rc.2(ai@7.0.44(zod@4.4.3))': dependencies: zod: 4.4.3 optionalDependencies: From bd31440e434d07af0e7b9e689220aa549ee2b20e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 16:56:50 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs(types):=20correct=20the=20NavigationAr?= =?UTF-8?q?ea=20retirement=20note=20=E2=80=94=20objectui=20DID=20gate=20on?= =?UTF-8?q?=20the=20area=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docblocks claimed no objectui renderer ever read area-level `visible` / `requiredPermissions`. That is false, and it repeats the exact mistake this round is documenting upstream: `AppSchemaRenderer`'s area switcher filtered areas by both. Following the retirement is still correct — the spec's area object is `.strict()`, so a v17-valid app cannot carry the keys and the filter was unreachable — but the reason has to be the true one. See objectui#3311. --- packages/types/src/app.ts | 10 ++++++++-- packages/types/src/zod/app.zod.ts | 14 +++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 569c6f82d8..4517af9690 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -294,8 +294,14 @@ export interface NavigationItem { * `order`, `visible` and `requiredPermissions` were area-level keys until spec * 17.0.0 retired them (`AREA_VISIBLE_RETIRED` / * `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a layout grouping, not an - * access boundary — gate the navigation ITEM or the app instead. No objectui - * renderer ever read them at area level, so dropping them costs no behaviour. + * access boundary — gate the navigation ITEM or the app instead. + * + * Worth stating plainly, because the retirement's own rationale says an area + * "carries no gate of its own": objectui DID gate on them — + * `AppSchemaRenderer`'s area switcher filtered areas by `visible` and + * `requiredPermissions`. That filter is gone, and the gating it did now happens + * one level down in `NavigationRenderer`, which is where the spec moved it. + * The premise mismatch is recorded in objectui#3311. * * One key is pinned locally, for a reason that outlives a spec release: * diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 5922c29462..c11e325291 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -129,11 +129,15 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => z.object({ * `@objectstack/spec` 17.0.0 retired them (`AREA_VISIBLE_RETIRED` / * `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a layout grouping, not an * access boundary, so gating belongs on the navigation ITEM (`visible` / - * `requiredPermissions`, both still there) or on the app. No objectui renderer - * ever read the area-level trio — `AppSchemaRenderer`, `AppSidebar`, - * `UnifiedSidebar` and `AppHeader` read only `id` / `label` / `navigation` — - * so following the spec here drops no behaviour. The spec object is `.strict()`, - * so keeping them locally would have meant accepting areas the platform rejects. + * `requiredPermissions`, both still there) or on the app. + * + * Note the retirement's rationale ("an area carries no gate of its own") did + * NOT hold here: `AppSchemaRenderer`'s area switcher filtered areas by + * `visible` / `requiredPermissions`. That filter is gone and the gating moved + * down to `NavigationRenderer` — see objectui#3311 for the premise mismatch. + * Following the spec is still right: its area object is `.strict()`, so a + * v17-valid app cannot carry these keys, and keeping them locally would have + * meant accepting areas the platform rejects. * * Drift guard: `__tests__/page-nav-misc-spec-parity.test.ts`. */ From d351de5332782ec6a438693dae472967b38c1e02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 17:13:33 +0000 Subject: [PATCH 3/3] docs(changeset): stop promising the previews render homePageId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#3275's changeset says AppPreview renders `homePageId` as a nav item id. This branch retires the key and makes the preview DERIVE the landing entry instead, so the two changesets would compile into one release note that contradicts itself — one half telling readers the preview shows `homePageId`, the other telling them it no longer exists. Rewrites that one sentence to the post-retirement fact and nothing else; the bump level and the rest of the file are untouched. --- .changeset/previews-read-only-spec-declared-keys.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.changeset/previews-read-only-spec-declared-keys.md b/.changeset/previews-read-only-spec-declared-keys.md index 4956619356..b55c035cf5 100644 --- a/.changeset/previews-read-only-spec-declared-keys.md +++ b/.changeset/previews-read-only-spec-declared-keys.md @@ -35,9 +35,10 @@ a valid app showed generic badges, no targets and an invented `Landing: /`. Now (`objectName`/`pageName`/`dashboardName`/`url`/`reportName`/`componentRef`/ `actionDef.actionName`), and the route comes from `resolveHref`, the shell's own nav → URL mapping that `useNavPins` and `SearchResultsPage` already share — so a -link in the preview is the link the runtime follows. `homePageId` is rendered as -the nav item **id** it is, resolved to the entry it selects; it is never printed -as a path. +link in the preview is the link the runtime follows. The landing entry is +DERIVED, never read off the draft — it is the first navigation item that yields +a route, the same rule the console shell applies; `homePageId` was retired in +`@objectstack/spec` 17.0.0 and is not consulted. **`DatasourcePreview`** — `capabilities` is a `DatasourceCapabilities` object of boolean flags, and the preview tested `Array.isArray`, lighting the block up only