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
13 changes: 13 additions & 0 deletions .changeset/23088-verify-bootstack-metadata-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/verify': patch
---

fix(verify): `bootStack` composes the artifact-less `MetadataPlugin` that `objectstack serve` gives a host configuration, so a stack's declared `apis` endpoints answer under the harness as they answer under `serve` (#23088)

Clause-②: no

`bootStack`'s `metadata` slot was the kernel's in-memory fallback, which has no endpoint matcher (`matchEndpoint`). So every `apis` endpoint a stack declared answered `404 ENDPOINT_NOT_FOUND` in a test boot while `objectstack serve` of the same stack served it, and the boot logged `AppPlugin`'s "no endpoint matcher" warning. The harness now composes `@objectstack/metadata`'s `MetadataPlugin` the way `serve` composes it for a host configuration: no artifact, `bootstrap: 'lazy'`, and `rootDir` set to `BootOptions.hostRoot`. It reads none of the stack, so `AppPlugin` stays the single registrar of the stack's metadata.

- A declared endpoint answers as it does under `serve`: `401` without a session where it is `authRequired`, and its declared answer with one. An undeclared path under the same mount keeps the bare `404`.
- A `MetadataPlugin` you pass in `BootOptions.extraPlugins`, or one your app declares in its own `plugins`, takes the composed one's place. Pass your own to boot an artifact-backed one.
- Two other answers of a test boot move with the slot: `GET /api/v1/discovery` reports the `metadata` service as `available` instead of `degraded`, and `GET /api/v1/dev/metadata-events` (the development-only metadata reload stream, mounted because `bootStack` boots in the development posture) answers `200` instead of `404`, as under `objectstack dev`. The `GET /api/v1/meta/TYPE` answers are unchanged, measured over 22 types on the showcase and CRM examples.
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
// an object saved through `PUT /meta/object/:name` is listed with a real
// verdict (it declares a column the remote lacks, and exactly that column is
// reported); an object imported through `…/external/tables/:remote/import` is
// listed beside them. The verify harness composes no metadata plugin, so its
// `metadata` service is the kernel's fallback registered after the
// federation service's `init()`, as on `objectstack start`; the service reads
// it when validation runs ([#21876]), so each row here is a real comparison.
// listed beside them. The verify harness composes `@objectstack/metadata`'s
// artifact-less `MetadataPlugin`, as `objectstack serve` does for a host
// configuration (#23088); the service resolves what it reads when validation
// runs ([#21876]), so each row here is a real comparison.
// The seam pins sit beside the plugin
// (`packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts`).
//
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#21876] On a composition with no metadata plugin — the `objectstack start`
// shape — the federation service compares every federated object.
// [#21876] The federation service compares every federated object, on the
// composition the verify harness boots.
//
// ## What was broken
//
// `ExternalDatasourceServicePlugin.init()` read the `metadata` service once and
// kept the answer. `objectstack start` composes no metadata plugin: its
// `metadata` service is the kernel's in-memory fallback, registered after every
// plugin's `init()`, just before the start phase. So the federation service
// kept "no metadata service" for the life of the process. Measured on the
// showcase under `objectstack start`: `POST /datasources/:name/external/validate`
// answered `ok: true` with no rows, and the boot gate logged "all federated
// objects match their remote schema" with `objects: 0` — although two
// federated objects exist, and one of them could have drifted. Under
// `objectstack dev` (which composes the metadata plugin before this one) both
// objects were compared. The service now resolves `metadata` when it is used.
// kept the answer. On a composition with no metadata plugin, that service is
// the kernel's in-memory fallback, registered after every plugin's `init()`,
// just before the start phase. So the federation service kept "no metadata
// service" for the life of the process. Measured on the showcase at the time:
// `POST /datasources/:name/external/validate` answered `ok: true` with no rows,
// and the boot gate logged "all federated objects match their remote schema"
// with `objects: 0` — although two federated objects exist, and one of them
// could have drifted. The service now resolves `metadata` when it is used.
// That ordering fix is pinned at its seam, in
// `packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts`.
//
// ## Why this composition
//
// The verify harness composes no metadata plugin either, so its `metadata`
// service is the same kernel fallback, registered at the same moment, as on
// `objectstack start`. The first case asserts that, so a harness that one day
// composes a metadata plugin cannot turn this file into a silent pass. The
// harness does not mount the federation service, so this file mounts it, as
// The verify harness composes `@objectstack/metadata`'s `MetadataPlugin`, the
// artifact-less one `objectstack serve` and `start` give a host configuration
// such as the showcase (#23088). The first case asserts that occupant: the
// plugin is registered, and the `metadata` service is its manager, with the
// endpoint matcher. So a change to what the harness composes cannot pass
// through this file unnoticed. The harness does not mount the federation
// service, so this file mounts it, as
// `objectstack dev` and `start` do. It does not mount the boot gate either
// (`createExternalValidationPlugin`, `@objectstack/runtime`): the gate's input
// is the federation service's whole-farm sweep, `validateAll()`, which the
Expand All @@ -41,6 +43,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
import { ExternalDatasourceServicePlugin, resolveSqliteDriver } from '@objectstack/service-datasource';
import { MetadataManager } from '@objectstack/metadata';
import { type VerifyStack } from '@objectstack/verify';
import { bootShowcase } from './showcase-boot.js';
import { mkdtempSync, rmSync } from 'node:fs';
Expand Down Expand Up @@ -82,7 +85,7 @@ async function driftRemoteCustomers(): Promise<void> {
}
}

describe('federation on a composition with no metadata plugin (the objectstack start shape)', () => {
describe('federation on the harness composition, which holds the metadata plugin', () => {
let stack: VerifyStack;
let token: string;
let prevCwd: string;
Expand All @@ -107,10 +110,14 @@ describe('federation on a composition with no metadata plugin (the objectstack s
if (dir) rmSync(dir, { recursive: true, force: true });
});

it('precondition: the metadata service is the kernel\'s in-memory fallback, as on objectstack start', () => {
const metadata = stack.kernel.getService<{ __serviceInfo?: { status?: string; message?: string } }>('metadata');
expect(metadata.__serviceInfo?.status).toBe('degraded');
expect(metadata.__serviceInfo?.message).toMatch(/^In-memory metadata registry/);
it('precondition: the metadata service is the composed MetadataPlugin\'s manager, with its endpoint matcher', () => {
// The plugin is registered under its own name, and the `metadata` slot is
// its `MetadataManager`, not the kernel's in-memory fallback (a plain
// object with no matcher).
expect(stack.kernel.hasPlugin('com.objectstack.metadata')).toBe(true);
const metadata = stack.kernel.getService<{ matchEndpoint?: unknown }>('metadata');
expect(metadata).toBeInstanceOf(MetadataManager);
expect(typeof metadata.matchEndpoint).toBe('function');
});

it('validate compares each federated object, and reports the drifted column', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,9 @@ describe('dogfood: one artifact, two co-owning packages (ADR-0130 D4)', () => {
// #14599 — the METADATA door, which the block above does not reach
// ───────────────────────────────────────────────────────────────────────────
//
// `bootStack` registers `AppPlugin` and no `MetadataPlugin`, so everything
// above is measured on the ObjectQL registry alone. The defect this block pins
// `bootStack` registers `AppPlugin` and a `MetadataPlugin` with no artifact,
// which loads none of the stack (#23088), so everything above is measured on
// the ObjectQL registry alone. The defect this block pins
// lives in the OTHER reader — `MetadataPlugin._parseAndRegisterArtifact`, the
// door a real `objectstack dev` / `objectstack serve` boot loads the artifact
// through — and the two only disagree once both are present. That is why this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
// unit-tested by calling `dispatch()` directly, which is why nobody noticed
// that no route ever mounted the paths it claimed to serve.
//
// So this file stubs NOTHING. It boots the real showcase app, through the real
// artifact ingestion path (`MetadataPlugin` + a `local-file` artifact — the
// same path `objectstack dev` / `serve` take), over the real Hono transport,
// and asks the questions a caller asks:
// So this file stubs NOTHING. It boots the real showcase app as
// `objectstack serve` boots this host configuration — the harness's own
// artifact-less `MetadataPlugin`, with `AppPlugin` registering the declared
// endpoints — over the real Hono transport, and asks the questions a caller asks:
//
// 1. does a declared endpoint answer at all, and with the SAME body the
// built-in route gives for the same operation (#5040 §4's red line);
Expand Down Expand Up @@ -53,7 +53,6 @@ import { fileURLToPath } from 'node:url';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { type VerifyStack } from '@objectstack/verify';
import { bootShowcase } from './showcase-boot.js';
import { MetadataPlugin } from '@objectstack/metadata';
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
import { ConnectorMcpPlugin } from '@objectstack/connector-mcp';
Expand Down Expand Up @@ -82,26 +81,30 @@ let tempDir: string;
let prevCwd: string;
let stack: VerifyStack;
let adminToken: string;
/** Read back off disk — what `MetadataPlugin` actually ingested, not what we meant to write. */
/** Read back off disk — what the build-shaped writer actually wrote, not what we meant to write. */
let artifactOnDisk: Record<string, unknown>;

/**
* Boot the showcase the way a deployment boots it.
*
* `@objectstack/verify`'s lean harness composes no `MetadataPlugin` — its
* proofs are data/authorization ones that read metadata straight off the
* config. A declared endpoint cannot be reached without one: `matchEndpoint`
* is `IMetadataService`'s member, and `NodeMetadataManager` is its only
* implementation. So this file supplies it through the harness's `extraPlugins`
* seam, pointed at an artifact written from the showcase's own stack
* definition — the SAME `artifactSource: { mode: 'local-file' }` ingestion
* `createStandaloneStack` uses, which is what maps `apis:` → `api` metadata
* items (`ARTIFACT_FIELD_TO_TYPE` in `packages/metadata/src/plugin.ts`).
* A declared endpoint cannot be reached without a `MetadataPlugin`:
* `matchEndpoint` is `IMetadataService`'s member, and `NodeMetadataManager` is
* its only implementation. `@objectstack/verify`'s `bootStack` composes the one
* `objectstack serve` composes for a host configuration such as this one — no
* artifact, so `AppPlugin` registers the showcase's `apis:` as `api` items
* (#23088). This file used to hand in an artifact-backed `MetadataPlugin`
* through `extraPlugins`, because the harness composed none; it no longer does,
* so the endpoints below are the ones a deployment of this configuration
* serves, booted with nothing the harness does not compose itself. The
* artifact door's `apis:` → `api` ingestion (`ARTIFACT_FIELD_TO_TYPE` in
* `packages/metadata/src/plugin.ts`) keeps its own proofs
* (`declarative-endpoint-policy.dogfood.test.ts`,
* `declarative-endpoint-anonymous-guest.dogfood.test.ts`).
*
* Writing the artifact through `ObjectStackDefinitionSchema` — which the
* ingest performs on load — means this boot ALSO re-proves the E7 publish
* gates accept the restored declarations. A declaration that could not publish
* could not be ingested here either.
* The artifact is still written, through the build's own
* `ObjectStackDefinitionSchema` parse, so this file still re-proves the E7
* publish gates accept the restored declarations, and the cases in section 0
* read it back.
*/
beforeAll(async () => {
prevCwd = process.cwd();
Expand Down Expand Up @@ -137,13 +140,6 @@ beforeAll(async () => {
new ConnectorRestPlugin(),
new ConnectorOpenApiPlugin(),
new ConnectorMcpPlugin({ declarativeStdio: ['node'] }),
new MetadataPlugin({
rootDir: tempDir,
watch: false,
artifactWatch: false,
registerSystemObjects: false,
artifactSource: { mode: 'local-file', path: artifactPath },
}),
],
});
adminToken = await stack.signIn();
Expand All @@ -156,7 +152,7 @@ afterAll(async () => {
});

// ============================================================================
// 0. The artifact this boot ingests is the shape the build writes (commit c39a911ae)
// 0. The showcase's stand-in artifact is the shape the build writes (commit c39a911ae)
// ============================================================================

describe('[#6293] the stand-in artifact carries what a built one carries', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/verify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/plugin-approvals": "workspace:*",
Expand Down
142 changes: 142 additions & 0 deletions packages/verify/src/harness.metadata-door.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #23088: `bootStack` fills its `metadata` slot the way `objectstack serve`
* fills it for a host configuration (#22989): `@objectstack/metadata`'s
* `MetadataPlugin` with no artifact. Before, the slot was the kernel's
* in-memory fallback, which has no `matchEndpoint`, so every `apis` endpoint a
* stack declared answered `404 ENDPOINT_NOT_FOUND` under the harness while
* `serve` served it, and the boot logged `AppPlugin`'s no-matcher warning.
*
* Two boots:
*
* - the harness's own composition (nothing in `extraPlugins`): the declared
* path answers 401 without a session and 200 with one, an undeclared path
* under the same mount keeps the bare 404, and the no-matcher warning is
* not logged;
* - a caller's own `MetadataPlugin` in `extraPlugins`: that instance holds
* the slot, and the composed one is never registered (no supersede).
*
* Warnings are identified by their named subjects and structured fields (the
* app id, the remedy plugin, the superseded plugin's name), never by prose.
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { MetadataPlugin } from '@objectstack/metadata';
import { defineStack } from '@objectstack/spec';
import { bootStack, type VerifyStack } from './harness.js';

const BOOT_TIMEOUT = 120_000;
const APP_ID = 'com.example.mdoor';
const FEED = '/apps/mdoor/notes';

/** A fresh configuration per boot: the instance rule keys on identity. */
const stackWithApi = () =>
defineStack({
manifest: { id: APP_ID, name: 'Metadata Door', version: '1.0.0', type: 'app', namespace: 'mdoor' },
objects: [
{
name: 'mdoor_note',
label: 'Note',
pluralLabel: 'Notes',
sharingModel: 'public_read_write',
fields: { title: { name: 'title', type: 'text', label: 'Title' } },
},
],
apis: [
{
name: 'mdoor_note_feed',
path: `/api/v1${FEED}`,
method: 'GET',
summary: 'Note feed',
type: 'object_operation',
objectParams: { object: 'mdoor_note', operation: 'find' },
authRequired: true,
},
],
} as never) as unknown as Record<string, unknown>;

/** Every structured kernel-log line the boot wrote to stdout, ANSI stripped. */
async function bootCapturing(config: unknown, extraPlugins?: unknown[]): Promise<{ stack: VerifyStack; lines: string[] }> {
const lines: string[] = [];
const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => {
lines.push(...String(chunk).replace(/\u001b\[[0-9;]*m/g, '').split('\n'));
return true;
}) as never);
try {
const stack = await bootStack(config, extraPlugins ? { extraPlugins } : {});
return { stack, lines };
} finally {
spy.mockRestore();
}
}

/** The structured fields a kernel `WARN` line carries, or undefined for any other line. */
function warnFields(line: string): Record<string, unknown> | undefined {
if (!/ WARN /.test(line)) return undefined;
const at = line.indexOf(' {');
if (at < 0) return undefined;
try {
return JSON.parse(line.slice(at + 1)) as Record<string, unknown>;
} catch {
return undefined;
}
}

let stack: VerifyStack | undefined;
afterEach(async () => {
await stack?.stop();
stack = undefined;
});

describe('#23088 bootStack serves a declared endpoint, as objectstack serve does', () => {
it('composes a matcher, so the declared path is gated and served, and no matcher warning is logged', async () => {
const booted = await bootCapturing(stackWithApi());
stack = booted.stack;
const token = await stack.signIn();

const answer = async (res: Response) => ({
status: res.status,
code: ((await res.json()) as { error?: { code?: string } }).error?.code,
});
const observed = {
// The slot carries the endpoint matcher.
matcher: typeof stack.kernel.getService<{ matchEndpoint?: unknown }>('metadata').matchEndpoint,
anonymous: await answer(await stack.api(FEED, { method: 'GET' })),
signedIn: (await stack.apiAs(token, 'GET', FEED)).status,
// CONTROL: an undeclared path under the same mount keeps the bare 404.
undeclared: await answer(await stack.apiAs(token, 'GET', '/apps/mdoor/no-such-endpoint')),
matcherWarnings: booted.lines.filter((line) => {
const fields = warnFields(line);
return fields?.appId === APP_ID && line.includes('MetadataPlugin');
}),
};
// One composite, so a failure shows every observation at once.
expect(observed).toEqual({
matcher: 'function',
anonymous: { status: 401, code: 'UNAUTHENTICATED' },
signedIn: 200,
undeclared: { status: 404, code: 'ENDPOINT_NOT_FOUND' },
matcherWarnings: [],
});
}, BOOT_TIMEOUT);

it("a caller's own MetadataPlugin in extraPlugins holds the slot, and the composed one is never registered", async () => {
const own = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } });
const booted = await bootCapturing(stackWithApi(), [own]);
stack = booted.stack;

// The slot is the caller's instance's manager — by identity.
const ownManager = (own as unknown as { manager: unknown }).manager;
expect(stack.kernel.getService('metadata')).toBe(ownManager);

// Composed and then superseded would log the kernel's supersede warning
// for this name; not composed logs none.
const superseded = booted.lines.filter((line) => warnFields(line)?.plugin === own.name);
expect(superseded).toEqual([]);

// And the caller's instance serves the declared path.
const anonymous = await stack.api(FEED, { method: 'GET' });
expect(anonymous.status).toBe(401);
}, BOOT_TIMEOUT);
});
Loading
Loading