diff --git a/.changeset/28697-object-app-owner-stamp-refused.md b/.changeset/28697-object-app-owner-stamp-refused.md new file mode 100644 index 00000000000..022ec38a41f --- /dev/null +++ b/.changeset/28697-object-app-owner-stamp-refused.md @@ -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) + + + +**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. diff --git a/packages/metadata/src/metadata.test.ts b/packages/metadata/src/metadata.test.ts index 4311be00a2d..df8e66e0496 100644 --- a/packages/metadata/src/metadata.test.ts +++ b/packages/metadata/src/metadata.test.ts @@ -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()), 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' }, diff --git a/packages/metadata/src/plugin-artifact-owner-stamp.test.ts b/packages/metadata/src/plugin-artifact-owner-stamp.test.ts new file mode 100644 index 00000000000..c43cbebe396 --- /dev/null +++ b/packages/metadata/src/plugin-artifact-owner-stamp.test.ts @@ -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) => ({ + 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(); + }); +}); diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index dbbc705d7ee..0bf58e27090 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -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 { @@ -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, @@ -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, @@ -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 diff --git a/packages/objectql/src/engine-package-registration.ts b/packages/objectql/src/engine-package-registration.ts index 7c7c48c1d85..baef35f8f21 100644 --- a/packages/objectql/src/engine-package-registration.ts +++ b/packages/objectql/src/engine-package-registration.ts @@ -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, @@ -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'; /** @@ -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 { @@ -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 }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 4f34b389f62..98686183280 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4928,6 +4928,85 @@ export function collectionItemOwnerStampRefusal( return err; } +/** + * [#28697] {@link collectionItemOwnerStampRefusal} for the two collections a + * package body files through verbs of their own rather than the collection + * loop: its OBJECTS (`registerObject`, the array and the name-keyed map form) + * and its APPS (`registerApp`: each `apps[]` entry the loop would register, + * and the body itself when it registers as its own app). The first refusal + * found, in that order, or `undefined`. + * + * Asked BEFORE the body registers anything, so a refused body leaves no + * package record, object or app behind. An app wearing another package's + * `_packageId` kept it (`applyProtection` stamps an unset key only), and every + * reader that asks an app's owner — the per-package list filter, the + * disabled-package filter, the packaged-base gate — then filed it under the + * package it named. An object's stamp is read by the action-parse predicate + * of `registerObject` (`schema._packageId ?? packageId`). Refused, not + * overwritten, for the reason the collection loop refuses: one judge, so the + * build door (the stack schema, which judges `objects` and `apps` too) and + * this door cannot disagree. + * + * Called by {@link ObjectQL.registerApp} and by a nested plugin's + * registration (`registerPlugin`, which registers objects and the plugin + * itself as an app, and no `apps[]`). + * + * @param body The package body or nested plugin, as it arrived. + * @param ownerId The package the door registers it under. + * @param scope Which of the app shapes this door registers. + */ +export function packageBodyObjectAndAppOwnerStampRefusal( + body: any, + ownerId: string | undefined, + scope: { apps: boolean; selfAsApp: boolean }, +): ReturnType { + const objects = body?.objects; + if (objects && typeof objects === 'object') { + const entries: Array<[unknown, unknown]> = Array.isArray(objects) + ? objects.map((def: any) => [def?.name, def]) + : Object.entries(objects); + for (const [name, def] of entries) { + const refusal = collectionItemOwnerStampRefusal(def, 'object', typeof name === 'string' && name !== '' ? name : '(unnamed)', ownerId); + if (refusal) return refusal; + } + } + if (scope.apps && Array.isArray(body?.apps)) { + for (const app of body.apps) { + // The loop registers an app with a name only; a nameless one is skipped. + const appName = app?.name || app?.id; + if (!appName) continue; + const refusal = collectionItemOwnerStampRefusal(app, 'app', String(appName), ownerId); + if (refusal) return refusal; + } + } + if (scope.selfAsApp) { + const refusal = collectionItemOwnerStampRefusal(body, 'app', String(body?.name), ownerId); + if (refusal) return refusal; + } + return undefined; +} + +/** + * [#28697] Stamp an app object ({@link stampPackage}) and register it through + * the registry's app verb, recording the stamp the registration writes onto + * the caller's own object ({@link noteRegistrationStamp}): `stampPackage` (with + * a version) and `registerItem` (without one) stamp the object in place, so a + * module constant handed to a second registration arrives wearing the first + * one's stamp, which no body wrote. The stamp before is read BEFORE + * `stampPackage`, the order the collection and seed loops keep, so a versioned + * registration is recorded too. + */ +export function registerAppNotingStamp( + registry: SchemaRegistry, + app: any, + ownerId: string, + ownerVersion: string | undefined, +): void { + const stampBefore = (app as { _packageId?: unknown } | null | undefined)?._packageId; + registry.registerApp(stampPackage(app, ownerId, ownerVersion), ownerId); + noteRegistrationStamp(app, stampBefore); +} + /** * [#5320] The registration door's refusal of a `views:` entry that is not a * view CONTAINER (`isViewContainerShaped` is false: a ViewItem's @@ -8948,6 +9027,30 @@ export class ObjectQL implements IObjectQLEngine { // Stamped beside `id` on every item below ({@link stampPackage}), so an // item served from this path tells an upgrade's new item from its old one. const version = manifest.version as string | undefined; + // [#28697] The owner-stamp refusal for the objects and apps below, which + // file through verbs of their own (steps 2-4), asked before anything of + // this body registers. See {@link packageBodyObjectAndAppOwnerStampRefusal}. + { + const refusal = packageBodyObjectAndAppOwnerStampRefusal(manifest, id, { + apps: true, + selfAsApp: Boolean(manifest.name && manifest.navigation && !manifest.apps?.length), + }); + if (refusal) throw refusal; + // …and each nested plugin's objects and plugin-as-app (step 7, which + // registers them under this package after everything above), so a + // nested plugin's stamp is refused before this body registers too. + // `registerPlugin` asks the same of the plugin it is handed. + if (Array.isArray(manifest.plugins)) { + for (const plugin of manifest.plugins) { + if (!plugin || typeof plugin !== 'object') continue; + const nested = packageBodyObjectAndAppOwnerStampRefusal(plugin, id, { + apps: false, + selfAsApp: Boolean(plugin.name && plugin.navigation), + }); + if (nested) throw nested; + } + } + } this.invalidateSummaryIndex(); // new objects may add/change summary fields this.logger.debug('Registering package manifest', { id, namespace }); @@ -9032,7 +9135,7 @@ export class ObjectQL implements IObjectQLEngine { const appName = app.name || app.id; if (appName) { const resolved = namespace ? this.resolveNavObjectNames(app, namespace) : app; - this._registry.registerApp(stampPackage(resolved, id, version), id); + registerAppNotingStamp(this._registry, resolved, id, version); this.logger.debug('Registered App', { app: appName, from: id }); } } @@ -9042,7 +9145,7 @@ export class ObjectQL implements IObjectQLEngine { // This handles the case where the manifest IS the app definition (legacy/simple packages) if (manifest.name && manifest.navigation && !manifest.apps?.length) { const resolved = namespace ? this.resolveNavObjectNames(manifest, namespace) : manifest; - this._registry.registerApp(stampPackage(resolved, id, version), id); + registerAppNotingStamp(this._registry, resolved, id, version); this.logger.debug('Registered manifest-as-app', { app: manifest.name, from: id }); } diff --git a/packages/objectql/src/package-object-app-owner-stamp.test.ts b/packages/objectql/src/package-object-app-owner-stamp.test.ts new file mode 100644 index 00000000000..6323f00e310 --- /dev/null +++ b/packages/objectql/src/package-object-app-owner-stamp.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28697] A package body's OBJECTS and APPS — the two collections it files + * through verbs of their own (`registerObject`, `registerApp`) rather than the + * collection loop — are refused a body `_packageId` other than the package they + * register under, by the one judge the loop calls + * (`collectionItemOwnerStampRefusal` → `judgeCollectionItemOwnerStamp`), and + * BEFORE anything of the body registers. + * + * ## The defect, read before the fix + * + * `stampPackage` and `registerItem` stamp through `applyProtection`, which + * writes `_packageId` only while it is unset. An installed body whose `apps[0]` + * carried the host's id registered an app filed under the host: the + * per-package list filter and the disabled-package filter read that stamp. An + * object's stamp was kept on the definition and read by `registerObject`'s + * action-parse predicate (`schema._packageId ?? packageId`). + * + * ## What this file pins + * + * - an app (`apps[]`, the manifest-as-app, a nested plugin-as-app) and an + * object (array and map form, a nested plugin's) wearing another package's + * id: `VALIDATION_ERROR` / 400 at `_packageId` in the judge's words, and no + * package record, object or app left behind; + * - CONTROLS: an absent stamp and a stamp equal to the package register as + * before, and one app object re-registered (the in-place stamp a first + * registration leaves) is accepted under its own package and under another; + * - the residual door refuses a residual object's stamp in the same envelope. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { judgeCollectionItemOwnerStamp } from '@objectstack/spec/kernel'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +const PKG = 'com.probe.pkg'; +const HOST = 'com.probe.host'; + +const object = (stamp?: unknown, name = 'probe_thing') => ({ + name, + label: 'Probe thing', + fields: { title: { type: 'text', label: 'Title' } }, + ...(stamp === undefined ? {} : { _packageId: stamp }), +}); +const app = (stamp?: unknown, name = 'probe_app') => ({ + name, + label: 'Probe app', + navigation: [{ id: 'nav_thing', type: 'object', label: 'Things', objectName: 'probe_thing' }], + ...(stamp === undefined ? {} : { _packageId: stamp }), +}); + +/** Register once on a fresh engine; hand back what was thrown and what the registry holds. */ +function register(manifest: Record) { + const engine = new ObjectQL(); + let error: any; + try { + engine.registerApp(manifest as any); + } catch (e) { + error = e; + } + return { + error, + pkg: engine.registry.getPackage(String(manifest.id)), + objects: engine.registry.getAllObjects().map((o: any) => o.name), + apps: engine.registry.listItems('app').map((a: any) => ({ name: a.name, _packageId: a._packageId })), + }; +} + +function expectRefused(r: ReturnType, type: string, name: string) { + expect(r.error, 'the registration must be refused').toBeInstanceOf(Error); + expect(r.error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, httpStatus: 400 }); + expect(r.error.issues).toEqual([{ path: '_packageId', code: 'custom', message: expect.any(String) }]); + const judged = judgeCollectionItemOwnerStamp({ _packageId: HOST }, type, name, PKG, 'register')!; + expect(r.error.message, 'the one judge\'s words').toBe(judged.message); + expect(r.pkg, 'refused before the package record is written').toBeUndefined(); + expect(r.objects).not.toContain('probe_thing'); + expect(r.apps).toEqual([]); +} + +describe('[#28697] registerApp refuses an app or object whose body `_packageId` is not its package, before anything registers', () => { + it('an `apps[]` entry naming another package', () => { + expectRefused(register({ id: PKG, name: 'probe', objects: [object()], apps: [app(HOST)] }), 'app', 'probe_app'); + }); + + it('the manifest registered as its own app', () => { + const r = register({ id: PKG, name: 'probe', navigation: app().navigation, objects: [object()], _packageId: HOST }); + expectRefused(r, 'app', 'probe'); + }); + + it('an `objects[]` entry naming another package — array form and name-keyed map form', () => { + expectRefused(register({ id: PKG, name: 'probe', objects: [object(HOST)], apps: [app()] }), 'object', 'probe_thing'); + const { name: _n, ...unnamed } = object(HOST); + expectRefused(register({ id: PKG, name: 'probe', objects: { probe_thing: unnamed }, apps: [app()] }), 'object', 'probe_thing'); + }); + + it('a nested plugin\'s object and plugin-as-app, judged against the parent package before the PARENT registers anything', () => { + // The parent carries an object and an app of its own, both unstamped: + // the nested plugin's stamp is refused before either is registered. + const parent = { id: PKG, name: 'probe', version: '1.0.0', objects: [object(undefined, 'parent_thing')], apps: [app(undefined, 'parent_app')] }; + const r1 = register({ ...parent, plugins: [{ name: 'nested', objects: [object(HOST)] }] }); + expect(r1.error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(r1.error.message).toContain("from object 'probe_thing'"); + expect(r1.pkg, 'refused before the parent\'s package record is written').toBeUndefined(); + expect(r1.objects).not.toContain('probe_thing'); + expect(r1.objects).not.toContain('parent_thing'); + expect(r1.apps).toEqual([]); + const r2 = register({ ...parent, plugins: [{ ...app(HOST, 'nested_app') }] }); + expect(r2.error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(r2.error.message).toContain("from app 'nested_app'"); + expect(r2.pkg).toBeUndefined(); + expect(r2.objects).not.toContain('parent_thing'); + expect(r2.apps).toEqual([]); + }); + + it('CONTROL: a nested plugin whose object and plugin-as-app stamp nothing registers under the parent', () => { + const r = register({ id: PKG, name: 'probe', version: '1.0.0', plugins: [{ ...app(undefined, 'nested_app'), objects: [object()] }] }); + expect(r.error).toBeUndefined(); + expect(r.objects).toContain('probe_thing'); + expect(r.apps).toEqual([{ name: 'nested_app', _packageId: PKG }]); + }); + + it('`null` and the empty string are stamps too', () => { + for (const stamp of [null, '']) { + const r = register({ id: PKG, name: 'probe', apps: [app(stamp)] }); + expect(r.error, `stamp ${JSON.stringify(stamp)}`).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(r.apps).toEqual([]); + } + }); + + it('CONTROL: an absent stamp and a stamp equal to the package register as before', () => { + for (const stamp of [undefined, PKG]) { + const r = register({ id: PKG, name: 'probe', objects: [object(stamp)], apps: [app(stamp)] }); + expect(r.error, `stamp ${String(stamp)}`).toBeUndefined(); + expect(r.objects).toContain('probe_thing'); + expect(r.apps).toEqual([{ name: 'probe_app', _packageId: PKG }]); + } + }); + + it('CONTROL: one app object, stamped in place by a first registration, registers again under its own package and under another', () => { + // Versioned and version-less registrations stamp the caller's app at + // different points (`stampPackage` with a version, `registerItem` + // without one); both stamps are the registration's, never a body's. + for (const version of ['1.0.0', undefined]) { + const label = `version ${String(version)}`; + const shared = app(); + new ObjectQL().registerApp({ id: HOST, name: 'host', version, apps: [shared] } as any); + expect((shared as { _packageId?: unknown })._packageId, `${label}: the first registration stamped the caller's app`).toBe(HOST); + expect(register({ id: HOST, name: 'host', version, apps: [shared] }).error, label).toBeUndefined(); + // A module constant handed on: no body wrote that stamp. + expect(register({ id: PKG, name: 'probe', version, apps: [shared] }).error, label).toBeUndefined(); + // The same bytes ARRIVING as a body are the body's own stamp. + const arrived = register({ id: PKG, name: 'probe', version, apps: [JSON.parse(JSON.stringify(shared))] }); + expect(arrived.error, label).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + } + }); +}); + +// ── the residual door ────────────────────────────────────────────────────── + +type ManifestService = { register(m: unknown, options?: { source?: 'code' | 'marketplace' }): void | Promise }; + +const kernels: ObjectKernel[] = []; +afterEach(async () => { + while (kernels.length) { + const kernel = kernels.pop()!; + if (kernel.getState() === 'running') await kernel.shutdown(); + } +}); + +const STACK = 'com.probe.stack'; +const body = (id: string, namespace: string) => ({ id, name: namespace, namespace, version: '1.0.0', type: 'app' }); + +async function bootRegistering(artifact: unknown): Promise<{ ql: ObjectQL; error: any }> { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin()); + let error: any; + await kernel.use({ + name: 'test.object-app-owner-stamp.registrar', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + try { + await ctx.getService('manifest').register(artifact); + } catch (e) { + error = e; + } + }, + } as Plugin); + await kernel.bootstrap(); + return { ql: kernel.getService('objectql'), error }; +} + +describe('[#28697] the residual door refuses a residual object\'s and app\'s stamp in the body seam\'s envelope', () => { + const withResidual = (extra: Record) => ({ + manifest: { ...body(STACK, 'probestack') }, + ...body(STACK, 'probestack'), + packages: [{ manifest: body('com.probe.a', 'probea') }, { manifest: body('com.probe.b', 'probeb') }], + ...extra, + }); + + it('a residual object wearing another package\'s id: refused, not served', async () => { + const { ql, error } = await bootRegistering(withResidual({ objects: [object('com.probe.a')] })); + expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, httpStatus: 400 }); + expect(error.issues).toEqual([expect.objectContaining({ path: '_packageId' })]); + expect(error.message).toContain("from object 'probe_thing'"); + expect(error.message).toContain(`under package '${STACK}'`); + expect(ql.registry.getObject('probe_thing')).toBeUndefined(); + }); + + it('a residual app wearing another package\'s id: refused, not filed', async () => { + const { ql, error } = await bootRegistering(withResidual({ apps: [app('com.probe.a')] })); + expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(error.message).toContain("from app 'probe_app'"); + expect(ql.registry.listItems('app').find((a: any) => a?.name === 'probe_app')).toBeUndefined(); + }); + + it('CONTROL: an unstamped residual object and app register under the residual\'s id', async () => { + const { ql, error } = await bootRegistering(withResidual({ objects: [object()], apps: [app()] })); + expect(error).toBeUndefined(); + expect(ql.registry.getObjectOwner?.('probe_thing')?.packageId ?? (ql.registry.getObject('probe_thing') as any)?._packageId).toBe(STACK); + expect(ql.registry.listItems('app').find((a: any) => a?.name === 'probe_app')).toMatchObject({ _packageId: STACK }); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 8be6467aafb..0f1a979df2f 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -3086,6 +3086,16 @@ export class ObjectQLPlugin implements Plugin { ): void { const ql = this.ql; if (!ql) return; + // [#28697] The body seam's owner-stamp refusal for objects, in its + // envelope, asked of every residual object before any of them registers: + // a residual object wearing a `_packageId` other than the residual's id + // kept it through `applyProtection` and `registerObject` alike, which + // stamp only an unset key ({@link collectionItemOwnerStampRefusal}, `engine.ts`). + for (const object of residual.objects) { + const name = (object as { name?: unknown }).name; + const refusal = collectionItemOwnerStampRefusal(object, 'object', typeof name === 'string' && name !== '' ? name : '(unnamed)', residual.ownerId); + if (refusal) throw refusal; + } const bodyPicklists = new Set(); for (const body of bodies) for (const name of collectManifestPicklistNames(body)) bodyPicklists.add(name); const unserved: string[] = []; @@ -4189,6 +4199,17 @@ export class ObjectQLPlugin implements Plugin { // unstamped — a synthetic id like 'metadata-service' // would flip isArtifactBacked() and the two-tier write // authorization for genuinely runtime-authored items. + // + // [#28697] The stamp read here is the one the metadata + // plugin's body registration wrote, and that door judges + // a body's own `_packageId` against the body it found the + // item in before it stamps (`artifactItemOwnerStampRefusal`, + // `@objectstack/metadata`), the build door having judged + // every parsed stack first. This seam holds no body to + // judge against: it reads items, not the package that + // registered them, so the judgement lives at the door + // that writes the stamp, and a body stamp naming another + // package never reaches this line. if (this.ql?.registry?.registerItem) { this.ql.registry.registerItem(type, item, keyField, item._packageId); } diff --git a/packages/qa/dogfood/test/install-local-object-app-owner-stamp.dogfood.test.ts b/packages/qa/dogfood/test/install-local-object-app-owner-stamp.dogfood.test.ts new file mode 100644 index 00000000000..fefa530b571 --- /dev/null +++ b/packages/qa/dogfood/test/install-local-object-app-owner-stamp.dogfood.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — an installed package's `apps[]` and `objects[]` entries +// never keep a `_packageId` they wrote about themselves (#28697). +// +// ## What was measured before the fix +// +// Installed into the showcase through `POST /api/v1/marketplace/install-local`, +// an inline manifest whose `apps[0]` and `objects[0]` both carried the host's +// own `_packageId` answered 200 and hot-loaded. Its app was filed under the +// host: `GET /meta/app?package=` listed it beside the showcase's own app +// and `?package=` listed nothing. The engine's body seam refused a +// foreign stamp in 28 collections (#28534) and not on these two, which file +// through verbs of their own, and `applyProtection` stamps an unset key only. +// +// ## What this file pins, on one boot +// +// 1. an install whose `apps[0]` wears the host's id is refused 422 +// `PLUGIN_REGISTER_FAILED` in the one judge's words, and nothing of it is +// registered: not its app, not its (unstamped) object, no ledger entry; +// 2. the same for an install whose `objects[0]` wears the host's id; +// 3. controls: an install whose app and object stamp their OWN package, or +// nothing, installs 200, its app listed under its own package and not +// under the host's; the host's own apps list as they did at boot; and the +// uninstall stays keyed on the registering package, taking app and object. +// +// Boots a fixture stack of its own (the install-local plugin mounted), so it +// stays out of `SHARED_SHOWCASE`. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { bootShowcase } from './showcase-boot.js'; + +const ROUTE = '/marketplace/install-local'; +/** The showcase's own package id — a `code` row. */ +const HOST_PACKAGE_ID = 'com.example.showcase'; + +const APP_STAMPED_ID = 'com.dogfood.appstamp'; +const OBJECT_STAMPED_ID = 'com.dogfood.objstamp'; +const CONTROL_ID = 'com.dogfood.ownstamp'; + +const object = (name: string, stamp?: string) => ({ + name, + label: name, + fields: { title: { type: 'text', label: 'Title' } }, + ...(stamp === undefined ? {} : { _packageId: stamp }), +}); +const app = (name: string, objectName: string, stamp?: string) => ({ + name, + label: name, + navigation: [{ id: `nav_${objectName}`, type: 'object', label: objectName, objectName }], + ...(stamp === undefined ? {} : { _packageId: stamp }), +}); +const manifest = (id: string, namespace: string, extra: Record) => ({ + manifest: { id, namespace, name: `Owner stamp ${namespace}`, version: '1.0.0', type: 'app', ...extra }, +}); + +/** 1: the app names the host as its owner; its object names nothing. */ +const APP_STAMPED = manifest(APP_STAMPED_ID, 'appstamp', { + objects: [object('appstamp_thing')], + apps: [app('appstamp_app', 'appstamp_thing', HOST_PACKAGE_ID)], +}); +/** 2: the object names the host as its owner; its app names nothing. */ +const OBJECT_STAMPED = manifest(OBJECT_STAMPED_ID, 'objstamp', { + objects: [object('objstamp_thing', HOST_PACKAGE_ID)], + apps: [app('objstamp_app', 'objstamp_thing')], +}); +/** 3: one app and one object stamping their own package; one of each stamping nothing. */ +const CONTROL = manifest(CONTROL_ID, 'ownstamp', { + objects: [object('ownstamp_thing', CONTROL_ID), object('ownstamp_plain')], + apps: [app('ownstamp_app', 'ownstamp_thing', CONTROL_ID), app('ownstamp_plain_app', 'ownstamp_plain')], +}); + +type Answer = { status: number; body: any }; + +async function json(res: Response): Promise { + return { status: res.status, body: await res.json().catch(() => null) }; +} + +/** The names a `/meta/app` list answers, whatever envelope it wraps them in. */ +function appNames(answer: Answer): string[] { + const body = answer.body; + const items: any[] = Array.isArray(body) ? body + : Array.isArray(body?.items) ? body.items + : Array.isArray(body?.data) ? body.data + : Array.isArray(body?.data?.items) ? body.data.items + : []; + return items.map((i: any) => i?.name ?? i?.item?.name).filter((n: unknown): n is string => typeof n === 'string').sort(); +} + +describe('dogfood: an installed package\'s apps and objects never keep a `_packageId` they wrote about themselves', () => { + let stack: VerifyStack; + let storageDir: string; + let hostAppsAtBoot: Answer; + let appStamped: Answer; + let objectStamped: Answer; + let control: Answer; + const reads: Record = {}; + let ledger: Answer; + let uninstall: Answer; + const afterUninstall: Record = {}; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-object-app-owner-stamp-')); + stack = await bootShowcase({ + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + const token = await stack.signIn(); + const get = async (path: string) => json(await stack.apiAs(token, 'GET', path)); + + hostAppsAtBoot = await get(`/meta/app?package=${HOST_PACKAGE_ID}`); + appStamped = await json(await stack.apiAs(token, 'POST', ROUTE, APP_STAMPED)); + objectStamped = await json(await stack.apiAs(token, 'POST', ROUTE, OBJECT_STAMPED)); + control = await json(await stack.apiAs(token, 'POST', ROUTE, CONTROL)); + + reads.hostApps = await get(`/meta/app?package=${HOST_PACKAGE_ID}`); + reads.controlApps = await get(`/meta/app?package=${CONTROL_ID}`); + for (const name of ['appstamp_app', 'objstamp_app', 'ownstamp_app']) reads[`app:${name}`] = await get(`/meta/app/${name}`); + for (const name of ['appstamp_thing', 'objstamp_thing', 'ownstamp_thing']) reads[`object:${name}`] = await get(`/meta/object/${name}`); + ledger = await get(ROUTE); + + uninstall = await json(await stack.apiAs(token, 'DELETE', `${ROUTE}/${CONTROL_ID}`)); + afterUninstall.app = await get('/meta/app/ownstamp_app'); + afterUninstall.object = await get('/meta/object/ownstamp_thing'); + afterUninstall.hostApps = await get(`/meta/app?package=${HOST_PACKAGE_ID}`); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + const ledgerIds = (): string[] => (ledger.body?.data?.items ?? []).map((i: any) => i.manifestId); + + it('1: an app stamping the host\'s package id is refused at the install, nothing of the package registered', () => { + expect(appStamped.status, JSON.stringify(appStamped.body)).toBe(422); + expect(appStamped.body?.error?.code).toBe('PLUGIN_REGISTER_FAILED'); + expect(appStamped.body?.error?.message).toContain('Drop `_packageId` from app \'appstamp_app\''); + expect(appStamped.body?.error?.message).toContain(`"${HOST_PACKAGE_ID}"`); + expect(appStamped.body?.error?.message).toContain(`under package '${APP_STAMPED_ID}'`); + expect(reads['app:appstamp_app'].status, JSON.stringify(reads['app:appstamp_app'].body)).toBe(404); + expect(reads['object:appstamp_thing'].status, 'the unstamped object beside it is not registered either').toBe(404); + expect(appNames(reads.hostApps)).not.toContain('appstamp_app'); + expect(ledgerIds(), 'a refused inline install persists no ledger entry').not.toContain(APP_STAMPED_ID); + }); + + it('2: an object stamping the host\'s package id is refused at the install, nothing of the package registered', () => { + expect(objectStamped.status, JSON.stringify(objectStamped.body)).toBe(422); + expect(objectStamped.body?.error?.code).toBe('PLUGIN_REGISTER_FAILED'); + expect(objectStamped.body?.error?.message).toContain('Drop `_packageId` from object \'objstamp_thing\''); + expect(objectStamped.body?.error?.message).toContain(`under package '${OBJECT_STAMPED_ID}'`); + expect(reads['object:objstamp_thing'].status, JSON.stringify(reads['object:objstamp_thing'].body)).toBe(404); + expect(reads['app:objstamp_app'].status, 'the unstamped app beside it is not registered either').toBe(404); + expect(ledgerIds()).not.toContain(OBJECT_STAMPED_ID); + }); + + it('CONTROL: an app and object stamping their own package, or nothing, install; the apps list under their own package', () => { + expect(control.status, JSON.stringify(control.body)).toBe(200); + expect(control.body?.data?.hotLoaded).toBe(true); + expect(reads['app:ownstamp_app'].status).toBe(200); + expect(reads['object:ownstamp_thing'].status).toBe(200); + expect(appNames(reads.controlApps)).toEqual(['ownstamp_app', 'ownstamp_plain_app']); + expect(appNames(reads.hostApps)).not.toContain('ownstamp_app'); + expect(ledgerIds()).toContain(CONTROL_ID); + }); + + it('CONTROL: the host\'s own apps list as they did at boot, before and after the installs', () => { + expect(hostAppsAtBoot.status).toBe(200); + expect(appNames(hostAppsAtBoot).length, JSON.stringify(hostAppsAtBoot.body).slice(0, 400)).toBeGreaterThan(0); + expect(appNames(reads.hostApps)).toEqual(appNames(hostAppsAtBoot)); + expect(appNames(afterUninstall.hostApps)).toEqual(appNames(hostAppsAtBoot)); + }); + + it('CONTROL: the uninstall is keyed on the registering package, and takes its app and object', () => { + expect(uninstall.status, JSON.stringify(uninstall.body)).toBe(200); + expect(afterUninstall.app.status).toBe(404); + expect(afterUninstall.object.status).toBe(404); + }); +}); diff --git a/packages/spec/src/kernel/collection-item-owner-stamp.ts b/packages/spec/src/kernel/collection-item-owner-stamp.ts index 32e25be244d..684e2b65150 100644 --- a/packages/spec/src/kernel/collection-item-owner-stamp.ts +++ b/packages/spec/src/kernel/collection-item-owner-stamp.ts @@ -36,7 +36,7 @@ * | door | caller | the owner it passes | * |:--|:--|:--| * | `build` | the stack schema's refinement (`ObjectStackDefinitionSchema`, `stack.zod.ts`): `defineStack`, `os validate`, `os build`, the artifact door's parse | the stack's `manifest.id` (none without a manifest); each `packages[]` body's own `id` | - * | `register` | `ObjectQL.registerApp`'s collection loop, its seed datasets and its `viewItems:` channel, and the `manifest` service's residual registration (`@objectstack/objectql`) | the package id it registers under | + * | `register` | `ObjectQL.registerApp`'s collection loop, its seed datasets, its `viewItems:` channel, its objects and its apps (and a nested plugin's), the `manifest` service's residual registration (`@objectstack/objectql`), and the metadata plugin's body registration (`@objectstack/metadata`) | the package id it registers under | * * `@objectstack/spec` is the one layer both callers already depend on, so the * judge lives here and nowhere else. ⛔ Do not write a second comparison at a @@ -48,10 +48,11 @@ * ## Which collections * * {@link OWNER_STAMP_JUDGED_COLLECTIONS}: the stack collections whose items the - * registration door files through `registerItem` and judges, the seed `data` - * datasets included. The build door judges exactly these, so it refuses what - * the boot refuses and nothing the boot accepts. An objectql test holds the - * list equal to what `registerApp` refuses, key by key. + * registration door files under their package and judges, the seed `data` + * datasets, the `objects` and the `apps` included. The build door judges + * exactly these, so it refuses what the boot refuses and nothing the boot + * accepts. An objectql test holds the list equal to what `registerApp` + * refuses, key by key. * * ## The words * @@ -85,14 +86,19 @@ export interface CollectionItemOwnerStampRefusal { * package and judges by {@link judgeCollectionItemOwnerStamp}, in the stack * schema's spelling. * + * `objects` and `apps` file through verbs of their own (`registerObject`, + * `registerApp`) and are judged there, before the body registers anything: an + * app's stamp is what the per-package and disabled-package filters read, and + * an object's is read by the object registration's action-parse predicate. + * * Not judged, because the registration door files them through a path of its - * own and does not judge their stamp: `objects` and `objectExtensions` (the - * object-merge path), `apps` (registered by their own verb), - * `picklistExtensions` (merged into the list they name, not filed as items), - * and `datasources`, `datasourceMapping` and `translations` (configuration, - * not registry items). + * own and does not judge their stamp: `objectExtensions` (the object-merge + * path, keyed by the object it extends), `picklistExtensions` (merged into the + * list they name, not filed as items), and `datasources`, `datasourceMapping` + * and `translations` (configuration, not registry items). */ export const OWNER_STAMP_JUDGED_COLLECTIONS = Object.freeze([ + 'objects', 'apps', 'picklists', 'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'flows', 'webhooks', 'jobs',