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
29 changes: 29 additions & 0 deletions .changeset/28697-object-app-owner-stamp-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@objectstack/objectql": minor
"@objectstack/metadata": minor
"@objectstack/spec": minor
---

fix(objectql,metadata,spec)!: a package's apps and objects never name their own owning package — the owner-stamp refusal covers `objects` and `apps` at every door

The owner-stamp judge (`judgeCollectionItemOwnerStamp`, `@objectstack/spec/kernel`) now covers the two collections a package body files through verbs of their own, `objects` and `apps`, which it did not judge before. Each refusal is `VALIDATION_ERROR` / 400 with `issues[]` at `_packageId`, in the judge's words:

- **`ObjectQL.registerApp`** refuses an `objects` entry (array or name-keyed map), an `apps` entry, or the manifest registered as its own app, whose body carries a `_packageId` other than the registering package. It refuses before the body registers anything, so no package record, object or app is left behind. A nested plugin's objects and plugin-as-app are judged against the parent package the same way.
- **The `manifest` service's residual registration** refuses a residual object's stamp before any residual object registers. It already refused a residual app's.
- **The metadata plugin's body registration** (`@objectstack/metadata`: the artifact door and a config boot's residual) refuses a judged collection's item before it stamps and stores it. The engine's boot sync files every `app`, `view`, `flow` and `hook` it reads back under that stamp.
- **The stack schema** (`defineStack`, `os validate`, `os build`) judges `objects` and `apps` too (`OWNER_STAMP_JUDGED_COLLECTIONS` gains both), so the build door refuses what the boot refuses.

Clause-②: no (narrowing) — package registration, the metadata plugin's body registration and the stack schema refuse an object or app whose body `_packageId` names a package other than the one that registers it, which they accepted before with the body's stamp kept. Nothing is widened.

Metadata-protocol: narrowed (an object's or app's `_packageId`, declared by `MetadataProtectionFields`, may no longer name a package other than the one that registers it)

<!-- adr-0087: not-required (no-migration-prescription) No key is added, removed or renamed, and no value a legitimate producer writes is refused: the registration is the only writer of `_packageId` (`os build` writes none, the package export strips every underscore key, the artifact loader stamps with the very id the registration keys on), so a foreign stamp on an object or app exists only in a body somebody wrote by hand to claim another owner. A D2 conversion could not tell such a stamp from an agreeing one without the registration that judges it, and the refusal names the item, both ids and the remedy. -->

**BREAKING** accept-set narrowing at package registration and the build door (`@objectstack/objectql`, `@objectstack/metadata`, `@objectstack/spec`), shipped as `minor`.

- **Before.** An installed manifest `com.acme.app` whose `apps[0]` carried `_packageId: 'com.acme.host'` registered the app filed under the host. `GET /api/v1/meta/app?package=com.acme.host` listed it, `?package=com.acme.app` listed nothing, and disabling `com.acme.app` kept serving the app while its objects answered 404. An object's body stamp was kept on its definition.
- **Now.** The registration throws before anything of the package registers. Through `POST /api/v1/marketplace/install-local`, an inline install answers `422 PLUGIN_REGISTER_FAILED` with that message and persists nothing. `defineStack` / `os validate` report the same stamp as an issue at `objects.N._packageId` or `apps.N._packageId`. On upgrade, a config boot whose stack carries such a stamp fails to start with the judge's message, and a package installed earlier that carries one is not loaded at the next start and is reported at `error`.
- **Unchanged.** An absent `_packageId`, or one equal to the registering package, registers as before. An app object that an earlier registration stamped in place, such as a module constant handed to a second registration, registers as it always has. `objectExtensions` are not judged.
- **Who sees it.** Only someone who hand-writes `_packageId` on an object or app naming another package. No build, export or install writes one.

**The remedy.** Remove `_packageId` from the object or app. The package an item belongs to is the one that registers it, stamped by the registration.
6 changes: 5 additions & 1 deletion packages/metadata/src/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,11 @@ describe('MetadataPlugin', () => {
});

// Mock the spec kernel import
vi.mock('@objectstack/spec/kernel', () => ({
// The rest of the module is the real one: the artifact door judges a body's
// `_packageId` through `judgeCollectionItemOwnerStamp` over
// `OWNER_STAMP_JUDGED_COLLECTIONS` (#28697), and only the type registry is narrowed here.
vi.mock('@objectstack/spec/kernel', async (importOriginal) => ({
...(await importOriginal<typeof import('@objectstack/spec/kernel')>()),
DEFAULT_METADATA_TYPE_REGISTRY: [
{ type: 'object', label: 'Object', filePatterns: ['**/*.object.ts'], supportsOverlay: true, allowRuntimeCreate: false, supportsVersioning: true, loadOrder: 10, domain: 'data' },
{ type: 'view', label: 'View', filePatterns: ['**/*.view.ts'], supportsOverlay: true, allowRuntimeCreate: true, supportsVersioning: false, loadOrder: 50, domain: 'ui' },
Expand Down
85 changes: 85 additions & 0 deletions packages/metadata/src/plugin-artifact-owner-stamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#28697] The metadata plugin's body registration (`registerArtifactBodyCollections`,
* reached here through `MetadataPlugin.registerUnclaimedTopLevel`, the call a
* config boot makes with a stack no schema parse judged) refuses an item whose
* body already wears a `_packageId` other than the package it registers it
* under — by the one judge (`judgeCollectionItemOwnerStamp`), over the build
* door's collections, before the sink stores anything.
*
* Why it matters: `applyProtection` stamps an unset key only, so the body's own
* stamp used to be what this door stored, and the engine's boot sync
* (`ObjectQLPlugin.loadMetadataFromService`) files every `app` / `view` /
* `flow` / `hook` it reads back under that stamp.
*/

import { describe, it, expect } from 'vitest';
import { judgeCollectionItemOwnerStamp } from '@objectstack/spec/kernel';
import { MetadataPlugin } from './plugin.js';

const STACK = 'com.probe.stack';
const OTHER = 'com.probe.a';
const body = (id: string, namespace: string) => ({ id, name: namespace, namespace, version: '1.0.0', type: 'app' });

/** A two-body config stack whose top-level `extra` no body declares: the residual, owned by `STACK`. */
const withResidual = (extra: Record<string, unknown>) => ({
manifest: body(STACK, 'probestack'),
packages: [{ manifest: body(OTHER, 'probea') }, { manifest: body('com.probe.b', 'probeb') }],
...extra,
});

async function registerResidual(stack: unknown): Promise<{ error: any; stored: Array<{ type: string; name: string; item: any }> }> {
const stored: Array<{ type: string; name: string; item: any }> = [];
let error: any;
try {
await MetadataPlugin.registerUnclaimedTopLevel(
{ register: async (type, name, item) => { stored.push({ type, name, item }); }, warn: () => undefined },
stack,
{ door: 'config', label: 'probe' },
);
} catch (e) {
error = e;
}
return { error, stored };
}

const app = (stamp?: unknown) => ({ name: 'probe_app', label: 'Probe', ...(stamp === undefined ? {} : { _packageId: stamp }) });
const flow = (stamp?: unknown) => ({ name: 'probe_flow', label: 'Probe', type: 'autolaunched', ...(stamp === undefined ? {} : { _packageId: stamp }) });
const container = (stamp?: unknown) => ({
object: 'probe_thing',
list: { type: 'grid', columns: [{ field: 'name' }] },
...(stamp === undefined ? {} : { _packageId: stamp }),
});

describe('[#28697] the metadata door refuses a body stamp naming another package, before it stores anything', () => {
it.each([
['apps', 'app', 'probe_app', app],
['flows', 'flow', 'probe_flow', flow],
['views', 'view', 'probe_thing', container],
] as const)('a residual %s entry wearing another package\'s id: VALIDATION_ERROR / 400 at `_packageId`, nothing stored', async (key, type, name, make) => {
const { error, stored } = await registerResidual(withResidual({ [key]: [make(OTHER)] }));
expect(error, 'the registration must be refused').toBeInstanceOf(Error);
expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, httpStatus: 400 });
expect(error.issues).toEqual([{ path: '_packageId', code: 'custom', message: expect.any(String) }]);
expect(error.message).toBe(judgeCollectionItemOwnerStamp(make(OTHER), type, name, STACK, 'register')!.message);
expect(stored).toEqual([]);
});

it('CONTROL: an absent stamp and a stamp equal to the owner are stored, stamped with the owner', async () => {
for (const stamp of [undefined, STACK]) {
const { error, stored } = await registerResidual(withResidual({ apps: [app(stamp)], flows: [flow(stamp)] }));
expect(error, `stamp ${String(stamp)}`).toBeUndefined();
expect(stored.map((s) => [s.type, s.name, s.item._packageId])).toEqual([
['app', 'probe_app', STACK],
['flow', 'probe_flow', STACK],
]);
}
});

it('CONTROL: a collection the build door does not judge (`objectExtensions`) is stored as before', async () => {
const ext = { name: 'probe_ext', extend: 'probe_thing', fields: {}, _packageId: OTHER };
const { error } = await registerResidual(withResidual({ objectExtensions: [ext] }));
expect(error).toBeUndefined();
});
});
43 changes: 42 additions & 1 deletion packages/metadata/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Plugin, PluginContext } from '@objectstack/core';
import { resolveArtifactPackageOrder, artifactPackageId } from '@objectstack/core';
import { NodeMetadataManager } from './node-metadata-manager.js';
import { MemoryLoader } from './loaders/memory-loader.js';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
import { DEFAULT_METADATA_TYPE_REGISTRY, judgeCollectionItemOwnerStamp, OWNER_STAMP_JUDGED_COLLECTIONS } from '@objectstack/spec/kernel';
import type { MetadataPluginConfig } from '@objectstack/spec/kernel';
import { applyProtection } from '@objectstack/spec/shared';
import {
Expand Down Expand Up @@ -459,6 +459,43 @@ export function unclaimedTopLevel(stack: unknown): UnclaimedTopLevel | undefined
* the single-package branch D7 pins.
* @returns How many items this body registered.
*/
/**
* [#28697] The owner-stamp refusal at this door: an item of a judged
* collection ({@link OWNER_STAMP_JUDGED_COLLECTIONS}) whose body already wears
* a `_packageId` other than the body's own package, judged by the one judge
* (`judgeCollectionItemOwnerStamp`, `@objectstack/spec/kernel`) in the
* registration door's words and envelope (`VALIDATION_ERROR` / 400, `issues[]`
* at `_packageId`, the envelope the engine's body seam throws).
*
* Why here: `applyProtection` below stamps an unset key only, so a body's own
* stamp used to be what this door stored, and the engine's boot sync
* (`ObjectQLPlugin.loadMetadataFromService`) files every `app` / `view` /
* `flow` / `hook` it reads back under that stamp. A stack the schema parsed
* was judged at the build door first; a body no parse judged (a config boot's
* residual from a `strict: false` stack) was not, so this door judges every
* body it registers. The collections are the build door's, so neither door
* refuses what the other accepts.
*/
function artifactItemOwnerStampRefusal(
field: string,
metaType: string,
name: string,
item: unknown,
packageId: string | undefined,
): (Error & { code: 'VALIDATION_ERROR'; status: 400; httpStatus: 400; issues: Array<{ path: string; message: string; code: string }> }) | undefined {
if (!(OWNER_STAMP_JUDGED_COLLECTIONS as readonly string[]).includes(field)) return undefined;
const refusal = judgeCollectionItemOwnerStamp(item, metaType, name, packageId, 'register');
if (!refusal) return undefined;
const err = new Error(refusal.message) as Error & {
code: 'VALIDATION_ERROR'; status: 400; httpStatus: 400; issues: Array<{ path: string; message: string; code: string }>;
};
err.code = 'VALIDATION_ERROR';
err.status = 400;
err.httpStatus = 400;
err.issues = [{ path: '_packageId', message: refusal.issueMessage, code: 'custom' }];
return err;
}

async function registerArtifactBodyCollections(
sink: ArtifactRegistrationSink,
body: Record<string, unknown>,
Expand Down Expand Up @@ -496,6 +533,8 @@ async function registerArtifactBodyCollections(
// write; row 1 is unchanged for every type.
const nameRefusal = viewContainerNameRefusal(item, sourceLabel, packageId);
if (nameRefusal) throw nameRefusal;
const ownerStamp = artifactItemOwnerStampRefusal(field, metaType, viewObject, item, packageId);
if (ownerStamp) throw ownerStamp;
applyProtection(item as any, {
packageId: packageId,
packageVersion: packageVersion,
Expand Down Expand Up @@ -537,6 +576,8 @@ async function registerArtifactBodyCollections(
// it adds there is exactly the leading `object` term this path
// was missing.
const name = slot.name;
const ownerStamp = artifactItemOwnerStampRefusal(field, metaType, name, item, packageId);
if (ownerStamp) throw ownerStamp;
// ADR-0010 §3.7 — translate the author-facing
// `protection` block into the private `_lock` envelope
// and stamp package provenance in one call. Strips the
Expand Down
26 changes: 23 additions & 3 deletions packages/objectql/src/engine-package-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@
* `registerMetadataCollections`, `stampObjectPackage`). One home each: they
* stay declared in `engine.ts`, exported in place, and are imported here,
* never copied. Nothing re-exports them from the package entry, so the
* published surface does not move.
* published surface does not move. [#28697] Two more follow the same rule:
* `packageBodyObjectAndAppOwnerStampRefusal` and `registerAppNotingStamp`
* (`registerApp` and `registerPlugin`); the plugin-as-app now reaches
* `stampPackage` through `registerAppNotingStamp`, so this module no longer
* imports `stampPackage` itself.
*
* That import is a run-time module cycle (`engine.ts` imports this module,
* this module imports `engine.ts`). Nothing here runs at module evaluation,
Expand All @@ -50,7 +54,11 @@
import type { ServiceObject } from '@objectstack/spec/data';
import { applyArtifactForwardConversions, resolveDeclaredRange } from '@objectstack/metadata-core';
import { computeFQN } from './registry.js';
import { stampObjectPackage, stampPackage } from './engine.js';
import {
packageBodyObjectAndAppOwnerStampRefusal,
registerAppNotingStamp,
stampObjectPackage,
} from './engine.js';
import type { ObjectQL } from './engine.js';

/**
Expand Down Expand Up @@ -143,6 +151,18 @@ export function registerPlugin(
// contribute objects UNDER the parent's ownership.
const ownerId = parentId;

// [#28697] The owner-stamp refusal for the objects and the plugin-as-app
// below, before anything of the plugin registers and outside the two
// `try` blocks, which log a registry failure and go on: a body stamp is
// refused as the collection seam refuses it, not logged past.
{
const refusal = packageBodyObjectAndAppOwnerStampRefusal(plugin, ownerId, {
apps: false,
selfAsApp: Boolean(plugin.name && plugin.navigation),
});
if (refusal) throw refusal;
}

// Register objects (supports both Array and Map formats)
if (plugin.objects) {
try {
Expand Down Expand Up @@ -172,7 +192,7 @@ export function registerPlugin(
if (plugin.name && plugin.navigation) {
try {
const resolved = pluginNamespace ? this.resolveNavObjectNames(plugin, pluginNamespace) : plugin;
this._registry.registerApp(stampPackage(resolved, ownerId, parentVersion), ownerId);
registerAppNotingStamp(this._registry, resolved, ownerId, parentVersion);
this.logger.debug('Registered plugin-as-app', { app: plugin.name, from: pluginName });
} catch (err: any) {
this.logger.warn('Failed to register plugin as app', { pluginName, error: err.message });
Expand Down
Loading
Loading