From 88abc71212bbacf34f77f25542089fb0bcc1c7f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 15:17:14 +0000 Subject: [PATCH 1/6] fix(cli,runtime): a host-config os serve composes a MetadataPlugin; a matcher-less composition warns WIP: composition (A) and warning (B); tests follow. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/cli/src/commands/serve.ts | 102 +++++++++++++++++++++++------ packages/runtime/src/app-plugin.ts | 21 +++++- 2 files changed, 102 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 3b64f30af33..0fa07d1065a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3258,20 +3258,24 @@ export default class Serve extends Command { // as before this decision existed. // // The trade this makes, stated rather than discovered later: on that - // boot the dev HMR SSE endpoint is not mounted either, because the - // plugin that mounts it is the one not composed. That is the right - // way round. The alternative — compose the door anyway and only - // withhold the registrar — keeps a dev convenience by re-creating the - // TWO-WRITER divergence this block exists to remove: the watcher - // picks the artifact up if it later appears, and then registers a - // second copy behind a wrap that already registered its own. The - // endpoint is missing only where `os dev` was pointed at an artifact - // that is not there (`--artifact` skips auto-compile), and the - // warning above says so by name. + // boot nothing reloads the named artifact if it later appears, + // because the plugin that would watch it is the one not composed. + // That is the right way round. The alternative — compose the door + // anyway and only withhold the registrar — keeps a dev convenience by + // re-creating the TWO-WRITER divergence this block exists to remove: + // the watcher picks the artifact up if it later appears, and then + // registers a second copy behind a wrap that already registered its + // own. That boot still gets a `MetadataPlugin` — the host-config door + // below (#22989), which names no artifact, so it watches and + // registers none, and which mounts the dev HMR SSE endpoint under + // `NODE_ENV=development` in this door's place. The reload is missing + // only where `os dev` was pointed at an artifact that is not there + // (`--artifact` skips auto-compile), and the warning below says so by + // name. if (hmrArtifactPath && !/^https?:\/\//i.test(hmrArtifactPath)) { if (!fs.existsSync(hmrArtifactPath)) { console.warn(chalk.yellow( - ` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}` + ` ⚠ Dev artifact reload not enabled: no compiled artifact at ${hmrArtifactPath}` + '\n Stack-declared security metadata stays with the app wrap, its registrar on every' + '\n boot without a door.', )); @@ -3294,6 +3298,53 @@ export default class Serve extends Command { console.warn(chalk.yellow(` ⚠ Dev metadata-HMR endpoint not enabled: ${e?.message}`)); } } + + // ── The metadata door of a HOST config with no artifact door (#22989) ── + // A host config never reaches `createStandaloneStack`, the composition + // that gives every other config boot `@objectstack/metadata`'s + // `MetadataPlugin`. So unless the dev artifact door above composed, its + // `metadata` slot fell to the kernel's in-memory core fallback, which has + // no `matchEndpoint`: every `apis` endpoint the stack declares answered + // `404 ENDPOINT_NOT_FOUND` on `os serve` (and on `os start`, whose + // `serve` child takes this branch), while `os dev` over the compiled twin + // matched it. Maintainer ruling (#22989, option A): such a composition + // gets a `MetadataPlugin` with NO `artifactSource`, so the slot is a + // `MetadataManager` on both doors and the AppPlugin wrap's registration + // fills its endpoint index. + // + // What this door does and does not do, measured on `examples/app-showcase`: + // • `bootstrap: 'lazy'` skips the filesystem priming pass the default + // `eager` mode runs when no artifact is named. The artifact door this + // mirrors loads its artifact and never runs that pass; with no + // artifact the pass would scan `rootDir//` and register a + // second, differently stamped copy of whatever it found beside the + // wrap's (it finds 0 items under the showcase). + // • `rootDir` is the served app's root (`hostRoot`), the root + // `createStandaloneStack` hands its own door as `projectRoot`, so the + // repository it attaches sits under the app, never under whatever + // directory the operator stood in. Attaching writes nothing at boot. + // • It registers NONE of the stack's metadata: with no artifact it + // reads nothing of it. The wrap below therefore stays the single + // registrar of `positions` / `permissions` / `capabilities` / + // `sharingRules`, and its `securityMetadataRegistrar` stays the + // default `'app-plugin'`. ⛔ Never `'artifact-door'` for this door: + // that would leave the four collections with no registrar at all. + // • A composition that already holds a `MetadataPlugin` (its own, or + // the dev door above) is left alone. A config that is not a host + // config got one from the standalone stack already. + let hostConfigMetadataDoor: any; + if ( + !devArtifactDoor + && isHostConfig(config) + && !plugins.some((p: any) => p?.name === 'com.objectstack.metadata' || p?.constructor?.name === 'MetadataPlugin') + ) { + const { MetadataPlugin } = await import('@objectstack/metadata'); + hostConfigMetadataDoor = new MetadataPlugin({ + watch: false, + config: { bootstrap: 'lazy' }, + rootDir: hostRoot, + }); + } // ORDERING (#4085 → #4131/ADR-0116): the wrap is APPENDED to `plugins` // rather than registered here — but the append is no longer what makes // the boot correct. AppPlugin now DECLARES its ordering contract @@ -3310,8 +3361,10 @@ export default class Serve extends Command { const { AppPlugin } = await import('@objectstack/runtime'); // The registrar is DECLARED by this composition, never inferred by // AppPlugin — see the `devArtifactDoor` block above. Default - // (`'app-plugin'`) whenever no door composes, which is every - // non-dev boot of a host config. + // (`'app-plugin'`) whenever no artifact door composes, which is + // every non-dev boot of a host config — including every boot that + // composes `hostConfigMetadataDoor`, which reads no artifact and + // so registers none of the stack. plugins = [...plugins, new AppPlugin( config, undefined, @@ -3503,12 +3556,14 @@ export default class Serve extends Command { // ── Dev-only: metadata HMR SSE endpoint for host/aggregator configs ── // // Host configs (their `plugins[]` holds instantiated Plugin objects) skip - // the standalone stack — see `shouldBootWithLibrary()`. That stack is the - // only place `@objectstack/metadata`'s MetadataPlugin gets composed, and - // MetadataPlugin is what registers `GET /api/v1/dev/metadata-events` in - // its start(). So when serving a host config the Console's dev - // metadata-HMR reloader hits a 404 and the page never auto-reloads after - // a recompile. + // the standalone stack — see `shouldBootWithLibrary()`. That stack is + // where every other config gets `@objectstack/metadata`'s MetadataPlugin, + // and MetadataPlugin is what registers `GET /api/v1/dev/metadata-events` + // in its start(). So when serving a host config with no door the + // Console's dev metadata-HMR reloader hit a 404 and the page never + // auto-reloaded after a recompile. (A host config with no artifact door + // now gets the artifact-less `hostConfigMetadataDoor` instead, #22989, + // which mounts the same endpoint under development and reloads nothing.) // // Compose a lightweight, artifact-sourced MetadataPlugin here so the // endpoint exists regardless of boot shape. Guards: @@ -3543,6 +3598,15 @@ export default class Serve extends Command { console.warn(chalk.yellow(` ⚠ Dev metadata-HMR endpoint not enabled: ${e?.message}`)); } } + // [#22989] The host-config door with no artifact, at the same site and + // for the same ordering reason; it exists only when `devArtifactDoor` + // does not, so a boot composes at most one of the two. No catch: this + // door is the `metadata` slot's occupant, and a boot that cannot compose + // it must fail, not fall back to the matcher-less core fallback quietly. + if (hostConfigMetadataDoor) { + await kernel.use(hostConfigMetadataDoor); + trackPlugin('Metadata'); + } // Serve /runtime/assets/* unconditionally — branding logos, favicons, // and other static runtime assets must resolve even when the Console diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index c538f190904..afe89beb7fc 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -951,19 +951,36 @@ export class AppPlugin implements Plugin { * that carries `matchEndpoint` serves the items, which in-tree is * `@objectstack/metadata`'s manager. Where the `metadata` slot holds the * kernel's in-memory fallback instead, the items are listed and none is - * served. + * served — so that composition is told, once per boot of this app, at + * `warn`, with the count and the remedy (maintainer ruling on #22989, + * option B; AGENTS.md "Absence must be loud"). ⛔ No second matcher: the + * remedy is the composition's, never a fallback match here. Read in + * `start()`, after every `init()` has registered its services and the + * kernel has injected its fallbacks, so the slot's occupant is final. */ private registerDeclaredApiEndpoints(ctx: PluginContext, appId: string): void { if (this.securityMetadataRegistrar === 'artifact-door') return; if ((this.bundle as { packages?: unknown }).packages !== undefined) return; const apis: unknown = this.forwardConverted(ctx).definition?.apis; if (!Array.isArray(apis) || apis.length === 0) return; - let metadata: { registerInMemory?: (type: string, name: string, item: unknown) => void } | undefined; + let metadata: { + registerInMemory?: (type: string, name: string, item: unknown) => void; + matchEndpoint?: unknown; + } | undefined; try { metadata = ctx.getService('metadata'); } catch { // Not registered on this kernel — handled below. } + if (typeof metadata?.matchEndpoint !== 'function') { + ctx.logger.warn( + `[AppPlugin] app '${appId}' declares ${apis.length} api endpoint(s), but this boot's 'metadata' ` + + 'service has no endpoint matcher (matchEndpoint), so every declared path answers ' + + '404 ENDPOINT_NOT_FOUND. Compose @objectstack/metadata\'s MetadataPlugin on this kernel ' + + 'to serve them.', + { appId, count: apis.length }, + ); + } if (typeof metadata?.registerInMemory !== 'function') { ctx.logger.debug('[AppPlugin] no in-memory metadata registry — the stack-declared api endpoints are not registered', { appId }); return; From 8798a98e79498aee53719da8cfe85d9e2eccbdce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 15:30:38 +0000 Subject: [PATCH 2/6] test(cli,runtime): pin the host-config metadata door and the matcher-absent warning Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- ...erve-host-config-metadata-door.pin.test.ts | 144 ++++++++ ...host-config-security-registrar.pin.test.ts | 20 +- ...t-config-metadata-door.integration.test.ts | 307 ++++++++++++++++++ ...-plugin-api-matcher-absent-warning.test.ts | 128 ++++++++ 4 files changed, 593 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts create mode 100644 packages/cli/test/serve-host-config-metadata-door.integration.test.ts create mode 100644 packages/runtime/src/app-plugin-api-matcher-absent-warning.test.ts diff --git a/packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts b/packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts new file mode 100644 index 00000000000..5cf412fc3fb --- /dev/null +++ b/packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin: **a HOST config's `os serve` boot composes `@objectstack/metadata`'s + * `MetadataPlugin` when no artifact door does, and that door names no + * artifact, primes nothing and registers none of the stack** (#22989, the + * maintainer's option A). + * + * ## Why it exists + * + * A host config (its `plugins[]` holds plugin instances: `examples/app-showcase` + * and every `create-objectstack` scaffold) never reaches + * `createStandaloneStack`, the composition that gives every other config boot + * a `MetadataPlugin`. Outside `os dev`'s artifact door its `metadata` slot fell + * to the kernel's in-memory core fallback, which has no `matchEndpoint`, so + * every declared `apis` endpoint answered `404 ENDPOINT_NOT_FOUND` on + * `os serve` (measured on the showcase: `GET /api/v1/apps/showcase/tasks` + * with no session, 404 before, 401 after). The behavioural pin is + * `test/serve-host-config-metadata-door.integration.test.ts`; this file holds + * the composition's STRUCTURE, which that boot cannot see. + * + * ## What is pinned + * + * • ONE decision, a single named value, gated on the dev artifact door being + * absent, on a host config, and on the composition holding no + * `MetadataPlugin` of its own. + * • No `artifactSource`: the door reads nothing of the stack, so the AppPlugin + * wrap stays the single registrar of the four ADR-0057 security + * collections and keeps the default `'app-plugin'` registrar. ⛔ The wrap's + * `'artifact-door'` declaration is never keyed on this door. + * • `bootstrap: 'lazy'`: the default `eager` mode runs a filesystem priming + * pass when no artifact is named, which would register a second copy of + * anything under `rootDir//`. The behavioural leg below shows the + * option is load-bearing: the same root primes an item under `eager` and + * none under `lazy`. + * • `rootDir` is `hostRoot`, the served app's root, never the operator's cwd. + * • It is `kernel.use`d after the HTTP server plugin, at the dev door's site. + */ + +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { MetadataManager, MetadataPlugin } from '@objectstack/metadata'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SERVE_TS = path.join(HERE, 'serve.ts'); +const source = fs.readFileSync(SERVE_TS, 'utf8'); + +/** Occurrences of `needle` in the source, as a plain substring count. */ +function count(needle: string): number { + return source.split(needle).length - 1; +} + +/** The construction window: from the assignment to the closing `});`. */ +function constructionWindow(): string { + const at = source.indexOf('hostConfigMetadataDoor = new MetadataPlugin({'); + expect(at).toBeGreaterThan(-1); + return source.slice(at, source.indexOf('});', at) + 3); +} + +describe('#22989 A — `os serve` of a HOST config composes an artifact-less MetadataPlugin', () => { + it('the door is ONE named decision, gated on no artifact door, a host config and no MetadataPlugin', () => { + expect(count('let hostConfigMetadataDoor: any;')).toBe(1); + expect(count('hostConfigMetadataDoor = new MetadataPlugin({')).toBe(1); + const gateAt = source.indexOf('let hostConfigMetadataDoor: any;'); + const gate = source.slice(gateAt, source.indexOf('hostConfigMetadataDoor = new MetadataPlugin({')); + expect(gate).toContain('!devArtifactDoor'); + expect(gate).toContain('isHostConfig(config)'); + expect(gate).toContain( + "!plugins.some((p: any) => p?.name === 'com.objectstack.metadata' || p?.constructor?.name === 'MetadataPlugin')", + ); + // Decided after the dev door (it reads that value) and before the wrap. + expect(source.indexOf('let devArtifactDoor: any;')).toBeLessThan(gateAt); + expect(gateAt).toBeLessThan( + source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"), + ); + }); + + it('the door names no artifact, primes nothing, and roots at the served app', () => { + const ctor = constructionWindow(); + expect(ctor).not.toContain('artifactSource'); + expect(ctor).not.toContain('artifactWatch'); + expect(ctor).toContain("config: { bootstrap: 'lazy' },"); + expect(ctor).toContain('rootDir: hostRoot,'); + expect(ctor).toContain('watch: false,'); + }); + + it('the wrap\'s registrar is never keyed on this door: the four collections keep their one registrar', () => { + // The ONLY spelling of the declaration is the dev door's. + expect(count("securityMetadataRegistrar: 'artifact-door'")).toBe(1); + expect(source).toContain("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},"); + expect(source).not.toMatch(/hostConfigMetadataDoor\s*\?/); + }); + + it('the door is `kernel.use`d once, after the HTTP server plugin', () => { + expect(count('await kernel.use(hostConfigMetadataDoor);')).toBe(1); + expect(source.indexOf('await kernel.use(serverPlugin);')) + .toBeLessThan(source.indexOf('await kernel.use(hostConfigMetadataDoor);')); + }); + + it('behavioural: `bootstrap: \'lazy\'` is load-bearing — the same root primes an item under eager and none under lazy', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'os-22989-door-')); + try { + fs.mkdirSync(path.join(root, 'object'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'object', 'probe_door.object.json'), + JSON.stringify({ name: 'probe_door', label: 'Probe Door', fields: {} }), + ); + const primed = async (options: ConstructorParameters[0]) => { + const services = new Map(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => services.set(name, svc), + getService: (name: string) => { + if (!services.has(name)) throw new Error(`no ${name}`); + return services.get(name); + }, + hook: vi.fn(), + trigger: vi.fn(), + }; + const register = vi.spyOn(MetadataManager.prototype, 'register'); + const plugin = new MetadataPlugin(options); + try { + await plugin.init(ctx); + await plugin.start(ctx); + return register.mock.calls.map(([type, name]) => `${type}/${name}`); + } finally { + register.mockRestore(); + await plugin.destroy(); + } + }; + // CONTROL: the default mode primes the item this root holds. + expect(await primed({ watch: false, rootDir: root })).toEqual(['object/probe_door']); + // The composition's options: nothing is primed. + expect(await primed({ watch: false, config: { bootstrap: 'lazy' }, rootDir: root })).toEqual([]); + // Attaching the repository writes nothing at boot. + expect(fs.existsSync(path.join(root, '.objectstack', 'metadata'))).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts b/packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts index b159a5b7492..2c92a7d0348 100644 --- a/packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts +++ b/packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts @@ -117,10 +117,16 @@ describe('#14397 — `os dev` over a HOST config composes ONE registrar for stac expect(source).toContain( "if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {", ); - // Exactly one MetadataPlugin is constructed in this file, and it is - // that value — a second construction site is a second decision. - expect(count('new MetadataPlugin(')).toBe(1); - expect(source).toContain('devArtifactDoor = new MetadataPlugin({'); + // Exactly one ARTIFACT door is constructed in this file, and it is that + // value — a second artifact-door construction site is a second + // decision. The file's only other MetadataPlugin is the artifact-less + // host-config door (#22989), which exists only when this one does not + // and names no artifact; `serve-host-config-metadata-door.pin.test.ts` + // holds it to that. + expect(count('new MetadataPlugin(')).toBe(2); + expect(count('devArtifactDoor = new MetadataPlugin({')).toBe(1); + expect(count('hostConfigMetadataDoor = new MetadataPlugin({')).toBe(1); + expect(count("artifactSource: { mode: 'local-file', path: hmrArtifactPath },")).toBe(1); // The path still comes from the supervisor's own channel, never from // `/dist/objectstack.json` by accident. expect(source).toContain( @@ -145,9 +151,11 @@ describe('#14397 — `os dev` over a HOST config composes ONE registrar for stac expect(buildAt).toBeGreaterThan(-1); expect(gateAt).toBeLessThan(buildAt); // A missing artifact is not a silent downgrade: the warning names the - // path, and says the wrap keeps the collections. + // path, and says the wrap keeps the collections. What it names as lost + // is the artifact RELOAD: since #22989 that boot composes the + // artifact-less host-config door, which mounts the dev SSE endpoint. expect(source).toContain( - '` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`', + '` ⚠ Dev artifact reload not enabled: no compiled artifact at ${hmrArtifactPath}`', ); expect(source).toContain('Stack-declared security metadata stays with the app wrap'); }); diff --git a/packages/cli/test/serve-host-config-metadata-door.integration.test.ts b/packages/cli/test/serve-host-config-metadata-door.integration.test.ts new file mode 100644 index 00000000000..843e4823774 --- /dev/null +++ b/packages/cli/test/serve-host-config-metadata-door.integration.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22989 — `os serve` of a HOST config serves the stack's declared `apis` + * endpoints, because the boot now composes `@objectstack/metadata`'s + * `MetadataPlugin` (the maintainer's option A). + * + * ## The defect, measured at the public door before the fix + * + * `examples/app-showcase` is a host config (its `plugins[]` holds plugin + * instances), and so is every `create-objectstack` scaffold. On `os serve` such + * a config never reaches `createStandaloneStack`, the composition that gives + * every other config boot a `MetadataPlugin`, so its `metadata` slot was the + * kernel's in-memory core fallback — which has no `matchEndpoint`. Measured on + * the showcase, no session: + * + * | boot | GET /api/v1/apps/showcase/tasks | + * |--------------------------------------------------|---------------------------------| + * | `os serve objectstack.config.ts` (production) | 404 → 401 with this change | + * | `os serve --dev` over `dist/` (the artifact door) | 401 before and after | + * + * ## What each leg pins + * + * - **production** — `os serve objectstack.config.ts`, NODE_ENV unset, a host + * config with no `dist/`: the declared path answers `401 UNAUTHENTICATED` + * (the route matched; only auth stopped it), an undeclared path under the + * same namespace answers `404 ENDPOINT_NOT_FOUND`, and the dev-only + * `GET /api/v1/dev/metadata-events` stays unmounted. Its two security items + * (a position and a capability) are served exactly once each: the door + * registers none of the stack, so the AppPlugin wrap stays their single + * registrar. + * - **artifact** (CONTROL) — the same project with its compiled twin on disk, + * `os serve --dev`: the dev artifact door composes, and the same path + * answers the same `401`. + * + * ## Spawn shape + * + * `bin/run.js` with `NODE_ENV` unset — the shipped entry, resolving commands + * from `dist/`, so the boots measure the built packages (`requireBuiltCli`). + * Every boot runs in `beforeAll`; every `it` reads what it recorded, and a + * failed boot is recorded against its own leg. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + childEnv, + E2E_SECRET_KEY, + portContentionError, + portDriftError, + probeThroughChild, + randomPort, + requireBuiltCli, + RUN_JS_RESOLVES_FROM_DIST, +} from './helpers/serve-process.js'; +import { defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** `bin/run.js` — the SHIPPED entrypoint. */ +const CLI = resolve(HERE, '../bin/run.js'); + +/** The banner's tail — every row above it has printed. */ +const READY = /Press Ctrl\+C to stop/; +const BOOT_TIMEOUT_MS = 180_000; +const ALL_BOOTS_TIMEOUT_MS = 2 * (BOOT_TIMEOUT_MS + 30_000); + +const HOST_MARKER = 'com.example.fxh.host-marker'; +const FEED_PATH = '/api/v1/apps/fxh/tasks'; + +/** The stack, as data: the compiled twin is exactly this JSON. */ +const STACK = { + manifest: { id: 'com.example.fxh', namespace: 'fxh', version: '1.0.0', type: 'app', name: 'Fxh' }, + objects: [{ + name: 'fxh_task', + label: 'Task', + pluralLabel: 'Tasks', + sharingModel: 'public_read_write', + fields: { title: { type: 'text', label: 'Title' } }, + }], + apis: [{ + name: 'fxh_task_feed', + path: FEED_PATH, + method: 'GET', + summary: 'Task feed', + type: 'object_operation', + objectParams: { object: 'fxh_task', operation: 'find' }, + authRequired: true, + }], + positions: [{ name: 'fxh_reviewer', label: 'Reviewer' }], + capabilities: [{ name: 'fxh.export_data', label: 'Export', scope: 'org', packageId: 'com.example.fxh' }], +}; + +interface Live { + child: ChildProcess; + base: string; + output: () => string; +} + +interface Reading { + feed?: { status: number; code?: string }; + undeclared?: { status: number; code?: string }; + devEvents?: number; + positionCopies?: number; + capabilityCopies?: number; + error?: Error; +} + +const groups: ChildProcess[] = []; +let dir: string; +const readings: Partial> = {}; + +function boot(argv: string[], port: string, env: Record): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn(process.execPath, [CLI, ...argv], { + cwd: dir, + // `childEnv`, never a bare `...process.env` — see its header. `NODE_ENV` + // unset, so the built entry resolves its commands from `dist/`. + env: childEnv({ + NO_COLOR: '1', + OS_CLOUD_URL: 'off', + OS_DISABLE_CONSOLE: '1', + OS_SECRET_KEY: E2E_SECRET_KEY, + OS_LOG_LEVEL: 'warn', + OS_ARTIFACT_URL: undefined, + OS_ARTIFACT_PATH: undefined, + OS_MODE: undefined, + NODE_ENV: undefined, + ...env, + }), + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + groups.push(child); + let out = ''; + let settled = false; + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) rejectBoot(err); + else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`os ${argv[0]} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + BOOT_TIMEOUT_MS, + ); + const onData = (d: unknown) => { + out += String(d); + if (READY.test(out)) settle(portDriftError(out, `os ${argv[0]}`, port)); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('exit', (code) => + settle(portContentionError(out, `os ${argv[0]}`, port) + ?? new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + ); + }); +} + +async function stopGroup(child: ChildProcess): Promise { + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ } + done(); + }, 15_000); + child.once('exit', () => { clearTimeout(give); done(); }); + try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); } + }); +} + +/** One exchange, attributed to the child if the transport fails. ⛔ No assertion inside it. */ +function http(live: Live, method: string, path: string, token = '', body?: unknown) { + return probeThroughChild( + { + child: live.child, + transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`, + label: 'serve-host-config-metadata-door', + what: `${method} ${path}`, + }, + async () => { + const r = await fetch(`${live.base}${path}`, { + method, + headers: { + origin: live.base, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await r.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body: parsed }; + }, + ); +} + +/** How many items named `name` the `/meta/` answer carries. */ +function copies(body: any, name: string): number { + const items = body?.items ?? body?.data?.items ?? body?.data; + return Array.isArray(items) ? items.filter((i: any) => i?.name === name).length : -1; +} + +async function measure(leg: 'production' | 'artifact', argv: string[], env: Record): Promise { + const port = randomPort(); + let live: Live | undefined; + try { + live = await boot([...argv, '-p', port], port, env); + const feed = await http(live, 'GET', FEED_PATH); + const undeclared = await http(live, 'GET', '/api/v1/apps/fxh/no-such-endpoint'); + const reading: Reading = { + feed: { status: feed.status, code: feed.body?.error?.code }, + undeclared: { status: undeclared.status, code: undeclared.body?.error?.code }, + }; + if (leg === 'production') { + // The dev SSE route answers a stream when mounted; read the status only. + const ctl = new AbortController(); + const events = await fetch(`${live.base}/api/v1/dev/metadata-events`, { signal: ctl.signal }); + reading.devEvents = events.status; + ctl.abort(); + // The first sign-up on a fresh production database is the bootstrap admin. + const signUp = await http(live, 'POST', '/api/v1/auth/sign-up/email', '', { + email: 'fxh-admin@example.com', password: 'fxh-admin-password-22989', name: 'Fxh Admin', + }); + const token = signUp.body?.token; + if (signUp.status !== 200 || typeof token !== 'string') { + throw new Error(`sign-up answered ${signUp.status}: ${JSON.stringify(signUp.body)}\n--- output ---\n${live.output().slice(-3000)}`); + } + reading.positionCopies = copies((await http(live, 'GET', '/api/v1/meta/position', token)).body, 'fxh_reviewer'); + reading.capabilityCopies = copies((await http(live, 'GET', '/api/v1/meta/capability', token)).body, 'fxh.export_data'); + } + readings[leg] = reading; + } catch (error) { + readings[leg] = { error: error instanceof Error ? error : new Error(String(error)) }; + } finally { + if (live) await stopGroup(live.child); + } +} + +function reading(leg: 'production' | 'artifact'): Reading { + const r = readings[leg]; + if (!r) throw new Error(`no reading recorded for ${leg} — the beforeAll never reached it`); + if (r.error) throw r.error; + return r; +} + +beforeAll(async () => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); + dir = mkdtempSync(join(tmpdir(), 'os-22989-host-door-')); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fxh-host', private: true }), 'utf8'); + // A host config: a plugin INSTANCE in `plugins` (`isHostConfig`). + const literal = JSON.stringify(STACK, null, 2).replace(/\n}$/, `, + "plugins": [{ name: '${HOST_MARKER}', version: '1.0.0', init: async () => {}, start: async () => {} }] +}`); + // The splice must have landed, or this fixture silently stops being a host. + if (!literal.includes(HOST_MARKER)) throw new Error(`host-config fixture lost its plugin:\n${literal}`); + writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSourceFromLiteral(literal)); + linkSpec(dir); + + await measure('production', ['serve', 'objectstack.config.ts'], { + OS_DATABASE_URL: 'file:production.db', + OS_AUTH_SECRET: 'e2e-22989-production-leg-secret-not-for-real-use', + }); + + // The compiled twin, for the artifact door's control leg. + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(STACK, null, 2), 'utf8'); + await measure('artifact', ['serve', 'objectstack.config.ts', '--dev'], { + OS_DATABASE_URL: 'file:artifact.db', + }); +}, ALL_BOOTS_TIMEOUT_MS); + +afterAll(async () => { + for (const child of groups) await stopGroup(child); + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +}, 60_000); + +describe('#22989 — `os serve` of a HOST config serves its declared apis endpoints', () => { + it('production: the declared path answers 401 UNAUTHENTICATED, not 404', () => { + expect(reading('production').feed).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + }); + + it('CONTROL: an undeclared path under the same namespace still answers 404 ENDPOINT_NOT_FOUND', () => { + expect(reading('production').undeclared).toEqual({ status: 404, code: 'ENDPOINT_NOT_FOUND' }); + }); + + it('production: the dev-only metadata-events route stays unmounted', () => { + expect(reading('production').devEvents).toBe(404); + }); + + it('production: each stack-declared security item is served exactly once', () => { + const r = reading('production'); + expect(r.positionCopies).toBe(1); + expect(r.capabilityCopies).toBe(1); + }); + + it('CONTROL: the artifact door over the compiled twin answers the same 401', () => { + const r = reading('artifact'); + expect(r.feed).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + expect(r.undeclared).toEqual({ status: 404, code: 'ENDPOINT_NOT_FOUND' }); + }); +}); diff --git a/packages/runtime/src/app-plugin-api-matcher-absent-warning.test.ts b/packages/runtime/src/app-plugin-api-matcher-absent-warning.test.ts new file mode 100644 index 00000000000..1a5e5c2b2f9 --- /dev/null +++ b/packages/runtime/src/app-plugin-api-matcher-absent-warning.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22989 (the maintainer's option B): a composition whose `metadata` slot has + * no endpoint matcher says so ONCE when the stack declares `apis`, with the + * count and the remedy, instead of leaving every declared path to a bare + * `404 ENDPOINT_NOT_FOUND` (AGENTS.md, "Absence must be loud"). + * + * The matcher is `matchEndpoint`, which in-tree only `@objectstack/metadata`'s + * `MetadataManager` carries. A composition without `MetadataPlugin` boots on + * the kernel's in-memory core fallback (`createMemoryMetadata`): it lists the + * registered `api` items and serves none of them. `@objectstack/verify`'s lean + * `bootStack` is that composition; so was every host-config `os serve` before + * the CLI composed a `MetadataPlugin` there (option A, `serve.ts`). + * + * The compositions below are real kernel boots: `createStandaloneStack`'s + * plugins over an absent artifact, with and without its `MetadataPlugin`, + * plus the CLI's `new AppPlugin(config)` wrap. The warning is identified by + * its named subjects (the app id and the remedy plugin) and its structured + * fields, never by its prose. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { defineStack } from '@objectstack/spec'; +import { Runtime } from './runtime.js'; +import { AppPlugin } from './app-plugin.js'; +import { createStandaloneStack } from './standalone-stack.js'; + +// [#10126] Pay the first transform of these dist-resolved workspace deps at +// MODULE LOAD: the factory and the kernel boot below import them lazily, inside +// a clocked hook (`scripts/check-test-source-alias.mjs`, the clocked-window rule). +import '@objectstack/objectql'; +import '@objectstack/service-datasource'; +import '@objectstack/metadata'; + +const APP_ID = 'com.test.issue-22989-matcher'; +const FEED_PATH = '/api/v1/apps/probem/tasks'; +const BOOT_TIMEOUT = 120_000; + +const stack = (withApis: boolean) => defineStack({ + manifest: { id: APP_ID, name: 'Matcher Probe', version: '1.0.0', type: 'app', namespace: 'probem' }, + objects: [{ name: 'probem_task', label: 'Probe Task', fields: { title: { type: 'text', label: 'Title' } } }], + ...(withApis + ? { + apis: [{ + name: 'probem_task_feed', + path: FEED_PATH, + method: 'GET', + summary: 'Task feed', + type: 'object_operation', + objectParams: { object: 'probem_task', operation: 'find' }, + authRequired: true, + }], + } + : {}), +} as any); + +interface Booted { + /** Whether the booted `metadata` slot carries `matchEndpoint`. */ + hasMatcher: boolean; + /** The matcher's answer for the declared path, read while the kernel runs. */ + feedMatch: string | undefined; + /** Every `warn` the kernel logger took that names this app AND the remedy plugin. */ + matcherWarnings: Array<{ message: string; meta: any }>; +} + +async function boot(tag: string, opts: { metadataPlugin: boolean; withApis: boolean }): Promise { + const dir = mkdtempSync(join(tmpdir(), `os-22989-b-${tag}-`)); + let kernel: any; + try { + const standalone = await createStandaloneStack({ + artifactPath: join(dir, 'dist', 'objectstack.json'), + projectRoot: dir, + databaseUrl: ':memory:', + skipSeedData: true, + runPlatformMigrations: false, + }); + const plugins = opts.metadataPlugin + ? standalone.plugins + : standalone.plugins.filter((p: any) => p?.name !== 'com.objectstack.metadata'); + // CONTROL on the composition itself: the filter removed exactly the door. + expect(standalone.plugins.length - plugins.length).toBe(opts.metadataPlugin ? 0 : 1); + kernel = new Runtime({ cluster: false }).getKernel(); + // The context every plugin is handed carries the kernel's own logger + // instance, so a spy on that instance sees AppPlugin's warnings. + const warn = vi.spyOn(kernel.logger, 'warn'); + for (const p of [...plugins, new AppPlugin(stack(opts.withApis))]) await kernel.use(p); + await kernel.bootstrap(); + const matcherWarnings = warn.mock.calls + .map(([message, meta]) => ({ message: String(message), meta })) + .filter(({ message }) => message.includes(APP_ID) && message.includes('MetadataPlugin')); + const metadata = kernel.getService('metadata'); + const hasMatcher = typeof metadata?.matchEndpoint === 'function'; + const feedMatch = hasMatcher + ? (await metadata.matchEndpoint({ path: FEED_PATH, method: 'GET' }))?.endpoint?.name + : undefined; + return { hasMatcher, feedMatch, matcherWarnings }; + } finally { + try { await kernel?.shutdown(); } catch { /* noop */ } + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('#22989 B — a composition with no endpoint matcher warns once when the stack declares apis', () => { + it('the in-memory fallback composition logs exactly one warning, naming the count', async () => { + const booted = await boot('fallback', { metadataPlugin: false, withApis: true }); + // CONTROL: this boot's slot really is matcher-less. + expect(booted.hasMatcher).toBe(false); + expect(booted.matcherWarnings).toHaveLength(1); + expect(booted.matcherWarnings[0]!.meta).toEqual({ appId: APP_ID, count: 1 }); + }, BOOT_TIMEOUT); + + it('CONTROL: with a MetadataManager, no warning — and the declared path matches', async () => { + const booted = await boot('manager', { metadataPlugin: true, withApis: true }); + expect(booted.hasMatcher).toBe(true); + expect(booted.feedMatch).toBe('probem_task_feed'); + expect(booted.matcherWarnings).toEqual([]); + }, BOOT_TIMEOUT); + + it('CONTROL: with no apis declared, the matcher-less composition says nothing', async () => { + const booted = await boot('no-apis', { metadataPlugin: false, withApis: false }); + expect(booted.hasMatcher).toBe(false); + expect(booted.matcherWarnings).toEqual([]); + }, BOOT_TIMEOUT); +}); From 0b669449eb38ebbb47f455fd28436b18a1816152 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 15:35:58 +0000 Subject: [PATCH 3/6] chore(changeset): 22989 host-config metadata door Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/22989-host-config-metadata-door.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/22989-host-config-metadata-door.md diff --git a/.changeset/22989-host-config-metadata-door.md b/.changeset/22989-host-config-metadata-door.md new file mode 100644 index 00000000000..abd0ac1977f --- /dev/null +++ b/.changeset/22989-host-config-metadata-door.md @@ -0,0 +1,23 @@ +--- +'@objectstack/cli': patch +'@objectstack/runtime': patch +--- + +`os serve` of a host config serves the stack's declared `apis` endpoints, and a composition that still cannot serve them says so once + +Clause-②: no + +A host config is a stack whose `plugins` holds plugin instances: `examples/app-showcase` and every `create-objectstack` scaffold. On `os serve objectstack.config.ts` (and on `os start`, whose `serve` child boots the same way) such a config never got `@objectstack/metadata`'s `MetadataPlugin`, so its `metadata` service was the kernel's in-memory fallback, which has no endpoint matcher. Every declared `apis` path answered `404 ENDPOINT_NOT_FOUND` there, while `os dev` of the same stack matched it. Measured on the showcase with no session: `GET /api/v1/apps/showcase/tasks` answered 404 and now answers `401 UNAUTHENTICATED`; an undeclared path under the same namespace still answers 404. + +`os serve` now composes a `MetadataPlugin` with no artifact on a host-config boot that has none (when `os dev`'s artifact door does not compose and `plugins` holds no `MetadataPlugin` of its own). What an upgrading host author meets on that boot, measured on the showcase before and after: + +- `GET /api/v1/meta/` answers byte-for-byte the same for every type it served before (22 types read, including `app`, `view`, `page`, `dashboard`, `flow`, `position` and `permission`). +- `GET /api/v1/openapi.json` now documents the declared endpoints the boot serves; before, it listed none of them. +- No new database table: the `sys_metadata` storage tables were already provisioned, and no row is written at boot. +- Nothing is written into the project directory at boot. The metadata repository is rooted at the served app's directory, not at the directory the command was run from. +- The door reads none of the stack and primes nothing from the filesystem (`bootstrap: 'lazy'`), so `positions` / `permissions` / `capabilities` / `sharingRules` keep exactly one registrar. +- The dev-only `GET /api/v1/dev/metadata-events` stays unmounted unless `NODE_ENV=development`. Under `os serve --dev` with no compiled artifact it is now mounted, as it is on every other config's dev boot. +- With a remote cluster driver, the metadata service's cross-node cache invalidation (`metadata.changed`) now attaches; the boot warning that it was disabled is gone. +- Boot time and memory did not measurably move (median of 7 boots each: 8.47 s → 8.41 s to listening, 461 MB → 459 MB resident). + +A composition that still has no endpoint matcher (for example a kernel composed without `MetadataPlugin`) now logs one warning at boot per app that declares `apis`, with the count, naming the remedy: compose `@objectstack/metadata`'s `MetadataPlugin`. From b69c14d4a84a34a13836057f1e8d4748758ce43e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:05:38 +0000 Subject: [PATCH 4/6] test(cli): boot the host-config pin through the source entry; hold the production dev-route leg in-process The built-entry population of check-cli-test-child-env is a pinned set, so the boot legs use the tsx source entry and the production-only half is pinned on the composed door itself. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- ...erve-host-config-metadata-door.pin.test.ts | 44 +++++++ ...t-config-metadata-door.integration.test.ts | 111 ++++++++---------- 2 files changed, 93 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts b/packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts index 5cf412fc3fb..9321075c24d 100644 --- a/packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts +++ b/packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts @@ -35,6 +35,9 @@ * none under `lazy`. * • `rootDir` is `hostRoot`, the served app's root, never the operator's cwd. * • It is `kernel.use`d after the HTTP server plugin, at the dev door's site. + * • Under production it mounts no dev `GET /api/v1/dev/metadata-events`; the + * integration boots run the source entry, whose `NODE_ENV=development` + * cannot reach that posture, so this leg is held in-process here. */ import { describe, it, expect, vi } from 'vitest'; @@ -100,6 +103,47 @@ describe('#22989 A — `os serve` of a HOST config composes an artifact-less Met .toBeLessThan(source.indexOf('await kernel.use(hostConfigMetadataDoor);')); }); + it('behavioural: under production the door mounts no dev metadata-events route (CONTROL: development does)', async () => { + // The composition's options, on a context with an HTTP server whose raw + // app records every route mounted on it. + const mounted = async (nodeEnv: string) => { + const routes: string[] = []; + const app = { + get: (p: string) => { routes.push(`GET ${p}`); }, + post: (p: string) => { routes.push(`POST ${p}`); }, + }; + const services = new Map([['http.server', { getRawApp: () => app }]]); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => services.set(name, svc), + getService: (name: string) => { + if (!services.has(name)) throw new Error(`no ${name}`); + return services.get(name); + }, + hook: vi.fn(), + trigger: vi.fn(), + }; + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'os-22989-hmr-')); + const previous = process.env.NODE_ENV; + const plugin = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' }, rootDir: root }); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + process.env.NODE_ENV = nodeEnv; + await plugin.init(ctx); + await plugin.start(ctx); + return routes; + } finally { + if (previous === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previous; + log.mockRestore(); + await plugin.destroy(); + fs.rmSync(root, { recursive: true, force: true }); + } + }; + expect(await mounted('production')).toEqual([]); + expect(await mounted('development')).toContain('GET /api/v1/dev/metadata-events'); + }); + it('behavioural: `bootstrap: \'lazy\'` is load-bearing — the same root primes an item under eager and none under lazy', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'os-22989-door-')); try { diff --git a/packages/cli/test/serve-host-config-metadata-door.integration.test.ts b/packages/cli/test/serve-host-config-metadata-door.integration.test.ts index 843e4823774..658e7c05e6f 100644 --- a/packages/cli/test/serve-host-config-metadata-door.integration.test.ts +++ b/packages/cli/test/serve-host-config-metadata-door.integration.test.ts @@ -21,22 +21,29 @@ * * ## What each leg pins * - * - **production** — `os serve objectstack.config.ts`, NODE_ENV unset, a host - * config with no `dist/`: the declared path answers `401 UNAUTHENTICATED` - * (the route matched; only auth stopped it), an undeclared path under the - * same namespace answers `404 ENDPOINT_NOT_FOUND`, and the dev-only - * `GET /api/v1/dev/metadata-events` stays unmounted. Its two security items - * (a position and a capability) are served exactly once each: the door - * registers none of the stack, so the AppPlugin wrap stays their single - * registrar. - * - **artifact** (CONTROL) — the same project with its compiled twin on disk, - * `os serve --dev`: the dev artifact door composes, and the same path - * answers the same `401`. + * - **source** — `os serve objectstack.config.ts` of a host config with no + * `dist/`, so no artifact door can compose: the declared path answers + * `401 UNAUTHENTICATED` (the route matched; only auth stopped it), and an + * undeclared path under the same namespace answers `404 + * ENDPOINT_NOT_FOUND`. Its two security items (a position and a capability) + * are served exactly once each: the door registers none of the stack, so + * the AppPlugin wrap stays their single registrar. + * - **artifact** (CONTROL) — the same project with its compiled twin on disk: + * the dev artifact door composes, and the same path answers the same `401`. * - * ## Spawn shape + * ## Spawn shape, and the production posture it does not reach + * + * The tsx source entry (`bin/run-dev.js`), the harness the other boot-level + * files here use. That entry runs under `NODE_ENV=development`, so both legs + * boot the DEVELOPMENT posture. The host-config door is decided by the same + * three facts on both postures (no artifact door, a host config, no + * `MetadataPlugin` in the composition), and the source leg is the branch + * where no artifact door composes, exactly as on every production boot. What + * only production decides — the dev SSE route staying unmounted — is pinned + * in-process in `src/commands/serve-host-config-metadata-door.pin.test.ts`, + * and the production boot of `examples/app-showcase` was measured over HTTP + * on the card. * - * `bin/run.js` with `NODE_ENV` unset — the shipped entry, resolving commands - * from `dist/`, so the boots measure the built packages (`requireBuiltCli`). * Every boot runs in `beforeAll`; every `it` reads what it recorded, and a * failed boot is recorded against its own leg. */ @@ -45,30 +52,28 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawn, type ChildProcess } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; import { + CLI, childEnv, E2E_SECRET_KEY, portContentionError, portDriftError, probeThroughChild, randomPort, - requireBuiltCli, - RUN_JS_RESOLVES_FROM_DIST, + TSX, } from './helpers/serve-process.js'; import { defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js'; -const HERE = resolve(fileURLToPath(import.meta.url), '..'); -/** `bin/run.js` — the SHIPPED entrypoint. */ -const CLI = resolve(HERE, '../bin/run.js'); - /** The banner's tail — every row above it has printed. */ const READY = /Press Ctrl\+C to stop/; const BOOT_TIMEOUT_MS = 180_000; const ALL_BOOTS_TIMEOUT_MS = 2 * (BOOT_TIMEOUT_MS + 30_000); const HOST_MARKER = 'com.example.fxh.host-marker'; +/** The development dev-admin seed — the operator on every boot here. */ +const EMAIL = 'admin@objectos.ai'; +const PASSWORD = 'admin123'; const FEED_PATH = '/api/v1/apps/fxh/tasks'; /** The stack, as data: the compiled twin is exactly this JSON. */ @@ -103,7 +108,6 @@ interface Live { interface Reading { feed?: { status: number; code?: string }; undeclared?: { status: number; code?: string }; - devEvents?: number; positionCopies?: number; capabilityCopies?: number; error?: Error; @@ -111,14 +115,14 @@ interface Reading { const groups: ChildProcess[] = []; let dir: string; -const readings: Partial> = {}; +const readings: Partial> = {}; function boot(argv: string[], port: string, env: Record): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(process.execPath, [CLI, ...argv], { + const child = spawn(TSX, [CLI, ...argv, '-p', port], { cwd: dir, - // `childEnv`, never a bare `...process.env` — see its header. `NODE_ENV` - // unset, so the built entry resolves its commands from `dist/`. + // `childEnv`, never a bare `...process.env` — see its header. Neither + // artifact variable leaks in from the runner: each leg states its own. env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', @@ -128,7 +132,6 @@ function boot(argv: string[], port: string, env: Record i?.name === name).length : -1; } -async function measure(leg: 'production' | 'artifact', argv: string[], env: Record): Promise { - const port = randomPort(); +/** One leg: read what it pins from a boot already started, then stop it. */ +async function measure(leg: 'source' | 'artifact', started: Promise): Promise { let live: Live | undefined; try { - live = await boot([...argv, '-p', port], port, env); + live = await started; const feed = await http(live, 'GET', FEED_PATH); const undeclared = await http(live, 'GET', '/api/v1/apps/fxh/no-such-endpoint'); const reading: Reading = { feed: { status: feed.status, code: feed.body?.error?.code }, undeclared: { status: undeclared.status, code: undeclared.body?.error?.code }, }; - if (leg === 'production') { - // The dev SSE route answers a stream when mounted; read the status only. - const ctl = new AbortController(); - const events = await fetch(`${live.base}/api/v1/dev/metadata-events`, { signal: ctl.signal }); - reading.devEvents = events.status; - ctl.abort(); - // The first sign-up on a fresh production database is the bootstrap admin. - const signUp = await http(live, 'POST', '/api/v1/auth/sign-up/email', '', { - email: 'fxh-admin@example.com', password: 'fxh-admin-password-22989', name: 'Fxh Admin', - }); - const token = signUp.body?.token; - if (signUp.status !== 200 || typeof token !== 'string') { - throw new Error(`sign-up answered ${signUp.status}: ${JSON.stringify(signUp.body)}\n--- output ---\n${live.output().slice(-3000)}`); + if (leg === 'source') { + const signIn = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD }); + const token = signIn.body?.token; + if (signIn.status !== 200 || typeof token !== 'string') { + throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${live.output().slice(-3000)}`); } reading.positionCopies = copies((await http(live, 'GET', '/api/v1/meta/position', token)).body, 'fxh_reviewer'); reading.capabilityCopies = copies((await http(live, 'GET', '/api/v1/meta/capability', token)).body, 'fxh.export_data'); @@ -242,7 +237,7 @@ async function measure(leg: 'production' | 'artifact', argv: string[], env: Reco } } -function reading(leg: 'production' | 'artifact'): Reading { +function reading(leg: 'source' | 'artifact'): Reading { const r = readings[leg]; if (!r) throw new Error(`no reading recorded for ${leg} — the beforeAll never reached it`); if (r.error) throw r.error; @@ -250,7 +245,6 @@ function reading(leg: 'production' | 'artifact'): Reading { } beforeAll(async () => { - requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); dir = mkdtempSync(join(tmpdir(), 'os-22989-host-door-')); writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fxh-host', private: true }), 'utf8'); // A host config: a plugin INSTANCE in `plugins` (`isHostConfig`). @@ -262,17 +256,14 @@ beforeAll(async () => { writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSourceFromLiteral(literal)); linkSpec(dir); - await measure('production', ['serve', 'objectstack.config.ts'], { - OS_DATABASE_URL: 'file:production.db', - OS_AUTH_SECRET: 'e2e-22989-production-leg-secret-not-for-real-use', - }); + let port = randomPort(); + await measure('source', boot(['serve', 'objectstack.config.ts'], port, { OS_DATABASE_URL: 'file:source.db' })); // The compiled twin, for the artifact door's control leg. mkdirSync(join(dir, 'dist'), { recursive: true }); writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(STACK, null, 2), 'utf8'); - await measure('artifact', ['serve', 'objectstack.config.ts', '--dev'], { - OS_DATABASE_URL: 'file:artifact.db', - }); + port = randomPort(); + await measure('artifact', boot(['serve', 'objectstack.config.ts'], port, { OS_DATABASE_URL: 'file:artifact.db' })); }, ALL_BOOTS_TIMEOUT_MS); afterAll(async () => { @@ -281,20 +272,16 @@ afterAll(async () => { }, 60_000); describe('#22989 — `os serve` of a HOST config serves its declared apis endpoints', () => { - it('production: the declared path answers 401 UNAUTHENTICATED, not 404', () => { - expect(reading('production').feed).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + it('with no artifact door, the declared path answers 401 UNAUTHENTICATED, not 404', () => { + expect(reading('source').feed).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); }); it('CONTROL: an undeclared path under the same namespace still answers 404 ENDPOINT_NOT_FOUND', () => { - expect(reading('production').undeclared).toEqual({ status: 404, code: 'ENDPOINT_NOT_FOUND' }); - }); - - it('production: the dev-only metadata-events route stays unmounted', () => { - expect(reading('production').devEvents).toBe(404); + expect(reading('source').undeclared).toEqual({ status: 404, code: 'ENDPOINT_NOT_FOUND' }); }); - it('production: each stack-declared security item is served exactly once', () => { - const r = reading('production'); + it('each stack-declared security item is served exactly once', () => { + const r = reading('source'); expect(r.positionCopies).toBe(1); expect(r.capabilityCopies).toBe(1); }); From bce5b375ec09585f525e4e3944602987e34edfbf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:07:07 +0000 Subject: [PATCH 5/6] chore(changeset): 22989 wording Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/22989-host-config-metadata-door.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/22989-host-config-metadata-door.md b/.changeset/22989-host-config-metadata-door.md index abd0ac1977f..15fc780d957 100644 --- a/.changeset/22989-host-config-metadata-door.md +++ b/.changeset/22989-host-config-metadata-door.md @@ -11,10 +11,10 @@ A host config is a stack whose `plugins` holds plugin instances: `examples/app-s `os serve` now composes a `MetadataPlugin` with no artifact on a host-config boot that has none (when `os dev`'s artifact door does not compose and `plugins` holds no `MetadataPlugin` of its own). What an upgrading host author meets on that boot, measured on the showcase before and after: -- `GET /api/v1/meta/` answers byte-for-byte the same for every type it served before (22 types read, including `app`, `view`, `page`, `dashboard`, `flow`, `position` and `permission`). +- `GET /api/v1/meta/TYPE` answers byte-for-byte the same for every type it served before (22 types read, including `app`, `view`, `page`, `dashboard`, `flow`, `position` and `permission`). - `GET /api/v1/openapi.json` now documents the declared endpoints the boot serves; before, it listed none of them. - No new database table: the `sys_metadata` storage tables were already provisioned, and no row is written at boot. -- Nothing is written into the project directory at boot. The metadata repository is rooted at the served app's directory, not at the directory the command was run from. +- The door writes nothing into the project directory at boot. Its metadata repository is rooted at the served app's directory, not at the directory the command was run from. - The door reads none of the stack and primes nothing from the filesystem (`bootstrap: 'lazy'`), so `positions` / `permissions` / `capabilities` / `sharingRules` keep exactly one registrar. - The dev-only `GET /api/v1/dev/metadata-events` stays unmounted unless `NODE_ENV=development`. Under `os serve --dev` with no compiled artifact it is now mounted, as it is on every other config's dev boot. - With a remote cluster driver, the metadata service's cross-node cache invalidation (`metadata.changed`) now attaches; the boot warning that it was disabled is gone. From 8d4f551bb670652d40021544e105789813a7fe56 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:21:09 +0000 Subject: [PATCH 6/6] test(cli): declare serve's isHostConfig read as serve-only in the boot-preparation parity pin Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/cli/src/utils/boot-preparation-parity.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/utils/boot-preparation-parity.test.ts b/packages/cli/src/utils/boot-preparation-parity.test.ts index c7857db3587..aca17313a10 100644 --- a/packages/cli/src/utils/boot-preparation-parity.test.ts +++ b/packages/cli/src/utils/boot-preparation-parity.test.ts @@ -151,6 +151,10 @@ const SERVE_ONLY: Record = { 'serve\'s boot-mode dispatch between the standalone stack and a host config\'s own plugins; the one-shot ' + 'boot always boots the standalone data stack and composes a host config\'s plugins over it for their ' + 'declarations (buildSchemaMigrationPlugins)', + isHostConfig: + 'decides serve\'s artifact-less MetadataPlugin for a host config that no artifact door serves ([#22989]); ' + + 'the one-shot boot boots the standalone data stack, whose MetadataPlugin is always composed, and lays a ' + + 'host config\'s plugins over it (buildSchemaMigrationPlugins)', bundleDeclaresTranslations: 'decides serve\'s tier-gated i18n service, which the declaration boot does not compose ' + '(schema-migration-plugins.ts, "What this does NOT compose")',