diff --git a/.changeset/31283-install-local-uninstall-guards-audit.md b/.changeset/31283-install-local-uninstall-guards-audit.md new file mode 100644 index 00000000000..7dc750421a7 --- /dev/null +++ b/.changeset/31283-install-local-uninstall-guards-audit.md @@ -0,0 +1,20 @@ +--- +'@objectstack/cloud-connection': patch +'@objectstack/metadata-protocol': patch +--- + +fix(cloud-connection,metadata-protocol)!: install-local's uninstall door asks the uninstall verb's guards and writes its audit row (#31283) + +Clause-②: yes (narrowing) — both arms. Narrowing: `DELETE /api/v1/marketplace/install-local/:manifestId` refuses, with the uninstall verb's guard refusal (today the runtime's `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`), an uninstall it answered `200` before: one that would withdraw a field an active validation rule of another enabled package reads. Widening: two new public members of `ObjectStackProtocolImplementation` (`@objectstack/metadata-protocol`), `runUninstallGuards` and `recordPackageUninstall`, and one new exported type, `PackageUninstallRecord`. +Metadata-protocol: none +Skills-docs: one move — `content/docs/deployment/marketplace-handbook.mdx` (Uninstalling) states the refusal and the audit row; no `skills/**` page teaches this door's uninstall. + + + +**BREAKING** for a caller of install-local's uninstall door that uninstalled a package another package's active validation rule depends on. + +- **Before.** `DELETE /api/v1/marketplace/install-local/:manifestId` withdrew the package and ran the protocol's uninstall cleanups, but never went through the uninstall verb (`deletePackage`), so it skipped what the verb does before and after: the pre-uninstall guards (the runtime's dependent-rule refusal) and the package's own `package_uninstall` audit row. Uninstalling a package whose `objectExtensions` field an active rule of another package reads answered `200`; the field left the served object and the rule then failed closed on every write to it. No uninstall through this door was audited. +- **Now.** The door asks the verb's guards before anything moves, through the protocol's one runner of them (`runUninstallGuards`, which `deletePackage` asks too), and answers a refusal as the guard spelled it — code, status and message — with the ledger entry, the registration and the served fields as they were. A completed uninstall writes one `package_uninstall` row through the protocol's one writer of it (`recordPackageUninstall`, which `deletePackage` calls too), naming the package, the version removed and the operator. +- **Unchanged.** `deletePackage` asks the same guards and writes the same row as before. A runtime with no audit plugin writes no row; a protocol from a build without the two members is not asked, and the door behaves as before. + +**The remedy.** Switch off or change the rule the refusal names, or keep the package installed. diff --git a/content/docs/deployment/marketplace-handbook.mdx b/content/docs/deployment/marketplace-handbook.mdx index 3c97b719ef5..d2fba543a61 100644 --- a/content/docs/deployment/marketplace-handbook.mdx +++ b/content/docs/deployment/marketplace-handbook.mdx @@ -254,6 +254,11 @@ the detail. from the running kernel and revokes its permission sets and their grants. The answer's `note` and `cleanups` say whether any part stays loaded until the next restart. **Records stored in the app's objects stay in the database.** + An uninstall that would withdraw a field an active validation rule of + another enabled package reads is refused with + `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, naming the rule, and + nothing is removed — the same refusal `DELETE /api/v1/packages/:id` answers. + A completed uninstall is recorded in the audit log (`package_uninstall`). ## Staying current with platform versions diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 09ac8e82072..e9f4cc22a5d 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -427,6 +427,17 @@ type UninstallCleanupRunner = { runUninstallCleanups?( request: Pick & { declared?: UninstallDeclared }, ): Promise; + /** + * [#31283] The verb's pre-uninstall guards, asked through the protocol's + * one runner of them — the runner `deletePackage` asks at its first step. + */ + runUninstallGuards?: ObjectStackProtocolImplementation['runUninstallGuards']; + /** + * [#31283] The verb's `package_uninstall` ledger row (#31186), written + * through the protocol's one writer of it — the writer `deletePackage` + * calls at its last step. + */ + recordPackageUninstall?: ObjectStackProtocolImplementation['recordPackageUninstall']; }; /** The outcome name this door reports when the runner itself could not run. */ @@ -3343,6 +3354,20 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const walled = this.refuseWalledCatalogPackage(c, ctx, 'Uninstalling', manifestId, admission); if (walled) return walled; } + // [#31283] THE UNINSTALL VERB'S PRE-UNINSTALL GUARDS, ASKED BEFORE + // ANYTHING MOVES. This door cannot delegate to `deletePackage` (it + // answers `success: false` for a package with no stored rows, and + // drops the tables of the objects it removes), so it asks the verb's + // guards through the protocol's one runner of them + // (`runUninstallGuards`) — the runtime's dependent-rule refusal (#31232: + // an active validation rule of another enabled package reads a field + // this package adds through `objectExtensions`) among them — and holds + // no copy of any verdict. A refusal is the request's, spelled as the + // guard spelled it (`code`, `status`, `message`), with nothing moved. + { + const refused = await this.askUninstallGuards(c, ctx, manifestId, admission.userId); + if (refused) return refused; + } // [#28511, ADR-0146 D3] THE REGISTRY'S UNINSTALL REFUSAL, ASKED BEFORE // THE LEDGER WRITE. The withdrawal below asks the registry to // uninstall the package, and the registry refuses a base whose paired @@ -3383,6 +3408,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } } } + // [#31283] The version this uninstall removes, for the package's ledger row. + const removedVersion = this.ledger.read(manifestId).entry?.version; try { this.ledger.remove(manifestId); } catch (err: any) { @@ -3444,6 +3471,13 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // re-sync would only read it back. const resync = withdrawal ? undefined : await this.announceUninstall(ctx, manifestId); if (resync) cleanups.push(resync); + // [#31283] The package's own `package_uninstall` ledger row (#31186), + // written once the uninstall is durable — the ledger entry is gone — + // through the protocol's one writer of it (`recordPackageUninstall`), + // never on a refusal above. `itemsRemoved` / `itemsFailed` count the + // stored `sys_metadata` items an uninstall removes: an install-local + // package is registered from its ledger entry and has none. + await this.recordUninstall(ctx, manifestId, removedVersion, admission.userId); const withdrawals = (withdrawal ? 1 : 0) + (copies ? 1 : 0) + (resync ? 1 : 0); ctx.logger?.info?.( `[MarketplaceInstallLocal] uninstalled ${manifestId} (cached manifest removed; ` @@ -3696,6 +3730,65 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }; }; + /** + * [#31283] Ask the uninstall verb's pre-uninstall guards about `manifestId` + * through the protocol's one runner of them (`runUninstallGuards`), and + * answer the first refusal as this request's: `{ success: false, error: + * { code, message } }` under the guard's `status`. `undefined` when no + * guard refuses. + * + * No `protocol` service, or a protocol from a build without the runner, + * holds no guard this door can ask, and the door behaves as before — the + * capability question {@link runUninstallCleanups} asks of its runner. A + * throw that is not a coded client refusal is the guard's failure to + * judge: it propagates, as it does out of `deletePackage`, and the + * uninstall does not proceed. + */ + private askUninstallGuards = async ( + c: any, + ctx: PluginContext, + manifestId: string, + actor: string, + ): Promise => { + let protocol: UninstallCleanupRunner | undefined; + try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } + if (typeof protocol?.runUninstallGuards !== 'function') return undefined; + try { + await protocol.runUninstallGuards({ packageId: manifestId, ...(actor ? { actor } : {}) }); + return undefined; + } catch (err: any) { + const status = err?.status; + if (typeof err?.code === 'string' && typeof status === 'number' && status >= 400 && status < 500) { + return c.json({ success: false, error: { code: err.code, message: String(err.message) } }, status); + } + throw err; + } + }; + + /** + * [#31283] Write the package's `package_uninstall` ledger row through the + * protocol's one writer of it (`recordPackageUninstall`). No `protocol` + * service, or a protocol from a build without the writer, has no row to + * write; the writer itself never throws. + */ + private recordUninstall = async ( + ctx: PluginContext, + manifestId: string, + version: string | undefined, + actor: string, + ): Promise => { + let protocol: UninstallCleanupRunner | undefined; + try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } + if (typeof protocol?.recordPackageUninstall !== 'function') return; + await protocol.recordPackageUninstall({ + packageId: manifestId, + ...(version ? { version } : {}), + ...(actor ? { actor } : {}), + itemsRemoved: 0, + itemsFailed: 0, + }); + }; + /** * [#21490] Run the protocol's registered uninstall cleanups for a package * this door just removed from its ledger — ADR-0086 D3's data-plane diff --git a/packages/cloud-connection/src/marketplace-install-local-uninstall-guards-audit.test.ts b/packages/cloud-connection/src/marketplace-install-local-uninstall-guards-audit.test.ts new file mode 100644 index 00000000000..d75b782b2a7 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-uninstall-guards-audit.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #31283 — install-local's own uninstall door + * (`DELETE /api/v1/marketplace/install-local/:manifestId`) asks the uninstall + * verb's pre-uninstall guards before its first write, and writes the verb's + * `package_uninstall` audit row after a completed uninstall — through the + * protocol's public entry points, so there is one verdict and one writer. + * + * ## What was wrong + * + * The door withdraws the package itself and then runs the protocol's uninstall + * cleanups (#21490), but never goes through `deletePackage`: it does not fit + * this door (it answers `success: false` for a package with no stored rows and + * drops the tables of the objects it removes). So it missed what the verb does + * before and after the delete: the pre-uninstall guards (#31232 — the runtime's + * dependent-rule refusal: an active validation rule of another enabled package + * reads a field this package adds through `objectExtensions`), and the + * package's own ledger row (#31186). Measured before the fix on this rig: the + * uninstall answered `200`, the field left the served object, and no audit + * event was written. + * + * ## What is real here + * + * An `ObjectKernel` with `ObjectQLPlugin` — the real `SchemaRegistry` and the + * real `ObjectStackProtocolImplementation` it registers as `protocol` — and the + * runtime's `AppPlugin`, whose `start()` registers the dependent-rule guard on + * that protocol (the guard every caller of the verb meets). The `audit` slot is + * a recorder of the events the protocol hands it (plugin-audit turns them into + * rows, pinned there). The packages are registered the way the runtime's own + * #30999 rig registers them; the ledger entries are written the way an install + * writes them. + * + * ## What these cases pin + * + * - an uninstall of a package whose extension field an active rule of another + * package reads: the verb's `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` + * and its sentence, and nothing moved — the ledger entry, the registration, + * the served field, no cleanup run, no audit event; + * - CONTROL: a package whose field no rule reads uninstalls, and the protocol + * hands the audit slot exactly one `package_uninstall` event naming it, its + * version and the operator. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +// The plugin reaches `@objectstack/runtime` through a lazy `import()`; its first +// load is paid here, at module top, never inside a clocked `it`. +import { AppPlugin, formatPackageDisableDependents, readPackageDisableDependents } from '@objectstack/runtime'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { LocalManifestSource } from './local-manifest-source.js'; +import { INSTALLER_USER_ID, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; + +type Handler = (c: any) => Promise; + +const ROUTE = '/api/v1/marketplace/install-local'; +/** The package owning the object the others extend. */ +const JOBS = 'com.acme.jobs'; +/** The package under uninstall: it adds `acme_tier` to the jobs object. */ +const TIERS = 'com.acme.tiers'; +/** CONTROL: it adds `acme_note`, which no rule reads. */ +const NOTES = 'com.acme.notes'; +/** The package whose active validation rule reads `acme_tier`. */ +const READER = 'com.acme.reader'; +const JOB = 'acme_job'; +const TIER = 'acme_tier'; +const NOTE = 'acme_note'; +const RULE = 'acme_tier_known'; +const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + +let dir: string; +const kernels: ObjectKernel[] = []; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-uninstall-guards-')); }); +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** A ledger entry as an install writes it. */ +function ledger(manifestId: string) { + new LocalManifestSource(dir).write({ + packageId: manifestId, + versionId: 'v1', + manifestId, + version: '1.0.0', + manifest: { id: manifestId, name: manifestId, version: '1.0.0', type: 'module' }, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: INSTALLER_USER_ID, + withSampleData: false, + }); +} +const ledgered = (manifestId: string) => new LocalManifestSource(dir).read(manifestId).entry !== null; + +/** The jobs object's served field names. */ +const servedFields = (ql: ObjectQL) => Object.keys((ql.registry.getObject(JOB) as any)?.fields ?? {}); + +/** The real engine, protocol and dependent-rule guard; the four packages registered; TIERS and NOTES ledgered. */ +async function boot() { + const auditEvents: unknown[] = []; + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false } as never); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin()); + await kernel.use({ + name: 'test.audit-slot', + version: '1.0.0', + init: async (ctx: any) => { + ctx.registerService('audit', { + recordPackageLifecycleEvent: async (event: unknown) => { auditEvents.push(event); }, + }); + }, + start: async () => undefined, + } as never); + // An empty app: its `start()` registers the dependent-rule guard on the protocol. + await kernel.use(new AppPlugin({})); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + const protocol = kernel.getService('protocol'); + + for (const id of [JOBS, TIERS, NOTES, READER]) { + ql.registry.installPackage({ id, name: id, version: '1.0.0', type: 'module' } as any, 'authored'); + } + ql.registry.registerObject({ name: JOB, label: 'Job', fields: { name: { name: 'name', type: 'text', label: 'Name' } } } as any, JOBS); + ql.registry.registerObject({ name: JOB, fields: { [TIER]: { name: TIER, type: 'text', label: 'Tier' } } } as any, TIERS, undefined, 'extend'); + ql.registry.registerObject({ name: JOB, fields: { [NOTE]: { name: NOTE, type: 'text', label: 'Note' } } } as any, NOTES, undefined, 'extend'); + ql.registry.registerObject({ + name: JOB, + validations: [{ name: RULE, type: 'script', severity: 'error', message: 'Unknown tier', condition: `record.${TIER} == 'unknown'` }], + } as any, READER, undefined, 'extend'); + ledger(TIERS); + ledger(NOTES); + + const routes = new Map(); + const rawApp = { + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; + const hooks = new Map Promise>(); + const services: Record = { + manifest: kernel.getService('manifest'), + auth: installerAuthService(), + protocol, + objectql: withInstallerGrants({ registry: ql.registry, syncSchemas: async () => undefined }), + }; + const ctx = { + hook: (e: string, h: () => Promise) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (!(name in services)) throw new Error(`no ${name}`); + return services[name]; + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + trigger: async () => undefined, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + // The `kernel:ready` rehydrate is not what is pinned: the packages are registered above. + (plugin as unknown as Record).rehydrate = async () => undefined; + await plugin.start(ctx as never); + await hooks.get('kernel:ready')?.(); + + const uninstall = async (manifestId: string) => { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return routes.get(`DELETE ${ROUTE}/:manifestId`)!({ + req: { + url: `http://localhost:3000${ROUTE}/${manifestId}`, + raw: new Request(`http://localhost:3000${ROUTE}/${manifestId}`, { method: 'DELETE' }), + json: async () => ({}), + param: (name: string) => (name === 'manifestId' ? manifestId : undefined), + }, + json, + }); + }; + return { ql, protocol, auditEvents, uninstall }; +} + +describe('install-local uninstall asks the uninstall verb\'s guards and writes its audit row (#31283)', () => { + it('a package whose extension field an active rule of another package reads is refused with the verb\'s 409; nothing moves', async () => { + const { ql, protocol, auditEvents, uninstall } = await boot(); + const runCleanups = vi.spyOn(protocol, 'runUninstallCleanups'); + const expected = formatPackageDisableDependents( + `Package '${TIERS}' cannot be uninstalled`, + readPackageDisableDependents(ql.registry, TIERS), + { operation: 'uninstall' }, + ); + expect(expected, 'precondition: the rule depends on the field').toContain(RULE); + + const res = await uninstall(TIERS); + + expect(res.status, JSON.stringify(res.payload)).toBe(409); + expect(res.payload.success).toBe(false); + expect(res.payload.error.code).toBe(CODE); + expect(res.payload.error.message).toBe(expected); + // Nothing moved. + expect(ledgered(TIERS)).toBe(true); + expect(ql.registry.getPackage(TIERS)).toBeDefined(); + expect(servedFields(ql)).toEqual(expect.arrayContaining([TIER, NOTE])); + expect(runCleanups).not.toHaveBeenCalled(); + expect(auditEvents).toEqual([]); + }, 60_000); + + it('CONTROL — a package whose field no rule reads uninstalls and the protocol records one package_uninstall event', async () => { + const { ql, auditEvents, uninstall } = await boot(); + + const res = await uninstall(NOTES); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.success).toBe(true); + expect(ledgered(NOTES)).toBe(false); + expect(ql.registry.getPackage(NOTES)).toBeUndefined(); + expect(auditEvents).toEqual([{ + action: 'package_uninstall', + packageId: NOTES, + version: '1.0.0', + actor: INSTALLER_USER_ID, + itemsRemoved: 0, + itemsFailed: 0, + }]); + }, 60_000); +}); diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index c376e2c3a73..1ebe4bbe879 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -135,7 +135,7 @@ export type { SeedTenancySplit, SeedTenancyCollision, } from './migrations/seed-tenancy-backfill.js'; -export type { UninstallCleanup, UninstallCleanupOutcome, UninstallDeclared, UninstallGuard, UninstallGuardRefusal } from './protocol.js'; +export type { PackageUninstallRecord, UninstallCleanup, UninstallCleanupOutcome, UninstallDeclared, UninstallGuard, UninstallGuardRefusal } from './protocol.js'; export { captureUninstallDeclared } from './protocol.js'; // [#9960] The ONE declared shape of the `deletePackage` seam, exported so the // two consumers that speak it (`@objectstack/rest`'s direct-mount package diff --git a/packages/metadata-protocol/src/protocol-package-lifecycle-types.ts b/packages/metadata-protocol/src/protocol-package-lifecycle-types.ts index ad1108d2be0..7daf05e125d 100644 --- a/packages/metadata-protocol/src/protocol-package-lifecycle-types.ts +++ b/packages/metadata-protocol/src/protocol-package-lifecycle-types.ts @@ -85,6 +85,23 @@ export type UninstallGuard = (args: { actor?: string; }) => UninstallGuardRefusal | undefined | Promise; +/** + * [#31186, #31283] What an uninstall door states about the package it + * uninstalled, for the package's own `package_uninstall` ledger row + * ({@link ObjectStackProtocolImplementation.recordPackageUninstall}): the + * package's id, the version it removed, the operator, whether its object + * tables were kept, and how many of its stored `sys_metadata` items were + * removed and failed to be. The environment is the protocol's own. + */ +export interface PackageUninstallRecord { + packageId: string; + version?: string; + actor?: string; + keepData?: boolean; + itemsRemoved: number; + itemsFailed: number; +} + /** * [ADR-0090 D5, ADR-0131 D4] The security-catalog names an uninstalled package * held. A grant names its set, and the package's sets are served from the diff --git a/packages/metadata-protocol/src/protocol-package-uninstall.ts b/packages/metadata-protocol/src/protocol-package-uninstall.ts index bffdf746785..dd04786c7f8 100644 --- a/packages/metadata-protocol/src/protocol-package-uninstall.ts +++ b/packages/metadata-protocol/src/protocol-package-uninstall.ts @@ -33,7 +33,7 @@ import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { declaredUserMessage } from '@objectstack/types'; -import type { DeletePackageRequest, DeletePackageResponse, ObjectStackProtocolImplementation } from './protocol.js'; +import type { DeletePackageRequest, DeletePackageResponse, ObjectStackProtocolImplementation, PackageUninstallRecord } from './protocol.js'; import { canonicalMetaType, captureUninstallDeclared, metadataReadFailureError, packagePersistFailureError, remainingRowsPhrase } from './protocol.js'; /** @@ -101,7 +101,7 @@ export async function deletePackage(this: Pick = { package_id: request.packageId }; // [#8136] This read is the uninstall's FIRST database touch, and until @@ -385,16 +385,16 @@ export async function deletePackage(this: Pick 0 || failed.length > 0) { - await recordPackageUninstall(this.getServicesRegistry?.()?.get('audit'), { - action: 'package_uninstall', + await recordPackageUninstall(this, { packageId: request.packageId, ...(installed.version ? { version: installed.version } : {}), - ...(this.environmentId ? { environmentId: this.environmentId } : {}), ...(request.actor ? { actor: request.actor } : {}), ...(request.keepData === true ? { keepData: true } : {}), itemsRemoved: deleted.length, @@ -412,6 +412,31 @@ export async function deletePackage(this: Pick, + request: Pick, +): Promise { + await askUninstallGuards(host.uninstallGuards, request); +} + +/** The body of `ObjectStackProtocolImplementation#recordPackageUninstall`; the method's docblock there is its contract. */ +export async function recordPackageUninstall( + host: Pick, + record: PackageUninstallRecord, +): Promise { + await writePackageUninstallEvent(host.getServicesRegistry?.()?.get('audit'), { + action: 'package_uninstall', + packageId: record.packageId, + ...(record.version ? { version: record.version } : {}), + ...(host.environmentId ? { environmentId: host.environmentId } : {}), + ...(record.actor ? { actor: record.actor } : {}), + ...(record.keepData === true ? { keepData: true } : {}), + itemsRemoved: record.itemsRemoved, + itemsFailed: record.itemsFailed, + }); +} + /** * [#31232] Ask every registered pre-uninstall guard, in registration order, * and throw the first refusal one answers: an `Error` carrying the guard's @@ -420,7 +445,7 @@ export async function deletePackage(this: Pick, ): Promise { for (const guard of guards.values()) { const refusal = await guard({ @@ -476,7 +501,7 @@ function readInstalledPackage(registry: unknown, packageId: string): { held: boo * slot that does anyway is a foreign writer, and the uninstall it reports on * has already completed, so its failure is logged, never answered. */ -async function recordPackageUninstall(audit: unknown, event: PackageUninstallAuditEvent): Promise { +async function writePackageUninstallEvent(audit: unknown, event: PackageUninstallAuditEvent): Promise { const record = (audit as PackageLifecycleAuditSurface | undefined)?.recordPackageLifecycleEvent; if (typeof record !== 'function') return; try { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a929214cd72..57999bff853 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -383,7 +383,7 @@ import { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagel // [#25467 cut 35] The stored rows of a collection, folded over the registered ones. import { foldStoredCollection } from './protocol-stored-collection-fold.js'; // [#25467 cut 35] The environment-wide package uninstall verb. -import { deletePackage } from './protocol-package-uninstall.js'; +import { deletePackage, recordPackageUninstall, runUninstallGuards } from './protocol-package-uninstall.js'; // ADR-0146 D3 — the ensure door of a base's paired `.custom` package, and the // pair-shaped-id predicate the duplicate asks before it reads a row. import { ensureCustomizationPackage } from './protocol-customization-package.js'; @@ -522,6 +522,7 @@ export type { MetadataWriteFace } from './protocol-write-face.js'; import type { DeletePackageRequest, DeletePackageResponse, + PackageUninstallRecord, PublishMaterializeResult, PublishMaterializer, UninstallCleanup, @@ -533,6 +534,7 @@ export { captureUninstallDeclared } from './protocol-package-lifecycle-types.js' export type { DeletePackageRequest, DeletePackageResponse, + PackageUninstallRecord, PublishMaterializeResult, PublishMaterializer, UninstallCleanup, @@ -1441,6 +1443,45 @@ export class ObjectStackProtocolImplementation implements return ProtocolClassHeadFacet.registerUninstallGuard(protocolHost(this), name, guard); } + /** + * [#31283] Ask every registered pre-uninstall guard + * ({@link registerUninstallGuard}) about one package, in registration + * order, and throw the first refusal one answers — an `Error` carrying the + * guard's `message`, with its `code` and `status` — exactly as + * {@link deletePackage} asks them at its first step: THE one runner of the + * guard registry, which the verb calls too. A guard's own throw propagates + * as is. Resolves when no guard refuses (and at once when none is + * registered). Reads only: nothing is read from the store or moved. + * + * For a door that uninstalls a package the protocol never stored and so + * cannot delegate to {@link deletePackage} — install-local's + * `DELETE /api/v1/marketplace/install-local/:manifestId`, the reason + * {@link runUninstallCleanups} exists — asked before that door's first + * write, so every uninstall door answers the guards' verdict, and no door + * holds a copy of it. + */ + async runUninstallGuards(request: Pick): Promise { + return runUninstallGuards(protocolHost(this), request); + } + + /** + * [#31283] Write the package's own `package_uninstall` ledger row + * (#31186): hand the `audit` slot's `recordPackageLifecycleEvent` one + * event naming the package, the version removed, this protocol's + * environment and the operator — THE one writer of that row, which + * {@link deletePackage} calls as its last step. A host with no audit + * plugin has no ledger, so nothing is written; an audit slot that throws + * is logged, never thrown, since the uninstall it reports on has already + * completed. + * + * For a door that uninstalls a package without {@link deletePackage} + * (install-local's own uninstall door), called once, after that door's + * uninstall completed and never after a refusal. + */ + async recordPackageUninstall(record: PackageUninstallRecord): Promise { + return recordPackageUninstall(protocolHost(this), record); + } + /** * Run every registered uninstall cleanup for one package and report each * outcome (ADR-0086 D3, #2747) — THE one runner of the registry above. diff --git a/packages/metadata-protocol/src/protocol.uninstall-guards.test.ts b/packages/metadata-protocol/src/protocol.uninstall-guards.test.ts index e3ed87b3c3c..d436476ca42 100644 --- a/packages/metadata-protocol/src/protocol.uninstall-guards.test.ts +++ b/packages/metadata-protocol/src/protocol.uninstall-guards.test.ts @@ -132,3 +132,56 @@ describe('#31232: deletePackage asks every registered pre-uninstall guard before expect(res).toMatchObject({ success: false, deletedCount: 0, failedCount: 0 }); }); }); + +/** + * #31283 — the two public entry points an uninstall door that cannot delegate + * to `deletePackage` (install-local's) calls: `runUninstallGuards`, the runner + * the verb itself asks at its first step, and `recordPackageUninstall`, the + * writer of the `package_uninstall` row the verb calls at its last. The door's + * half is pinned in `@objectstack/cloud-connection` + * (`marketplace-install-local-uninstall-guards-audit.test.ts`). + */ +describe('#31283: the guard runner and the audit writer, reachable without deletePackage', () => { + it('runUninstallGuards throws the first refusal as the verb does, hands the guard the actor, and reads or moves nothing', async () => { + const rig = makeProtocol(); + const guard = vi.fn(() => ({ code: CODE, status: 409, message: 'a rule reads its field' })); + rig.protocol.registerUninstallGuard('qa.refuses', guard); + + await expect(rig.protocol.runUninstallGuards({ packageId: PACKAGE_ID, actor: 'usr_1' })) + .rejects.toMatchObject({ code: CODE, status: 409, message: 'a rule reads its field' }); + + expect(guard.mock.calls).toEqual([[{ packageId: PACKAGE_ID, actor: 'usr_1' }]]); + expect(touched(rig)).toEqual(NOTHING); + }); + + it('control: runUninstallGuards resolves when no guard refuses, and on a bare protocol', async () => { + const rig = makeProtocol(); + await expect(rig.protocol.runUninstallGuards({ packageId: PACKAGE_ID })).resolves.toBeUndefined(); + rig.protocol.registerUninstallGuard('qa.passes', () => undefined); + await expect(rig.protocol.runUninstallGuards({ packageId: PACKAGE_ID })).resolves.toBeUndefined(); + expect(touched(rig)).toEqual(NOTHING); + }); + + it('recordPackageUninstall hands the audit slot one package_uninstall event, with the protocol\'s environment', async () => { + const events: unknown[] = []; + const services = new Map([['audit', { recordPackageLifecycleEvent: async (e: unknown) => { events.push(e); } }]]); + const protocol = new ObjectStackProtocolImplementation({ registry: {} } as never, () => services as never, 'env_31283'); + + await protocol.recordPackageUninstall({ packageId: PACKAGE_ID, version: '1.2.0', actor: 'usr_1', itemsRemoved: 0, itemsFailed: 0 }); + + expect(events).toEqual([{ + action: 'package_uninstall', + packageId: PACKAGE_ID, + version: '1.2.0', + environmentId: 'env_31283', + actor: 'usr_1', + itemsRemoved: 0, + itemsFailed: 0, + }]); + }); + + it('control: recordPackageUninstall writes nothing and does not throw with no audit slot', async () => { + const protocol = new ObjectStackProtocolImplementation({ registry: {} } as never, () => new Map() as never); + await expect(protocol.recordPackageUninstall({ packageId: PACKAGE_ID, itemsRemoved: 0, itemsFailed: 0 })).resolves.toBeUndefined(); + }); +});