diff --git a/.changeset/23357-page-component-view-data-api-refused.md b/.changeset/23357-page-component-view-data-api-refused.md new file mode 100644 index 00000000000..fadcedc8fe7 --- /dev/null +++ b/.changeset/23357-page-component-view-data-api-refused.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: the page parse refuses a page component's `data` prop that names the retired view-level `api` data source, so the Studio, REST and MCP save door refuses it as `os validate` does (#23357) + +**BREAKING** — a page whose `object-grid`, `object-map`, `object-gantt` or `object-tree` component carries `properties.data` with `provider: 'api'` is now refused by `PageSchema`, at the component's `properties.data.provider`, with the retirement's own prescription. Those four components take a view data source (`ViewDataSchema`) as `data`, so the step-18 retirement of the view-level `api` source reached them through the same schema; but the page parse leaves a component's `properties` an open record, and the component-props gate that refused the binding runs at the CLI doors only. A page saved through the Studio, REST or MCP therefore kept the binding, with its request headers, and the console kept sending it. + +Clause-②: yes (narrowing) + +The judge is the retirement's own: `ViewDataSchema` parses the component's `data`, and the prescription it raises is raised by the page parse unchanged (one text for a view and a page; the page's issue `code` is `custom`, as every object-level check on `PageSchema` raises). It reaches every component under `regions` and `slots`, at any depth, a component nested in a `page:tabs` item or a container included. It judges nothing else in the bag: another `data` provider, a component whose `data` is a row array (`object-kanban`, `object-calendar`, `object-timeline`), a custom or registered type with no props row, and a component's own `dataSource` binding are untouched. `checkPageComponentViewData` is exported from `@objectstack/spec/ui`, like the four other `PageSchema` checks, so a mirror built from `PageSchema.shape` can re-attach it. + +## FROM → TO + +| you wrote (accepted by the page parse until this change) | write instead | +| --- | --- | +| `properties.data` with `provider: 'api'` (and its `read` / `write` request blocks) on an `object-grid`, `object-map`, `object-gantt` or `object-tree` page component | `properties.data` with `provider: 'object'` and `object: ''`, or `provider: 'value'` with `items: [...]`; an endpoint the page must read is a page component's `dataSource: { provider: 'api', read: { url: '/api/...' }, path? }`, same-origin and with no credential key | +| `read.headers: { Authorization: '...' }` inside that binding | nothing: a credential never lives in metadata, and the console reads an element binding with its own signed-in fetch | + +**The one-line fix:** bind each such component to its object or to static rows, and move any endpoint read onto a page component's `dataSource`. + +**What an author who still writes it sees.** `definePage()`, `defineStack({ pages })`, `os validate` and `PUT /api/v1/meta/page/:name` refuse the page at `regions[i].components[j].properties.data.provider` (or the nested or slot position) with the prescription. At the save door that is `422 INVALID_METADATA`, nothing persisted. + +**A page already stored with such a component.** `os migrate meta --stored` lists it by page and component (the step-18 entry `view-data-api-provider-retired`), and nothing rewrites it: deleting `data` would make the component query its object instead. Left as stored, the page still loads and is served, registered under `[metadata_spec_invalid]` with the refusal in the `_diagnostics` of every read, and its next save is refused with 422. The console stops sending the request when its own reader of the retired arm is deleted (objectstack-ai/objectui#12325); until then a stored page keeps sending it. + + diff --git a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts index 6ee6350c48f..77e7718d238 100644 --- a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts +++ b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts @@ -491,3 +491,85 @@ describe('#20230 a flattened overlay carrying a retired owner/hidden is refused } }); }); + +/** + * #23357 — a page whose component binds the retired view-level `api` data + * source, at the save door: `saveMetaItem`, the `PUT /api/v1/meta/page/:name` + * write path the Studio, a REST caller and an MCP author reach. + * + * Before: the page parse left a component's `properties` an open record and the + * component-props gate that refuses the binding runs at the CLI doors only, so + * an `object-grid` whose `data` named the retired provider saved — one row + * persisted with its request headers — and the console kept sending it. After: + * `PageSchema`'s `checkPageComponentViewData` refuses it, and this envelope is + * what the author meets. Pinned HERE and not only in spec for the reason the + * #20230 block above gives: a spec refusal that did not reach this envelope + * would be a refusal nobody sees. + */ +describe('#23357 a page whose component binds the retired view-level api source is refused at the save door', () => { + // Spelled through a constant: the spec's tree-scoped absence pin reads + // authored literals, and these bodies are refusals, not authorings. + const RETIRED_PROVIDER = 'api' as const; + const RETIRED_BINDING = { + provider: RETIRED_PROVIDER, + read: { url: 'https://partner.example.com/cases', headers: { Authorization: 'Bearer s3cr3t' } }, + }; + const PRESCRIPTION = /^`view\.data\.provider: 'api'` was removed in @objectstack\/spec 18 \(ADR-0049/; + const grid = (data: unknown) => ({ id: 'cases', type: 'object-grid', properties: { objectName: 'crm_case', data } }); + const pageBody = (components: unknown[]) => ({ + name: 'partner_page', + label: 'Partner', + type: 'app', + regions: [{ name: 'main', components }], + }); + const savePage = (protocol: any, body: unknown) => save(protocol, body, 'partner_page', 'page'); + + it('an `object-grid` binding it answers 422 INVALID_METADATA, persists nothing, and locates the prescription at the component', async () => { + const { protocol, rows } = makeProtocol(); + + const err = await rejection(savePage(protocol, pageBody([grid(RETIRED_BINDING)]))); + + expect(err.code).toBe('INVALID_METADATA'); + expect(err.status).toBe(422); + expect(rows.size).toBe(0); + const at = err.issues.find((i: any) => i.path === 'regions.0.components.0.properties.data.provider'); + expect(at, `an issue at the component's data provider; got ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(at.code).toBe('custom'); + expect(at.message).toMatch(PRESCRIPTION); + }); + + it('a component nested in a `page:tabs` item is refused at its own position', async () => { + const { protocol, rows } = makeProtocol(); + const tabs = { id: 'tabs', type: 'page:tabs', properties: { items: [{ label: 'Cases', children: [grid(RETIRED_BINDING)] }] } }; + + const err = await rejection(savePage(protocol, pageBody([tabs]))); + + expect(err.code).toBe('INVALID_METADATA'); + expect(err.status).toBe(422); + expect(rows.size).toBe(0); + expect(err.issues.map((i: any) => i.path)).toContain('regions.0.components.0.properties.items.0.children.0.properties.data.provider'); + }); + + it('CONTROL: the same page with the `object` or `value` provider saves, one row', async () => { + for (const data of [{ provider: 'object', object: 'crm_case' }, { provider: 'value', items: [{ name: 'a' }] }]) { + const { protocol, rows } = makeProtocol(); + const result = await savePage(protocol, pageBody([grid(data)])); + expect(result.success, JSON.stringify(data)).toBe(true); + expect(rows.size).toBe(1); + } + }); + + it('READ PATH: a stored page still carrying the binding is served, with the refusal in its `_diagnostics`', async () => { + const { protocol } = makeProtocol([ + { type: 'page', name: 'partner_page', metadata: pageBody([grid(RETIRED_BINDING)]) }, + ]); + + const served = (await protocol.getMetaItem({ type: 'page', name: 'partner_page' })).item; + + expect(served.regions[0].components[0].type).toBe('object-grid'); + expect(served._diagnostics?.valid).toBe(false); + const at = served._diagnostics.errors.find((e: any) => e.path === 'regions.0.components.0.properties.data.provider'); + expect(at, JSON.stringify(served._diagnostics)).toBeDefined(); + expect(at.message).toMatch(PRESCRIPTION); + }); +}); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 073646c6e59..f82a10d0277 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -496,6 +496,7 @@ "checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization (function)", "checkListViewChartBinding (function)", + "checkPageComponentViewData (function)", "checkPagePrintComposition (function)", "checkPageRequiresKind (function)", "checkPageSlotPair (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index de94775bf7a..172775ef975 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -481,6 +481,7 @@ "checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", "checkListViewChartBinding": "src/ui/view.zod.ts#checkListViewChartBinding (function)", + "checkPageComponentViewData": "src/ui/page.zod.ts#checkPageComponentViewData (function)", "checkPagePrintComposition": "src/ui/page.zod.ts#checkPagePrintComposition (function)", "checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)", "checkPageSlotPair": "src/ui/page.zod.ts#checkPageSlotPair (function)", diff --git a/packages/spec/src/conversions/refused-shapes.test.ts b/packages/spec/src/conversions/refused-shapes.test.ts index e648493ea32..0d8ed992fda 100644 --- a/packages/spec/src/conversions/refused-shapes.test.ts +++ b/packages/spec/src/conversions/refused-shapes.test.ts @@ -360,7 +360,8 @@ describe('#23317 — a stored page whose component binds the retired API source const todo = todos[0]!; expect(todo.reason).toContain(`The \`${type}\` component \`block\` of page \`partner_page\`, at pages[0].regions[0].components[0], binds the retired view-level API data source.`); expect(todo.reason).toMatch(/No mechanical move: the successor is a page component's `dataSource` binding/); - expect(todo.reason).toMatch(/Left as stored, the page still loads and saves, because the page parse does not judge a component's properties/); + expect(todo.reason).toMatch(/Left as stored, the parse refuses the page: it still loads and is served, registered under `\[metadata_spec_invalid\]`/); + expect(todo.reason).toMatch(/its next save through the Studio, REST or MCP is refused with 422/); expect(todo.reason).toMatch(PRESCRIPTION); }); @@ -374,6 +375,12 @@ describe('#23317 — a stored page whose component binds the retired API source 'pages[0].regions[0].components[0].properties.items[0].children[0].properties.data', 'pages[0].slots.details.properties.data', ]); + // [#23357] …and the page parse refuses exactly those two positions. + const verdict = PageSchema.safeParse(row); + expect(verdict.success ? [] : verdict.error.issues.map((i) => i.path.join('.'))).toEqual([ + 'regions.0.components.0.properties.items.0.children.0.properties.data.provider', + 'slots.details.properties.data.provider', + ]); }); it('never prints a credential: the URL and the method are shown, every other request key is withheld', () => { @@ -414,9 +421,24 @@ describe('#23317 — a stored page whose component binds the retired API source } }); - it('the fate it states holds: the page parse accepts the page, and the component props row refuses the binding', () => { + // [#23357] Until the page parse judged this binding the fate read "the page + // still loads and saves", and this pin asserted `PageSchema` ACCEPTED the + // row. Since `checkPageComponentViewData` the page parse refuses each listed + // component at the listed position with the same prescription, which is + // what the save door's 422 and the read / boot `_diagnostics` resolve. + it('the fate it states holds: the page parse refuses each listed component at the listed position, and so does its props row', () => { const row = pageRow(VIEW_DATA_COMPONENTS.map((type, i) => component(type, apiData(), `b${i}`))); - expect(PageSchema.safeParse(row).success).toBe(true); + const { todos } = storedTodos('page', row); + const verdict = PageSchema.safeParse(row); + expect(verdict.success).toBe(false); + const refused = verdict.success ? [] : verdict.error.issues; + // The parse locates the issue at `…properties.data.provider`; the listing + // names the `data` prop it would rewrite, in the conversion walker's spelling. + const listedAt = (path: readonly PropertyKey[]) => + `pages[0]${path.slice(0, -1).map((k) => (typeof k === 'number' ? `[${k}]` : `.${String(k)}`)).join('')}`; + expect(refused.map((i) => listedAt(i.path))).toEqual(todos.map((t) => t.path)); + expect(todos).toHaveLength(VIEW_DATA_COMPONENTS.length); + for (const issue of refused) expect(issue.message).toMatch(PRESCRIPTION); for (const type of VIEW_DATA_COMPONENTS) { expect(ComponentPropsMap[type].safeParse({ objectName: 'crm_case', data: apiData() }).success, type).toBe(false); } diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts index ca0541b4b80..74ac33bc530 100644 --- a/packages/spec/src/conversions/refused-shapes.ts +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -35,7 +35,9 @@ * `controlled_by_parent` refinement's * ({@link controlledByParentMasterRefusal}), `ViewDataSchema`'s parse — for * a page component, through the component's own props row, which the - * component-props gate parses — and the value-slot judge + * component-props gate parses and whose verdict on this one retirement the + * page parse shares (`checkPageComponentViewData`, #23357) — and the + * value-slot judge * `AutomationEngine.registerFlow` and `objectstack validate` run * ({@link flowNodeValueTemplateRefusals}). A site is listed here exactly * when that door refuses it. @@ -205,13 +207,18 @@ interface SafeParseable { * when that refusal is this retirement's — `undefined` otherwise (#23317). * * The page parse leaves a component's `properties` an open record; the door - * that judges them is the component-props gate, which parses the bag against + * that judges the bag is the component-props gate, which parses it against * the type's `ComponentPropsMap` row. So the judge here is that row's own * `data` member, and the site is listed only when it refuses the binding with * the prescription `ViewDataSchema` refuses it with. That holds for every * component whose `data` is a view data source and for no other, without a * list of types to keep in step: a row whose `data` is a bare array refuses * the binding for its shape, never with this prescription. + * + * Since #23357 the page parse refuses this one binding too + * (`checkPageComponentViewData`, `ui/page.zod.ts`), on the same four types and + * with the same prescription, so a listed site is refused at the save door as + * well; `refused-shapes.test.ts` pins the two judges to one set of types. */ function componentApiBindingRefusal(type: unknown, data: unknown): string | undefined { const prescription = apiBindingRefusal(data); @@ -237,10 +244,11 @@ const VIEW_API_BINDING_FATE = const COMPONENT_API_BINDING_FATE = 'No mechanical move: the successor is a page component\'s `dataSource` binding, which takes a same-origin ' + '`url` and no other request key, so the request cannot be carried over as written; and deleting `data` ' - + 'would make the component query its object instead and change what it shows. Left as stored, the page ' - + 'still loads and saves, because the page parse does not judge a component\'s properties, while ' - + '`os validate`, `os build` and `os lint` refuse the binding in source. Decide what the component binds now, ' - + 'and which same-origin endpoint, if any, replaces the request.'; + + 'would make the component query its object instead and change what it shows. Left as stored, the parse ' + + 'refuses the page: it still loads and is served, registered under `[metadata_spec_invalid]` with the ' + + 'refusal in the `_diagnostics` of every read, and its next save through the Studio, REST or MCP is refused ' + + 'with 422, as `os validate`, `os build` and `os lint` refuse the binding in source. Decide what the component ' + + 'binds now, and which same-origin endpoint, if any, replaces the request.'; const viewDataApiProvider: RefusedStoredShape = { id: 'view-data-api-provider-retired', diff --git a/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts index 25cfed93468..8c65cd22ac8 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts @@ -37,8 +37,9 @@ export const entry: SemanticMigration = { + 'move either: deleting its `data` would make it query its object instead.', acceptanceCriteria: "No view in `stack.views[]`, in any object's `listViews` / `formViews` map or in a stored " + "`sys_metadata` view row carries `data.provider: 'api'`, and no page component's `data` prop does; the " - + 'parse refuses one in a view, and the component-props gate of `os validate`, `os build` and `os lint` ' - + 'refuses one in a page component, with the prescription. ' + + 'parse refuses one in a view and in a page component, at every door that parses a page (the metadata ' + + 'save door\'s 422 included), and the component-props gate of `os validate`, `os build` and `os lint` ' + + 'refuses one in a page component too, with the prescription. ' + "Every endpoint such a view read is either a page component's API binding on a same-origin path " + 'with no credential key, or dropped on purpose; and every converted view binds its object or static ' + 'rows and loads. `os migrate meta --stored` lists each stored view, and each page component of a stored ' diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index bae76fa5c25..211b22708d1 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -62,6 +62,7 @@ import { checkPageRequiresKind, checkPagePrintComposition, checkPageSlotPair, + checkPageComponentViewData, } from './page.zod'; import { GlobalFilterSchema, @@ -299,6 +300,53 @@ const pageSlotPairFixtures: Fixture[] = [ { label: 'a slot map with neither', value: { ...SLOT_PAGE, slots: { discussion: [] } }, refusesAt: [] }, ]; +// [#23357] A component's `data` prop naming the retired view-level `api` +// provider is refused at the component's `properties.data.provider`, at any +// depth under `regions` and `slots`, on the four types whose `data` is a view +// data source. No fixture here carries `print`, `source`, `requires` or a +// `details` + `tabs` pair, so the four siblings stay silent and each row +// exercises THIS check alone. The provider is spelled through a constant: the +// spec's tree-scoped absence pin reads authored literals, and these bodies are +// refusals, not authorings. +const RETIRED_PROVIDER = 'api' as const; +const RETIRED_BINDING = { provider: RETIRED_PROVIDER, read: { url: 'https://partner.example.com/cases' } } as const; +const viewDataBlock = (type: string, data: unknown) => ({ id: 'cases', type, properties: { objectName: 'crm_case', data } }); +const pageComponentViewDataFixtures: Fixture[] = [ + { + label: 'an `object-grid` in a region binding the retired provider', + value: { ...PAGE_BASE, regions: [{ name: 'main', components: [viewDataBlock('object-grid', RETIRED_BINDING)] }] }, + refusesAt: ['regions.0.components.0.properties.data.provider'], + }, + { + label: 'an `object-tree` nested in a `page:tabs` item binding it', + value: { + ...PAGE_BASE, + regions: [{ name: 'main', components: [{ type: 'page:tabs', properties: { items: [{ label: 'Cases', children: [viewDataBlock('object-tree', RETIRED_BINDING)] }] } }] }], + }, + refusesAt: ['regions.0.components.0.properties.items.0.children.0.properties.data.provider'], + }, + { + label: 'an `object-map` in a slot binding it', + value: { ...SLOT_PAGE, slots: { details: viewDataBlock('object-map', RETIRED_BINDING) } }, + refusesAt: ['slots.details.properties.data.provider'], + }, + { + label: 'an `object-gantt` binding its object', + value: { ...PAGE_BASE, regions: [{ name: 'main', components: [viewDataBlock('object-gantt', { provider: 'object', object: 'crm_case' })] }] }, + refusesAt: [], + }, + { + label: 'an `object-kanban` (a row-array `data`, its own contract) carrying the retired provider', + value: { ...PAGE_BASE, regions: [{ name: 'main', components: [viewDataBlock('object-kanban', RETIRED_BINDING)] }] }, + refusesAt: [], + }, + { + label: 'a type with no props row carrying the retired provider', + value: { ...PAGE_BASE, regions: [{ name: 'main', components: [viewDataBlock('custom:partner-widget', RETIRED_BINDING)] }] }, + refusesAt: [], + }, +]; + const DATE_FILTER = { field: 'created_at', type: 'date' } as const; const dateDefaultFixtures: Fixture[] = [ @@ -514,6 +562,7 @@ const MIRRORED: MirroredSchema[] = [ { name: 'checkPageRequiresKind', check: checkPageRequiresKind, fixtures: pageRequiresFixtures }, { name: 'checkPagePrintComposition', check: checkPagePrintComposition, fixtures: pagePrintFixtures }, { name: 'checkPageSlotPair', check: checkPageSlotPair, fixtures: pageSlotPairFixtures }, + { name: 'checkPageComponentViewData', check: checkPageComponentViewData, fixtures: pageComponentViewDataFixtures }, ], cleanFixtures: [{ ...PAGE_BASE }], }, @@ -669,9 +718,9 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () expect(attachments(src, 'checkListViewPageMount')).toBe(0); }); - it('page.zod.ts declares all four exports and attaches each to PageSchema', () => { + it('page.zod.ts declares all five exports and attaches each to PageSchema', () => { const src = read('page.zod.ts'); - for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageSlotPair']) { + for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageSlotPair', 'checkPageComponentViewData']) { expect(src).toContain(`export function ${name}(`); expect(declarations(src, name)).toBe(1); expect(attachments(src, name)).toBe(1); @@ -706,6 +755,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi ['checkPageRequiresKind', checkPageRequiresKind], ['checkPagePrintComposition', checkPagePrintComposition], ['checkPageSlotPair', checkPageSlotPair], + ['checkPageComponentViewData', checkPageComponentViewData], ['checkGlobalFilterDateDefaultValue', checkGlobalFilterDateDefaultValue], ] as const)('%s — reference identity, and the `(value, ctx)` arity', (name, fn) => { expect((ui as Record)[name]).toBe(fn); diff --git a/packages/spec/src/ui/page-component-view-data.test.ts b/packages/spec/src/ui/page-component-view-data.test.ts new file mode 100644 index 00000000000..52738d89343 --- /dev/null +++ b/packages/spec/src/ui/page-component-view-data.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; + +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { ObjectStackSchema } from '../stack.zod'; +import { ComponentPropsMap } from './component.zod'; +import { PageSchema, definePage } from './page.zod'; +import { ViewDataSchema } from './view.zod'; + +// ─── #23357: the page parse refuses a component's retired `api` data binding ── +// +// The view-level `api` data source is retired (`view.zod.ts`, ADR-0049): a +// view's own `data` naming it is refused by `ViewDataSchema`'s parse. A page +// component whose `data` prop is that same schema (`object-grid`, `object-map`, +// `object-gantt`, `object-tree`) took the same arm, but the page parse leaves +// `properties` an open record and the component-props gate that judges the bag +// runs at the CLI doors only. So a page saved through the Studio, REST or MCP +// kept the binding. `checkPageComponentViewData` is the narrow judge triage +// ruled: this one retirement, at every door that parses a page. +// +// Assertion set (the `view-data-api-provider-retirement.test.ts` precedent): a +// schema refusal raises a `ZodError` whose issues carry `code` and `path` but no +// ADR-0112 `status`, so these pins assert the refusal, the issue `code`, the +// `path` naming WHICH component refused, and the prescription text — the +// retirement's own, byte for byte (the 422 envelope at the save door is pinned +// in `@objectstack/metadata-protocol`'s `protocol.save-union-issues.test.ts`). +// +// The provider is spelled through a constant: the spec's tree-scoped absence pin +// reads authored literals, and these bodies are refusals, not authorings. + +const RETIRED_PROVIDER = 'api' as const; +const RETIRED_BINDING = { + provider: RETIRED_PROVIDER, + read: { url: 'https://partner.example.com/cases', method: 'GET', headers: { Authorization: 'Bearer s3cr3t' } }, +} as const; + +/** The four types whose `data` is a view data source, as the D3 entry names them. */ +const VIEW_DATA_COMPONENTS = ['object-grid', 'object-map', 'object-gantt', 'object-tree'] as const; + +/** The retirement's own prescription — read off `ViewDataSchema`, never retyped. */ +const PRESCRIPTION = (() => { + const verdict = ViewDataSchema.safeParse(RETIRED_BINDING); + if (verdict.success) throw new Error('ViewDataSchema accepts the retired provider — the retirement is gone'); + return verdict.error.issues.find((issue) => issue.path.join('.') === 'provider')!.message; +})(); + +const block = (type: string, data: unknown, id = 'cases') => ({ id, type, properties: { objectName: 'crm_case', data } }); +const pageOf = (components: unknown[], extra: Record = {}) => ({ + name: 'partner_page', + label: 'Partner', + type: 'app', + regions: [{ name: 'main', components }], + ...extra, +}); + +function refusalsOf(result: z.ZodSafeParseResult) { + return result.success ? [] : result.error.issues.map((issue) => ({ code: issue.code, path: issue.path.join('.'), message: issue.message })); +} + +describe('PageSchema — a component `data` prop naming the retired `api` provider is refused', () => { + it('the prescription read off ViewDataSchema is the retirement\'s (anti-vacuity)', () => { + expect(PRESCRIPTION).toMatch(/^`view\.data\.provider: 'api'` was removed in @objectstack\/spec 18 \(ADR-0049/); + }); + + it.each(VIEW_DATA_COMPONENTS)('%s in a region: one `custom` issue at its `properties.data.provider`, carrying the prescription', (type) => { + expect(refusalsOf(PageSchema.safeParse(pageOf([block(type, RETIRED_BINDING)])))).toEqual([ + { code: 'custom', path: 'regions.0.components.0.properties.data.provider', message: PRESCRIPTION }, + ]); + }); + + it('the bare `{ provider }` is refused the same way — the value, not the request block, is what left', () => { + expect(refusalsOf(PageSchema.safeParse(pageOf([block('object-grid', { provider: RETIRED_PROVIDER })])))).toEqual([ + { code: 'custom', path: 'regions.0.components.0.properties.data.provider', message: PRESCRIPTION }, + ]); + }); + + it('reaches a component nested in another component, at any depth, and a component in a slot', () => { + const tabs = { + id: 'tabs', + type: 'page:tabs', + properties: { items: [{ label: 'Cases', children: [block('object-grid', RETIRED_BINDING, 'in_tab')] }] }, + }; + const flex = { id: 'row', type: 'flex', properties: { children: [{ id: 'card', type: 'page:card', properties: { children: [block('object-tree', RETIRED_BINDING, 'deep')] } }] } }; + const page = pageOf([tabs, flex], { kind: 'slotted', type: 'record', object: 'crm_case', slots: { details: block('object-map', RETIRED_BINDING, 'slotted'), header: [block('object-gantt', RETIRED_BINDING, 'head')] } }); + expect(refusalsOf(PageSchema.safeParse(page)).map((r) => [r.code, r.path])).toEqual([ + ['custom', 'regions.0.components.0.properties.items.0.children.0.properties.data.provider'], + ['custom', 'regions.0.components.1.properties.children.0.properties.children.0.properties.data.provider'], + // The parse emits the slot map in the schema's own key order, `header` first. + ['custom', 'slots.header.0.properties.data.provider'], + ['custom', 'slots.details.properties.data.provider'], + ]); + }); + + it('every door that parses a page refuses it: `definePage`, the `page` metadata-type schema, and the stack', () => { + const page = pageOf([block('object-grid', RETIRED_BINDING)]); + expect(() => definePage(page as Parameters[0])).toThrow(/`view\.data\.provider: 'api'` was removed/); + // The schema the metadata save door and the read / boot diagnostics resolve. + const door = getMetadataTypeSchema('page')!; + expect(refusalsOf(door.safeParse(page))).toEqual([ + { code: 'custom', path: 'regions.0.components.0.properties.data.provider', message: PRESCRIPTION }, + ]); + expect(refusalsOf(ObjectStackSchema.safeParse({ pages: [page] }))).toEqual([ + { code: 'custom', path: 'pages.0.regions.0.components.0.properties.data.provider', message: PRESCRIPTION }, + ]); + }); + + it('a self-referencing component (a hand-built stack object) is judged once and the walk ends', () => { + const loop: { id: string; type: string; properties: { children: unknown[] } } = { id: 'loop', type: 'flex', properties: { children: [] } }; + loop.properties.children.push(loop, block('object-grid', RETIRED_BINDING)); + expect(refusalsOf(PageSchema.safeParse(pageOf([loop]))).map((r) => r.path)).toEqual([ + 'regions.0.components.0.properties.children.1.properties.data.provider', + ]); + }); +}); + +describe('PageSchema — what the check leaves alone (controls)', () => { + it.each([ + ['the `object` provider', block('object-grid', { provider: 'object', object: 'crm_case' })], + ['the `value` provider', block('object-map', { provider: 'value', items: [{ name: 'a' }] })], + ['the `schema` provider', block('object-tree', { provider: 'schema', schemaId: 'report' })], + ['no `data` at all', { id: 'cases', type: 'object-gantt', properties: { objectName: 'crm_case' } }], + ['another unknown provider — the props gate\'s to judge, not this retirement\'s', block('object-grid', { provider: 'rest' })], + ['a row-array `data` type carrying the retired provider — its own contract', block('object-kanban', RETIRED_BINDING)], + ['a type with no props row carrying the retired provider', block('custom:partner-widget', RETIRED_BINDING)], + ['the governed element binding on the component itself', { + id: 'cases', type: 'object-grid', properties: { objectName: 'crm_case' }, dataSource: { provider: RETIRED_PROVIDER, read: { url: '/api/v1/cases' } }, + }], + ])('accepts: %s', (_label, node) => { + const result = PageSchema.safeParse(pageOf([node])); + expect(result.success, JSON.stringify(refusalsOf(result))).toBe(true); + }); +}); + +describe('the check covers exactly the props rows whose `data` is ViewDataSchema — derived, both directions', () => { + const defOf = (schema: unknown): unknown => (schema as { _zod?: { def?: unknown } } | undefined)?._zod?.def; + /** A row member through its `optional` / `default` / `nullable` wrappers. */ + const unwrapped = (schema: unknown): unknown => { + let current = schema; + for (;;) { + const def = defOf(current) as { type?: string; innerType?: unknown } | undefined; + if (!def || !['optional', 'default', 'nullable'].includes(def.type ?? '')) return current; + current = def.innerType; + } + }; + const rowTakesViewData = (type: string): boolean => { + const row = (ComponentPropsMap as Record }>)[type]!; + const member = row.shape?.data; + return member !== undefined && defOf(unwrapped(member)) === defOf(ViewDataSchema); + }; + + it('the derivation from ComponentPropsMap is the four types (anti-vacuity: it is not empty, and the map is populated)', () => { + const types = Object.keys(ComponentPropsMap); + expect(types.length).toBeGreaterThan(40); + expect(types.filter(rowTakesViewData)).toEqual([...VIEW_DATA_COMPONENTS]); + }); + + it('for every props row, the page parse refuses the retired binding exactly when the row\'s `data` is ViewDataSchema', () => { + // Read at the binding's own position: a type the vocabulary retired by name + // (`user:profile`, …) is refused at `type` for that, which is not this check. + const AT = 'regions.0.components.0.properties.data.provider'; + for (const type of Object.keys(ComponentPropsMap)) { + const issues = refusalsOf(PageSchema.safeParse(pageOf([block(type, RETIRED_BINDING)]))); + expect(issues.some((issue) => issue.path === AT), type).toBe(rowTakesViewData(type)); + } + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 5ad67683d54..ed11df9efb6 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -17,6 +17,7 @@ import { AddRecordConfigSchema, ListColumnSchema, EmptyStateSchema, + ViewDataSchema, } from './view.zod'; import { lazySchema } from '../shared/lazy-schema'; @@ -1073,6 +1074,113 @@ export function checkPageSlotPair( }); } +/** + * The page-component types whose `data` prop is a view data source — the + * `object-*` blocks whose `ComponentPropsMap` row (`component.zod.ts`) + * declares `data` as {@link ViewDataSchema}. Read by + * {@link checkPageComponentViewData} and by nothing else. + * + * Declared here, not derived, because this module cannot read those rows: + * `component.zod.ts` imports this module (`ElementDataSourceSchema`, + * `RETIRED_PAGE_COMPONENT_TYPES`), and this package has no import cycle. The + * set is held equal to the derivation — every row whose `data` member is + * `ViewDataSchema` by identity, and no other — by + * `page-component-view-data.test.ts`, so a row that gains or loses the view + * data source fails that pin the day it lands (the + * {@link PRINT_REFUSED_PAGE_COMPONENT_TYPES} discipline). A type whose row + * takes a row array as `data` (`object-kanban`, `object-calendar`, + * `object-timeline`), and a type with no row (a custom or registered SDUI + * block), is not in it: there `data` is that component's own contract. + */ +const VIEW_DATA_SOURCE_COMPONENT_TYPES: ReadonlySet = new Set([ + 'object-grid', + 'object-map', + 'object-gantt', + 'object-tree', +]); + +/** + * The page parse refuses a component's `data` prop that names the retired + * view-level `api` provider, as a view's own `data` is refused (#23357; the + * retirement is `view.zod.ts`'s, ADR-0049 enforce-or-remove). + * + * Why the page parse: it leaves a component's `properties` an open record, + * and the door that judges the bag — the component-props gate, against the + * type's `ComponentPropsMap` row — runs at the CLI doors only, a rollout + * decision this check does not reopen. So a page saved through the Studio, + * REST or MCP kept a component's retired binding, and the console kept + * sending its request with the stored headers. This check is the narrow + * judge: one retirement, at every door that parses a page (`definePage`, + * `defineStack`, the metadata save door's `422 INVALID_METADATA`, and the + * read and boot diagnostics), and nothing else about the bag. + * + * The judge is the retirement's own. `ViewDataSchema` parses the component's + * `data`, and the prescription its issue at `provider` carries is raised + * here, located at the component's `properties.data.provider`. Nothing is + * copied, so a page and a view refuse with one text. The code is `custom`, + * the code of every object-level check on this schema (the view's own + * `invalid_union` is the discriminated union's). + * + * Reach: every component node under `regions` and `slots`, at any depth — a + * node nested in another component's `properties` (a `page:tabs` item's + * `children`, a container's `children`) included. The canonical slot list + * (`pageComponentSlotPositions()`) lives in `component.zod.ts`, out of this + * module's reach for the cycle reason above, so the walk descends every + * object and array under `regions` and `slots` instead. That is a superset of + * every slot position, so it reaches no less than `os migrate meta --stored` + * lists or the props gate judges, and what it judges at a node stays narrow: + * a type in {@link VIEW_DATA_SOURCE_COMPONENT_TYPES} whose `properties.data` + * names the retired provider. A binding found anywhere else is still a + * request, and its headers, stored in metadata and served to every reader. + * + * Exported for the reason {@link checkPageSourceCompleteness} is: a + * downstream mirror that derives its schema from `PageSchema.shape` drops + * every object-level check, and re-attaches this one with + * `.superRefine(checkPageComponentViewData)`. `PageSchema` attaches this same + * binding — pinned in `object-refinement-check-exports.test.ts`. + */ +export function checkPageComponentViewData( + page: { regions?: unknown; slots?: unknown }, + ctx: z.RefinementCtx, +): void { + // A hand-built `defineStack` object can share a node or refer back to one; + // each object is visited once. + const seen = new Set(); + const visit = (value: unknown, path: PropertyKey[]): void => { + if (value === null || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + value.forEach((entry, i) => visit(entry, [...path, i])); + return; + } + const node = value as Record; + const properties = node.properties; + if ( + typeof node.type === 'string' && VIEW_DATA_SOURCE_COMPONENT_TYPES.has(node.type) + && properties !== null && typeof properties === 'object' && !Array.isArray(properties) + ) { + const bound = (properties as Record).data; + // The retired spelling only: any other `data` is the props gate's to judge. + if (bound !== null && typeof bound === 'object' && (bound as { provider?: unknown }).provider === 'api') { + const verdict = ViewDataSchema.safeParse(bound); + const retirement = verdict.success + ? undefined + : verdict.error.issues.find((issue) => issue.path.length === 1 && issue.path[0] === 'provider'); + if (retirement !== undefined) { + ctx.addIssue({ + code: 'custom', + message: retirement.message, + path: [...path, 'properties', 'data', ...retirement.path], + }); + } + } + } + for (const [key, child] of Object.entries(node)) visit(child, [...path, key]); + }; + visit(page.regions, ['regions']); + visit(page.slots, ['slots']); +} + /** * Page Schema * Defines a composition of components for a specific context. @@ -1723,11 +1831,15 @@ export const PageSchema = lazySchema(() => strictObject({ // #22568: `slots.details` beside `slots.tabs` never renders — the `tabs` // slot replaces the strip the Details tab lives in. Attached by identifier // for the same reason as the checks above. - .superRefine(checkPageSlotPair)); -// PageSchema's cross-field rules are the four checks above: the + .superRefine(checkPageSlotPair) + // #23357: a component's `data` prop naming the retired view-level `api` + // provider is refused by the page parse, as a view's own `data` is. + // Attached by identifier for the same reason as the checks above. + .superRefine(checkPageComponentViewData)); +// PageSchema's cross-field rules are the five checks above: the // ADR-0080/0081 source completeness check, the compiled-kind `requires` -// check, the print-page composition check and the `details` + `tabs` slot -// pair check. It once also required +// check, the print-page composition check, the `details` + `tabs` slot +// pair check and the component view-data check. It once also required // `recordReview`/`blankLayout` and `slots` (all removed — unrendered roadmap / // "required-but-unauthorable" Studio traps). diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 1c8a4fbbb9a..3544c84a316 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -188,6 +188,11 @@ export type { HttpMethodSubset } from '../shared/http.zod'; * silently change what the view shows. An author or a stored row still carrying * the arm is refused at parse with this prescription; the D3 semantic entry * `view-data-api-provider-retired` carries the judgement an upgrader owes. + * + * A page component whose `data` prop is this schema (`object-grid`, + * `object-map`, `object-gantt`, `object-tree`) is refused by the page parse + * with this prescription, read off this union's own issue rather than copied + * (`checkPageComponentViewData`, `ui/page.zod.ts`, #23357). */ const VIEW_DATA_API_PROVIDER_RETIRED = "`view.data.provider: 'api'` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — "