diff --git a/.changeset/20312-save-door-compiles-html-page-source.md b/.changeset/20312-save-door-compiles-html-page-source.md new file mode 100644 index 00000000000..8a87b3abe86 --- /dev/null +++ b/.changeset/20312-save-door-compiles-html-page-source.md @@ -0,0 +1,51 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/cli': minor +--- + +`os serve` hands the runtime metadata save door the deployment's SDUI component manifest, and the save door compiles an html page's `source` against it: an unknown component or a `requires` that disagrees with the source is refused with a `422`, and `requires` is stamped from the compiled source (ADR-0080 §5). + +Clause-②: yes (narrowing — on a host that registers a manifest, the runtime metadata save door newly refuses an html page whose source uses a component the manifest does not declare, or whose `requires` disagrees with its source; the new exported `SDUI_MANIFEST_SERVICE` widens `@objectstack/metadata-protocol`) + + + +**BREAKING** — an accept-set narrowing on the runtime metadata save door, shipped +as `minor` under the launch-window convention (`check-changeset-no-major` refuses +`major` until GA; breaking-ness is carried by this banner and the ADR-0087 +disposition above, not by the level). On a server that has a manifest, a +`PUT /api/v1/meta/page/NAME` (and the draft publish) of a `kind: 'html'` page used +to store the source unjudged; it now answers `422 INVALID_METADATA` when the source +uses a component the deployment's console does not provide, naming the component in +each issue's `where` and `message`, or when a hand-written `requires` lists a +namespace the source does not use, omits one it does, or names one no component in +the manifest carries. **One-line fix:** use a component the manifest declares (or +install the plugin that provides it in the console the deployment serves), and omit +`requires` — it is derived from the source. + +**The channel.** `@objectstack/metadata-protocol` exports `SDUI_MANIFEST_SERVICE` +(`'sdui-manifest'`), a plain service key. `os serve` resolves the manifest once at +boot through the same resolver `os validate` uses — the project's own +`sdui.manifest.json` beside the served config, then the copy `@objectstack/console` +ships — and registers it under that key. The save door reads the key on every +publish, so a host that registers or replaces it later is seen by the next save. + +**The compile.** The save door runs `@objectstack/sdui-parser`'s `compile()`, the +compiler behind `os validate`'s JSX page gate, against the registered manifest. Its +diagnostics carry the same rule ids the CLI reports (`jsx-forbidden-tag`, +`jsx-unknown-component`, …); errors refuse the publish, warnings ride the response's +`advisories`. A disagreeing `requires` is refused under +`page-requires-disagrees-with-source`. A page that compiles is stored with the +`requires` its source yields, on a draft save too; a draft that does not compile, or +whose `requires` disagrees, is stored as written (drafts are not gated) and its +publish refuses it. + +**Without a manifest nothing changes.** A host that resolves no manifest registers +nothing and prints one boot line — `Page source and \`requires\` not validated at +save`, naming every place looked — and the save door stores html pages exactly as +before. A registered value with no `components` map is warned about once and never +compiled against. + +Measured before the refusal shipped: the three html pages in this repository +(`examples/app-showcase`: `showcase_capability_map`, `showcase_command_center_jsx`, +`showcase_start_here`) all compile against the pinned console's manifest with no +diagnostic and yield `requires: ['ui']`; none authors `requires`. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 015071d4532..49a4e9e287a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3086,6 +3086,24 @@ export default class Serve extends Command { }); const kernel = runtime.getKernel(); + // ── The deployment's SDUI component manifest (#20312, ADR-0080 §5) ── + // Resolved ONCE, beside the served config, through the same resolver the + // authoring commands use (the project's own sdui.manifest.json, then the + // copy @objectstack/console ships), and registered under the key the save + // door reads per publish — where every html page's `source` is compiled + // against it and its `requires` stamped and checked. Registered before any + // plugin inits, so no plugin can see the kernel without it. No manifest: + // nothing is registered, one line says what that costs, and the boot goes on. + { + const { SDUI_MANIFEST_SERVICE } = await import('@objectstack/metadata-protocol'); + const { registerDeploymentSduiManifest } = await import('../utils/sdui-manifest.js'); + const sduiManifestLine = registerDeploymentSduiManifest( + (manifest) => { kernel.registerService(SDUI_MANIFEST_SERVICE, manifest); }, + path.dirname(absolutePath), + ); + if (sduiManifestLine) console.warn(chalk.yellow(` ⚠ ${sduiManifestLine}`)); + } + // Load plugins from configuration let plugins = config.plugins || []; diff --git a/packages/cli/src/utils/sdui-manifest.test.ts b/packages/cli/src/utils/sdui-manifest.test.ts index f5665f1d4d1..069c1f9e3e9 100644 --- a/packages/cli/src/utils/sdui-manifest.test.ts +++ b/packages/cli/src/utils/sdui-manifest.test.ts @@ -21,6 +21,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { validateJsxPages } from '@objectstack/lint'; +import { SDUI_MANIFEST_SERVICE } from '@objectstack/metadata-protocol'; import { CONSOLE_SDUI_MANIFEST, JSX_PARSE_LEVEL_ONLY_RULE, @@ -30,6 +31,7 @@ import { countJsxGatePages, jsxGateStacks, printJsxGateNotices, + registerDeploymentSduiManifest, resolveJsxGateManifest, resolveSduiManifest, type SduiManifestResolution, @@ -525,3 +527,66 @@ describe('printJsxGateNotices — the text face of `os validate` / `os build`', } }); }); + +describe('registerDeploymentSduiManifest — `os serve` hands the save door its manifest, or says once why not (#20312)', () => { + const PROJECT = '/srv/app'; + + it('resolved: registers the manifest itself and prints nothing', () => { + const registered: unknown[] = []; + const line = registerDeploymentSduiManifest((m) => registered.push(m), PROJECT, { + status: 'resolved', + manifest: MANIFEST, + path: `${PROJECT}/sdui.manifest.json`, + }); + expect(line).toBeUndefined(); + expect(registered).toEqual([MANIFEST]); + expect(registered[0]).toBe(MANIFEST); + }); + + it('absent: registers nothing, and ONE line naming what is not validated and every place looked', () => { + const registered: unknown[] = []; + const lookedAt = [`${PROJECT}/sdui.manifest.json`, '/opt/node_modules/@objectstack/console/dist/sdui.manifest.json']; + const line = registerDeploymentSduiManifest((m) => registered.push(m), PROJECT, { status: 'absent', lookedAt }); + expect(registered).toEqual([]); + expect(line).toMatch(/^Page source and `requires` not validated at save: /); + for (const place of lookedAt) expect(line).toContain(place); + expect(line?.split('\n')).toHaveLength(1); + }); + + it('unusable: registers nothing and names the file and the reason — the boot is not refused', () => { + const registered: unknown[] = []; + const path = `${PROJECT}/sdui.manifest.json`; + const line = registerDeploymentSduiManifest((m) => registered.push(m), PROJECT, { + status: 'unusable', + source: 'project', + path, + reason: 'it is not valid JSON (Unexpected token)', + }); + expect(registered).toEqual([]); + expect(line).toMatch(/^Page source and `requires` not validated at save: /); + expect(line).toContain(path); + expect(line).toContain('it is not valid JSON'); + }); + + it('defaults to the resolver over the project directory: a project manifest is what gets registered', () => { + const dir = mkdtempSync(join(tmpdir(), 'os-serve-sdui-')); + try { + writeFileSync(join(dir, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(MANIFEST)); + const registered: unknown[] = []; + expect(registerDeploymentSduiManifest((m) => registered.push(m), dir)).toBeUndefined(); + expect(registered).toEqual([MANIFEST]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('`os serve` registers it under the save door\'s key, from the served config\'s directory, once', () => { + expect(SDUI_MANIFEST_SERVICE).toBe('sdui-manifest'); + const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'commands', 'serve.ts'), 'utf8'); + const calls = source.match(/registerDeploymentSduiManifest\(/g) ?? []; + expect(calls).toHaveLength(1); + expect(source).toMatch( + /registerDeploymentSduiManifest\(\s*\(manifest\) => \{ kernel\.registerService\(SDUI_MANIFEST_SERVICE, manifest\); \},\s*path\.dirname\(absolutePath\),\s*\);/, + ); + }); +}); diff --git a/packages/cli/src/utils/sdui-manifest.ts b/packages/cli/src/utils/sdui-manifest.ts index fb0908a2335..f1e81285063 100644 --- a/packages/cli/src/utils/sdui-manifest.ts +++ b/packages/cli/src/utils/sdui-manifest.ts @@ -229,6 +229,50 @@ export function resolveSduiManifest( return { status: 'absent', lookedAt: [projectManifest, consoleManifest ?? CONSOLE_SDUI_MANIFEST] }; } +/** + * `os serve`'s half of the save door's page compile (#20312, ADR-0080 §5): + * hand the deployment's manifest to the runtime once, at boot, or say once why + * there is none. + * + * `register` receives the manifest when {@link resolveSduiManifest} answers + * `resolved` — `os serve` registers it under `@objectstack/metadata-protocol`'s + * `SDUI_MANIFEST_SERVICE`, where the save door reads it per publish and + * compiles every html page's `source` against it. For `absent` and `unusable` + * nothing is registered, the boot continues, and the returned line is the one + * thing the host prints: without a manifest the save door stores an html page + * as it always did, with its source and `requires` unjudged, and AGENTS.md + * "Route & surface ownership" rule 3 says that absence is said once at boot, + * naming the remedy. `undefined` when a manifest was registered. + * + * ⛔ An `unusable` manifest is not refused here the way the authoring commands + * refuse it ({@link resolveJsxGateManifest}): those judge a project, and their + * author asked for full validation; a server that refused to boot over it would + * take the whole deployment down for one damaged file. It is named in the line + * instead. + * + * `projectDir` is the directory of the config being served, as for the + * authoring commands; `resolution` is the pins' seam. + */ +export function registerDeploymentSduiManifest( + register: (manifest: unknown) => void, + projectDir: string, + resolution: SduiManifestResolution = resolveSduiManifest(projectDir), +): string | undefined { + if (resolution.status === 'resolved') { + register(resolution.manifest); + return undefined; + } + const why = + resolution.status === 'unusable' + ? `${resolution.path} is not a usable SDUI component manifest: ${resolution.reason}` + : `no SDUI component manifest at ${resolution.lookedAt.join(' or ')}`; + return ( + `Page source and \`requires\` not validated at save: ${why}. Html pages are stored without being ` + + `compiled against this deployment's components — add ${join(projectDir, PROJECT_SDUI_MANIFEST_FILE)} ` + + `or install @objectstack/console with its manifest.` + ); +} + /** * Every stack the JSX gate is handed in one run of `os validate` / `os build` / * `os lint`, from the stack the command parsed: the union run's diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 8179854b3bd..29e99440ace 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -43,6 +43,7 @@ "@objectstack/lint": "workspace:*", "@objectstack/metadata": "workspace:*", "@objectstack/metadata-core": "workspace:*", + "@objectstack/sdui-parser": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "zod": "^4.6.1" diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index ca7e60d48cd..03c68f9dc4b 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -25,6 +25,8 @@ export { } from './write-response-internal-fields.js'; export { createMetadataProtocolPlugin, assembleMetadataProtocol, shouldRunPlatformMigrations } from './plugin.js'; export type { MetadataProtocolPluginOptions, AssembleMetadataProtocolOptions } from './plugin.js'; +// [#20312] The service key a host registers its SDUI component manifest under, read by the save door per publish. +export { SDUI_MANIFEST_SERVICE } from './runtime-authoring-gate.js'; // [#6710] The declared authoring channel — the explicit expression of ADR-0005's // "package author's own bootstrap channel", replacing the `environmentId === // undefined` proxy the #4463 gate used to key its activation off. diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index a13868f96ad..b0c9dc66aab 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -38,6 +38,7 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFin import { validateSemanticRoles } from '@objectstack/lint'; import { ObjectStackProtocolImplementation } from './protocol.js'; import type { MetadataAuthoringChannel } from './protocol.js'; +import { SDUI_MANIFEST_SERVICE } from './index.js'; /** The issue's body. Zod-valid: `approvers[].value` is just a string to the schema. */ const brokenApprovalFlow = () => ({ @@ -1070,3 +1071,164 @@ describe('runtime authoring gate on PERMISSION writes — the engine judge (#201 expect(calls).toEqual([]); }); }); + +/** + * [#20312] ADR-0080 §5 at the save door — an html page's `source` is compiled + * against the deployment's SDUI component manifest, read per publish from the + * `SDUI_MANIFEST_SERVICE` key the host (`os serve`) registers. + * + * The manifest here is a minimal stand-in with the real one's shape: `flex` and + * `box` in the `ui` namespace (as in the pinned console's manifest) and one + * plugin component in `plugin-kanban`. + */ +describe('html page source compiled at the save door against the SDUI manifest (#20312)', () => { + const slot = { name: 'children', type: 'slot' }; + const manifest = () => ({ + components: { + flex: { type: 'flex', namespace: 'ui', isContainer: true, inputs: [slot] }, + box: { type: 'box', namespace: 'ui', isContainer: true, inputs: [slot] }, + kanban: { type: 'kanban', namespace: 'plugin-kanban', inputs: [] }, + }, + }); + const htmlPage = (source: string, extra: Record = {}) => ({ + name: 'landing', label: 'Landing', kind: 'html', source, ...extra, + }); + const KNOWN = 'hello'; + const UNKNOWN = ''; + + /** A protocol whose services table the test holds, so the key can be set and changed per publish. */ + function hostWith(services: Map) { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_test') as any; + return { protocol, rows }; + } + const pageRows = (rows: Map) => Array.from(rows.values()).filter((r) => r.type === 'page'); + const storedPage = (rows: Map, state = 'active') => { + const row = pageRows(rows).find((r) => r.state === state); + return row ? JSON.parse(row.metadata) : undefined; + }; + const savePage = (protocol: any, item: unknown, extra: Record = {}) => + protocol.saveMetaItem({ type: 'page', name: 'landing', item, ...extra }); + const refusal = (e: any) => ({ code: e?.code, status: e?.status }); + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + }); + + it('the exported key is the one the save door reads', () => { + expect(SDUI_MANIFEST_SERVICE).toBe('sdui-manifest'); + }); + + it('refuses an unknown component with a 422 whose issues name it, and persists nothing', async () => { + const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]])); + const err = await savePage(protocol, htmlPage(UNKNOWN)).catch((e: any) => e); + expect(refusal(err)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const named = err.issues.filter((i: any) => i.rule.startsWith('jsx-')); + expect(named.length, JSON.stringify(err.issues)).toBeGreaterThan(0); + for (const issue of named) { + expect(issue.where).toBe('page "landing" › '); + expect(issue.message).toContain(''); + expect(issue.path).toBe('pages.landing.source'); + } + expect(err.rulesRun).toContain('html-page-source-compile'); + expect(pageRows(rows)).toEqual([]); + }); + + it('saves a page built from known components and stamps `requires` from the compile', async () => { + const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]])); + const result = await savePage(protocol, htmlPage(`a`)); + expect(result.success).toBe(true); + expect(storedPage(rows)?.requires).toEqual(['ui', 'plugin-kanban']); + }); + + it('keeps a hand-written `requires` that agrees with the source, in the compiled order', async () => { + const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]])); + const result = await savePage(protocol, htmlPage(``, { requires: ['plugin-kanban', 'ui'] })); + expect(result.success).toBe(true); + expect(storedPage(rows)?.requires).toEqual(['ui', 'plugin-kanban']); + }); + + it('refuses a hand-written `requires` that disagrees with the source, naming each namespace', async () => { + const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]])); + + const unused = await savePage(protocol, htmlPage(KNOWN, { requires: ['ui', 'plugin-kanban'] })).catch((e: any) => e); + expect(refusal(unused)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const unusedIssue = unused.issues.find((i: any) => i.rule === 'page-requires-disagrees-with-source'); + expect(unusedIssue?.path).toBe('pages.landing.requires'); + expect(unusedIssue?.message).toContain(`'plugin-kanban' is not used by the source`); + + const unprovided = await savePage(protocol, htmlPage(KNOWN, { requires: ['ui', 'plugin-absent'] })).catch((e: any) => e); + expect(refusal(unprovided)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(unprovided.issues.map((i: any) => i.message).join('\n')) + .toContain(`'plugin-absent' is a namespace no component in this deployment's manifest carries`); + + const missing = await savePage(protocol, htmlPage(``, { requires: ['ui'] })).catch((e: any) => e); + expect(refusal(missing)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(missing.issues.map((i: any) => i.message).join('\n')) + .toContain(`'plugin-kanban' is used by the source but not listed`); + + expect(pageRows(rows)).toEqual([]); + }); + + it('a host with no manifest saves exactly as before: nothing compiled, nothing stamped', async () => { + const { protocol, rows } = hostWith(new Map()); + const result = await savePage(protocol, htmlPage(UNKNOWN, { requires: ['plugin-absent'] })); + expect(result.success).toBe(true); + const stored = storedPage(rows); + expect(stored?.source).toBe(UNKNOWN); + expect(stored?.requires).toEqual(['plugin-absent']); + }); + + it('reads the key per publish: registering, fixing or removing it takes effect on the next save', async () => { + const services = new Map(); + const { protocol } = hostWith(services); + + await expect(savePage(protocol, htmlPage(UNKNOWN))).resolves.toMatchObject({ success: true }); + + services.set('sdui-manifest', manifest()); + const refused = await savePage(protocol, htmlPage(UNKNOWN)).catch((e: any) => e); + expect(refusal(refused)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + + const widened = manifest() as any; + widened.components['plugin-nonexistent'] = { type: 'plugin-nonexistent', namespace: 'plugin-extra', inputs: [] }; + services.set('sdui-manifest', widened); + await expect(savePage(protocol, htmlPage(UNKNOWN))).resolves.toMatchObject({ success: true }); + + services.delete('sdui-manifest'); + await expect(savePage(protocol, htmlPage(''))).resolves.toMatchObject({ success: true }); + }); + + it('a draft is not gated but its publish is — the draft door is not a bypass', async () => { + const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]])); + await expect(savePage(protocol, htmlPage(KNOWN, { requires: ['plugin-absent'] }), { mode: 'draft' })) + .resolves.toMatchObject({ success: true }); + // Left as written for the publish to refuse, not silently re-stamped. + expect(storedPage(rows, 'draft')?.requires).toEqual(['plugin-absent']); + + const err = await protocol.publishMetaItem({ type: 'page', name: 'landing' }).catch((e: any) => e); + expect(refusal(err)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(err.issues.map((i: any) => i.rule)).toContain('page-requires-disagrees-with-source'); + }); + + it('a draft that compiles is stamped at its save, and publishes clean', async () => { + const { protocol, rows } = hostWith(new Map([['sdui-manifest', manifest()]])); + await savePage(protocol, htmlPage(KNOWN), { mode: 'draft' }); + expect(storedPage(rows, 'draft')?.requires).toEqual(['ui']); + await expect(protocol.publishMetaItem({ type: 'page', name: 'landing' })) + .resolves.toMatchObject({ success: true }); + }); + + it('a registered value that is not a manifest is warned about once and compiled against never', async () => { + const { protocol, rows } = hostWith(new Map([['sdui-manifest', { oops: true }]])); + await expect(savePage(protocol, htmlPage(UNKNOWN))).resolves.toMatchObject({ success: true }); + await expect(savePage(protocol, htmlPage(UNKNOWN))).resolves.toMatchObject({ success: true }); + expect(storedPage(rows)?.requires).toBeUndefined(); + const lines = (warn.mock.calls as unknown[][]).map((c) => String(c[0])).filter((m) => m.includes(`'sdui-manifest' service`)); + expect(lines).toHaveLength(1); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8eeae62ea43..20fbb99d0e2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -29,6 +29,9 @@ import { markErasedAuthoringInput } from './erased-authoring-mark.js'; import { evaluateRuntimeAuthoringGate, CLOSURE_CONTEXT_KEY_BY_TYPE, + SDUI_MANIFEST_SERVICE, + isUsableSduiManifest, + stampHtmlPageRequires, type RuntimePendingDeclarations, } from './runtime-authoring-gate.js'; // [#7560] ADR-0070's read-only-package rule, shared with the `/packages` @@ -4833,6 +4836,40 @@ export class ObjectStackProtocolImplementation implements return (name, body) => canonicalize.call(automation, name, body); } + /** + * [#20312] The deployment's ADR-0080 SDUI component manifest, when the host + * registered one under {@link SDUI_MANIFEST_SERVICE} — `os serve` does, from + * the CLI's `resolveSduiManifest`. Read per publish, exactly as + * {@link resolveFlowCanonicalizer} reads `automation` and for its reason: a + * host may register the key after this protocol is assembled, and a value + * cached from a too-early read would switch the save door's page compile + * off for the life of the process. A re-registered value is therefore seen + * by the next publish. + * + * `undefined` when nothing is registered: the save door then judges an html + * page exactly as it did before the key existed, and the host that + * registers nothing is the one that says so at boot. A registered value + * that is not a manifest (no `components` map) is a host fault: it is + * warned about once and read as nothing, never compiled against. + */ + private resolveSduiManifest(): unknown { + const value = this.getServicesRegistry?.().get(SDUI_MANIFEST_SERVICE); + if (value === undefined || isUsableSduiManifest(value)) return value; + if (!this.unusableSduiManifestWarned) { + this.unusableSduiManifestWarned = true; + console.warn( + `[Protocol] the '${SDUI_MANIFEST_SERVICE}' service is not an SDUI component manifest ` + + `(a JSON object with a \`components\` map) — html page sources are saved without being ` + + `compiled against it, and a page's \`requires\` is not validated. Register the parsed ` + + `sdui.manifest.json of the console this deployment serves.`, + ); + } + return undefined; + } + + /** [#20312] One warn per process for an unusable registered manifest — see {@link resolveSduiManifest}. */ + private unusableSduiManifestWarned = false; + /** * @param authoringChannel [#6710] which channel this kernel's metadata * writes arrive on. Omitted ⇒ `'environment'` ⇒ the #4463 runtime @@ -5166,6 +5203,10 @@ export class ObjectStackProtocolImplementation implements ? (objectName, where, options) => this.engine.judgeFilter(objectName, where, options) : undefined; + // [#20312] The host's SDUI component manifest — a host fact of the + // #6285 kind, read here per publish and passed in so the gate stays pure. + const sduiManifest = this.resolveSduiManifest(); + const verdict = evaluateRuntimeAuthoringGate({ type: singular, name: evt.name, @@ -5183,6 +5224,9 @@ export class ObjectStackProtocolImplementation implements orgWallEnforced: this.orgWallEnforced(), ...(engineJudge !== undefined ? { judgeFilter: engineJudge } : {}), ...(restoredCredentialPaths !== undefined ? { restoredCredentialPaths } : {}), + // [#20312] The deployment's component manifest, read per publish; + // with it the gate compiles an html page's source (ADR-0080 §5). + ...(sduiManifest !== undefined ? { sduiManifest } : {}), }); if (verdict.error) throw verdict.error; return verdict.advisories; @@ -16708,6 +16752,16 @@ export class ObjectStackProtocolImplementation implements }), }); + // [#20312] ADR-0080 §5: `requires` is derived from the source, not + // authored. With the deployment's manifest in hand, an html page is + // stored with the `requires` its compiled source yields — on a draft + // too, because the stamp is a derivation, not a gate (a draft whose + // source does not compile, or whose hand-written `requires` disagrees, + // is left as written for its publish to refuse; see + // `stampHtmlPageRequires`). A host with no manifest stores the body + // exactly as before. + request.item = stampHtmlPageRequires(singularType, request.item, this.resolveSduiManifest()); + // Pre-persistence authoring gate (#3050): a domain plugin may veto the // body before it persists (throws propagate to the caller with their // status/code). Runs for BOTH draft and publish-mode saves, so a later diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index 65ca4141fac..4c48a1b9aa0 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -62,6 +62,18 @@ import { // may only reach that package through its kernel-safe `/runtime` entry (the // wiring guard's third invariant), and `walkFlowNodes` is not on it. import { FLOW_REGION_SLOTS_BY_TYPE } from '@objectstack/spec/automation'; +// [#20312] The ADR-0080 compiler itself — the function behind the CLI-only +// `validateJsxPages` rule, imported rather than re-implemented, so the save +// door and `os validate` judge a page's source with one compiler. Imported from +// its own package, never through `@objectstack/lint`: the wiring guard allows +// this file only the kernel-safe `/runtime` entry and no registry rule by name. +// The parser is pure (no dependencies, never executes the source), so it adds +// nothing the kernel boot path may not load (`runtime-lazy-deps.test.ts`). +import { + compile as compileSduiSource, + type CompileResult as SduiCompileResult, + type Manifest as SduiManifest, +} from '@objectstack/sdui-parser'; import type { RuntimeAuthoringIssue } from '@objectstack/spec/api'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -518,6 +530,163 @@ export function mergePendingDeclarations( return [...kept, ...pending]; } +// ───────────────────────────────────────────────────────────────────────────── +// #20312 — an html page's source is compiled at save against the deployment's +// SDUI component manifest (ADR-0080 §5). +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The service key a host registers its deployment's ADR-0080 SDUI component + * manifest under — the parsed `sdui.manifest.json` object, a JSON object with a + * `components` map. `os serve` resolves it once at boot through the CLI's + * `resolveSduiManifest` and registers the result here; a host that registers + * nothing keeps the save door exactly as it was before this key existed. + * + * A plain service key, deliberately not a `CoreServiceName` slot: the manifest + * is a fact about the console this deployment serves, not a kernel capability. + * The protocol reads it per publish (`resolveSduiManifest` on the protocol, + * the `resolveFlowCanonicalizer` pattern), never at construction, so a value + * registered after the protocol is assembled is still seen by the next publish. + */ +export const SDUI_MANIFEST_SERVICE = 'sdui-manifest' as const; + +/** + * The gate-local rule that refuses a page whose hand-written `requires` + * disagrees with the namespaces its compiled source uses. A namespace the + * deployment's manifest does not carry at all always disagrees: the compiled + * `requires` holds only namespaces of components the manifest declares. + */ +export const PAGE_REQUIRES_DISAGREES_WITH_SOURCE = 'page-requires-disagrees-with-source'; + +/** + * The `rulesRun` name for the save door's own compile of an html page's + * source. Its findings carry the compiler's diagnostic codes as `jsx-CODE` — + * the rule ids `os validate` / `os build` report for the same source against + * the same manifest, so a page refused here is refused under the same name on + * the CLI. + */ +export const HTML_PAGE_SOURCE_COMPILE = 'html-page-source-compile'; + +/** The page kinds whose `source` is constrained JSX (the deprecated `jsx` spells `html`). */ +const COMPILED_PAGE_KINDS: ReadonlySet = new Set(['html', 'jsx']); + +/** + * Whether a value can be compiled against: an object with a `components` map, + * the one key `compile()` dereferences unconditionally — the same floor the + * CLI's `resolveSduiManifest` holds a manifest file to. + */ +export function isUsableSduiManifest(value: unknown): value is SduiManifest { + return isRec(value) && isRec(value.components); +} + +/** The compile of one html page body, or `undefined` when the save door does not compile it. */ +function compileHtmlPage( + type: string, + body: unknown, + sduiManifest: unknown, +): { name: string; result: SduiCompileResult } | undefined { + if (type !== 'page' || !isRec(body) || !COMPILED_PAGE_KINDS.has(body.kind)) return undefined; + if (!isUsableSduiManifest(sduiManifest)) return undefined; + // An empty source is PageSchema's refusal, already made before this gate. + if (typeof body.source !== 'string' || body.source.trim() === '') return undefined; + const name = typeof body.name === 'string' && body.name !== '' ? body.name : 'page'; + return { name, result: compileSduiSource(body.source, sduiManifest) }; +} + +const sameNamespaces = (a: readonly string[], b: readonly string[]): boolean => { + const left = new Set(a); + const right = new Set(b); + return left.size === right.size && [...left].every((ns) => right.has(ns)); +}; + +/** + * Judge an html page's source against the deployment's manifest: every + * compiler diagnostic becomes a finding (errors refuse, warnings advise), and + * a hand-written `requires` that disagrees with the compiled one is refused + * with each disagreeing namespace named. + * + * Returns `null` when nothing was judged — not a page, not an html page, or + * no usable manifest — so the caller discloses these rules only when they ran. + */ +export function findHtmlPageSourceGaps(args: { + type: string; + body: unknown; + sduiManifest?: unknown; +}): AuthoringFinding[] | null { + const compiled = compileHtmlPage(args.type, args.body, args.sduiManifest); + if (!compiled) return null; + const { name, result } = compiled; + const findings: AuthoringFinding[] = result.diagnostics.map((d) => ({ + severity: d.severity === 'error' ? 'error' : 'warning', + rule: `jsx-${d.code}`, + where: d.tag ? `page "${name}" › <${d.tag}>` : `page "${name}"`, + path: `pages.${name}.source`, + message: d.message, + hint: 'The source is compiled at save against the SDUI component manifest of the console this ' + + 'deployment serves — fix the JSX; a component the manifest does not declare needs the plugin ' + + 'that provides it installed in that console.', + })); + // A source that does not compile has no trustworthy namespace set to + // compare against; its own errors are the verdict. + if (!result.ok) return findings; + + const declared = (args.body as AnyRec).requires; + if (declared === undefined) return findings; + const declaredList: unknown[] = Array.isArray(declared) ? declared : [declared]; + const declaredNames = declaredList.filter((ns): ns is string => typeof ns === 'string'); + if (declaredNames.length === declaredList.length && sameNamespaces(declaredNames, result.requires)) { + return findings; + } + + const manifestNamespaces = new Set( + Object.values((args.sduiManifest as SduiManifest).components) + .map((c) => c?.namespace) + .filter((ns): ns is string => typeof ns === 'string'), + ); + const used = new Set(result.requires); + const unprovided = declaredNames.filter((ns) => !manifestNamespaces.has(ns)); + const unused = declaredNames.filter((ns) => manifestNamespaces.has(ns) && !used.has(ns)); + const missing = result.requires.filter((ns) => !declaredNames.includes(ns)); + const clauses = [ + ...unprovided.map((ns) => `'${ns}' is a namespace no component in this deployment's manifest carries`), + ...unused.map((ns) => `'${ns}' is not used by the source`), + ...missing.map((ns) => `'${ns}' is used by the source but not listed`), + ]; + if (declaredNames.length !== declaredList.length) clauses.push('every entry must be a namespace string'); + findings.push({ + severity: 'error', + rule: PAGE_REQUIRES_DISAGREES_WITH_SOURCE, + where: `page "${name}"`, + path: `pages.${name}.requires`, + message: `\`requires\` disagrees with the source: ${clauses.join('; ')}.`, + hint: `\`requires\` is derived from the source at save — omit it, or write exactly ` + + `${JSON.stringify(result.requires)}.`, + }); + return findings; +} + +/** + * The body to persist for an html page saved on a host with a manifest: its + * `requires` stamped from the compiled source. Returned unchanged — the same + * reference — when there is nothing to stamp: not an html page, no usable + * manifest, a source that does not compile, or a hand-written `requires` that + * disagrees. The last two are refusals on a publish and are left as written on + * a draft (drafts are not gated, #4463 D1), so the draft's own publish refuses + * them rather than a stamp silently replacing what the author wrote. + */ +export function stampHtmlPageRequires(type: string, body: unknown, sduiManifest: unknown): unknown { + const compiled = compileHtmlPage(type, body, sduiManifest); + if (!compiled || !compiled.result.ok) return body; + const declared = (body as AnyRec).requires; + if (declared !== undefined) { + const agrees = Array.isArray(declared) + && declared.every((ns) => typeof ns === 'string') + && sameNamespaces(declared as string[], compiled.result.requires); + if (!agrees) return body; + } + return { ...(body as AnyRec), requires: [...compiled.result.requires] }; +} + const toIssue = (f: AuthoringFinding): RuntimeAuthoringIssue => ({ rule: f.rule, path: f.path, @@ -622,7 +791,15 @@ export function evaluateRuntimeAuthoringGate(args: { * publish. */ pending?: RuntimePendingDeclarations; - /** ADR-0080 SDUI manifest when the host has one. */ + /** + * ADR-0080 SDUI manifest when the host has one — the value registered under + * {@link SDUI_MANIFEST_SERVICE}, read by the caller per publish. + * + * [#20312] A usable one (a `components` map) makes the gate compile an html + * page's `source` against it ({@link findHtmlPageSourceGaps}): an unknown + * component or a `requires` that disagrees with the source refuses the + * write. Absent, an html page is judged exactly as before. + */ sduiManifest?: unknown; /** * [#9612] The package this write belongs to, and the transitive closure of @@ -725,7 +902,7 @@ export function evaluateRuntimeAuthoringGate(args: { // wiring guard's invariant that this file names no REGISTRY rule is // untouched, and everything downstream — the 422, the issues array, the // migration hatch, the `rulesRun` disclosure — treats it identically. - const localIssues = findPlatformScheduleOrgGaps({ + const scheduleOrgGaps = findPlatformScheduleOrgGaps({ type: args.type, name: args.name, body: args.body, @@ -733,15 +910,36 @@ export function evaluateRuntimeAuthoringGate(args: { orgWallEnforced: args.orgWallEnforced === true, }); + // [#20312] The save door's own compile of an html page's source against + // the deployment's manifest (ADR-0080 §5) — gate-local for the reason the + // #6285 refusal above is: the manifest is a fact about the deployment, and + // the registry's `validateJsxPages` is CLI-only. Same verdict set, same + // 422, same hatch. `null` when it did not run (no usable manifest, not an + // html page), which keeps an html page on a manifest-less host judged + // exactly as before. + const pageSourceFindings = findHtmlPageSourceGaps({ + type: args.type, + body: args.body, + ...(args.sduiManifest !== undefined ? { sduiManifest: args.sduiManifest } : {}), + }); + const localIssues = [ + ...scheduleOrgGaps, + ...(pageSourceFindings ?? []).filter((f) => f.severity === 'error').map(toIssue), + ]; + const advisoryFindings = [ + ...result.advisories, + ...(pageSourceFindings ?? []).filter((f) => f.severity !== 'error'), + ]; + // [#4717] The advisory half of D3, now with somewhere to go. The deduped // log below is KEPT — it is the operator's channel and costs one Set lookup // — but it is no longer the only one: these travel back to the caller in // `advisories` and `saveMetaItem` puts them on the 2xx response, which is // the channel the Studio / MCP / AI author this gate exists for can // actually read. - const advisories = result.advisories.map(toIssue); + const advisories = advisoryFindings.map(toIssue); - for (const advisory of result.advisories) { + for (const advisory of advisoryFindings) { const key = `${args.type}|${args.name}|${advisory.rule}|${advisory.path}`; if (_advisoryWarned.has(key)) continue; _advisoryWarned.add(key); @@ -782,9 +980,11 @@ export function evaluateRuntimeAuthoringGate(args: { // applicable to this type. `rulesRun` exists so a caller can tell "clean" // from "nothing ran"; a judgement that can refuse a write and never appears // here would reintroduce exactly the ambiguity it was added to remove. - const rulesRun = args.type === 'flow' - ? [...result.rulesRun, PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING] - : result.rulesRun; + const rulesRun = [ + ...result.rulesRun, + ...(args.type === 'flow' ? [PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING] : []), + ...(pageSourceFindings !== null ? [HTML_PAGE_SOURCE_COMPILE, PAGE_REQUIRES_DISAGREES_WITH_SOURCE] : []), + ]; if (unlintedWritesAllowed()) { // Loud by construction (#4463 acceptance): the operator who set the diff --git a/packages/metadata-protocol/vitest.config.ts b/packages/metadata-protocol/vitest.config.ts index 5e0591efc44..87a915b5e69 100644 --- a/packages/metadata-protocol/vitest.config.ts +++ b/packages/metadata-protocol/vitest.config.ts @@ -1,9 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// This config exists for exactly one setting; everything else stays on +// This config exists for exactly two settings; everything else stays on // vitest's defaults, deliberately — a key added here re-specifies behaviour // for every test file in the package (packages/cli/vitest.config.ts's header // records the incident that taught that). +import path from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ @@ -16,4 +17,16 @@ export default defineConfig({ // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. disableConsoleIntercept: true, }, + resolve: { + alias: [ + // [#20312] The save door compiles html page source with the ADR-0080 + // compiler; its tests judge the compiler in this checkout, not a stale + // `dist/` (scripts/check-test-source-alias.mjs). Anchored, so no subpath + // is swallowed by the file replacement. + { + find: /^@objectstack\/sdui-parser$/, + replacement: path.resolve(__dirname, '../sdui-parser/src/index.ts'), + }, + ], + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b57123e50ee..b77872c5e05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1322,6 +1322,9 @@ importers: '@objectstack/metadata-core': specifier: workspace:* version: link:../metadata-core + '@objectstack/sdui-parser': + specifier: workspace:* + version: link:../sdui-parser '@objectstack/spec': specifier: workspace:* version: link:../spec