Skip to content
Merged
26 changes: 26 additions & 0 deletions .changeset/23357-page-component-view-data-api-refused.md
Original file line number Diff line number Diff line change
@@ -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: '<object_name>'`, 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.

<!-- adr-0087: not-required (already-registered view-data-api-provider-retired) the step-18 entry already names the data prop of these four page components in its scope; this change makes the page parse refuse what that entry retired, and its acceptance criteria now name the page parse beside the component-props gate -->
82 changes: 82 additions & 0 deletions packages/metadata-protocol/src/protocol.save-union-issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@
"checkGlobalFilterDateDefaultValue (function)",
"checkListViewCalendarVisualization (function)",
"checkListViewChartBinding (function)",
"checkPageComponentViewData (function)",
"checkPagePrintComposition (function)",
"checkPageRequiresKind (function)",
"checkPageSlotPair (function)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
28 changes: 25 additions & 3 deletions packages/spec/src/conversions/refused-shapes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
}
Expand Down
20 changes: 14 additions & 6 deletions packages/spec/src/conversions/refused-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand Down
Loading
Loading