From e15036c9f2fcb71307c5b5abb28bb4dcfc076c0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:58:48 +0000 Subject: [PATCH 1/3] feat(core,types)!: retire the `module` widget source and its runtime dynamic import WidgetRegistry.load() resolved a `{ type: 'module', url }` source with `new Function('url', 'return import(url)')`, the runtime dynamic import AGENTS.md commandment #6 forbids. Remove the `module` member of RuntimeWidgetSource, the WidgetSourceModule interface and the loader arm. The `default:` arm is now exhaustive over the union and refuses an untyped old manifest by name (naming both replacements) before anything is fetched; any other unknown source type is refused with the supported types listed. Claude-Session: https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ Co-authored-by: Claude --- .../13450-retire-widget-module-source.md | 23 ++++ packages/core/src/registry/WidgetRegistry.ts | 55 +++++---- .../registry/__tests__/WidgetRegistry.test.ts | 109 +++++++++++++++++- .../page-nav-misc-spec-parity.test.ts | 2 +- packages/types/src/index.ts | 1 - packages/types/src/widget.ts | 48 ++++---- 6 files changed, 186 insertions(+), 52 deletions(-) create mode 100644 .changeset/13450-retire-widget-module-source.md diff --git a/.changeset/13450-retire-widget-module-source.md b/.changeset/13450-retire-widget-module-source.md new file mode 100644 index 0000000000..fdff04b3ad --- /dev/null +++ b/.changeset/13450-retire-widget-module-source.md @@ -0,0 +1,23 @@ +--- +'@object-ui/core': minor +'@object-ui/types': minor +--- + +**BREAKING:** the `module` widget source is retired. `WidgetRegistry` no longer loads a widget's code from a URL at runtime (objectui#13450). + +`RuntimeWidgetSource` had a `module` member, `{ type: 'module', url, exportName? }`, and `WidgetRegistry.load()` resolved it with a dynamic `import()` built through the Function constructor so that bundlers could not see it. That is the runtime dynamic import AGENTS.md commandment #6 forbids ("No `eval()` / runtime dynamic imports to load components"), and it also needs `unsafe-eval` under a strict Content Security Policy. The member, its `WidgetSourceModule` interface and the loader arm are removed. `RuntimeWidgetSource` is now `WidgetSourceInline | WidgetSourceRegistry`. + +An old manifest is refused loudly, not skipped. In TypeScript, `type: 'module'` no longer type-checks, and importing `WidgetSourceModule` is `TS2305`. A manifest that reaches `WidgetRegistry.load()` untyped (plain JavaScript, a cast, or an older `@object-ui/types`) is rejected before anything is fetched. The rejection names the retired `module` source and both replacements, `load()` emits `widget:error`, and `loadAll()` reports the error for that widget. Any other unknown source type is rejected the same way, and the message lists the supported types. + +Migration, FROM → TO: + +- FROM `source: { type: 'module', url: '/widgets/chart.js', exportName: 'Chart' }` +- TO `import { Chart } from './widgets/chart';` and then `source: { type: 'inline', component: Chart }`. The component is imported at build time, so the bundler sees it. +- OR register the component once with the component registry (`ComponentRegistry.register('chart', Chart)`) and declare `source: { type: 'registry', registryKey: 'chart' }`. + +One-line fix: replace each `{ type: 'module', url }` with `{ type: 'inline', component }`, where `component` is imported at build time. + +Remote widgets as a product capability would be a new contract (a signed manifest and an allow-listed origin, decided in `@objectstack/spec`). This release does not add one. + +Clause-②: no +Metadata-protocol: narrowed diff --git a/packages/core/src/registry/WidgetRegistry.ts b/packages/core/src/registry/WidgetRegistry.ts index 33de4228ef..f2960ba8a3 100644 --- a/packages/core/src/registry/WidgetRegistry.ts +++ b/packages/core/src/registry/WidgetRegistry.ts @@ -10,8 +10,11 @@ * WidgetRegistry - Runtime widget management with auto-discovery. * * Provides registration, loading, and lookup of runtime widgets - * described by RuntimeWidgetManifest objects. Widgets can be loaded from - * ES module URLs, provided inline, or resolved from the component registry. + * described by RuntimeWidgetManifest objects. A widget's component is either + * provided inline (imported at build time) or resolved from the component + * registry. Nothing is ever loaded from a URL: the former `module` source, + * which did that through a runtime dynamic import, is retired (AGENTS.md + * commandment #6, objectui#13450) and refused by name in `load()`. * * @module */ @@ -40,6 +43,8 @@ export interface WidgetRegistryOptions { * * @example * ```ts + * import { CustomChart } from './widgets/CustomChart'; + * * const widgets = new WidgetRegistry({ componentRegistry: registry }); * * widgets.register({ @@ -47,7 +52,7 @@ export interface WidgetRegistryOptions { * version: '1.0.0', * type: 'chart', * label: 'Custom Chart', - * source: { type: 'module', url: '/widgets/chart.js' }, + * source: { type: 'inline', component: CustomChart }, * }); * * const resolved = await widgets.load('custom-chart'); @@ -279,32 +284,32 @@ export class WidgetRegistry { return component; } - case 'module': { - // Runtime-only dynamic import for loading widgets from external URLs - // This uses Function constructor to prevent bundlers (Webpack/Turbopack/Vite) - // from attempting static analysis at build time, which would fail since - // source.url is only known at runtime. - // - // Security: Widget URLs must be from trusted sources only. Never pass - // user-supplied URLs directly to RuntimeWidgetManifest. URLs should be validated - // and controlled by the application developer. - // - // CSP Consideration: If your application uses strict Content Security Policy, - // ensure dynamic imports are allowed or use 'inline' or 'registry' source types. - const dynamicImport = new Function('url', 'return import(url)'); - const mod = await dynamicImport(source.url); - const exportName = source.exportName ?? 'default'; - const component = mod[exportName]; - if (!component) { + default: { + // Exhaustive over `RuntimeWidgetSource`: a member added to the union + // without an arm above fails to compile here. What still reaches this + // line at runtime is a manifest that did not come through the type — + // plain JavaScript, a cast, or one written against an older + // `@object-ui/types` — and it is refused loudly, never skipped. + const unhandled: never = source; + const sourceType = (unhandled as { type?: unknown }).type; + if (sourceType === 'module') { + // The retired `module` source (objectui#13450). It loaded code from + // `source.url` through a dynamic `import()` built with the Function + // constructor — the runtime dynamic import AGENTS.md commandment #6 + // forbids. Refused by name so an old manifest says what to do. throw new Error( - `Widget "${manifest.name}" module at "${source.url}" does not export "${exportName}"`, + `Widget "${manifest.name}" uses the retired "module" source: loading widget code ` + + `from a URL at runtime is no longer supported. Import the component at build ` + + `time and declare { type: 'inline', component }, or register it in the ` + + `component registry and declare { type: 'registry', registryKey }.`, ); } - return component; + const shown = typeof sourceType === 'string' ? `"${sourceType}"` : String(sourceType); + throw new Error( + `Widget "${manifest.name}" has an unknown source type ${shown}; ` + + `the supported source types are "inline" and "registry".`, + ); } - - default: - throw new Error(`Unknown widget source type for "${manifest.name}"`); } } diff --git a/packages/core/src/registry/__tests__/WidgetRegistry.test.ts b/packages/core/src/registry/__tests__/WidgetRegistry.test.ts index 16b840197d..14613d0609 100644 --- a/packages/core/src/registry/__tests__/WidgetRegistry.test.ts +++ b/packages/core/src/registry/__tests__/WidgetRegistry.test.ts @@ -5,7 +5,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { WidgetRegistry } from '../WidgetRegistry'; import { Registry } from '../Registry'; -import type { RuntimeWidgetManifest, WidgetRegistryEvent } from '@object-ui/types'; +import type { + RuntimeWidgetManifest, + RuntimeWidgetSource, + WidgetRegistryEvent, +} from '@object-ui/types'; function createManifest(overrides: Partial = {}): RuntimeWidgetManifest { return { @@ -318,4 +322,107 @@ describe('WidgetRegistry', () => { expect(stats.categories).toHaveLength(2); }); }); + + describe('the retired `module` source (objectui#13450)', () => { + /** + * Runs `body` with the global Function constructor replaced by a proxy that + * records every construction and call. The retired arm built its loader + * with `new Function('url', 'return import(url)')`, so a refusal that still + * reached that sink shows up here even if the import itself then failed. + */ + async function withFunctionSpy(body: () => Promise): Promise { + const original = globalThis.Function; + const calls: string[] = []; + globalThis.Function = new Proxy(original, { + construct(target, args) { + calls.push(`new Function(${args.map(String).join(', ')})`); + return Reflect.construct(target, args); + }, + apply(target, thisArg, args) { + calls.push(`Function(${args.map(String).join(', ')})`); + return Reflect.apply(target, thisArg, args); + }, + }); + try { + await body(); + } finally { + globalThis.Function = original; + } + return calls; + } + + it('refuses a manifest that still declares a `module` source, by name, without loading code', async () => { + const componentRegistry = new Registry(); + const reg = new WidgetRegistry({ componentRegistry }); + reg.register( + createManifest({ + type: 'retired-module-widget', + // @ts-expect-error `module` is no longer a member of RuntimeWidgetSource. + source: { type: 'module', url: '/widgets/chart.js' }, + }), + ); + + let refusal: unknown; + const functionCalls = await withFunctionSpy(async () => { + refusal = await reg.load('test-widget').then( + () => undefined, + (err: unknown) => err, + ); + }); + + expect(refusal).toBeInstanceOf(Error); + const message = (refusal as Error).message; + // The named subjects, not the wording: the widget, the retired source + // type, and both replacements an author can move to. + expect(message).toContain('"test-widget"'); + expect(message).toContain('"module"'); + expect(message).toContain("type: 'inline'"); + expect(message).toContain("type: 'registry'"); + // Nothing was built to fetch the URL, and nothing was registered. + expect(functionCalls).toEqual([]); + expect(reg.isLoaded('test-widget')).toBe(false); + expect(componentRegistry.get('retired-module-widget')).toBeUndefined(); + }); + + it('emits widget:error for the refusal, and loadAll() reports it instead of skipping it', async () => { + widgetRegistry.register(createManifest({ name: 'good', type: 'good' })); + widgetRegistry.register( + createManifest({ + name: 'old', + type: 'old', + source: { type: 'module', url: '/widgets/old.js' } as unknown as RuntimeWidgetSource, + }), + ); + const events: WidgetRegistryEvent[] = []; + widgetRegistry.on((e) => events.push(e)); + + const results = await widgetRegistry.loadAll(); + + const old = results.find((r) => r.name === 'old'); + expect(old?.result).toBeInstanceOf(Error); + expect((old?.result as Error).message).toContain('"module"'); + expect(results.find((r) => r.name === 'good')?.result).not.toBeInstanceOf(Error); + expect(events.some((e) => e.type === 'widget:error' && e.name === 'old')).toBe(true); + }); + + it('refuses any other unknown source type loudly, naming the supported ones', async () => { + widgetRegistry.register( + createManifest({ + source: { type: 'remote', url: 'https://cdn.example.com/w.mjs' } as unknown as RuntimeWidgetSource, + }), + ); + + const refusal = await widgetRegistry.load('test-widget').then( + () => undefined, + (err: unknown) => err, + ); + + expect(refusal).toBeInstanceOf(Error); + const message = (refusal as Error).message; + expect(message).toContain('"remote"'); + expect(message).toContain('"inline"'); + expect(message).toContain('"registry"'); + expect(message).not.toContain('retired'); + }); + }); }); diff --git a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts index dc5ef14f37..8e431fb302 100644 --- a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts @@ -789,7 +789,7 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 ['PageNodeRegionSchema', 'zod twin of PageNodeRegion'], // `MobileResponsiveConfig` retired with its type (objectui#7519). ['RuntimeWidgetManifest', 'SDUI component manifest, not the field-widget plugin'], - ['RuntimeWidgetSource', 'objectui module/inline/registry loader union'], + ['RuntimeWidgetSource', 'objectui inline/registry loader union'], ['InspectorFlowNode', "app-shell's flow-inspector read type, `id` REQUIRED"], ['ScopeFlowNode', "app-shell's pre-validation scope probe, members `unknown`"], ])('the spec does not own `%s` (%s)', (name) => { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1f3daadff0..bb2ee6914c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1556,7 +1556,6 @@ export type { export type { RuntimeWidgetManifest, RuntimeWidgetSource, - WidgetSourceModule, WidgetSourceInline, WidgetSourceRegistry, WidgetInput, diff --git a/packages/types/src/widget.ts b/packages/types/src/widget.ts index c140406294..49a1f82a4f 100644 --- a/packages/types/src/widget.ts +++ b/packages/types/src/widget.ts @@ -24,8 +24,11 @@ import type { ComponentInputControlType } from './base.js'; * `@object-ui/core`'s `WidgetRegistry` registers, and what the designer palette * lists. * - * A manifest provides all metadata needed to discover, load, and render - * a widget without requiring an upfront import of its code. + * A manifest provides the metadata needed to list, register and render a + * widget. Its code is never fetched by the manifest: the component is either + * imported at build time and handed over (`inline`) or already registered in + * the component registry (`registry`). See {@link RuntimeWidgetSource} for why + * there is no URL-loaded source. * * Today this is objectui's ONLY widget-registration contract: no published * `@objectstack/spec` schema models one. @@ -61,6 +64,8 @@ import type { ComponentInputControlType } from './base.js'; * * @example * ```ts + * import { CustomChart } from './widgets/CustomChart'; + * * const manifest: RuntimeWidgetManifest = { * name: 'custom-chart', * version: '1.0.0', @@ -69,7 +74,8 @@ import type { ComponentInputControlType } from './base.js'; * description: 'A custom chart powered by D3.', * category: 'data-visualization', * icon: 'BarChart', - * source: { type: 'module', url: '/widgets/custom-chart.js' }, + * // Imported at build time, so the bundler sees it; never a URL. + * source: { type: 'inline', component: CustomChart }, * }; * ``` */ @@ -139,11 +145,25 @@ export interface RuntimeWidgetManifest { * the spec's `inline` carried source **code** to evaluate (`{ type: 'inline', * code: string }`), objectui's carries an **already-resolved component** * ({@link WidgetSourceInline}). The other members never overlapped — the spec - * had `npm`/`remote`, objectui has `module`/`registry` — so a value of one + * had `npm`/`remote`, objectui had `module`/`registry` — so a value of one * union was never a valid value of the other. ⚠️ The spec's union was retired * with the rest of the widget-registration vocabulary (objectstack#5055): the * union declared below is objectui's own, and never derived from it. * + * RETIRED — the `module` member (`{ type: 'module', url, exportName? }`, the + * `WidgetSourceModule` interface) was removed by objectui#13450. It loaded a + * widget's code from a URL at runtime through a dynamic `import()` built with + * the Function constructor, which AGENTS.md commandment #6 forbids ("No + * `eval()` / runtime dynamic imports to load components"). There is no + * tombstone member: a manifest written in TypeScript is refused here by the + * discriminant, and one that reaches `WidgetRegistry.load()` untyped is refused + * there by name, before any code is fetched. Migration: import the component + * at build time and declare `{ type: 'inline', component }`, or register it + * in the component registry and declare `{ type: 'registry', registryKey }`. + * Remote widgets as a product capability would be a new contract (a signed + * manifest and an allow-listed origin, decided in `@objectstack/spec`), not a + * member of this union. + * * The variant interfaces keep their `WidgetSource…` names: the spec never * exported those, and renaming them would churn the public surface without * removing a collision. @@ -152,29 +172,9 @@ export interface RuntimeWidgetManifest { * longer owns `WidgetSource`" row pins the vacancy. */ export type RuntimeWidgetSource = - | WidgetSourceModule | WidgetSourceInline | WidgetSourceRegistry; -/** - * Load from an ES module URL. - * - * ⚠️ SECURITY WARNING: Only use URLs from trusted sources. - * Never pass user-supplied URLs directly. URLs should be validated - * and controlled by your application. This feature uses dynamic imports - * which bypass static analysis and may be restricted by Content Security Policy. - */ -export interface WidgetSourceModule { - type: 'module'; - /** - * URL to the ES module (e.g., '/widgets/chart.js' or 'https://cdn.example.com/widget.mjs') - * Must be from a trusted source - never user input. - */ - url: string; - /** Named export to use (default: 'default') */ - exportName?: string; -} - /** The component is provided inline (already loaded) */ export interface WidgetSourceInline { type: 'inline'; From be01ada3307a0f48037f76aa22ff79460b3f2b4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:32:10 +0000 Subject: [PATCH 2/3] docs(core,types): make the two inline-source @example blocks compile The retirement rewrote the RuntimeWidgetManifest and WidgetRegistry JSDoc examples to import CustomChart from a relative './widgets/CustomChart' path. The JSDoc example gate (check-doc-example-types.mjs) compiles each block in isolation, where that module does not exist: TS2307 made the types example an undeclared failure and drifted the core example's ledger row (declared TS2304, produced TS2304 + TS2307). Both examples now define a trivial local CustomChart component and name the build-time import in a comment, so the types example compiles and the core example fails exactly as its existing ledger row declares. Claude-Session: https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ Co-authored-by: Claude --- packages/core/src/registry/WidgetRegistry.ts | 6 +++++- packages/types/src/widget.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/registry/WidgetRegistry.ts b/packages/core/src/registry/WidgetRegistry.ts index f2960ba8a3..bc1bca632f 100644 --- a/packages/core/src/registry/WidgetRegistry.ts +++ b/packages/core/src/registry/WidgetRegistry.ts @@ -43,7 +43,11 @@ export interface WidgetRegistryOptions { * * @example * ```ts - * import { CustomChart } from './widgets/CustomChart'; + * // The widget's React component. In an app it is imported at build time + * // (e.g. `import { CustomChart } from './widgets/CustomChart'`). + * function CustomChart() { + * return null; + * } * * const widgets = new WidgetRegistry({ componentRegistry: registry }); * diff --git a/packages/types/src/widget.ts b/packages/types/src/widget.ts index 49a1f82a4f..8c6b9679a0 100644 --- a/packages/types/src/widget.ts +++ b/packages/types/src/widget.ts @@ -64,7 +64,11 @@ import type { ComponentInputControlType } from './base.js'; * * @example * ```ts - * import { CustomChart } from './widgets/CustomChart'; + * // The widget's React component. In an app it is imported at build time + * // (e.g. `import { CustomChart } from './widgets/CustomChart'`). + * function CustomChart() { + * return null; + * } * * const manifest: RuntimeWidgetManifest = { * name: 'custom-chart', From 504c0ee96646bfc99d689fb98296673ae41c06a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:42:40 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(changeset):=20declare=20Clause-?= =?UTF-8?q?=E2=91=A1=20as=20no=20(narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retirement removes the module member of a published union and an export, so the clause line must name the narrowing arm; a bare no declares no direction. Claude-Session: https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ Co-authored-by: Claude --- .changeset/13450-retire-widget-module-source.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/13450-retire-widget-module-source.md b/.changeset/13450-retire-widget-module-source.md index fdff04b3ad..f10f989080 100644 --- a/.changeset/13450-retire-widget-module-source.md +++ b/.changeset/13450-retire-widget-module-source.md @@ -19,5 +19,5 @@ One-line fix: replace each `{ type: 'module', url }` with `{ type: 'inline', com Remote widgets as a product capability would be a new contract (a signed manifest and an allow-listed origin, decided in `@objectstack/spec`). This release does not add one. -Clause-②: no +Clause-②: no (narrowing) Metadata-protocol: narrowed