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
27 changes: 27 additions & 0 deletions .changeset/plugin-schema-ui-required-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/spec": minor
"@objectstack/core": minor
---

`PluginSchema` now REQUIRES `staticPath` and `slug` when `type` is `'ui'`, and core's `Plugin` interface inherits every `PluginSchema` key from `PluginDefinition` instead of restating two of them.

**BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). `packages/spec/src/kernel/plugin.zod.ts` described `staticPath` and `slug` as *"Required for type=\"ui\""* while declaring both `.optional()`, with nothing behind the prose; since `kernel.use()` runs the schema on the boot path (#16049), that was a promise the runtime visibly did not keep. This is the spec half of #16049, split by director ruling (decision batch #58, 2026-09-06).

**Exactly what is newly refused.** A plugin object with `type: 'ui'` that omits `staticPath`, omits `slug`, or spells either as `undefined`. Nothing else: every other declared type (`standard`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`), and a plugin declaring no `type` at all, still parses with neither key. A PRESENT value is judged exactly as before — `slug` keeps its `/^[a-z0-9-_]+$/` regex, `staticPath` stays any string, and the empty string is not refused by this change.

**What a refusal looks like.** One zod issue per missing key, `path` naming the key, the new stable code `PLUGIN_UI_REQUIRED_KEY_MISSING` (exported from `@objectstack/spec/kernel`) at the head of the issue `message` and on the issue's `params.code`. At `kernel.use()` it rides the existing `PLUGIN_CONTRACT_VIOLATION` envelope unchanged, because the loader surfaces the first issue's `path` and `message` and reads nothing else:

```
PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
plugin contract at 'staticPath': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'`
plugin must declare `staticPath` — the absolute path of the static assets it
serves. Declare it, or drop `type: 'ui'` if this plugin serves no assets.
```

**The fix for an affected plugin** is the one the message names: declare both keys (`staticPath`: the absolute path of the assets it serves; `slug`: the URL segment it is mounted under), or drop `type: 'ui'` if the plugin serves no assets. There is no fallback to lean on: the Hono server's `slug || name.split('/').pop()` derivation is no longer reachable through the kernel, because the object is refused before it is stored.

**`@objectstack/core` — `Plugin` derives its metadata keys.** `Plugin` now `extends PluginDefinition` (`z.input<typeof PluginSchema>`), so `id`, `type`, `staticPath`, `slug`, `default`, `version`, `description`, `author` and `homepage` are ONE declaration shared with the schema the kernel enforces. Additive for every existing implementer: `type` and `version` keep the shapes they had (`type` is still `PluginType | undefined`, pinned type-equal in `packages/rest`; `version` still `string | undefined`), and the seven other keys are new optional members. A `ui` plugin can now carry `staticPath` / `slug` without widening its own type. Runtime-only members (`name`, `dependencies`, `optionalDependencies`, `requiresServices`, `providesServices`, `init`, `start`, `destroy`) stay declared on the interface.

**Blast radius, measured.** No in-repo plugin object outside test fixtures declares `type: 'ui'` (searched `packages/`, `apps/`, `examples/` non-dist sources for a `type` key or class field holding the literal `'ui'`: three test files, nothing shipped), so no in-repo composition changes behaviour. Externally authored `ui` plugins that relied on the slug derivation, or declared no assets, are the population this reaches — and they are refused at boot, by name, with the key to add.

<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing on plugin OBJECTS, which are never stored metadata: `PluginSchema` gains a refinement and one exported constant; no metadata key, object definition or stored representation is added, removed or renamed, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The channel that reaches an affected plugin author is the refusal itself, which names the missing key at `kernel.use()`; which value that key should carry is authoring intent no ledger entry can decide. -->
101 changes: 88 additions & 13 deletions packages/core/src/plugin-contract-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { describe, expect, it } from 'vitest';
import { ObjectKernel } from './kernel.js';
import { PluginLoader } from './plugin-loader.js';
import { ObjectLogger } from './logger.js';
import { PLUGIN_UI_REQUIRED_KEY_MISSING } from '@objectstack/spec/kernel';
import type { Plugin, PluginContext } from './types.js';

/** A kernel that registers plugins and installs no process signal handlers. */
Expand All @@ -48,17 +49,21 @@ function stored(kernel: ObjectKernel, name: string): Record<string, unknown> | u
}

/**
* A plugin object with an arbitrary extra surface. The keys under test
* (`type`, `slug`, `homepage`, `id`) are declared by `PluginSchema` and NOT by
* the `Plugin` interface, which is one reason the repo contained no producer of
* them — so the fixture states the extra surface rather than casting it away.
* A plugin object under test. The keys under test (`type`, `slug`, `homepage`,
* `id`, `staticPath`) used to be declared by `PluginSchema` and NOT by the
* `Plugin` interface — one reason the repo contained no producer of them, and
* why this alias once had to widen `Plugin` to spell them. Since #16334
* `Plugin` inherits every `PluginSchema` key through `PluginDefinition`, so a
* plain `Plugin` states the whole surface; the alias survives as the name.
*/
type Fixture = Plugin & {
id?: string;
slug?: string;
homepage?: string;
staticPath?: string;
};
type Fixture = Plugin;

/**
* A `type: 'ui'` fixture owes `staticPath` and `slug` (#16334), so every `ui`
* fixture below carries both unless the case is ABOUT one of them. Nothing at
* `kernel.use()` reads the path off disk — the loader validates the object.
*/
const UI_STATIC_PATH = '/srv/os-fixture/ui/dist';

/**
* The refusal `promise` produced, or a loud failure if it produced none.
Expand Down Expand Up @@ -93,6 +98,10 @@ describe('A — the legacy `ui-plugin` value is refused at kernel.use() (#15638,
// The value #15638 MEASURED as accepted, stored verbatim and mounting
// routes. It is not a member of `CORE_PLUGIN_TYPES`.
type: 'ui-plugin' as unknown as Plugin['type'],
// Both `ui` keys declared (#16334), so the calibration twin below
// differs from this fixture in `type` and nothing else.
staticPath: UI_STATIC_PATH,
slug: 'legacy-ui',
});

await expect(kernel.use(legacy)).rejects.toThrow(/PLUGIN_CONTRACT_VIOLATION/);
Expand All @@ -111,7 +120,12 @@ describe('A — the legacy `ui-plugin` value is refused at kernel.use() (#15638,

it('CALIBRATION — the same fixture with the modern `ui` value loads', async () => {
const kernel = makeKernel();
const modern = fixture({ name: '@os-fixture/modern-ui', type: 'ui' });
const modern = fixture({
name: '@os-fixture/modern-ui',
type: 'ui',
staticPath: UI_STATIC_PATH,
slug: 'modern-ui',
});

await expect(kernel.use(modern)).resolves.toBe(kernel);
expect(stored(kernel, '@os-fixture/modern-ui')?.type).toBe('ui');
Expand Down Expand Up @@ -208,7 +222,7 @@ describe('C — ⭐ a CLASS-BASED plugin still loads, prototype chain intact', (
describe('D — the other two refusals the changeset states', () => {
it('refuses an invalid `slug`', async () => {
const kernel = makeKernel();
const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', slug: 'Not A Slug' });
const bad = fixture({ name: '@os-fixture/bad-slug', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'Not A Slug' });

const err = await refusal(kernel.use(bad));
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
Expand All @@ -217,7 +231,7 @@ describe('D — the other two refusals the changeset states', () => {

it('CALIBRATION — the same fixture with a legal slug loads', async () => {
const kernel = makeKernel();
const good = fixture({ name: '@os-fixture/good-slug', type: 'ui', slug: 'not-a-slug' });
const good = fixture({ name: '@os-fixture/good-slug', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'not-a-slug' });

await expect(kernel.use(good)).resolves.toBe(kernel);
});
Expand All @@ -239,6 +253,67 @@ describe('D — the other two refusals the changeset states', () => {
});
});

describe('F — a `ui` plugin owes `staticPath` and `slug`, refused at kernel.use() (#16334)', () => {
/**
* The spec half of #16049: `PluginSchema` describes both keys as
* `(Required for type="ui")` and, since #16334, refuses a `ui` plugin
* missing either — one issue per missing key, `path` naming the key,
* `PLUGIN_UI_REQUIRED_KEY_MISSING` at the head of the issue message. These
* pins measure that the boot path SURFACES that code unchanged: the loader
* re-emits the first issue's `path` and `message`, so the spec's code rides
* inside `PLUGIN_CONTRACT_VIOLATION`'s envelope. Group B's untyped and
* `standard` fixtures, which declare neither key and load, are the scope
* control: only `type: 'ui'` owes them.
*/
it('refuses a `ui` plugin with no `staticPath`, naming the key and the spec code', async () => {
const kernel = makeKernel();
const bad = fixture({ name: '@os-fixture/ui-no-static-path', type: 'ui', slug: 'ui-no-static-path' });

const err = await refusal(kernel.use(bad));
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
expect(err.message).toContain("at 'staticPath'");
expect(err.message).toContain(PLUGIN_UI_REQUIRED_KEY_MISSING);
expect(stored(kernel, '@os-fixture/ui-no-static-path')).toBeUndefined();
});

it('refuses a `ui` plugin with no `slug`, naming the key and the spec code', async () => {
const kernel = makeKernel();
const bad = fixture({ name: '@os-fixture/ui-no-slug', type: 'ui', staticPath: UI_STATIC_PATH });

const err = await refusal(kernel.use(bad));
expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION');
expect(err.message).toContain("at 'slug'");
expect(err.message).toContain(PLUGIN_UI_REQUIRED_KEY_MISSING);
});

it('CALIBRATION — the same `ui` fixture with both keys loads, stored verbatim', async () => {
const kernel = makeKernel();
const good = fixture({ name: '@os-fixture/ui-complete', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'ui-complete' });

await expect(kernel.use(good)).resolves.toBe(kernel);
const entry = stored(kernel, '@os-fixture/ui-complete');
expect(entry).toBe(good);
expect(entry?.staticPath).toBe(UI_STATIC_PATH);
expect(entry?.slug).toBe('ui-complete');
});

it('SCOPE — a `standard` plugin declaring neither key still loads', async () => {
const kernel = makeKernel();
const plain = fixture({ name: '@os-fixture/standard-keyless', type: 'standard' });

await expect(kernel.use(plain)).resolves.toBe(kernel);
});

it('the two keys are members of `Plugin` itself — inherited from PluginDefinition, not restated', () => {
// Compile-time half of the derivation (#16334): before it `staticPath`
// and `slug` were not members of `Plugin`, and every fixture in this
// file needed a widening alias to spell them. A plain `Plugin` now does.
const declared: Plugin = { name: 'x', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'x', init() {} };
expect(declared.slug).toBe('x');
expect(declared.staticPath).toBe(UI_STATIC_PATH);
});
});

describe('E — `version` is DELIBERATELY not enforced from the schema', () => {
/**
* `PluginSchema.version` is `/^\d+\.\d+\.\d+$/` and refuses the prerelease
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/plugin-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,15 @@ export class PluginLoader {
* All eight are `.optional()`, which admits absence and `undefined` but
* never an explicit `null` — so `null` on any of the eight is refused too.
*
* Since #16334 the schema carries ONE conditional requirement on top of
* the eight: `type: 'ui'` owes `staticPath` and `slug`, and `PluginSchema`
* refuses a `ui` plugin missing either with `PLUGIN_UI_REQUIRED_KEY_MISSING`
* at the head of the issue message (`packages/spec/src/kernel/plugin.zod.ts`).
* That refusal rides this method's envelope unchanged — reported as
* `at 'staticPath'` / `at 'slug'` with the spec's code inside the message —
* because this method surfaces `path` and `message` and reads nothing
* else. `plugin-contract-enforcement.test.ts` group F pins the surfacing.
*
* ⛔ ENUMERATE ALL EIGHT wherever this is restated. The changeset ships to
* consumers as `CHANGELOG.md` and is what an upgrading author greps after
* the refusal, so a shorter enumeration there does not merely omit keys —
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/plugin-type-closed-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,21 @@ describe('Plugin.type closed set — runtime parity with the spec enum (#13925)'
expect(CORE_PLUGIN_TYPES).toHaveLength(7);
});

/**
* The minimal spec-legal object per member. `ui` alone owes more than its
* `type`: `staticPath` and `slug` are required for it since #16334
* (`plugin-ui-required-keys.test.ts` in spec pins that), so a bare
* `{ type: 'ui' }` is refused at `['staticPath']` / `['slug']` — a reading
* about those two keys, not about the enum this file pins. Every other
* member is legal with its `type` alone, which the bare `{ type }` states.
*/
function minimalLegal(type: PluginType): Record<string, unknown> {
return type === 'ui' ? { type, staticPath: '/srv/ui/dist', slug: 'ui' } : { type };
}

it('every union member parses through PluginSchema', () => {
for (const type of UNION_MEMBERS) {
const result = PluginSchema.safeParse({ type });
const result = PluginSchema.safeParse(minimalLegal(type));
expect(result.success, `PluginSchema refused union member '${type}'`).toBe(true);
}
});
Expand Down
74 changes: 45 additions & 29 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { ObjectKernel } from './kernel.js';
import type { Logger, LifecycleEventName } from '@objectstack/spec/contracts';
import type { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel';
import type { CORE_PLUGIN_TYPES, PluginDefinition } from '@objectstack/spec/kernel';

/**
* PluginContext - Runtime context available to plugins
Expand Down Expand Up @@ -106,41 +106,57 @@ export type PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number];

/**
* Plugin Interface
*
*
* All ObjectStack plugins must implement this interface.
*
* ## Two halves, one contract (#16334)
*
* **The metadata half is inherited, not restated.** Every key `PluginSchema`
* declares (`@objectstack/spec`, `kernel/plugin.zod.ts`) — `id`, `type`,
* `staticPath`, `slug`, `default`, `version`, `description`, `author`,
* `homepage` — arrives here through `PluginDefinition`
* (`z.input<typeof PluginSchema>`), so the keys the compiler accepts on a
* plugin object and the keys `kernel.use()` validates
* (`PluginLoader.validatePluginContract`, #16049) are ONE declaration. Before
* this the interface spelled `type` and `version` itself and declared neither
* `staticPath` nor `slug`, so an in-repo `ui` plugin could not carry the two
* keys the schema requires of it without widening its own type — two shapes
* for one contract, free to drift.
*
* **The runtime half is declared here and only here**: `name`, the ADR-0116
* ordering declarations, and the `init` / `start` / `destroy` lifecycle. The
* spec's schema describes what a plugin OBJECT may say about itself, never
* what it does.
*
* ### `type`
*
* The inherited `type` is a {@link PluginType} — the closed set the spec
* declares (`'standard'` plus `CORE_PLUGIN_TYPES`); `packages/rest`'s
* `plugin-type-closed-set.pin.test.ts` pins that the inherited key and the
* exported alias are the same union. Absent means `'standard'` at the schema
* (`.default('standard')`), and the loader never writes that default back
* onto the object. A value outside the set no longer type-checks, and since
* #16049 `kernel.use()` REFUSES it at boot — `PluginLoader.validatePluginContract`
* runs `PluginSchema` over every plugin object and raises
* `PLUGIN_CONTRACT_VIOLATION` naming the plugin and the first violated key.
* `type: 'ui'` additionally owes `staticPath` and `slug` (#16334,
* `PLUGIN_UI_REQUIRED_KEY_MISSING`), refused on the same path.
*
* ⚠️ This comment used to say a bad `type` was refused "at parse". It was
* measured false (#16049, from #15638): `PluginSchema` had no runtime caller,
* kernel plugin objects were never parsed, and a `type` outside the set was
* accepted and stored verbatim. The refusal described here is the one that
* now exists, on the boot path, and the compiler's arm is the second half
* rather than the only one — `kernel.use(plugin as any)` is a shipped
* in-repo pattern, and externally authored plugins never meet this compiler
* at all.
*/
export interface Plugin {
export interface Plugin extends PluginDefinition {
/**
* Unique plugin name (e.g., 'com.objectstack.engine.objectql')
*/
name: string;

/**
* Plugin version
*/
version?: string;

/**
* Plugin type categorisation for runtime behaviour — a {@link PluginType},
* the closed set the spec declares. The enumeration lives on that type
* (derived from `CORE_PLUGIN_TYPES`), not in this comment: a value outside
* it no longer type-checks, and since #16049 `kernel.use()` REFUSES it at
* boot — `PluginLoader.validatePluginContract` runs `PluginSchema` over
* every plugin object and raises `PLUGIN_CONTRACT_VIOLATION` naming the
* plugin and the first violated key.
*
* ⚠️ This sentence used to say the value was refused "at parse". It was
* measured false (#16049, from #15638): `PluginSchema` had no runtime
* caller, kernel plugin objects were never parsed, and a `type` outside the
* set was accepted and stored verbatim. The refusal this comment describes
* is the one that now exists, on the boot path, and the compiler's arm is
* the second half rather than the only one — `kernel.use(plugin as any)` is
* a shipped in-repo pattern, and externally authored plugins never meet
* this compiler at all.
* @default 'standard'
*/
type?: PluginType;

/**
* List of other plugin names that this plugin depends on.
* The kernel ensures these plugins are initialized before this one.
Expand Down
Loading
Loading