From a03c3fb9edcc6c775d6ab87a35beb9c1d1a82d93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:01:53 +0000 Subject: [PATCH 1/6] fix(metadata-protocol): installPackage judges the whole manifest by the install door's declaration Claude-Session: https://claude.ai/code/session_012HLZ1LzgJX5mNE9C4DqDYE Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 47 ++++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5975e10fca8..28f6fdb4ed6 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -213,6 +213,9 @@ import { validateObjectNamespacePrefix, deriveNamespaceFromPackageId } from '@ob // [#19417] The package-id declaration and its remedy text, imported to be // SURFACED rather than restated — see the gate at the top of `installPackage`. import { ManifestSchema, manifestIdRefusal } from '@objectstack/spec/kernel'; +// [#24086] The install door's own manifest declaration — `ManifestSchema` with +// `scope` narrowed to `project` — judged whole at the top of `installPackage`. +import { PackageInstallManifestSchema } from '@objectstack/spec/api'; import { stripReadDecorations } from '@objectstack/spec/kernel'; import { REFERENCE_SITES } from './reference-sites.js'; // [#5488] The `@objectstack/spec/api` import that stood here — `ApiEndpointSchema`, @@ -31372,10 +31375,9 @@ export class ObjectStackProtocolImplementation implements // reach `deriveNamespaceFromPackageId` below, which already ASSUMES the // reverse-domain shape it was never given a chance to enforce. // - // ⛔ SCOPE — THE `id` LEG ALONE. `InstallPackageRequestSchema` / - // `ManifestSchema` are still not parsed whole here; the residual - // classes the HTTP door's own docblock records are untouched by this - // and are each their own narrowing of a published contract. + // The id leg keeps its own gate, ahead of the whole-manifest parse + // below (#24086), so its sentence and its repair arm are not + // displaced by a generic issue list. const rawId = (request.manifest as { id?: unknown } | undefined)?.id; const declaredId = ManifestSchema.shape.id.safeParse(rawId); if (!declaredId.success) { @@ -31398,6 +31400,43 @@ export class ObjectStackProtocolImplementation implements manifest.version = '0.1.0'; } + // [#24086] ⭐ THE WHOLE MANIFEST IS JUDGED BY THE DOOR'S DECLARATION — + // `PackageInstallManifestSchema`, the one `POST /api/v1/packages` + // parses its body through, ⛔ not `ManifestSchema`, which still admits + // the platform scopes (`cloud` / `system`) that declaration refuses + // for an authored install. The id leg above was the only leg judged + // here, so an in-process caller could store what the door answers + // `400` for — an off-spec `type` such as `'application'`, an off-spec + // `scope` such as `'environment'`, an unknown key — and the row was + // read back on every boot. + // + // Placed AFTER the version default and BEFORE the namespace one, on + // purpose: the declaration REQUIRES `version`, and the versionless + // manifest this primitive has completed since #2532 keeps installing; + // `namespace` is optional in the declaration, so its derivation needs + // nothing from the parse. Nothing else is supplied — a manifest with + // no `type` is refused, as the door refuses it. + // + // A GATE, not a normaliser, as at the door: the parse's defaults + // (`scope`, `defaultDatasource`) are not copied onto the manifest that + // is stored. Each issue is surfaced as the declaration words it, under + // its `manifest.` path — no sentence of this file's own for a rule. + // + // ⛔ Rows already in `sys_packages` are not judged by this: they are + // data at rest, and the boot hydration (`PackageServicePlugin.start`, + // `@objectstack/service-package`) replays them as stored — see the + // tolerance stated there. + const declaredManifest = PackageInstallManifestSchema.safeParse(manifest); + if (!declaredManifest.success) { + const located = declaredManifest.error.issues + .map((issue) => `${['manifest', ...issue.path.map(String)].join('.')}: ${issue.message}`) + .join('; '); + throw Object.assign( + new Error(`Invalid package manifest — ${located}`), + { statusCode: 400 }, + ); + } + // Studio-authored writable packages arrive WITHOUT a namespace. The // protocol mandates a package namespace whose prefix every object name // must carry (manifest.zod `namespace`); `defineStack` enforces it at From a3a4d89bdedd1a570ad927f5877bcc114732cfe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:13:32 +0000 Subject: [PATCH 2/6] test: install primitive declaration pins; fixtures carry a declared type Claude-Session: https://claude.ai/code/session_012HLZ1LzgJX5mNE9C4DqDYE Co-authored-by: Claude --- .../src/durable-package.test.ts | 10 +- .../src/protocol-handshake-install.test.ts | 8 +- ....duplicate-package-install-refusal.test.ts | 4 +- ...duplicate-package-target-namespace.test.ts | 2 +- .../src/protocol.flow-canonicalizer.test.ts | 2 +- ...tocol.install-manifest-declaration.test.ts | 117 +++++++++++++ .../src/protocol.install-manifest-id.test.ts | 4 +- .../protocol.package-persist-failure.test.ts | 8 +- ...ol-duplicate-package-names-in-step.test.ts | 4 +- ...cate-package-objects-key-collision.test.ts | 2 +- ...cate-package-permission-field-keys.test.ts | 6 +- ...uplicate-package-unprefixed-object.test.ts | 2 +- .../src/protocol-install-package.test.ts | 14 +- .../src/protocol-package-lifecycle.test.ts | 2 +- ...l-references-permission-field-keys.test.ts | 2 +- ...ocol-save-permission-fls-key-forms.test.ts | 2 +- .../services/service-package/src/index.ts | 9 + .../src/install-primitive-declaration.test.ts | 155 ++++++++++++++++++ 18 files changed, 317 insertions(+), 36 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.install-manifest-declaration.test.ts create mode 100644 packages/services/service-package/src/install-primitive-declaration.test.ts diff --git a/packages/metadata-protocol/src/durable-package.test.ts b/packages/metadata-protocol/src/durable-package.test.ts index f2f354266a9..71cd30ce759 100644 --- a/packages/metadata-protocol/src/durable-package.test.ts +++ b/packages/metadata-protocol/src/durable-package.test.ts @@ -95,7 +95,7 @@ describe('installPackage — durable persistence (#2532)', () => { it('persists a VERSIONLESS manifest by defaulting version (the vanish-on-restart bug)', async () => { const { impl, registryCalls, publish } = makeImpl(); const res: any = await (impl as any).installPackage({ - manifest: { id: 'com.example.orders', name: '订单中心' }, + manifest: { id: 'com.example.orders', name: '订单中心', type: 'app' }, }); // In-memory half. @@ -111,7 +111,7 @@ describe('installPackage — durable persistence (#2532)', () => { it('keeps an explicit version untouched', async () => { const { impl, publish } = makeImpl(); - await (impl as any).installPackage({ manifest: { id: 'com.example.v', version: '2.3.4' } }); + await (impl as any).installPackage({ manifest: { id: 'com.example.v', name: 'V', type: 'app', version: '2.3.4' } }); expect((publish.mock.calls[0][0] as any).manifest.version).toBe('2.3.4'); }); @@ -208,7 +208,7 @@ describe("service-ABSENT host — the degraded path (#17676 ruling A' items 2/3) const { impl, registryCalls, publish, registry } = makeImpl({ packageService: false }); const res: any = await (impl as any).installPackage({ - manifest: { id: 'com.example.leave', name: '请假' }, + manifest: { id: 'com.example.leave', name: '请假', type: 'app' }, }); // The door still answers 201 — which is exactly why the card calls the @@ -282,7 +282,7 @@ describe("the restart split this card reported (#17676 ruling A' items 3/5)", () // ── process 1 ───────────────────────────────────────────────────────── const before = makeImpl({ packageService: false, publish: sysPackages.publish }); await (before.impl as any).installPackage({ - manifest: { id: 'com.example.leave', name: '请假' }, + manifest: { id: 'com.example.leave', name: '请假', type: 'app' }, }); expect(before.registry.getPackage('com.example.leave')).toBeTruthy(); expect([...sysPackages.rows.keys()]).toEqual([]); @@ -310,7 +310,7 @@ describe("the restart split this card reported (#17676 ruling A' items 3/5)", () const before = makeImpl({ publish: sysPackages.publish }); await (before.impl as any).installPackage({ - manifest: { id: 'com.example.leave', name: '请假' }, + manifest: { id: 'com.example.leave', name: '请假', type: 'app' }, }); // The durable half ran, so the restart has something to replay. expect([...sysPackages.rows.keys()]).toEqual(['com.example.leave']); diff --git a/packages/metadata-protocol/src/protocol-handshake-install.test.ts b/packages/metadata-protocol/src/protocol-handshake-install.test.ts index a1b5073a5a1..3b24a613f2a 100644 --- a/packages/metadata-protocol/src/protocol-handshake-install.test.ts +++ b/packages/metadata-protocol/src/protocol-handshake-install.test.ts @@ -35,7 +35,7 @@ describe('installPackage — protocol handshake (ADR-0087 P0)', () => { const { impl, registryCalls, publish } = makeImpl(); await expect( (impl as any).installPackage({ - manifest: { id: 'com.acme.crm', version: '1.0.0', engines: { protocol: OLD } }, + manifest: { id: 'com.acme.crm', name: 'CRM', type: 'app', version: '1.0.0', engines: { protocol: OLD } }, }), ).rejects.toBeInstanceOf(ProtocolIncompatibleError); // The refusal happens BEFORE any registry write or durable persist. @@ -47,7 +47,7 @@ describe('installPackage — protocol handshake (ADR-0087 P0)', () => { const { impl } = makeImpl(); try { await (impl as any).installPackage({ - manifest: { id: 'com.acme.crm', version: '1.0.0', engines: { protocol: OLD } }, + manifest: { id: 'com.acme.crm', name: 'CRM', type: 'app', version: '1.0.0', engines: { protocol: OLD } }, }); throw new Error('expected installPackage to throw'); } catch (e) { @@ -63,7 +63,7 @@ describe('installPackage — protocol handshake (ADR-0087 P0)', () => { it('installs a package that declares a compatible range', async () => { const { impl, registryCalls } = makeImpl(); const res: any = await (impl as any).installPackage({ - manifest: { id: 'com.acme.ok', version: '1.0.0', engines: { protocol: CURRENT } }, + manifest: { id: 'com.acme.ok', name: 'OK', type: 'app', version: '1.0.0', engines: { protocol: CURRENT } }, }); expect(registryCalls).toHaveLength(1); expect(res.package.status).toBe('installed'); @@ -74,7 +74,7 @@ describe('installPackage — protocol handshake (ADR-0087 P0)', () => { try { const { impl, registryCalls } = makeImpl(); const res: any = await (impl as any).installPackage({ - manifest: { id: 'com.acme.legacy', version: '1.0.0' }, + manifest: { id: 'com.acme.legacy', name: 'Legacy', type: 'app', version: '1.0.0' }, }); expect(registryCalls).toHaveLength(1); expect(res.package.status).toBe('installed'); diff --git a/packages/metadata-protocol/src/protocol.duplicate-package-install-refusal.test.ts b/packages/metadata-protocol/src/protocol.duplicate-package-install-refusal.test.ts index d470a686192..4645e778252 100644 --- a/packages/metadata-protocol/src/protocol.duplicate-package-install-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.duplicate-package-install-refusal.test.ts @@ -79,8 +79,8 @@ function makeHarness(opts: { publish?: (data: unknown) => Promise } = { metadata: JSON.stringify({ name: 'leave_ticket', label: 'Ticket' }), }]; const packages = new Map([ - [SOURCE, { manifest: { id: SOURCE, name: 'Leave', namespace: 'leave', version: '1.0.0' } }], - [OWNER, { manifest: { id: OWNER, name: 'Taken', namespace: 'taken', version: '1.0.0' } }], + [SOURCE, { manifest: { id: SOURCE, name: 'Leave', type: 'app', namespace: 'leave', version: '1.0.0' } }], + [OWNER, { manifest: { id: OWNER, name: 'Taken', type: 'app', namespace: 'taken', version: '1.0.0' } }], ]); const namespaceOwners = new Map([['leave', [SOURCE]], ['taken', [OWNER]]]); const thrown: unknown[] = []; diff --git a/packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts b/packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts index 289322c09f5..44ff050590f 100644 --- a/packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts +++ b/packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts @@ -73,7 +73,7 @@ function makeDuplicateImpl(sourceId = 'com.example.leave') { find: vi.fn(async () => rows), registry: { getPackage: vi.fn(() => ({ - manifest: { id: sourceId, name: 'Leave', namespace: 'leave', version: '1.0.0' }, + manifest: { id: sourceId, name: 'Leave', type: 'app', namespace: 'leave', version: '1.0.0' }, })), installPackage: vi.fn((manifest: any) => { installed.push(manifest); diff --git a/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts b/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts index aa2ba780210..95e20643971 100644 --- a/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts @@ -86,7 +86,7 @@ function makeProtocol( seeded.filter((r) => matches(r, opts?.where ?? {}))), registry: { getPackage: vi.fn(() => ({ - manifest: { id: 'app.iojn', name: 'Repair', namespace: 'iojn', version: '1.0.0', type: 'application' }, + manifest: { id: 'app.iojn', name: 'Repair', namespace: 'iojn', version: '1.0.0', type: 'app' }, })), installPackage: vi.fn(), }, diff --git a/packages/metadata-protocol/src/protocol.install-manifest-declaration.test.ts b/packages/metadata-protocol/src/protocol.install-manifest-declaration.test.ts new file mode 100644 index 00000000000..956e1907905 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.install-manifest-declaration.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24086] The protocol install primitive judges the WHOLE manifest by the + * install door's declaration, `PackageInstallManifestSchema`. + * + * `installPackage` parsed only the `id` leg, so an in-process caller stored what + * `POST /api/v1/packages` answers `400 VALIDATION_ERROR` for: an off-spec + * `type` (`'application'`), an off-spec `scope` (`'environment'`), a platform + * scope (`'cloud'` / `'system'`, which that declaration narrows away from + * `ManifestSchema`), a missing `type`, an unknown key. + * + * Every refusal is pinned by its envelope — what an HTTP boundary answers for + * the throw (`resolveThrownHttpError`), status AND code — by the key path it + * names, and by the absence of both writes. The lit controls hold the other + * direction: the versionless manifest the primitive has completed since #2532 + * still installs, and the gate stores the manifest as sent, not the parse's + * defaulted copy. + */ +import { describe, it, expect, vi } from 'vitest'; +import { manifestIdRefusal } from '@objectstack/spec/kernel'; +import { packageInstallScopeRefusal } from '@objectstack/spec/api'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +function makeImpl() { + const registryCalls: Array<{ manifest: any }> = []; + const engine = { + registry: { + installPackage: (manifest: any) => { + registryCalls.push({ manifest }); + return { manifest, status: 'installed', enabled: true }; + }, + }, + find: async () => [], + }; + const publish = vi.fn(async () => ({ success: true })); + const services = new Map([['package', { publish }]]); + const impl = new ObjectStackProtocolImplementation(engine as never, () => services); + return { impl, registryCalls, publish }; +} + +/** The thrown refusal, or a failure naming what happened instead. */ +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the call to be refused, but it resolved'); +} + +const BASE = { id: 'com.example.leave', name: 'Leave', version: '1.0.0', type: 'app' } as const; + +/** Manifests the door's declaration refuses, with the key path the refusal names. */ +const REFUSED: ReadonlyArray, string]> = [ + ["an off-spec type 'application' (cloud's AI authoring)", { ...BASE, type: 'application' }, 'manifest.type'], + ["an off-spec scope 'environment' (cloud's AI authoring)", { ...BASE, scope: 'environment' }, 'manifest.scope'], + ["the platform scope 'cloud', which the install declaration narrows away", { ...BASE, scope: 'cloud' }, 'manifest.scope'], + ["the platform scope 'system'", { ...BASE, scope: 'system' }, 'manifest.scope'], + ['a manifest with no type at all', { id: BASE.id, name: BASE.name, version: BASE.version }, 'manifest.type'], + ['an unknown key (a metadata collection on the manifest)', { ...BASE, pages: [] }, 'manifest'], +]; + +describe('[#24086] installPackage judges the whole manifest by PackageInstallManifestSchema', () => { + for (const [why, manifest, path] of REFUSED) { + it(`refuses ${why} — 400 VALIDATION_ERROR naming ${path}, and writes nothing`, async () => { + const { impl, registryCalls, publish } = makeImpl(); + const err = await refusalOf(() => (impl as any).installPackage({ manifest })); + // What an HTTP boundary answers for this throw — the door's envelope. + expect(resolveThrownHttpError(err)).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(err.message).toContain(`${path}`); + // Neither writer ran: not the in-memory registry, not the durable row. + expect(registryCalls).toHaveLength(0); + expect(publish).not.toHaveBeenCalled(); + }); + } + + it("surfaces the declaration's own sentence for a platform scope, not a reworded one", async () => { + const { impl } = makeImpl(); + const err = await refusalOf(() => (impl as any).installPackage({ manifest: { ...BASE, scope: 'cloud' } })); + expect(err.message).toContain(`manifest.scope: ${packageInstallScopeRefusal('cloud')}`); + }); + + it('the id leg still answers first, with its own sentence and repair', async () => { + const { impl } = makeImpl(); + const err = await refusalOf(() => (impl as any).installPackage({ + manifest: { ...BASE, id: 'com.example.my_erp', type: 'application' }, + })); + expect(resolveThrownHttpError(err)).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(err.message).toBe(manifestIdRefusal('manifest.id', 'com.example.my_erp')); + }); + + it('lit control — a VERSIONLESS manifest still installs with the defaulted version (#2532)', async () => { + const { impl, registryCalls, publish } = makeImpl(); + const res: any = await (impl as any).installPackage({ + manifest: { id: BASE.id, name: BASE.name, type: 'app' }, + }); + expect(res.package.status).toBe('installed'); + expect(registryCalls).toHaveLength(1); + expect(registryCalls[0].manifest).toMatchObject({ id: BASE.id, version: '0.1.0', namespace: 'leave' }); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it("lit control — scope 'project' installs, and the gate stores the manifest as SENT (no parse defaults)", async () => { + const { impl, registryCalls } = makeImpl(); + await (impl as any).installPackage({ manifest: { ...BASE, scope: 'project' } }); + expect(registryCalls[0].manifest.scope).toBe('project'); + + const plain = makeImpl(); + await (plain.impl as any).installPackage({ manifest: { ...BASE } }); + // `PackageInstallManifestSchema` defaults `scope` and `defaultDatasource`; + // neither is copied onto what is stored. + expect(plain.registryCalls[0].manifest).not.toHaveProperty('scope'); + expect(plain.registryCalls[0].manifest).not.toHaveProperty('defaultDatasource'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.install-manifest-id.test.ts b/packages/metadata-protocol/src/protocol.install-manifest-id.test.ts index 5e6c084aaf8..dfd7d4e733c 100644 --- a/packages/metadata-protocol/src/protocol.install-manifest-id.test.ts +++ b/packages/metadata-protocol/src/protocol.install-manifest-id.test.ts @@ -122,7 +122,7 @@ describe('[#19417] installPackage parses `manifest.id` through its declaration', it(`lit control — '${id}' still installs`, async () => { const { impl, registryCalls } = makeImpl(); const res: any = await (impl as any).installPackage({ - manifest: { id, name: 'X', version: '1.0.0' }, + manifest: { id, name: 'X', type: 'app', version: '1.0.0' }, }); expect(registryCalls).toHaveLength(1); expect(registryCalls[0].manifest.id).toBe(id); @@ -156,7 +156,7 @@ function makeDuplicateImpl(sourceId = 'com.example.leave') { find: vi.fn(async () => rows), registry: { getPackage: vi.fn(() => ({ - manifest: { id: sourceId, name: 'Leave', namespace: 'leave', version: '1.0.0' }, + manifest: { id: sourceId, name: 'Leave', type: 'app', namespace: 'leave', version: '1.0.0' }, })), installPackage: vi.fn((manifest: any) => { installed.push(manifest); diff --git a/packages/metadata-protocol/src/protocol.package-persist-failure.test.ts b/packages/metadata-protocol/src/protocol.package-persist-failure.test.ts index b570f087b10..845d6cadc0b 100644 --- a/packages/metadata-protocol/src/protocol.package-persist-failure.test.ts +++ b/packages/metadata-protocol/src/protocol.package-persist-failure.test.ts @@ -159,7 +159,7 @@ describe('#21243 installPackage — a refused sys_packages write fails the insta ] as const)('fresh id, %s → 500 %s, no row, no namespace', async (_label, code, publish) => { const { impl, registry } = makeImpl(publish); - const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave' } })); + const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave', type: 'app' } })); expect(door(err)).toMatchObject({ status: 500, code }); // The driver's words stay on `cause` for the operator, never in the caller's sentence. @@ -178,7 +178,7 @@ describe('#21243 installPackage — a refused sys_packages write fails the insta const priorContent = JSON.parse(JSON.stringify(registry.getPackage('com.example.leave'))); const err = await rejectionOf(impl.installPackage({ - manifest: { id: 'com.example.leave', name: 'Leave v2', version: '2.0.0', namespace: 'leave' }, + manifest: { id: 'com.example.leave', name: 'Leave v2', type: 'app', version: '2.0.0', namespace: 'leave' }, enableOnInstall: true, })); @@ -198,7 +198,7 @@ describe('#21243 installPackage — a refused sys_packages write fails the insta throw refusal; }); - const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave' } })); + const err = await rejectionOf(impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave', type: 'app' } })); expect(err).toBe(refusal); expect(door(err)).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' }); @@ -208,7 +208,7 @@ describe('#21243 installPackage — a refused sys_packages write fails the insta it('CONTROL — a landed write answers the installed row, exactly as before', async () => { const { impl, registry, publish } = makeImpl(async () => ({ success: true })); - const res = await impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave' } }); + const res = await impl.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave', type: 'app' } }); expect(res.package.manifest.id).toBe('com.example.leave'); expect(registry.getPackage('com.example.leave')).toBe(res.package); diff --git a/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts b/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts index a0fe8d062db..981ddaaadd8 100644 --- a/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts @@ -217,10 +217,10 @@ describe('ADR-0070 D4 — duplicatePackage keeps a copied name in step with what /** Seeds the third package and the source, then duplicates the source. */ async function duplicate(): Promise { - await protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'application' } }); + await protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'app' } }); await save(R, 'object', accountBody); await save(R, 'object', extBody); - await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'application' } }); + await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save(P, 'object', customerBody); await save(P, 'object', ticketBody); diff --git a/packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts b/packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts index 8054b108065..e0076613d47 100644 --- a/packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts @@ -161,7 +161,7 @@ describe('ADR-0070 D4 — duplicatePackage refuses a renamed reference that meet expect(res.success).toBe(true); } async function seed(type: string, item: { name: string }): Promise { - await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'application' } }); + await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save('object', customerBody); await save(type, item); diff --git a/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts index eb9555bfb0b..60ead6c2647 100644 --- a/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts @@ -200,9 +200,9 @@ describe('ADR-0070 D4 — duplicatePackage re-points a copied permission set\'s /** Seeds the source and the third package, duplicates, and returns the copy's `fields`. */ async function duplicate(): Promise> { - await protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'application' } }); + await protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'app' } }); await save(R, 'object', accountBody); - await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'application' } }); + await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save(P, 'object', customerBody); await save(P, 'object', ticketBody); @@ -265,7 +265,7 @@ describe('ADR-0070 D4 — duplicatePackage re-points a copied permission set\'s }); it('a renamed key that meets a key the set already holds is refused, never merged', async () => { - await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'application' } }); + await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); await save(P, 'object', customerBody); // `dupdst_customer.full_name` names no object of the package, so it // keeps its name, and `dupsrc_customer.full_name` follows onto it. diff --git a/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts index 29c900f36c3..e033e2d3be8 100644 --- a/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts @@ -196,7 +196,7 @@ describe('ADR-0070 D4 — duplicatePackage of an object whose name lacks the sou } async function seedSource(extra: Array<[string, { name: string }]> = []): Promise { - await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'application' } }); + await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); await save('object', customerBody); await save('object', ticketBody); diff --git a/packages/objectql/src/protocol-install-package.test.ts b/packages/objectql/src/protocol-install-package.test.ts index bd3afc2beb2..a7181483c3b 100644 --- a/packages/objectql/src/protocol-install-package.test.ts +++ b/packages/objectql/src/protocol-install-package.test.ts @@ -52,7 +52,7 @@ describe('protocol.installPackage (ADR-0033 consolidation)', () => { it('writes the in-memory registry AND persists via the package service', async () => { const publish = vi.fn(async () => ({ success: true })); const { protocol, registry } = makeProtocol({ pkgSvc: { publish } }); - const manifest = { id: 'app.demo', name: 'Demo', version: '1.0.0', type: 'application' }; + const manifest = { id: 'app.demo', name: 'Demo', version: '1.0.0', type: 'app' }; const res = (await protocol.installPackage({ manifest } as never)) as { package: { manifest: { id: string } }; @@ -76,7 +76,7 @@ describe('protocol.installPackage (ADR-0033 consolidation)', () => { it('derives + persists a namespace from the id when the manifest declares none', async () => { const { protocol, registry } = makeProtocol(); - const manifest = { id: 'com.example.leave', name: 'Leave', version: '1.0.0', type: 'application' }; + const manifest = { id: 'com.example.leave', name: 'Leave', version: '1.0.0', type: 'app' }; await protocol.installPackage({ manifest } as never); // `com.example.leave` → namespace `leave` (last dot-segment). expect((registry.installPackage.mock.calls[0][0] as any).namespace).toBe('leave'); @@ -85,21 +85,21 @@ describe('protocol.installPackage (ADR-0033 consolidation)', () => { it('does NOT override an explicitly declared namespace', async () => { const { protocol, registry } = makeProtocol(); // HotCRM ships namespace `crm`, which differs from the id last segment. - const manifest = { id: 'app.objectstack.hotcrm', name: 'HotCRM', version: '1.0.0', type: 'application', namespace: 'crm' }; + const manifest = { id: 'app.objectstack.hotcrm', name: 'HotCRM', version: '1.0.0', type: 'app', namespace: 'crm' }; await protocol.installPackage({ manifest } as never); expect((registry.installPackage.mock.calls[0][0] as any).namespace).toBe('crm'); }); it('forwards install-time settings to the registry', async () => { const { protocol, registry } = makeProtocol(); - const manifest = { id: 'app.s', name: 'S', version: '1.0.0', type: 'application' }; + const manifest = { id: 'app.s', name: 'S', version: '1.0.0', type: 'app' }; await protocol.installPackage({ manifest, settings: { theme: 'dark' } } as never); expect(registry.installPackage).toHaveBeenCalledWith(manifest, 'authored', { theme: 'dark' }); }); it('still registers in-memory when no package service is present (non-fatal)', async () => { const { protocol, registry } = makeProtocol(); - const manifest = { id: 'app.nopkg', name: 'NoPkg', version: '1.0.0', type: 'application' }; + const manifest = { id: 'app.nopkg', name: 'NoPkg', version: '1.0.0', type: 'app' }; const res = (await protocol.installPackage({ manifest } as never)) as { package: { manifest: { id: string } }; @@ -120,7 +120,7 @@ describe('protocol.installPackage (ADR-0033 consolidation)', () => { throw dbDown; }); const { protocol, registry } = makeProtocol({ pkgSvc: { publish } }); - const manifest = { id: 'app.err', name: 'Err', version: '1.0.0', type: 'application' }; + const manifest = { id: 'app.err', name: 'Err', version: '1.0.0', type: 'app' }; let thrown: unknown; try { @@ -150,7 +150,7 @@ describe('protocol.installPackage (ADR-0033 consolidation)', () => { // Now installPackage defaults version to '0.1.0' so they key into sys_packages. const publish = vi.fn(async () => ({ success: true })); const { protocol, registry } = makeProtocol({ pkgSvc: { publish } }); - const manifest = { id: 'app.nov', name: 'NoVer', type: 'application' }; + const manifest = { id: 'app.nov', name: 'NoVer', type: 'app' }; await protocol.installPackage({ manifest } as never); diff --git a/packages/objectql/src/protocol-package-lifecycle.test.ts b/packages/objectql/src/protocol-package-lifecycle.test.ts index 356338251a1..98f70c295cc 100644 --- a/packages/objectql/src/protocol-package-lifecycle.test.ts +++ b/packages/objectql/src/protocol-package-lifecycle.test.ts @@ -128,7 +128,7 @@ describe('protocol.duplicatePackage (ADR-0070 D4)', () => { find: vi.fn(async () => rows), registry: { getPackage: vi.fn(() => ({ - manifest: { id: 'app.iojn', name: 'Repair', namespace: 'iojn', version: '1.0.0', type: 'application', scope: 'environment' }, + manifest: { id: 'app.iojn', name: 'Repair', namespace: 'iojn', version: '1.0.0', type: 'app', scope: 'environment' }, })), installPackage, }, diff --git a/packages/objectql/src/protocol-references-permission-field-keys.test.ts b/packages/objectql/src/protocol-references-permission-field-keys.test.ts index 5164b49a6b6..1105b1bbfa2 100644 --- a/packages/objectql/src/protocol-references-permission-field-keys.test.ts +++ b/packages/objectql/src/protocol-references-permission-field-keys.test.ts @@ -159,7 +159,7 @@ describe('findReferencesToMeta lists a permission set that names an object only await kernel.bootstrap(); protocol = kernel.getService('protocol'); - await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'application' } }); + await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'app' } }); for (const [type, item] of [ ['object', customerBody], ['object', invoiceBody], diff --git a/packages/objectql/src/protocol-save-permission-fls-key-forms.test.ts b/packages/objectql/src/protocol-save-permission-fls-key-forms.test.ts index cf19fc2271c..582251a8728 100644 --- a/packages/objectql/src/protocol-save-permission-fls-key-forms.test.ts +++ b/packages/objectql/src/protocol-save-permission-fls-key-forms.test.ts @@ -147,7 +147,7 @@ describe('the save door judges a permission set\'s FLS key forms as `os validate await kernel.bootstrap(); protocol = kernel.getService('protocol'); - await protocol.installPackage({ manifest: { id: P, name: 'FLS keys', type: 'application' } }); + await protocol.installPackage({ manifest: { id: P, name: 'FLS keys', type: 'app' } }); const res = await protocol.saveMetaItem({ type: 'object', name: customerBody.name, packageId: P, item: customerBody }); expect(res.success).toBe(true); }); diff --git a/packages/services/service-package/src/index.ts b/packages/services/service-package/src/index.ts index 168264652bb..b14842e9983 100644 --- a/packages/services/service-package/src/index.ts +++ b/packages/services/service-package/src/index.ts @@ -738,6 +738,15 @@ export class PackageServicePlugin implements Plugin { // the records after it keep hydrating. It used to throw out of the // whole loop into the catch below, which logged it at `debug` — // and every durable package after the bad one was silently absent. + // + // [#24086] ⚠️ STATED TOLERANCE: the row's `type` / `scope` are NOT + // judged here. `installPackage` now refuses a manifest the install + // declaration (`PackageInstallManifestSchema`) refuses, but rows it + // wrote before that — cloud's AI-authored bases and apps carry + // `type: 'application'`, `scope: 'environment'` — are data at rest + // and keep hydrating as stored. The tolerance is this READ's only: + // a new write of the same manifest is refused at the primitive + // (pinned in `install-primitive-declaration.test.ts`). try { registry.installPackage(convertForward ? convertForward(rec.manifest) : rec.manifest, 'authored'); hydrated++; diff --git a/packages/services/service-package/src/install-primitive-declaration.test.ts b/packages/services/service-package/src/install-primitive-declaration.test.ts new file mode 100644 index 00000000000..db87628c238 --- /dev/null +++ b/packages/services/service-package/src/install-primitive-declaration.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24086] Over the REAL stack — an `ObjectKernel` with `ObjectQLPlugin` (the + * real `SchemaRegistry`), a real `SqlDriver` (better-sqlite3, on disk), this + * package's plugin and the real protocol — both halves of the change: + * + * - **the write is refused.** `protocol.installPackage` judges the whole + * manifest by the install door's declaration (`PackageInstallManifestSchema`), + * so the manifests cloud's AI authoring used to store — `type: + * 'application'`, `scope: 'environment'` — and a platform scope are each + * answered `400` (`VALIDATION_ERROR` at the boundary), and neither the + * registry nor `sys_packages` holds anything afterwards; + * - **the read is tolerant.** A row written before the change, with those + * same values, is data at rest: the boot hydration replays it as stored, + * and the registry serves it after a restart. That tolerance is stated at + * the hydration seam (`PackageServicePlugin.start`). + * + * The row "written before the change" is put in place through the `package` + * service's own `publish` — the very write the primitive delegated to. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { PackageServicePlugin, type PackageService } from './index.js'; + +type Bag = Record; +type Line = { level: 'info' | 'warn' | 'error'; text: string }; + +const dirs: string[] = []; +const kernels: ObjectKernel[] = []; +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } + while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); +}); + +async function boot(dir: string) { + const lines: Line[] = []; + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: join(dir, 'data.sqlite') }, + useNullAsDefault: true, + }); + const recorder: Plugin = { + name: 'test.sqlite-and-log-recorder', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.getService('objectql').registerDriver(driver as never, true); + }, + async start(ctx: PluginContext) { + const logger = ctx.logger as unknown as Bag; + for (const level of ['info', 'warn', 'error'] as const) { + logger[level] = (text: unknown) => { lines.push({ level, text: String(text) }); }; + } + }, + }; + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false } as never); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(recorder); + await kernel.use(new PackageServicePlugin()); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + const packages = kernel.getService('package'); + const protocol = new ObjectStackProtocolImplementation( + ql as never, + () => new Map([['package', packages]]), + ); + return { kernel, ql, lines, packages, protocol }; +} + +function freshDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'os-install-primitive-declaration-')); + dirs.push(dir); + return dir; +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e as Error & { statusCode?: number }; + } + throw new Error('expected the install to be refused, but it resolved'); +} + +/** The shape cloud's AI authoring (`ensureNamedBasePackage`, `ensureAppPackage`) wrote. */ +const AI_BASE = { id: 'com.example.ai_base', name: 'AI base', version: '1.0.0', type: 'application', scope: 'environment' }; + +const REFUSED: ReadonlyArray = [ + ["type 'application'", { id: 'com.example.app_typed', name: 'A', version: '1.0.0', type: 'application' }, 'manifest.type'], + ["scope 'environment'", { id: 'com.example.env_scoped', name: 'E', version: '1.0.0', type: 'app', scope: 'environment' }, 'manifest.scope'], + ["scope 'cloud'", { id: 'com.example.cloud_scoped', name: 'C', version: '1.0.0', type: 'app', scope: 'cloud' }, 'manifest.scope'], +]; + +describe('[#24086] the install primitive refuses what the install declaration refuses — real stack', () => { + for (const [why, manifest, path] of REFUSED) { + it(`refuses ${why} with 400 VALIDATION_ERROR, and neither the registry nor sys_packages holds it`, async () => { + const { ql, packages, protocol } = await boot(freshDir()); + const err = await refusalOf(() => protocol.installPackage({ manifest } as never)); + expect(err.statusCode).toBe(400); + expect(standardErrorCodeForHttpStatus(err.statusCode!)).toBe('VALIDATION_ERROR'); + expect(err.message).toContain(path); + expect(ql.registry.getPackage(manifest.id)).toBeUndefined(); + expect(await packages.get(manifest.id)).toBeNull(); + }); + } + + it('CONTROL — a versionless { id, name, type } installs, persists 0.1.0, and survives a restart', async () => { + const dir = freshDir(); + const first = await boot(dir); + await first.protocol.installPackage({ manifest: { id: 'com.example.leave', name: 'Leave', type: 'app' } } as never); + expect(first.ql.registry.getPackage('com.example.leave')).toBeDefined(); + await first.kernel.shutdown(); + + const second = await boot(dir); + const row = second.ql.registry.getPackage('com.example.leave'); + expect(row?.manifest).toMatchObject({ id: 'com.example.leave', version: '0.1.0', type: 'app' }); + }); +}); + +describe('[#24086] rows stored before the change keep hydrating (stated tolerance at the read)', () => { + it("a stored type 'application' / scope 'environment' row hydrates as stored after a restart", async () => { + const dir = freshDir(); + const first = await boot(dir); + expect(await first.packages.publish({ manifest: AI_BASE as never, metadata: {} as never })).toEqual({ success: true }); + await first.kernel.shutdown(); + + const second = await boot(dir); + const row = second.ql.registry.getPackage(AI_BASE.id); + expect(row, 'the pre-change row was not hydrated').toBeDefined(); + expect(row!.source).toBe('authored'); + expect(row!.manifest).toMatchObject({ type: 'application', scope: 'environment' }); + expect(second.lines.filter((l) => l.level === 'error' && l.text.includes(AI_BASE.id))).toEqual([]); + + // The tolerance is the read's only: a fresh write of the same manifest is refused. + const err = await refusalOf(() => second.protocol.installPackage({ + manifest: { ...AI_BASE, id: 'com.example.ai_base_two' }, + } as never)); + expect(err.statusCode).toBe(400); + expect(await second.packages.get('com.example.ai_base_two')).toBeNull(); + }); +}); From ab68e443cb9aee6bfe0b05065c1ce8256f223fb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:16:16 +0000 Subject: [PATCH 3/6] test: flip the assembled-body install pin to the declaration's refusal; state the hydration tolerance Claude-Session: https://claude.ai/code/session_012HLZ1LzgJX5mNE9C4DqDYE Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 6 ++-- .../src/hydration-convert-forward.test.ts | 28 +++++++++++-------- .../src/install-primitive-declaration.test.ts | 12 ++++---- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 28f6fdb4ed6..115a51f6624 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -31471,9 +31471,9 @@ export class ObjectStackProtocolImplementation implements // manifest exactly as handed, and no forward conversion runs here (the // window the registration door, the `sys_packages` hydration and the // marketplace install open is for bodies at rest). Its callers hand it - // a manifest being written now: `POST /api/v1/packages` parses the - // whole body through the closed `ManifestSchema` first, which refuses - // a metadata collection such as `pages` outright; `duplicatePackage` + // a manifest being written now, and the declaration parse above + // (#24086, the one `POST /api/v1/packages` also runs) has already + // refused a metadata collection such as `pages` outright; `duplicatePackage` // copies the registry's own record, already judged; the unmanaged // install's base carries identity keys only; and AI Studio's blueprint // tools author in-process. A retired declaration there is the author's diff --git a/packages/services/service-package/src/hydration-convert-forward.test.ts b/packages/services/service-package/src/hydration-convert-forward.test.ts index 938065bb67c..cf90be87281 100644 --- a/packages/services/service-package/src/hydration-convert-forward.test.ts +++ b/packages/services/service-package/src/hydration-convert-forward.test.ts @@ -40,11 +40,14 @@ * * ## The install door stays strict, and why (`protocol.installPackage`) * - * The same converting body handed to the install primitive is REFUSED by the - * registry, and nothing lands in `sys_packages`. That door is authoring-side: - * its HTTP caller, `POST /api/v1/packages`, parses the whole body through the - * closed `ManifestSchema` first, which refuses a `pages` key outright, so no - * package body carrying metadata collections reaches the primitive over HTTP; + * The same converting body handed to the install primitive is REFUSED, and + * nothing lands in `sys_packages`. Since #24086 the primitive itself parses the + * whole manifest through the install declaration (`PackageInstallManifestSchema`, + * a strict close), so a body carrying a metadata collection such as `pages` is + * refused there, `400`, before the registry is reached. That door is + * authoring-side: its HTTP caller, `POST /api/v1/packages`, parses the same + * declaration first, so no package body carrying metadata collections reaches + * the primitive over HTTP; * `duplicatePackage` copies the registry's own record (already judged), and * the unmanaged install's base is identity keys only. What it does receive is * a manifest being authored now (AI Studio's blueprint tools call it @@ -63,6 +66,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; import { PackageServicePlugin, type PackageService } from './index.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -173,7 +177,7 @@ const expectMisplacedDefault = (line: Line | undefined, id: string) => { }; describe('the install primitive is authoring-side and stays strict', () => { - it('protocol.installPackage refuses the same body with the registry\'s refusal, and writes no sys_packages row', async () => { + it('protocol.installPackage refuses the same body at the install declaration, and writes no sys_packages row', async () => { const dir = mkdtempSync(join(tmpdir(), 'os-sys-packages-install-')); dirs.push(dir); const { ql, lines, packages } = await boot(dir); @@ -182,16 +186,18 @@ describe('the install primitive is authoring-side and stays strict', () => { () => new Map([['package', packages]]), ); - let refusal: (Error & { code?: string; status?: number }) | undefined; + let refusal: (Error & { statusCode?: number }) | undefined; try { await protocol.installPackage({ manifest: hotcrmBody() } as never); } catch (e) { refusal = e as typeof refusal; } - expect(refusal, 'the install primitive accepted a retired declaration').toBeDefined(); - expect({ name: refusal!.name, code: refusal!.code, status: refusal!.status }) - .toEqual({ name: 'MisplacedDefaultPageError', code: 'INVALID_METADATA', status: 422 }); - expect(refusal!.message).toContain(`Page "sales_home_page" of package "${HOTCRM_ID}": \`isDefault: true\` is refused on a \`type: 'home'\` page`); + expect(refusal, 'the install primitive accepted an assembled body').toBeDefined(); + // [#24086] The install declaration's strict close refuses the body's + // metadata collections before the registry ever judges a page. + expect(refusal!.statusCode).toBe(400); + expect(standardErrorCodeForHttpStatus(refusal!.statusCode!)).toBe('VALIDATION_ERROR'); + expect(refusal!.message).toMatch(/^Invalid package manifest — manifest: .*\bpages\b/); expect(ql.registry.getPackage(HOTCRM_ID)).toBeUndefined(); expect(await packages.get(HOTCRM_ID)).toBeNull(); expect(lines.filter((l) => l.text.includes('ADR-0087 conversion'))).toEqual([]); diff --git a/packages/services/service-package/src/install-primitive-declaration.test.ts b/packages/services/service-package/src/install-primitive-declaration.test.ts index db87628c238..ac92280da22 100644 --- a/packages/services/service-package/src/install-primitive-declaration.test.ts +++ b/packages/services/service-package/src/install-primitive-declaration.test.ts @@ -97,12 +97,12 @@ async function refusalOf(run: () => Promise): Promise = [ - ["type 'application'", { id: 'com.example.app_typed', name: 'A', version: '1.0.0', type: 'application' }, 'manifest.type'], - ["scope 'environment'", { id: 'com.example.env_scoped', name: 'E', version: '1.0.0', type: 'app', scope: 'environment' }, 'manifest.scope'], - ["scope 'cloud'", { id: 'com.example.cloud_scoped', name: 'C', version: '1.0.0', type: 'app', scope: 'cloud' }, 'manifest.scope'], + ["type 'application'", { id: 'com.example.app-typed', name: 'A', version: '1.0.0', type: 'application' }, 'manifest.type'], + ["scope 'environment'", { id: 'com.example.env-scoped', name: 'E', version: '1.0.0', type: 'app', scope: 'environment' }, 'manifest.scope'], + ["scope 'cloud'", { id: 'com.example.cloud-scoped', name: 'C', version: '1.0.0', type: 'app', scope: 'cloud' }, 'manifest.scope'], ]; describe('[#24086] the install primitive refuses what the install declaration refuses — real stack', () => { @@ -147,9 +147,9 @@ describe('[#24086] rows stored before the change keep hydrating (stated toleranc // The tolerance is the read's only: a fresh write of the same manifest is refused. const err = await refusalOf(() => second.protocol.installPackage({ - manifest: { ...AI_BASE, id: 'com.example.ai_base_two' }, + manifest: { ...AI_BASE, id: 'com.example.ai-base-two' }, } as never)); expect(err.statusCode).toBe(400); - expect(await second.packages.get('com.example.ai_base_two')).toBeNull(); + expect(await second.packages.get('com.example.ai-base-two')).toBeNull(); }); }); From 73567ca77f45afdf41d51af093d794ba1dbffc50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:23:07 +0000 Subject: [PATCH 4/6] chore: changeset for the install primitive's manifest parse Claude-Session: https://claude.ai/code/session_012HLZ1LzgJX5mNE9C4DqDYE Co-authored-by: Claude --- ...24086-install-primitive-parses-manifest.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/24086-install-primitive-parses-manifest.md diff --git a/.changeset/24086-install-primitive-parses-manifest.md b/.changeset/24086-install-primitive-parses-manifest.md new file mode 100644 index 00000000000..1adef1c8278 --- /dev/null +++ b/.changeset/24086-install-primitive-parses-manifest.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: `installPackage` judges the whole manifest by the install door's declaration, so the primitive refuses a manifest `POST /api/v1/packages` refuses + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`ObjectStackProtocolImplementation.installPackage` parsed only `manifest.id`, so an in-process caller stored manifests the install door answers `400 VALIDATION_ERROR` for. The primitive now parses the manifest through `PackageInstallManifestSchema` (`@objectstack/spec/api`, `ManifestSchema` with `scope` narrowed to `project`), the declaration the door parses, after its version default and before any write. + +- **FROM:** `protocol.installPackage({ manifest })` with `type: 'application'`, `scope: 'environment'`, `scope: 'cloud'` / `'system'`, no `type`, or a key the manifest does not declare (such as a metadata collection, `pages`) registered the package and wrote its `sys_packages` row. +- **TO:** the call throws with `statusCode: 400` (an HTTP boundary answers `VALIDATION_ERROR`), the message names each refused key path (`manifest.type`, `manifest.scope`, …) with the declaration's own sentence, and neither the registry nor `sys_packages` is written. A malformed `manifest.id` still answers first, with its own sentence and repair. +- **Unchanged:** a manifest with no `version` still installs with `version: '0.1.0'`, a missing `namespace` is still derived from the id, and the manifest is stored as sent (the parse's `scope` / `defaultDatasource` defaults are not copied onto it). +- **Rows already stored** with `type: 'application'` / `scope: 'environment'` keep hydrating at boot, as stored. A new write of the same manifest, including a `duplicatePackage` of such a base, is refused. +- **The fix for a caller:** write a declared `type` (`app` for an application package) and leave `scope` out, or write `scope: 'project'`. From 3ca7f9773c9daa754e2bf274703235e5d5e352e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:48:53 +0000 Subject: [PATCH 5/6] test(service-package): alias @objectstack/spec/api to source for the install-refusal pins Claude-Session: https://claude.ai/code/session_012HLZ1LzgJX5mNE9C4DqDYE Co-authored-by: Claude --- packages/services/service-package/vitest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/services/service-package/vitest.config.ts b/packages/services/service-package/vitest.config.ts index 396849ec16d..7c9964fdb58 100644 --- a/packages/services/service-package/vitest.config.ts +++ b/packages/services/service-package/vitest.config.ts @@ -28,6 +28,9 @@ export default defineConfig({ { find: /^@objectstack\/objectql$/, replacement: path.resolve(__dirname, '../../objectql/src/index.ts') }, { find: /^@objectstack\/metadata-protocol$/, replacement: path.resolve(__dirname, '../../metadata-protocol/src/index.ts') }, { find: /^@objectstack\/driver-sql$/, replacement: path.resolve(__dirname, '../../drivers/driver-sql/src/index.ts') }, + // The install-refusal pins read the boundary's status-to-code mapping + // (`standardErrorCodeForHttpStatus`) from the source, like the rest. + { find: /^@objectstack\/spec\/api$/, replacement: path.resolve(__dirname, '../../spec/src/api/index.ts') }, ], }, }); From a263c0885f1376f32d866f2c106b88bf90187608 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:32:19 +0000 Subject: [PATCH 6/6] test(service-package): duplicating a pre-change stored app is refused and reported Claude-Session: https://claude.ai/code/session_012HLZ1LzgJX5mNE9C4DqDYE Co-authored-by: Claude --- .../src/install-primitive-declaration.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/services/service-package/src/install-primitive-declaration.test.ts b/packages/services/service-package/src/install-primitive-declaration.test.ts index ac92280da22..21c1d7a2751 100644 --- a/packages/services/service-package/src/install-primitive-declaration.test.ts +++ b/packages/services/service-package/src/install-primitive-declaration.test.ts @@ -152,4 +152,32 @@ describe('[#24086] rows stored before the change keep hydrating (stated toleranc expect(err.statusCode).toBe(400); expect(await second.packages.get('com.example.ai-base-two')).toBeNull(); }); + + // `duplicatePackage` is a RE-ENTRY path for a stored manifest: it spreads the + // source's stored manifest into `installPackage`. A pre-change AI app + // (`type: 'application'`, `scope: 'environment'`, an undeclared `icon`) is + // hydrated by the read, but the copy is a new WRITE, so it is refused, and + // the refusal reaches the caller rather than being swallowed: ⛔ no + // tolerance at that write. (`scope` is stripped by the duplicate itself.) + it('duplicating a pre-change stored app is refused and REPORTED, and the copy is written nowhere', async () => { + const AI_APP = { ...AI_BASE, id: 'com.example.ai-app', name: 'AI app', icon: 'sparkles' }; + const dir = freshDir(); + const first = await boot(dir); + expect(await first.packages.publish({ manifest: AI_APP as never, metadata: {} as never })).toEqual({ success: true }); + await first.kernel.shutdown(); + + const second = await boot(dir); + expect(second.ql.registry.getPackage(AI_APP.id), 'the pre-change app was not hydrated').toBeDefined(); + + const err = await refusalOf(() => second.protocol.duplicatePackage({ + sourcePackageId: AI_APP.id, + targetPackageId: 'com.example.ai-app-copy', + })); + expect(err.statusCode).toBe(400); + expect(standardErrorCodeForHttpStatus(err.statusCode!)).toBe('VALIDATION_ERROR'); + expect(err.message).toContain('manifest.type'); + expect(err.message).toContain('icon'); + expect(second.ql.registry.getPackage('com.example.ai-app-copy')).toBeUndefined(); + expect(await second.packages.get('com.example.ai-app-copy')).toBeNull(); + }); });