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
57 changes: 57 additions & 0 deletions .changeset/ui-cloud-connection-widgets-unknown-keys-refused.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/spec": minor
---

feat(spec): declare `cloud-connection:panel` / `marketplace:installed-list` in `ComponentPropsMap` — undeclared keys on the two are refused (#11575)

**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
launch-window convention ships it as `minor`; the migration prescription is
registered under protocol major 18, where `os migrate meta` users will look).

These were two more instances of the #8691/#8744 silent no-op class:
console-registered widgets on `@objectstack/cloud-connection`'s published
Setup pages, reachable through the component type union's open string arm,
with registered renderers but no `ComponentPropsMap` row — so the #5068
component-props gate's dispatch skipped them as unregistered and any authored
key rode through every validator in silence.

The new rows are strict and **empty**, measured from the renderers' actual
read points at the objectui pin, not from the registrations' declared-input
lists (#8691/#8744 record where those diverge — here the two happen to
agree): both registrations discard the schema node entirely
(`() => <CloudConnectionPanel />`, `() => <InstalledList />`) and neither
component function takes a prop, so the widgets accept **no configuration at
all**, and an authored key is now a publish-time refusal naming the surface
instead of a silent no-op.

**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the
shape both plugin-shipped pages (`cloud_connection_settings`,
`marketplace_installed`) author today, byte-identically. Node-level keys
(`visibleWhen`, `id`, `style`, …) are unaffected: they live on the component
node, and the refusal's guidance says so.

## FROM → TO

```ts
// before — parsed green everywhere; the panel polls on its own schedule anyway
{
type: 'cloud-connection:panel',
properties: { pollInterval: 5 }, // silent no-op: the widget reads nothing
}

// after — any key is a publish-time refusal naming the zero-prop surface;
// write the measured shape
{
type: 'cloud-connection:panel',
properties: {},
}
```

There is deliberately no automatic rewrite: a key authored on either widget
configures nothing and is removed, not renamed — behaviour that seems to need
one is a renderer capability request against objectui, not a metadata key.
`os migrate meta` surfaces the change as a structured TODO (semantic entry
`ui-cloud-connection-widgets-unknown-keys-refused`, protocol major 18 — this
refusal is not part of the v17.0.0 cut).

<!-- adr-0087: registered ui-cloud-connection-widgets-unknown-keys-refused -->
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,16 +20,19 @@
* preconditions, the verdict, and a downgrade control proving the detector
* reaches these real exports.
*
* ## The two exempted component types
* ## No standing exemptions (#11575)
*
* `cloud-connection:panel` and `marketplace:installed-list` are
* console-registered widgets with no `ComponentPropsMap` row, so door 3 has
* no schema to read their `properties` with. The exemption is asserted
* EXACTLY (a new unmapped type reds), and it is valid only while those
* components author an EMPTY props bag — nothing authored is nothing to
* serve bare. The moment either widget grows a real authored prop, the
* emptiness assert reds and forces the decision: give the type a
* `ComponentPropsMap` row, or widen the exemption knowingly.
* `cloud-connection:panel` and `marketplace:installed-list` were exempted
* here between #11480 and #11575: console-registered widgets with no
* `ComponentPropsMap` row, so door 3 had no schema to read their
* `properties` with. #11575 gave both types their rows (strict, empty —
* measured from the renderers' read points at the `.objectui-sha` pin), so
* door 3 now reads both bags and the exemption lists are empty. The
* machinery stays: the exemption set is still asserted EXACTLY, so any NEW
* unmapped type reds and forces the same decision — declare the props
* schema in `ComponentPropsMap`, or record the exemption here with the
* reason (and then also pin the exempted bags empty, as the pre-#11575
* revision of this file did).
*/

import { readFileSync, readdirSync } from 'node:fs';
Expand All@@ -40,7 +43,6 @@ import type { Page } from '@objectstack/spec/ui';
import {
auditPageExpressionEnvelopes,
renderBareExpressionFindings,
walkPageComponents,
} from '@objectstack/lint';
import { CloudConnectionSettingsPage } from './cloud-connection-ui.js';
import { MarketplaceInstalledPage } from './marketplace-ui.js';
Expand All@@ -56,18 +58,19 @@ const HERE = dirname(fileURLToPath(import.meta.url));

/**
* Every page this package ships, audited by export name — with the unmapped
* component types each page is EXPECTED to report (the exemptions above).
* component types each page is EXPECTED to report (none since #11575; see
* the module header).
*/
const AUDITED_PAGES: { exportName: string; page: Page; exemptUnmappedTypes: string[] }[] = [
{
exportName: 'CloudConnectionSettingsPage',
page: CloudConnectionSettingsPage,
exemptUnmappedTypes: ['cloud-connection:panel'],
exemptUnmappedTypes: [],
},
{
exportName: 'MarketplaceInstalledPage',
page: MarketplaceInstalledPage,
exemptUnmappedTypes: ['marketplace:installed-list'],
exemptUnmappedTypes: [],
},
];

Expand DownExpand Up@@ -170,27 +173,14 @@ describe('cloud-connection Page exports serve canonical expression envelopes', (
});

it.each(AUDITS)('$exportName: unmapped component types are EXACTLY the recorded exemptions (door 3 precondition)', ({ audit, exemptUnmappedTypes }) => {
// See the module header for why these two types are exempt. Anything else
// No exemptions stand since #11575 (see the module header). Anything
// unmapped is a new door-3 blind spot: declare the props schema in
// `ComponentPropsMap`, or record the exemption here with the reason.
// `ComponentPropsMap`, or record the exemption here with the reason —
// and then also pin the exempted bags empty, as the pre-#11575 revision
// of this file did.
expect(audit.unmappedTypes.map(e => e.type).sort()).toEqual([...exemptUnmappedTypes].sort());
});

it.each(AUDITS)('$exportName: every exempted component authors an EMPTY props bag', ({ page, exemptUnmappedTypes }) => {
// The exemption above is only sound while there is nothing authored for
// door 3 to miss. A real key landing in one of these bags must force a
// decision (props schema row, or a conscious wider exemption) — not ride
// through a standing exemption silently.
const offenders = walkPageComponents(page as AnyRec, '')
.filter(w => typeof w.component.type === 'string' && exemptUnmappedTypes.includes(w.component.type))
.filter(w => {
const props = w.component.properties;
return !!props && typeof props === 'object' && Object.keys(props).length > 0;
})
.map(w => `${w.path} [${String(w.component.type)}]`);
expect(offenders.join('\n')).toBe('');
});

it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => {
expect(
audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'),
Expand Down
54 changes: 54 additions & 0 deletions packages/lint/src/validate-component-props.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -775,3 +775,57 @@ describe('validateComponentProps — record:alert / record:quick_actions / recor
expect(findings[0].message).toContain('`dock`');
});
});

/**
* #11575 — the two `@objectstack/cloud-connection` console widgets, so the
* gate's dispatch reaches them.
*
* The pre-fix state these pin against: `cloud-connection:panel` and
* `marketplace:installed-list` had no `ComponentPropsMap` row (they are
* open-string-arm types — never in `PageComponentType` — so nothing else
* judged them either), and the walker's unregistered-type skip swallowed the
* whole props bag: any authored key produced ZERO findings from
* validate/build. Their rows are strict and EMPTY — measured from the
* renderers' read points at the `.objectui-sha` pin, where both registrations
* discard the schema node (`() => <Widget />`) — so EVERY authored key is a
* finding. Remove either map row and its loud test here goes back to that
* silence.
*/
describe('validateComponentProps — cloud-connection:panel / marketplace:installed-list are dispatched (#11575)', () => {
it('reports any key authored on `cloud-connection:panel`, naming the zero-prop surface', () => {
const findings = validateComponentProps(
stackWith([{
type: 'cloud-connection:panel',
properties: { pollInterval: 5 },
}]),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
expect(findings[0].where).toBe('page "probe_page" · cloud-connection:panel');
expect(findings[0].message).toContain('`pollInterval`');
expect(findings[0].message).toContain('cloud-connection:panel');
});

it('reports any key authored on `marketplace:installed-list` the same way', () => {
const findings = validateComponentProps(
stackWith([{
type: 'marketplace:installed-list',
properties: { filter: 'installed' },
}]),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
expect(findings[0].where).toBe('page "probe_page" · marketplace:installed-list');
expect(findings[0].message).toContain('`filter`');
});

it('stays silent on the empty bag both plugin-shipped pages author', () => {
const findings = validateComponentProps(
stackWith([
{ type: 'cloud-connection:panel', properties: {} },
{ type: 'marketplace:installed-list', properties: {} },
]),
);
expect(findings).toEqual([]);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'ui-cloud-connection-widgets-unknown-keys-refused',
surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — '
+ '`properties` (any key at all: both widgets declare no props)',
replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. Neither '
+ 'widget reads any prop: the console registrations discard the schema node '
+ '(`() => <Widget />`) and the components take no arguments, so there is no declared '
+ 'key to move to — a key authored on either widget configures nothing and is removed, '
+ 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component '
+ 'node, where the page runtime reads them.',
reason:
'These were two more instances of the #8691/#8744 class: console-registered widgets on '
+ '`@objectstack/cloud-connection`\'s published Setup pages, reachable through the '
+ 'component type union\'s open string arm, with registered renderers but no '
+ '`ComponentPropsMap` row — so the #5068 props gate\'s dispatch skipped them as '
+ 'unregistered and any authored key rode through every validator in silence. The new '
+ 'rows are strict and EMPTY, measured from the renderers\' actual read points at the '
+ 'objectui pin (not from the registrations\' declared-input lists): both registrations '
+ 'ignore the component node entirely, so the widgets accept no configuration at all, '
+ 'and an authored key is now a publish-time refusal naming the surface instead of a '
+ 'silent no-op.',
acceptanceCriteria:
'Every `cloud-connection:panel` / `marketplace:installed-list` node authors an empty '
+ '(or absent) `properties` bag and validates clean — the two plugin-shipped pages '
+ '(`cloud_connection_settings`, `marketplace_installed`) already do; `objectstack '
+ 'validate` reports no `component-props-unknown-key` finding for these types. Any '
+ 'remaining authored key on either widget is deleted (it never configured anything), '
+ 'and behaviour that seems to need one is a renderer capability request against '
+ 'objectui, not a metadata key.',
};
30 changes: 30 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6139,6 +6139,36 @@ const step18: MigrationStep = {
+ 'of an envelope-level code; constructing an ApiError with a retired spelling '
+ 'fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently.',
},
{
id: 'ui-cloud-connection-widgets-unknown-keys-refused',
surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — '
+ '`properties` (any key at all: both widgets declare no props)',
replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. Neither '
+ 'widget reads any prop: the console registrations discard the schema node '
+ '(`() => <Widget />`) and the components take no arguments, so there is no declared '
+ 'key to move to — a key authored on either widget configures nothing and is removed, '
+ 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component '
+ 'node, where the page runtime reads them.',
reason:
'These were two more instances of the #8691/#8744 class: console-registered widgets on '
+ '`@objectstack/cloud-connection`\'s published Setup pages, reachable through the '
+ 'component type union\'s open string arm, with registered renderers but no '
+ '`ComponentPropsMap` row — so the #5068 props gate\'s dispatch skipped them as '
+ 'unregistered and any authored key rode through every validator in silence. The new '
+ 'rows are strict and EMPTY, measured from the renderers\' actual read points at the '
+ 'objectui pin (not from the registrations\' declared-input lists): both registrations '
+ 'ignore the component node entirely, so the widgets accept no configuration at all, '
+ 'and an authored key is now a publish-time refusal naming the surface instead of a '
+ 'silent no-op.',
acceptanceCriteria:
'Every `cloud-connection:panel` / `marketplace:installed-list` node authors an empty '
+ '(or absent) `properties` bag and validates clean — the two plugin-shipped pages '
+ '(`cloud_connection_settings`, `marketplace_installed`) already do; `objectstack '
+ 'validate` reports no `component-props-unknown-key` finding for these types. Any '
+ 'remaining authored key on either widget is deleted (it never configured anything), '
+ 'and behaviour that seems to need one is a renderer capability request against '
+ 'objectui, not a metadata key.',
},
{
id: 'ui-record-blocks-unknown-keys-refused',
surface: 'page `record:alert` / `record:quick_actions` / `record:history` / '
Expand Down
37 changes: 37 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -885,6 +885,43 @@ describe('ComponentPropsMap', () => {
expect(() => ComponentPropsMap['global:search'].parse({})).not.toThrow();
expect(() => ComponentPropsMap['user:profile'].parse({})).not.toThrow();
});

// #11575 — the two `@objectstack/cloud-connection` console widgets. Rows
// exist so the #5068 gate's dispatch reaches them; the accepted key set is
// EMPTY, measured from the renderers' read points at the `.objectui-sha`
// pin (both registrations discard the schema node — `() => <Widget />`).
describe('plugin console widgets (#11575)', () => {
it('declares rows for cloud-connection:panel and marketplace:installed-list', () => {
expect(ComponentPropsMap['cloud-connection:panel']).toBeDefined();
expect(ComponentPropsMap['marketplace:installed-list']).toBeDefined();
});

it('accepts the empty bag both shipped pages author', () => {
expect(() => ComponentPropsMap['cloud-connection:panel'].parse({})).not.toThrow();
expect(() => ComponentPropsMap['marketplace:installed-list'].parse({})).not.toThrow();
});

it('refuses any authored key, naming the surface — the pre-row silent no-op', () => {
// Before the rows, both keys below rode through every validator in
// silence (the widgets read nothing). The refusal must name WHICH
// zero-prop component refused, or the author is left guessing.
const panel = ComponentPropsMap['cloud-connection:panel'].safeParse({ pollInterval: 5 });
expect(panel.success).toBe(false);
if (!panel.success) {
const message = panel.error.issues.map((i) => i.message).join('\n');
expect(message).toContain('cloud-connection:panel');
expect(message).toContain('pollInterval');
}

const list = ComponentPropsMap['marketplace:installed-list'].safeParse({ filter: 'installed' });
expect(list.success).toBe(false);
if (!list.success) {
const message = list.error.issues.map((i) => i.message).join('\n');
expect(message).toContain('marketplace:installed-list');
expect(message).toContain('filter');
}
});
});
});

// ---------------------------------------------------------------------------
Expand Down
24 changes: 21 additions & 3 deletions packages/spec/src/ui/component.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -316,12 +316,13 @@ const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [
/**
* A component that declares no props at all — `app:launcher`, `nav:menu`,
* `nav:breadcrumb`, `global:search`, `global:notifications`, `user:profile`,
* `element:divider`.
* `element:divider`, and the two plugin console widgets `cloud-connection:panel`
* and `marketplace:installed-list` (#11575).
*
* A factory rather than one shared `EmptyProps` const, because the surface name
* is the whole value of the rejection here: an empty shape has no candidate
* keys, so the edit-distance fallback can say nothing, and "unrecognized key on
* this component" would leave the author guessing which of the seven it meant.
* this component" would leave the author guessing which of the nine it meant.
* One `strictObject(` call site either way — the ledger counts sites from the
* AST, and this is one.
*
Expand DownExpand Up@@ -2574,7 +2575,24 @@ export const ComponentPropsMap = {
'global:search': emptyProps('global:search'),
'global:notifications': emptyProps('global:notifications'),
'user:profile': emptyProps('user:profile'),


// Plugin console widgets — #11575, the #8691/#8744 mechanism two instances
// over, on `@objectstack/cloud-connection`'s published Setup pages: both
// types are console-registered renderers reachable only through the type
// union's open string arm, and with no row here the #5068 gate's dispatch
// skipped them as unregistered — any authored key would have ridden through
// in silence (nothing authors one today: both shipped pages carry `{}`).
// Key sets measured from the renderers' ACTUAL read points at the
// `.objectui-sha` pin (app-shell `console/cloud-connection/
// CloudConnectionPanel.tsx`, `console/marketplace/InstalledListWidget.tsx`):
// both registrations discard the schema node entirely (`() => <Widget />`)
// and neither component function takes a prop, so the accepted key set is
// EMPTY — strict, refuses every key. The registrations' declared
// `inputs: []` happen to agree here, but the row is the measurement, not
// the claim (#8691/#8744 record where those diverge).
'cloud-connection:panel': emptyProps('cloud-connection:panel'),
'marketplace:installed-list': emptyProps('marketplace:installed-list'),

// AI
'ai:chat_window': AIChatWindowProps,
'ai:suggestion': strictObject({
Expand Down
Loading