Skip to content
Merged
23 changes: 23 additions & 0 deletions .changeset/22989-host-config-metadata-door.md
Original file line number Diff line number Diff line change
@@ -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/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.
- 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.
- 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`.
188 changes: 188 additions & 0 deletions packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// 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/<type>/`. 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.
* • 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';
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: 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<string, unknown>([['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 {
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<typeof MetadataPlugin>[0]) => {
const services = new Map<string, unknown>();
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 });
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<cwd>/dist/objectstack.json` by accident.
expect(source).toContain(
Expand All @@ -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');
});
Expand Down
Loading
Loading