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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/9409-retire-page-assigned-profiles.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions .changeset/9736-twins-spec-by-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
63 changes: 41 additions & 22 deletions packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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<PageNodeSchema['assignedProfiles'], Page['assignedProfiles']> = true;
const admitsNoValue: Equal<PageNodeSchema['assignedProfiles'], undefined> = 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<PageNodeSchema> ? 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);
});
});

Expand All @@ -138,16 +169,4 @@ describe('the twin-only omissions keep the twin\'s own member, unwidened', () =>
const headerIsSpec: Equal<DashboardComponentSchema['header'], Dashboard['header']> = 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<PageNodeSchema['assignedProfiles'], string[] | undefined> = true;
const declared: 'assignedProfiles' extends DeclaredKeys<PageNodeSchema> ? true : false = true;
expect([assignedProfiles, declared]).toEqual([true, true]);
});
});
31 changes: 14 additions & 17 deletions packages/types/src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`)
Expand All @@ -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<SpecPage, (typeof PAGE_SPEC_EXCLUDED)[number] | 'slots' | 'assignedProfiles'> {
export interface PageNodeSchema extends BaseSchema, Omit<SpecPage, (typeof PAGE_SPEC_EXCLUDED)[number] | 'slots'> {
type: 'page';
/**
* ⛔ REFUSED BY NAME — `actions` is not a member of this node and never was
Expand Down Expand Up @@ -1590,10 +1591,6 @@ export interface PageNodeSchema extends BaseSchema, Omit<SpecPage, (typeof PAGE_
* objectui#7963), not this card's.
*/
isDefault?: boolean;
/**
* Profiles that can access this page
*/
assignedProfiles?: string[];
/**
* ARIA accessibility attributes.
* Aligned with @objectstack/spec AriaPropsSchema.
Expand Down
11 changes: 10 additions & 1 deletion packages/types/src/zod/layout.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,16 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({
.optional()
.describe('Main content — one node or a list of nodes'),
isDefault: z.boolean().optional().describe('Whether this is the default page'),
assignedProfiles: z.array(z.string()).optional().describe('Profiles that can access this page'),
// objectui#9409: `assignedProfiles` is RETIRED, and deliberately not declared
// here. @objectstack/spec 17.5.0 turned its `PageSchema.assignedProfiles` into
// a `retiredKey()` tombstone (ADR-0090 D2 deleted the Profile concept the key
// was named after; ADR-0049 enforce-or-remove), and it reaches this node BY
// REFERENCE through {@link SpecPageFields}, the way App `version` and
// Dashboard `refreshInterval` do. So an authored value is refused at
// `assignedProfiles` with the spec's own message, which names the
// permission-set route. The `string[]` override this line used to carry
// shadowed that tombstone and accepted the key under a description that
// called it access control, which nothing ever enforced.
})
// ⭐ THE SPEC'S OBJECT-LEVEL CHECK, re-attached (objectui#7715, ruling B1).
// {@link SpecPageFields} rebuilds a fresh object from the spec's `.shape`, so
Expand Down
Loading