diff --git a/.changeset/9409-retire-page-assigned-profiles.md b/.changeset/9409-retire-page-assigned-profiles.md new file mode 100644 index 0000000000..c2996f4d92 --- /dev/null +++ b/.changeset/9409-retire-page-assigned-profiles.md @@ -0,0 +1,35 @@ +--- +'@object-ui/types': minor +--- + +**BREAKING (authoring):** `assignedProfiles` on a `page` node is now refused on both published faces (objectui#9409). + +`@objectstack/spec` 17.5.0 retired `page.assignedProfiles`. ADR-0090 D2 deleted the Profile +concept the key was named after, and under ADR-0049 enforce-or-remove the spec's `PageSchema` +now declares the key as a `retiredKey()` tombstone that refuses any value. `@object-ui/types` +still declared it as an authorable `string[]`, described as "Profiles that can access this +page". Nothing in this repository ever read the key, so a page that listed profiles stayed +open to everyone who could reach it: the key read as access control and enforced nothing. + +Both faces now take the spec's tombstone by reference, the way App `version` and Dashboard +`refreshInterval` already do: + +- **Zod mirror.** `PageNodeSchema` no longer overrides the key. An authored value, an empty + list included, fails to parse at `assignedProfiles` with the spec's own message, which + names the remedy. +- **TypeScript.** `PageNodeSchema.assignedProfiles` is now the spec's member, which admits no + value, so authoring one is a compile error. The hand-written `string[]` member is gone. +- **Docs.** The `PageNodeSchema` table in the schema reference marks the key as retired. + +What to do: delete the key. Page audience comes from permission sets. Gate the data the page +shows with the object's permission sets, and grant those sets to people through positions. + +```ts +// before: compiled and parsed, and gated nothing +const page: PageNodeSchema = { type: 'page', assignedProfiles: ['sales'] }; +// after: refused by tsc and by the validator. Delete the key. +const page: PageNodeSchema = { type: 'page' }; +``` + +This is released as `minor`, following this repository's version policy: breaking semantics +are marked `minor` and described here. diff --git a/.changeset/9736-twins-spec-by-reference.md b/.changeset/9736-twins-spec-by-reference.md index e2e64d2b0a..3b0bb3020d 100644 --- a/.changeset/9736-twins-spec-by-reference.md +++ b/.changeset/9736-twins-spec-by-reference.md @@ -38,3 +38,6 @@ spelling moves from a parse-time refusal to a compile-time refusal. This repo ma semantics `minor` rather than `major`. This change affects types only. It changes no runtime code and narrows no mirror. + +⚠️ **Dated note, 2026-09-30 — `PageNodeSchema.assignedProfiles` is no longer withheld, retired in this same release — objectui#9409.** +The sentences above saying `PageNodeSchema.assignedProfiles` is withheld from the spec projection "for forward compatibility", with its hand-written `string[]` member unchanged, no longer hold. Later in this release this repository began resolving `@objectstack/spec` 17.5.0, which retires the key as a `retiredKey()` tombstone, and objectui#9409 dropped both the omission and the `string[]` member: the twin now takes the spec's tombstone by reference, like the app and dashboard tombstones listed above, so authoring a value is a TypeScript error and a parse failure. `PageNodeSchema.slots` is still withheld as described. Everything else above is unchanged. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 681ee67b40..a38140747e 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -138,7 +138,7 @@ Top-level page container. Defines a full page with optional regions (header, sid | `regions` | `PageRegion[]` | Named layout regions (header, sidebar, footer). | | `children` | `SchemaNode \| SchemaNode[]` | Main page content when the page declares no regions — one node, or a list of them. Spelled `body` until objectui#6771 retired that spelling. | | `isDefault` | `boolean` | Whether this is the default page for the object. | -| `assignedProfiles` | `string[]` | Security profiles that can access this page. | +| `assignedProfiles` | *retired* | ⛔ Refused by name (objectui#9409). `@objectstack/spec` retired the key: ADR-0090 D2 deleted the Profile concept it was named after, and nothing ever enforced it, so a page that listed profiles stayed open to everyone who could reach it. Both published faces take the spec's tombstone: any value is a TypeScript error and a parse failure at `assignedProfiles`. Delete the key. Page audience comes from permission sets: gate the data the page shows with the object's permission sets, and grant those sets to people through positions. | | `aria` | `AriaProps` | ARIA attributes for the page's root element: `ariaLabel` (a plain string, or an inline locale map such as `{ "en": "Orders", "fr": "Commandes" }`, resolved for the display locale) renders `aria-label`, `ariaDescribedBy` renders `aria-describedby`, and `role` renders `role`. This is the spec's inline vocabulary, not the keyed flat `ariaLabel` described under BaseSchema. The page adds no default role. | **Related:** [AppSchema](/docs/core/app-schema), [DivSchema](#divschema), [GridSchema](#gridschema) diff --git a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts index d522fa0059..88d56b7946 100644 --- a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts +++ b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -16,10 +16,12 @@ * `DASHBOARD_SPEC_EXCLUDED`, `PAGE_SPEC_EXCLUDED`), plus — on the TypeScript * face only — the twin member whose hand-written type is not assignable to * the spec's (`slots` on the page, ledgered as drift in - * `zod-mirror-parity.test.ts`), plus the page's `assignedProfiles`, which - * objectstack `main` retires (a forward-compat omission, objectui#9409). The - * dashboard's `header` was the second such member until objectui#7759 group A - * dropped the omission: the twin now inherits the spec's `header` by reference. + * `zod-mirror-parity.test.ts`). The dashboard's `header` was a second such + * member until objectui#7759 group A dropped the omission: the twin now + * inherits the spec's `header` by reference. The page's `assignedProfiles` + * was a third, a forward-compat omission, until objectui#9409 retired it: + * `@objectstack/spec` 17.5.0 made it a `retiredKey()` tombstone, and both faces + * now take that tombstone by reference. * * ## Why the positive pins are TYPE equalities, not assignments * @@ -115,12 +117,41 @@ describe('spec tombstones surface on the twin as a refusal — the verdict the m expect(DashboardMirror.safeParse(dashboard).success).toBe(true); }); - it('`Page` carries no tombstone on the installed pin — its admitted keys pass both faces', () => { - // ⚠️ Recorded rather than assumed: the spec's `PageSchema` has no - // `retiredKey()` member on @objectstack/spec 17.4.0, so the page twin's pin - // is the admitted half only. - const page: PageNodeSchema = { type: 'page', source: 'pages/home.tsx', requires: ['crm'] }; - expect(PageMirror.safeParse(page).success).toBe(true); + it('Page `assignedProfiles` (objectui#9409): authoring a value is a compile error AND a parse failure AT the key', () => { + // The spec's `retiredKey()` tombstone since @objectstack/spec 17.5.0 + // (ADR-0090 D2 deleted the Profile concept). Both faces take it by + // reference, so the twin's member IS the spec's, and it admits no value. + const isSpecMember: Equal = true; + const admitsNoValue: Equal = true; + // The `@ts-expect-error` below only sticks because the key is DECLARED; + // undeclared, the index signature would absorb it as `any`. + const declared: 'assignedProfiles' extends DeclaredKeys ? true : false = true; + expect([isSpecMember, admitsNoValue, declared]).toEqual([true, true, true]); + + // @ts-expect-error — `assignedProfiles` is the spec's `retiredKey()` tombstone. + const page: PageNodeSchema = { type: 'page', assignedProfiles: ['admin'] }; + const verdict = PageMirror.safeParse(page); + expect(verdict.success).toBe(false); + // The refusal is this key's, and the only issue: the rest of the document is valid. + const issues = verdict.success ? [] : verdict.error.issues; + expect(issues.map((i) => ({ code: i.code, path: i.path }))).toEqual([ + { code: 'invalid_type', path: ['assignedProfiles'] }, + ]); + expect(issues[0]?.message).toContain('assignedProfiles'); + + // A tombstone refuses ANY value, an empty list included: the key is gone, + // not narrowed. + // @ts-expect-error — the same tombstone. + const emptyList: PageNodeSchema = { type: 'page', assignedProfiles: [] }; + const emptyVerdict = PageMirror.safeParse(emptyList); + expect(emptyVerdict.success ? [] : emptyVerdict.error.issues.map((i) => i.path)).toEqual([['assignedProfiles']]); + }); + + it('the Page control: the same document without `assignedProfiles`, and the admitted spec keys, pass both faces', () => { + const bare: PageNodeSchema = { type: 'page' }; + const admitted: PageNodeSchema = { type: 'page', source: 'pages/home.tsx', requires: ['crm'] }; + expect(PageMirror.safeParse(bare).success).toBe(true); + expect(PageMirror.safeParse(admitted).success).toBe(true); }); }); @@ -138,16 +169,4 @@ describe('the twin-only omissions keep the twin\'s own member, unwidened', () => const headerIsSpec: Equal = true; expect(headerIsSpec).toBe(true); }); - - it('Page `assignedProfiles` keeps the hand-written `string[]` whatever the spec pin declares', () => { - // A FORWARD-COMPAT omission: objectstack `main` retires the key (its input - // type becomes `undefined`), and the hand-written member would then stop - // compiling in the `extends` clause. Spelling it beside the shared list keeps - // the twin compiling against both the installed pin and `main` (the - // `Spec Main Shape Gate`). Retiring it is objectui#9409's decision, ⛔ not - // this file's, so the member must stay exactly what it was. - const assignedProfiles: Equal = true; - const declared: 'assignedProfiles' extends DeclaredKeys ? true : false = true; - expect([assignedProfiles, declared]).toEqual([true, true]); - }); }); diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index d1eba1b5bc..1f97e51e96 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -1378,8 +1378,8 @@ export interface PageNodeRegion { * mirror's `specFieldsExcept` call also reads, so both faces project the same * spec surface and move together on a pin bump. * - * TWO keys are omitted from the spec projection beyond the shared list, on - * the TypeScript face only: + * ONE key is omitted from the spec projection beyond the shared list, on the + * TypeScript face only: * - `slots` — the member below types each slot as objectui's `SchemaNode` * (which admits primitives and `null`), not the spec's page-component * shape, so it is not assignable to the spec's member and cannot sit beside @@ -1388,16 +1388,17 @@ export interface PageNodeRegion { * ⛔ not changed here. The mirror keeps validating the spec's `slots` — the * omission is type-side only, so it is spelled beside the shared list, not * inside it. - * - `assignedProfiles` — a FORWARD-COMPAT omission. On the installed spec the - * spec's member is `string[]` and the one below matches it, but objectstack - * `main` has retired the key (`retiredKey()`, so its input type is - * `undefined`), and the hand-written `string[]` is not assignable to that. - * Without this omission the twin would stop compiling at the next pin bump, - * which `Spec Main Shape Gate` measures today. Retiring the member here is - * objectui#9409's decision, which is on hold until the installed spec - * refuses the key. ⛔ It is not retired, and not widened, here. Like - * `slots`, it is spelled beside the shared list, so the mirror keeps - * validating whatever the installed spec declares. + * + * `assignedProfiles` is RETIRED (objectui#9409) and is no longer omitted. + * @objectstack/spec 17.5.0 made the spec's member a `retiredKey()` tombstone + * (ADR-0090 D2 deleted the Profile concept it was named after; ADR-0049 + * enforce-or-remove), so this interface now takes it BY REFERENCE, like App + * `version` and Dashboard `refreshInterval`: its type is the spec's, which + * admits no value, and authoring one is a `tsc` error. The zod mirror refuses + * the same value at parse with the spec's own message. The hand-written + * `string[]` member, described as "Profiles that can access this page", is + * gone: nothing in this repository ever enforced it, so it read as access + * control while gating nothing. Page audience is the permission set's. * * The other members this interface writes itself (`icon`, `object`, * `template`, `variables`, `isDefault`, `aria`, `kind`) @@ -1413,7 +1414,7 @@ export interface PageNodeRegion { * `@object-ui/components` registers `PageRenderer` under, i.e. the wire key * authored metadata carries. Nothing else in the repo pins it. */ -export interface PageNodeSchema extends BaseSchema, Omit { +export interface PageNodeSchema extends BaseSchema, Omit { type: 'page'; /** * ⛔ REFUSED BY NAME — `actions` is not a member of this node and never was @@ -1590,10 +1591,6 @@ export interface PageNodeSchema extends BaseSchema, Omit