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
51 changes: 51 additions & 0 deletions .changeset/20312-save-door-compiles-html-page-source.md
Original file line number Diff line number Diff line change
@@ -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`)

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable changes spelling or type: `packages/spec` is untouched, and `page.source` and `page.requires` keep their declared shapes. What changes is that the runtime metadata write door, on a host that registers the deployment's SDUI component manifest, now refuses two authored shapes at publish: an html page whose source uses a component the manifest does not declare, and one whose `requires` disagrees with the namespaces its source uses. `objectstack migrate meta` could not rewrite either even in principle: which component a page meant, and which plugin should provide it, is the author's decision. Rows at rest are not judged or rewritten. -->

**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`.
18 changes: 18 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];

Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/utils/sdui-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,6 +31,7 @@ import {
countJsxGatePages,
jsxGateStacks,
printJsxGateNotices,
registerDeploymentSduiManifest,
resolveJsxGateManifest,
resolveSduiManifest,
type SduiManifestResolution,
Expand Down Expand Up @@ -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*\);/,
);
});
});
44 changes: 44 additions & 0 deletions packages/cli/src/utils/sdui-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/metadata-protocol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading