Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/31283-install-local-uninstall-guards-audit.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed or re-shaped and no stored row is read differently: the narrowing is one runtime refusal at one door, the refusal `DELETE /api/v1/packages/:id` already answers for the same package, and its remedy is the rule it names (switch the rule off or change it, or keep the package installed); the two protocol members are additions, so there is nothing for `objectstack migrate meta` to rewrite. -->

**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.
5 changes: 5 additions & 0 deletions content/docs/deployment/marketplace-handbook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 93 additions & 0 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,17 @@ type UninstallCleanupRunner = {
runUninstallCleanups?(
request: Pick<DeletePackageRequest, 'packageId' | 'actor'> & { declared?: UninstallDeclared },
): Promise<UninstallCleanupOutcome[]>;
/**
* [#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. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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; `
Expand Down Expand Up @@ -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<Response | undefined> => {
let protocol: UninstallCleanupRunner | undefined;
try { protocol = ctx.getService<UninstallCleanupRunner>('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<void> => {
let protocol: UninstallCleanupRunner | undefined;
try { protocol = ctx.getService<UninstallCleanupRunner>('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
Expand Down
Loading
Loading