diff --git a/.changeset/22113-revert-package-code-shipped-members.md b/.changeset/22113-revert-package-code-shipped-members.md new file mode 100644 index 0000000000..98b211fec0 --- /dev/null +++ b/.changeset/22113-revert-package-code-shipped-members.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata': patch +'@objectstack/runtime': patch +--- + +fix(runtime,metadata): publishing or reverting a read-only package is refused with `422 WRITABLE_PACKAGE_REQUIRED`, and package membership reads the `_packageId` stamp + +Clause-②: no + +ADR-0070 D2 makes a code or installed package read-only. `POST /api/v1/packages/:id/publish` and `POST /api/v1/packages/:id/revert` did not check this. These two answers change: + +- **Publish of a code or installed package: 200 → 422.** Before, the publish door answered 200. For a package whose items carry an authored `packageId`, for example `com.example.showcase`'s two capabilities, it answered `success: true` and wrote `publishedDefinition`, `state` and `version` onto those read-only items. For every other code package it answered `success: false`, "No metadata items found". It now answers `422 WRITABLE_PACKAGE_REQUIRED` before anything is written, the same refusal that `PATCH /packages/:id/disable` and `DELETE /packages/:id` already give. +- **Revert of a code or installed package: 404 or 409 → 422.** Before, the revert door answered `404 RESOURCE_NOT_FOUND` "No metadata items found" (for example `com.objectstack.setup` and the platform packages that ship objects) or `409 RESOURCE_CONFLICT` "Package '…' has never been published" (for example `com.example.showcase`). It now answers `422 WRITABLE_PACKAGE_REQUIRED`. The check runs after the protocol's stored-row answer, so a revert of a code package that has a stored row bound to it, such as an organization overlay draft, keeps its answer. + +To customise what a code package provides, use an ADR-0005 organization overlay. Overlay drafts publish through `POST /api/v1/packages/:id/publish-drafts` and the per-item publish door, and this change leaves both alone. + +**Unchanged:** a writable package's publish and revert, and an id that nothing carries (revert 404; publish 200 with `success: false`). + +`MetadataManager.publishPackage` and `revertPackage` now find a package's members by `packageId`, `package` or the private `_packageId` stamp. The artifact loader writes that stamp through `applyProtection`, and the ObjectQL object bridge copies it onto every object it registers. Before, an item that carried only the stamp was not a member. `MetadataManager` has no notion of package kind; the refusal of read-only packages lives at the two doors above. diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 8cf73463b7..ac8a91196a 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -1795,6 +1795,47 @@ export class MetadataManager implements IMetadataService { } } + /** + * [#22113] The registry items that belong to `packageId`, for the two + * package-wide writes ({@link publishPackage}, {@link revertPackage}). + * + * An item belongs to a package when any of three keys names it: + * + * - `packageId` — the publish envelope's own key, and what a caller that + * registers an item for a package writes; + * - `package` — the legacy package stamp; + * - `_packageId` — the private provenance stamp. `applyProtection` + * (`@objectstack/spec/shared`, ADR-0010 §3.7) writes it on every item a + * code-shipped artifact registers here, and the ObjectQL object bridge + * copies it onto every object it registers here. A code-shipped item + * carries ONLY this key, and it is the key the metadata protocol scopes + * this registry's items by when it serves a package's reads. + * + * Reading the first two alone made a package's publish and revert miss + * every item that carries only the stamp ("No metadata items found" while + * every read served them). + * + * ⛔ Not a writability check. This class has no package-kind concept, so it + * cannot tell a read-only code package (ADR-0070 D2) from a writable one, + * and with the stamp read it would publish a code package's items like any + * other. The refusal belongs to the caller that holds the predicate: the + * `POST /packages/:id/publish` and `/revert` doors (`@objectstack/runtime`) + * refuse a non-writable package with `422 WRITABLE_PACKAGE_REQUIRED` before + * calling either method. + */ + private collectPackageMembers(packageId: string): Array<{ type: string; name: string; data: any }> { + const members: Array<{ type: string; name: string; data: any }> = []; + for (const [type, typeStore] of this.registry) { + for (const [name, data] of typeStore) { + const meta = data as any; + if (meta?.packageId === packageId || meta?.package === packageId || meta?._packageId === packageId) { + members.push({ type, name, data: meta }); + } + } + } + return members; + } + /** * Publish an entire package: * 1. Validate all draft items @@ -1832,16 +1873,10 @@ export class MetadataManager implements IMetadataService { const shouldValidate = options?.validate !== false; const publishedBy = options?.publishedBy; - // Collect all items belonging to this package - const packageItems: Array<{ type: string; name: string; data: any }> = []; - for (const [type, typeStore] of this.registry) { - for (const [name, data] of typeStore) { - const meta = data as any; - if (meta?.packageId === packageId || meta?.package === packageId) { - packageItems.push({ type, name, data: meta }); - } - } - } + // Collect all items belonging to this package — the same membership + // `revertPackage` reads. A read-only code package never gets here through + // the publish door, which refuses it first (see `collectPackageMembers`). + const packageItems = this.collectPackageMembers(packageId); if (packageItems.length === 0) { return { @@ -2060,15 +2095,7 @@ export class MetadataManager implements IMetadataService { * Restores all metadata definitions from their published snapshots. */ async revertPackage(packageId: string): Promise { - const packageItems: Array<{ type: string; name: string; data: any }> = []; - for (const [type, typeStore] of this.registry) { - for (const [name, data] of typeStore) { - const meta = data as any; - if (meta?.packageId === packageId || meta?.package === packageId) { - packageItems.push({ type, name, data: meta }); - } - } - } + const packageItems = this.collectPackageMembers(packageId); // [#7559] ADR-0112 — both refusals below carry a DECLARED `code` + `status`. // They are the ordinary answers to an ordinary request (revert a package id diff --git a/packages/metadata/src/metadata-service.test.ts b/packages/metadata/src/metadata-service.test.ts index 012a1ddce7..6637ded36a 100644 --- a/packages/metadata/src/metadata-service.test.ts +++ b/packages/metadata/src/metadata-service.test.ts @@ -822,6 +822,71 @@ describe('MetadataManager — IMetadataService Contract', () => { message: expect.stringContaining('has never been published'), }); }); + + // [#22113] An item can carry ONLY the private `_packageId` stamp — + // `applyProtection` writes it on every item an artifact registers, and the + // ObjectQL object bridge copies it onto every object. Collecting members by + // `packageId` / `package` alone answered 404 "No metadata items found" for a + // package every read serves. (Whether the package may be reverted at all is + // the door's question — it refuses a read-only one with 422 first.) + it('finds a package by the _packageId stamp: never published ⇒ 409, not 404', async () => { + await manager.register('object', 'code_item', { + name: 'code_item', label: 'Code Item', _packageId: 'com.acme.code', _provenance: 'package', + }); + + await expect(manager.revertPackage('com.acme.code')).rejects.toMatchObject({ + code: 'RESOURCE_CONFLICT', + status: 409, + message: "Package 'com.acme.code' has never been published", + }); + }); + + it('membership is any of packageId, package or _packageId: each member with a snapshot is restored', async () => { + const snapshot = (label: string) => ({ label }); + await manager.register('object', 'by_package_id', { + name: 'by_package_id', packageId: 'com.acme.keys', publishedDefinition: snapshot('A'), state: 'draft', + }); + await manager.register('object', 'by_package', { + name: 'by_package', package: 'com.acme.keys', publishedDefinition: snapshot('B'), state: 'draft', + }); + await manager.register('object', 'by_stamp', { + name: 'by_stamp', _packageId: 'com.acme.keys', publishedDefinition: snapshot('C'), state: 'draft', + }); + // Control: a different package's item is not a member. + await manager.register('object', 'other_pkg', { + name: 'other_pkg', _packageId: 'com.acme.other', publishedDefinition: snapshot('D'), state: 'draft', + }); + + await manager.revertPackage('com.acme.keys'); + + for (const [name, label] of [['by_package_id', 'A'], ['by_package', 'B'], ['by_stamp', 'C']] as const) { + const item = await manager.get('object', name) as any; + expect({ name, state: item.state, metadata: item.metadata }).toEqual({ name, state: 'active', metadata: { label } }); + } + const other = await manager.get('object', 'other_pkg') as any; + expect(other.state).toBe('draft'); + expect(other.metadata).toBeUndefined(); + }); + }); + + // [#22113] `publishPackage` reads the same membership as `revertPackage`: + // an item carrying only the stamp is a member. (Flipped from the pin that + // held it OUT while nothing refused a read-only code package; the + // `POST /packages/:id/publish` door now refuses one with + // `422 WRITABLE_PACKAGE_REQUIRED` before this method runs.) + describe('publishPackage — an item carrying only the _packageId stamp', () => { + it('is a member: it is snapshotted and published', async () => { + await manager.register('object', 'stamped_item', { + name: 'stamped_item', label: 'Stamped Item', _packageId: 'com.acme.stamped', _provenance: 'package', + }); + + const result = await manager.publishPackage('com.acme.stamped', { validate: false }); + + expect(result).toMatchObject({ success: true, itemsPublished: 1, version: 1 }); + const item = await manager.get('object', 'stamped_item') as any; + expect(item.state).toBe('active'); + expect(item.publishedDefinition).toMatchObject({ name: 'stamped_item', label: 'Stamped Item' }); + }); }); describe('getPublished', () => { diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 2df234a954..d422204c00 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -378,6 +378,12 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon * authoring one ("switch to a writable package in the package selector") names * a remedy that makes no sense for a delete. * + * [#22113] `POST /packages/:id/publish` and `POST /packages/:id/revert` ask it + * too, with the same predicate and refusal: publishing or reverting a code + * package's shipped items is an authoring write into a read-only package + * (ADR-0070 D2). Revert asks it only after the protocol's stored-row answer, so + * a `sys_metadata` row bound to the package keeps its own answer. + * * [#14451] The remedy it names is ADR-0005 org overlay, and it used to name * `POST /packages/:id/duplicate` instead. That sentence sent a caller holding a * read-only package at a route which — for exactly the packages this refusal @@ -1459,6 +1465,16 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [#22113] ADR-0070 D2: a code or installed package is read-only, so + // this door refuses it BEFORE `publishPackage`, which would otherwise + // snapshot and re-register the package's items as published (measured: + // a platform package's objects, and the showcase's capabilities, at + // 200). The metadata service has no package-kind concept to refuse + // with; the door's one predicate does. An organization overlay draft + // on a code package is not published here: it is a `sys_metadata` row + // the protocol publishes through `publish-drafts` and the per-item + // publish door, neither of which passes this branch. + const readOnly = requireWritablePackage(deps, qlService, id, 'publish'); if (readOnly) return readOnly; const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).publishPackage === 'function') { const result = await (metadataService as any).publishPackage(id, body || {}); @@ -1917,6 +1933,14 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin return { handled: true, response: deps.errorFromThrown(e, 500) }; } } + // [#22113] ADR-0070 D2: a code or installed package has nothing of its + // own to revert, so it is refused here, AFTER the protocol's stored-row + // answer above. A stored row bound to it (an organization overlay + // draft) is still answered by the protocol, exactly as before; only the + // metadata service's revert of the package's shipped items is refused. + // "Has never been published" would point the caller at publishing, + // which the publish door above refuses for the same package. + const readOnly = requireWritablePackage(deps, qlService, id, 'revert'); if (readOnly) return readOnly; const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).revertPackage === 'function') { await (metadataService as any).revertPackage(id); diff --git a/packages/runtime/src/package-revert-code-shipped-members.integration.test.ts b/packages/runtime/src/package-revert-code-shipped-members.integration.test.ts new file mode 100644 index 0000000000..00adda4e57 --- /dev/null +++ b/packages/runtime/src/package-revert-code-shipped-members.integration.test.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Real-engine door pins for #22113 — `POST /packages/:id/publish` and +// `POST /packages/:id/revert` on a read-only (code or installed) package, and +// the package membership the metadata service reads for a writable one. + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { MetadataManager } from '@objectstack/metadata'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + SysMetadataObject, + SysMetadataHistoryObject, + SysMetadataAuditObject, + SysMetadataCommitObject, +} from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { applyProtection } from '@objectstack/spec/shared'; +import { HttpDispatcher } from './http-dispatcher.js'; + +/** + * ## The defect, measured at the door + * + * On a `objectstack dev` boot of `examples/app-showcase` at the base of this + * change, the two package-wide doors answered a read-only package three ways, + * none of them ADR-0070 D2's: + * + * - `com.objectstack.setup` (its items live only in ObjectQL's SchemaRegistry) + * and eighteen platform packages that ship objects: revert 404 "No metadata + * items found", publish 200 `success: false` with the same sentence; + * - `com.example.showcase` (booted from its artifact): revert 409 "has never + * been published", and publish 200 `success: true` — two capabilities + * snapshotted and re-registered as published, a write into a read-only + * package. + * + * Both doors now refuse a non-writable package with the door's existing + * `422 WRITABLE_PACKAGE_REQUIRED` (the predicate `disable` and `delete` ask). + * Revert asks it only AFTER the protocol's stored-row answer, so a stored row + * bound to a code package (an organization overlay draft) keeps its answer. + * + * The metadata service's member lookup now also reads the private `_packageId` + * stamp. Its two producers are used here as in production: the ObjectQL object + * bridge (`getAllObjects()` → `register`) and the artifact loader's + * `applyProtection`. + * + * ## Why a REAL engine, a REAL protocol and a REAL metadata service + * + * The writability predicate reads the engine's manifests and package registry, + * the revert door asks the protocol's stored rows first, and the membership is + * the metadata service's own registry; a double of any of them would pin the + * double. The sibling suite `package-revert-stored-members.integration.test.ts` + * holds the Studio-package half. + */ + +/** A platform package that ships objects: installed with `scope: 'system'`, its objects bridged. */ +const OBJECTS_PKG = 'com.example.code_objects'; +/** A platform package whose items the metadata service never holds (the `com.objectstack.setup` shape). */ +const REGISTRY_ONLY_PKG = 'com.example.registry_only'; +/** A package booted from its artifact (the `com.example.showcase` shape): in the engine's manifests. */ +const BOOTED_PKG = 'com.example.booted'; +/** A writable base whose metadata-service members carry only the stamp. */ +const STAMPED_BASE = 'com.example.stamped_base'; +/** A writable package whose items carry an authored `packageId` (the control). */ +const AUTHORED_PKG = 'com.example.authored'; +/** An id nothing in the process carries. */ +const UNKNOWN_PKG = 'com.example.no_such_package'; + +let cleanup: Array<() => void> = []; +afterEach(() => { + for (const c of cleanup) c(); + cleanup = []; +}); + +/** REAL ObjectQL over on-disk better-sqlite3, the REAL protocol, the REAL metadata service, one door. */ +async function boot() { + const dir = mkdtempSync(join(tmpdir(), 'os-22113-')); + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: join(dir, 'data.sqlite') }, + useNullAsDefault: true, + }); + const sysObjects = [ + SysMetadataObject, + SysMetadataHistoryObject, + SysMetadataAuditObject, + SysMetadataCommitObject, + ] as any[]; + await driver.initObjects(sysObjects); + + const engine = new ObjectQL(); + engine.registerDriver(driver as any, true); + await engine.init(); + cleanup.push(() => { void engine.destroy(); }); + + // A platform package that ships objects, as a platform plugin's manifest + // installs it: `scope: 'system'`, and the four `sys_metadata*` objects + // registered under it stand in for its objects. + engine.registry.installPackage({ id: OBJECTS_PKG, name: OBJECTS_PKG, version: '1.0.0', scope: 'system' } as any); + for (const o of sysObjects) engine.registry.registerObject(o, OBJECTS_PKG); + // A platform package that ships no objects — nothing of it reaches the + // metadata service. + engine.registry.installPackage({ id: REGISTRY_ONLY_PKG, name: REGISTRY_ONLY_PKG, version: '1.0.0', scope: 'system' } as any); + // A writable base: project-scoped, not booted. + engine.registry.installPackage({ id: STAMPED_BASE, name: STAMPED_BASE, version: '1.0.0', scope: 'project' } as any); + + const protocol = new ObjectStackProtocolImplementation( + engine as any, undefined, undefined, 'package-author', + ); + + const metadata = new MetadataManager({ formats: ['json'] }); + metadata.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY); + + const services: Record = { protocol, objectql: engine, metadata }; + const get = (name: string) => services[name] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + + /** `POST /packages/:id/`, as a caller holding `manage_metadata`. */ + const call = async (packageId: string, verb: 'revert' | 'publish') => { + const context = { + request: {}, + executionContext: { userId: 'u_pkg_admin', systemPermissions: ['manage_metadata', 'studio.access'] }, + } as any; + const result = await dispatcher.handlePackages(`/${encodeURIComponent(packageId)}/${verb}`, 'POST', {}, {}, context); + expect(result.handled).toBe(true); + return { status: result.response?.status, body: result.response?.body }; + }; + + return { engine, protocol: protocol as any, metadata, revert: (id: string) => call(id, 'revert'), publish: (id: string) => call(id, 'publish') }; +} + +/** The ObjectQL object bridge's loop body (`bridgeObjectsToMetadataService`), on the real registry. */ +async function bridgeObjects(engine: ObjectQL, metadata: MetadataManager): Promise { + const bridged: string[] = []; + for (const obj of engine.registry.getAllObjects()) { + await metadata.register('object', obj.name, obj, { notify: false }); + bridged.push(obj.name); + } + return bridged; +} + +/** The metadata plugin's artifact registration of one item: stamp, then register. */ +async function registerArtifactItem(metadata: MetadataManager, packageId: string, type: string, item: Record) { + applyProtection(item, { packageId, packageVersion: '1.0.0' }); + await metadata.register(type, String(item.name), item, { notify: false }); +} + +/** The booted package, as the showcase is: stamped artifact items plus one authored-`packageId` capability, then its manifest booted. */ +async function bootArtifactPackage(engine: ObjectQL, metadata: MetadataManager) { + await registerArtifactItem(metadata, BOOTED_PKG, 'capability', { name: 'booted.restricted_ops', label: 'Restricted', scope: 'org' }); + await metadata.register('capability', 'booted.export_data', { + name: 'booted.export_data', label: 'Export Booted Data', scope: 'org', packageId: BOOTED_PKG, + }, { notify: false }); + engine.registerApp({ id: BOOTED_PKG, name: BOOTED_PKG, version: '1.0.0' }); +} + +/** The refusal envelope both doors answer for a read-only package. */ +function expectReadOnlyRefusal(answer: { status?: number; body?: any }, verb: 'publish' | 'revert', packageId: string) { + expect(answer.status).toBe(422); + expect(answer.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + expect(String(answer.body?.error?.message)).toMatch(new RegExp(`^\\[writable_package_required\\] Cannot ${verb} package '${packageId.replace(/\./g, '\\.')}': it is read-only`)); +} + +describe('#22113 publish and revert refuse a read-only package (ADR-0070 D2)', () => { + it('a platform package that ships objects: revert 422 (was 404, then 409) and publish 422 (was 200 success:false)', async () => { + const { engine, metadata, revert, publish } = await boot(); + expect(await bridgeObjects(engine, metadata)).toContain('sys_metadata'); + // The precondition the membership fix needs, read off the real producer: + // the stamp is there, the two keys the old lookup read are not. + const sample = await metadata.get('object', 'sys_metadata') as any; + expect({ _packageId: sample._packageId, packageId: sample.packageId, package: sample.package }) + .toEqual({ _packageId: OBJECTS_PKG, packageId: undefined, package: undefined }); + + expectReadOnlyRefusal(await revert(OBJECTS_PKG), 'revert', OBJECTS_PKG); + expectReadOnlyRefusal(await publish(OBJECTS_PKG), 'publish', OBJECTS_PKG); + + // Refused BEFORE the metadata service: nothing was snapshotted. + const after = await metadata.get('object', 'sys_metadata') as any; + expect({ publishedDefinition: after.publishedDefinition, state: after.state, version: after.version }) + .toEqual({ publishedDefinition: undefined, state: undefined, version: undefined }); + }); + + it('a platform package the metadata service never holds (the setup shape): revert 422 (was 404) and publish 422 (was 200 success:false)', async () => { + const { revert, publish } = await boot(); + + expectReadOnlyRefusal(await revert(REGISTRY_ONLY_PKG), 'revert', REGISTRY_ONLY_PKG); + expectReadOnlyRefusal(await publish(REGISTRY_ONLY_PKG), 'publish', REGISTRY_ONLY_PKG); + }); + + it('a package booted from its artifact (the showcase shape): revert 422 (was 409) and publish 422 (was 200 success:true)', async () => { + const { engine, metadata, revert, publish } = await boot(); + await bootArtifactPackage(engine, metadata); + + expectReadOnlyRefusal(await publish(BOOTED_PKG), 'publish', BOOTED_PKG); + expectReadOnlyRefusal(await revert(BOOTED_PKG), 'revert', BOOTED_PKG); + + // The base hole this closes: the authored-`packageId` capability used to be + // snapshotted and re-registered as published. Nothing was. + const cap = await metadata.get('capability', 'booted.export_data') as any; + expect({ publishedDefinition: cap.publishedDefinition, state: cap.state }) + .toEqual({ publishedDefinition: undefined, state: undefined }); + }); +}); + +describe('#22113 controls', () => { + it('an unknown id: revert stays 404 RESOURCE_NOT_FOUND, publish stays 200 success:false', async () => { + const { engine, metadata, revert, publish } = await boot(); + await bridgeObjects(engine, metadata); + + const reverted = await revert(UNKNOWN_PKG); + expect(reverted.status).toBe(404); + expect(reverted.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + + const published = await publish(UNKNOWN_PKG); + expect(published.status).toBe(200); + expect(published.body?.data).toMatchObject({ success: false, itemsPublished: 0 }); + }); + + it('a writable package with authored packageId members: publish 200, then edit, then revert 200 restores the snapshot', async () => { + const { metadata, revert, publish } = await boot(); + await metadata.register('capability', 'authored.export_data', { + name: 'authored.export_data', label: 'Export Authored Data', scope: 'org', packageId: AUTHORED_PKG, + }); + + const published = await publish(AUTHORED_PKG); + expect(published.status).toBe(200); + expect(published.body?.data).toMatchObject({ success: true, itemsPublished: 1 }); + const item = await metadata.get('capability', 'authored.export_data') as any; + await metadata.register('capability', 'authored.export_data', { + ...item, metadata: { ...item.publishedDefinition, label: 'Edited after publish' }, state: 'draft', + }); + + const answer = await revert(AUTHORED_PKG); + + expect(answer.status).toBe(200); + const reverted = await metadata.get('capability', 'authored.export_data') as any; + expect(reverted.state).toBe('active'); + expect(reverted.metadata.label).toBe('Export Authored Data'); + }); + + it('a writable base whose members carry only the stamp: both doors find them — revert 409 before a publish, publish 200, revert 200 after', async () => { + const { metadata, revert, publish } = await boot(); + await registerArtifactItem(metadata, STAMPED_BASE, 'capability', { name: 'base.export_data', label: 'Export Base Data', scope: 'org' }); + + const early = await revert(STAMPED_BASE); + expect(early.status).toBe(409); + expect(early.body?.error?.code).toBe('RESOURCE_CONFLICT'); + expect(early.body?.error?.message).toBe(`Package '${STAMPED_BASE}' has never been published`); + + const published = await publish(STAMPED_BASE); + expect(published.status).toBe(200); + expect(published.body?.data).toMatchObject({ success: true, itemsPublished: 1 }); + + const answer = await revert(STAMPED_BASE); + expect(answer.status).toBe(200); + const reverted = await metadata.get('capability', 'base.export_data') as any; + expect(reverted.state).toBe('active'); + }); + + it('a stored row bound to a read-only package keeps the protocol’s answer: the refusal sits after it', async () => { + const { engine, metadata, protocol, revert } = await boot(); + // The #22090 contract-review shape: a draft row in `sys_metadata` bound to + // a code package. Written while the id is not yet booted — the authoring + // gate refuses a new item into a booted package — then the package boots. + await protocol.saveMetaItem({ + type: 'view', + name: 'booted_board', + item: { + name: 'booted_board', label: 'Overlay draft', type: 'grid', object: 'anything', viewKind: 'list', + data: { provider: 'object', object: 'anything' }, columns: ['id'], + }, + packageId: BOOTED_PKG, + mode: 'draft', + }); + await bootArtifactPackage(engine, metadata); + + const answer = await revert(BOOTED_PKG); + + // The protocol's stored-row answer (#22090), not the door's 422. + expect(answer.status).toBe(409); + expect(answer.body?.error?.code).toBe('RESOURCE_CONFLICT'); + expect(String(answer.body?.error?.message)).toMatch(/^Package 'com\.example\.booted' has never been published/); + }); +}); diff --git a/packages/runtime/src/package-revert-stored-members.integration.test.ts b/packages/runtime/src/package-revert-stored-members.integration.test.ts index 1743419a4f..9a517d3c76 100644 --- a/packages/runtime/src/package-revert-stored-members.integration.test.ts +++ b/packages/runtime/src/package-revert-stored-members.integration.test.ts @@ -62,11 +62,11 @@ const DRAFT_ONLY_PKG = 'com.example.repairs_draft_only'; /** * The code-shipped control. `examples/app-showcase` ships this package id, and * its two capabilities (`src/security/capabilities.ts`) are authored with - * `packageId: 'com.example.showcase'` — the key `MetadataManager.revertPackage` - * collects by, which is why a live boot of the showcase answers its revert with - * the registry's own `409 "has never been published"`. The two items are - * restated here as a literal, not imported: importing the example would make - * this test read outside its package. + * `packageId: 'com.example.showcase'`. [#22113] A live boot of the showcase now + * answers its revert with `422 WRITABLE_PACKAGE_REQUIRED` (ADR-0070 D2: a + * booted package is read-only); the cases below boot it the same way. The two + * items are restated here as a literal, not imported: importing the example + * would make this test read outside its package. */ const SHOWCASE_PKG = 'com.example.showcase'; const SHOWCASE_CAPABILITIES = [ @@ -275,7 +275,7 @@ describe('#22090 POST /packages/:id/revert on a Studio-authored package', () => }); }); -describe('#22090 controls — a package with no stored row is the metadata service’s, exactly as before', () => { +describe('#22090 controls — a package with no stored row is not the protocol’s', () => { it('(c) an unknown package id: 404 RESOURCE_NOT_FOUND, the metadata service’s own answer', async () => { const { revert } = await boot(); @@ -286,19 +286,26 @@ describe('#22090 controls — a package with no stored row is the metadata servi expect(answer.body?.error?.message).toBe("No metadata items found for package 'com.example.no_such_package'"); }); - it('(d) the code-shipped com.example.showcase, never published: 409, the registry’s own sentence', async () => { - const { metadata, revert } = await boot(); + // [#22113] FLIPPED. Both cases boot the showcase as the dev boot does (its + // manifest in the engine), which makes it a read-only code package (ADR-0070 + // D2). With no stored row, the door now refuses it after the protocol's + // answer, with `422 WRITABLE_PACKAGE_REQUIRED`, instead of handing it to the + // metadata service (which answered 409 "has never been published", and 200 + // once a publish had snapshotted the capabilities). A writable package's + // metadata-service revert is held in `package-revert-code-shipped-members.integration.test.ts`. + it('(d) the code-shipped com.example.showcase, never published: 422 WRITABLE_PACKAGE_REQUIRED (was: 409)', async () => { + const { engine, metadata, revert } = await boot(); for (const cap of SHOWCASE_CAPABILITIES) await metadata.register('capability', cap.name, { ...cap }); + engine.registerApp({ id: SHOWCASE_PKG, name: SHOWCASE_PKG, version: '1.0.0' }); const answer = await revert(SHOWCASE_PKG); - expect(answer.status).toBe(409); - expect(answer.body?.error?.code).toBe('RESOURCE_CONFLICT'); - expect(answer.body?.error?.message).toBe(`Package '${SHOWCASE_PKG}' has never been published`); + expect(answer.status).toBe(422); + expect(answer.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); }); - it('(d) the code-shipped com.example.showcase, published then edited: 200, restored from its published snapshot', async () => { - const { metadata, revert } = await boot(); + it('(d) the code-shipped com.example.showcase, published then edited: 422 WRITABLE_PACKAGE_REQUIRED, and nothing is restored (was: 200)', async () => { + const { engine, metadata, revert } = await boot(); for (const cap of SHOWCASE_CAPABILITIES) await metadata.register('capability', cap.name, { ...cap }); const published = await metadata.publishPackage(SHOWCASE_PKG, { validate: false }); expect(published.success).toBe(true); @@ -306,13 +313,14 @@ describe('#22090 controls — a package with no stored row is the metadata servi await metadata.register('capability', 'showcase.export_data', { ...item, metadata: { ...item.publishedDefinition, label: 'Edited after publish' }, state: 'draft', }); + engine.registerApp({ id: SHOWCASE_PKG, name: SHOWCASE_PKG, version: '1.0.0' }); const answer = await revert(SHOWCASE_PKG); - expect(answer.status).toBe(200); - const reverted = await metadata.get('capability', 'showcase.export_data') as any; - expect(reverted.state).toBe('active'); - expect(reverted.metadata).toEqual(reverted.publishedDefinition); - expect(reverted.metadata.label).toBe('Export Showcase Data'); + expect(answer.status).toBe(422); + expect(answer.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + const after = await metadata.get('capability', 'showcase.export_data') as any; + expect(after.state).toBe('draft'); + expect(after.metadata.label).toBe('Edited after publish'); }); });