diff --git a/.changeset/uninstall-permission-cleanup.md b/.changeset/uninstall-permission-cleanup.md new file mode 100644 index 0000000000..b06f62f86f --- /dev/null +++ b/.changeset/uninstall-permission-cleanup.md @@ -0,0 +1,34 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/plugin-security": minor +"@objectstack/rest": patch +--- + +Package uninstall now revokes the package's data-plane permission rows (#2747, ADR-0086 D3 / ADR-0090 D5 "no ghost grants"). + +**`@objectstack/metadata-protocol`**: `deletePackage` gains an +uninstall-cleanup seam — the exact mirror of the publish materializer: +domain plugins register named cleanups via `registerUninstallCleanup(name, +fn)` and every cleanup runs with the uninstalled package id, its outcome +reported on the new `cleanups` array of the response (a failed revocation is +visible, never silent). `deletePackage` also unregisters the package from +the in-memory SchemaRegistry (best-effort), so the running kernel stops +serving it without waiting for a restart. + +**`@objectstack/plugin-security`**: registers the +`security.package-permissions` cleanup — deletes the package's own +`sys_permission_set` rows (`managed_by: 'package'` + matching `package_id` +only; env-authored and foreign-package rows are never touched, ADR-0086 D4), +their `sys_position_permission_set` / `sys_user_permission_set` bindings +(bindings first, so no dangling grants), and the package's +`sys_audience_binding_suggestion` rows (a reinstall re-prompts fresh). +Also fixes the engine-call signature in the suggestion module: `find`/`delete` +read `context` from their second argument — the previous trailing +`{ context }` argument was ignored, so deletes ran principal-less. + +**`@objectstack/rest`**: `DELETE /api/v1/packages/:id` (no version pin) now +goes through `protocol.deletePackage` — one uninstall semantic instead of a +bare `sys_packages` row delete — removing the package's metadata, durable +record, registry entry, and running the cleanups; the response carries +`deletedCount` + `cleanups`. A version-scoped delete keeps the narrow +durable-registry semantics. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 4eb0527336..f82db373f2 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -193,7 +193,11 @@ A package ships its own sets (`managedBy: 'package'` + `packageId`), seeded idempotently at boot and re-seeded on upgrade; environment-authored sets (`platform`/`user`) are never clobbered. The data layer refuses admin-door writes to package rows (two-doors separation), which is what makes package -uninstall well-defined. +uninstall well-defined — and enforced: uninstalling a package +(`DELETE /api/v1/packages/:id`) revokes its own sets, their position/user +bindings, and its pending audience-binding suggestions in the same request +(no ghost grants); the uninstall response reports the revocation under +`cleanups`. Environment-authored sets and other packages' rows survive. --- diff --git a/packages/metadata-protocol/src/durable-package.test.ts b/packages/metadata-protocol/src/durable-package.test.ts index 61d7815d95..f4be7aac12 100644 --- a/packages/metadata-protocol/src/durable-package.test.ts +++ b/packages/metadata-protocol/src/durable-package.test.ts @@ -77,3 +77,43 @@ describe('deletePackage — durable un-registration (#2532 counterpart)', () => expect(del).toHaveBeenCalledWith('com.example.orders'); }); }); + +describe('deletePackage — uninstall cleanups (#2747)', () => { + it('invokes registered cleanups with the package id and reports outcomes', async () => { + const { impl } = makeImpl(); + const cleanup = vi.fn(async () => ({ success: true, removed: 3 })); + (impl as any).registerUninstallCleanup('security.package-permissions', cleanup); + + const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders', actor: 'usr_1' }); + + expect(cleanup).toHaveBeenCalledWith(expect.objectContaining({ packageId: 'com.example.orders', actor: 'usr_1' })); + expect(res.cleanups).toEqual([ + { name: 'security.package-permissions', success: true, removed: 3 }, + ]); + }); + + it('reports a throwing cleanup as failed instead of aborting the uninstall', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { impl } = makeImpl(); + (impl as any).registerUninstallCleanup('boom', async () => { throw new Error('db down'); }); + const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders' }); + expect(res.cleanups).toEqual([ + { name: 'boom', success: false, removed: 0, error: 'db down' }, + ]); + } finally { + warn.mockRestore(); + } + }); + + it('re-registration under the same name replaces (idempotent re-init)', async () => { + const { impl } = makeImpl(); + const first = vi.fn(async () => ({ success: true, removed: 1 })); + const second = vi.fn(async () => ({ success: true, removed: 2 })); + (impl as any).registerUninstallCleanup('x', first); + (impl as any).registerUninstallCleanup('x', second); + const res: any = await (impl as any).deletePackage({ packageId: 'p' }); + expect(first).not.toHaveBeenCalled(); + expect(res.cleanups[0].removed).toBe(2); + }); +}); diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 718a264f41..9b17cc5a0a 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js'; +export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; export type { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index bcfc401076..cb7fd814e1 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -720,6 +720,31 @@ export type PublishMaterializer = (args: { actor: string; }) => Promise; +/** + * Uninstall-time data-plane cleanup (ADR-0086 D3, #2747). The exact mirror of + * {@link PublishMaterializer}: domain plugins own data-plane tables the + * protocol layer must not know the shape of (e.g. plugin-security's + * `sys_permission_set` and its binding tables), so they register a named + * cleanup here and {@link ObjectStackProtocolImplementation.deletePackage} + * invokes every cleanup with the uninstalled package id. Cleanups run + * best-effort — a failure is REPORTED on the uninstall response (`cleanups`), + * never thrown — but ghost grants are a security condition, so callers must + * surface a failed cleanup, not swallow it. + */ +export type UninstallCleanup = (args: { + packageId: string; + organizationId?: string; + actor?: string; +}) => Promise<{ success: boolean; removed: number; error?: string }>; + +/** Per-cleanup outcome reported on the `deletePackage` response. */ +export interface UninstallCleanupOutcome { + name: string; + success: boolean; + removed: number; + error?: string; +} + /** * Post-persistence metadata-mutation notification (#2588). Emitted by * `saveMetaItem` / `publishMetaItem` / `deleteMetaItem` AFTER the write @@ -769,6 +794,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { */ private publishMaterializers = new Map(); + /** [#2747] Named uninstall cleanups, run by {@link deletePackage}. */ + private uninstallCleanups = new Map(); + constructor( engine: IDataEngine, getServicesRegistry?: () => Map, @@ -793,6 +821,18 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { this.publishMaterializers.set(singular, materializer); } + /** + * Register a named uninstall-time data-plane cleanup (ADR-0086 D3, #2747). + * Called by domain plugins at init — e.g. plugin-security registers the + * cleanup that removes its package-owned `sys_permission_set` rows and + * their bindings when the owning package is uninstalled, so grants are + * revoked everywhere at once (no ghost grants). One cleanup per name; a + * second registration replaces the first (idempotent re-init). + */ + registerUninstallCleanup(name: string, cleanup: UninstallCleanup): void { + this.uninstallCleanups.set(name, cleanup); + } + /** * Runtime-mutation listeners (#2588). Every metadata mutation that lands * through this protocol — `saveMetaItem` (draft AND direct-active saves), @@ -4716,6 +4756,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { failedCount: number; deleted: Array<{ type: string; name: string; state: string }>; failed: Array<{ type: string; name: string; error: string; code?: string }>; + cleanups: UninstallCleanupOutcome[]; }> { const where: Record = { package_id: request.packageId }; if (request.organizationId) where.organization_id = request.organizationId; @@ -4766,12 +4807,56 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { ); } + // [#2747] Unregister from the in-memory SchemaRegistry too, so the + // running kernel stops serving the package without waiting for a + // restart. Best-effort: the HTTP dispatcher already unregisters + // before calling us (second call is a no-op warn), and a package + // with live extenders refuses unregistration — that failure is + // logged, not fatal (the durable row is gone, so the next boot is + // clean either way). + try { + (this.engine as any)?.registry?.uninstallPackage?.(request.packageId); + } catch (e) { + console.warn( + `[protocol.deletePackage] registry unregistration skipped for '${request.packageId}': ${(e as Error)?.message}`, + ); + } + + // [#2747] Data-plane cleanups registered by domain plugins (mirror of + // the publish materializers): revoke what the package's metadata + // granted — e.g. plugin-security removes its package-owned + // sys_permission_set rows and their bindings. Best-effort per cleanup; + // outcomes ride on the response so a failed revocation (ghost grants — + // a security condition) is visible to the caller, never silent. + const cleanups: UninstallCleanupOutcome[] = []; + for (const [name, cleanup] of this.uninstallCleanups) { + try { + const r = await cleanup({ + packageId: request.packageId, + ...(request.organizationId ? { organizationId: request.organizationId } : {}), + ...(request.actor ? { actor: request.actor } : {}), + }); + cleanups.push({ + name, + success: r?.success !== false, + removed: typeof r?.removed === 'number' ? r.removed : 0, + ...(r?.error ? { error: r.error } : {}), + }); + } catch (e: any) { + cleanups.push({ name, success: false, removed: 0, error: e?.message ?? 'cleanup failed' }); + console.warn( + `[protocol.deletePackage] uninstall cleanup '${name}' failed for '${request.packageId}': ${e?.message}`, + ); + } + } + return { success: failed.length === 0 && deleted.length > 0, deletedCount: deleted.length, failedCount: failed.length, deleted, failed, + cleanups, }; } diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts new file mode 100644 index 0000000000..73c224434b --- /dev/null +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// #2747 — uninstall-time revocation of a package's data-plane permission rows +// (ADR-0086 D3 provenance consumed at last; ADR-0090 D5 "no ghost grants"). + +import { describe, it, expect } from 'vitest'; +import { cleanupPackagePermissions } from './cleanup-package-permissions'; + +function makeQl() { + const tables: Record = { + sys_permission_set: [ + // the uninstalled package's own rows + { id: 'ps_crm_ro', name: 'crm_readonly', package_id: 'com.example.crm', managed_by: 'package' }, + { id: 'ps_crm_admin', name: 'crm_admin', package_id: 'com.example.crm', managed_by: 'package' }, + // another package's row — must survive + { id: 'ps_other', name: 'other_set', package_id: 'com.other', managed_by: 'package' }, + // env-authored rows — must survive even if a package_id is present + { id: 'ps_env', name: 'member_default', managed_by: 'user' }, + { id: 'ps_legacy', name: 'admin_full_access' }, + ], + sys_position_permission_set: [ + { id: 'pps_1', position_id: 'pos_everyone', permission_set_id: 'ps_crm_ro' }, + { id: 'pps_2', position_id: 'pos_sales', permission_set_id: 'ps_crm_admin' }, + { id: 'pps_other', position_id: 'pos_sales', permission_set_id: 'ps_other' }, + ], + sys_user_permission_set: [ + { id: 'ups_1', user_id: 'u1', permission_set_id: 'ps_crm_admin' }, + { id: 'ups_env', user_id: 'u1', permission_set_id: 'ps_env' }, + ], + sys_audience_binding_suggestion: [ + { id: 'sug_1', package_id: 'com.example.crm', permission_set_name: 'crm_readonly', anchor: 'everyone', status: 'confirmed' }, + { id: 'sug_2', package_id: 'com.example.crm', permission_set_name: 'crm_admin', anchor: 'everyone', status: 'dismissed' }, + { id: 'sug_other', package_id: 'com.other', permission_set_name: 'other_set', anchor: 'everyone', status: 'pending' }, + ], + }; + return { + tables, + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + return (tables[object] ?? []).filter((r) => + Object.entries(where).every(([k, v]) => (r as any)[k] === v), + ); + }, + async delete(object: string, opts: any) { + const id = opts?.where?.id; + const t = tables[object] ?? []; + const i = t.findIndex((r) => r.id === id); + if (i >= 0) t.splice(i, 1); + return true; + }, + } as any; +} + +describe('cleanupPackagePermissions (#2747)', () => { + it('removes the package-owned sets, their bindings/grants, and its suggestion rows', async () => { + const ql = makeQl(); + const out = await cleanupPackagePermissions(ql, 'com.example.crm'); + + expect(out).toEqual({ sets: 2, positionBindings: 2, userGrants: 1, suggestions: 2 }); + + // no ghost grants: nothing referencing the removed sets survives + expect(ql.tables.sys_permission_set.map((r: any) => r.id)).toEqual(['ps_other', 'ps_env', 'ps_legacy']); + expect(ql.tables.sys_position_permission_set.map((r: any) => r.id)).toEqual(['pps_other']); + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env']); + expect(ql.tables.sys_audience_binding_suggestion.map((r: any) => r.id)).toEqual(['sug_other']); + }); + + it('never touches env-authored or foreign-package rows (ADR-0086 D4 provenance)', async () => { + const ql = makeQl(); + await cleanupPackagePermissions(ql, 'com.example.crm'); + const names = ql.tables.sys_permission_set.map((r: any) => r.name); + expect(names).toContain('other_set'); + expect(names).toContain('member_default'); + expect(names).toContain('admin_full_access'); + }); + + it('is idempotent — a second run removes nothing', async () => { + const ql = makeQl(); + await cleanupPackagePermissions(ql, 'com.example.crm'); + const second = await cleanupPackagePermissions(ql, 'com.example.crm'); + expect(second).toEqual({ sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0 }); + }); + + it('no-ops safely on a missing package id or a non-engine handle', async () => { + expect(await cleanupPackagePermissions(makeQl(), '')).toEqual({ sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0 }); + expect(await cleanupPackagePermissions(null, 'x')).toEqual({ sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0 }); + }); +}); diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts new file mode 100644 index 0000000000..be8fac5e00 --- /dev/null +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * cleanupPackagePermissions — uninstall-time revocation of a package's + * data-plane permission rows (ADR-0086 D3, #2747). + * + * ADR-0090 D5 promises: "uninstalling the package (removing its sets by + * `packageId`) revokes it everywhere at once. No ghost grants." The + * `package_id`/`managed_by` provenance columns (and the `package_id` index on + * `sys_permission_set`) exist precisely for this query; this module is the + * wiring that consumes them. Registered with the protocol's uninstall-cleanup + * seam (the mirror of the publish materializer) so `deletePackage` triggers it + * without the protocol layer learning `sys_permission_set`'s shape. + * + * Scope — provenance rules identical to the seeder's (ADR-0086 D4): + * - ONLY rows `managed_by: 'package'` with `package_id` = the uninstalled + * package are touched. Env-authored sets (`platform`/`user`/absent) and + * other packages' sets are never removed, even on a name collision. + * - Bindings referencing a removed set (`sys_position_permission_set`, + * `sys_user_permission_set`) are deleted first, so no dangling grant rows + * survive and re-resolution never sees a half-removed state. + * - `sys_audience_binding_suggestion` rows for the package are removed in + * every status: with the sets gone, confirmed/dismissed history points at + * nothing, and a fresh reinstall should re-prompt (D5 — admin confirms). + * + * System-context writes: uninstall is a package-door operation, exactly like + * the boot seeder — the admin already authorized it by uninstalling. + */ + +const SYSTEM_CTX = { isSystem: true }; + +// Engine signatures: `find(object, query)` and `delete(object, options)` both +// read `context` from their SECOND argument — a trailing `{ context }` arg is +// silently ignored, which turns a system write into a principal-less one that +// the D12 gate correctly fails CLOSED on the governed RBAC tables. +async function tryFind(ql: any, object: string, where: any, limit = 1000): Promise { + try { + const rows = await ql.find(object, { where, limit, context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : []; + } catch { return []; } +} + +/** Delete rows by id one at a time; returns how many were removed. */ +async function deleteRows(ql: any, object: string, rows: any[]): Promise { + let removed = 0; + for (const row of rows) { + if (!row?.id) continue; + try { + await ql.delete(object, { where: { id: row.id }, context: SYSTEM_CTX }); + removed += 1; + } catch { /* per-row best-effort; count reflects reality */ } + } + return removed; +} + +export interface PackagePermissionCleanupOutcome { + /** Package-owned sys_permission_set rows removed. */ + sets: number; + /** sys_position_permission_set rows referencing those sets. */ + positionBindings: number; + /** sys_user_permission_set rows referencing those sets. */ + userGrants: number; + /** sys_audience_binding_suggestion rows for the package (any status). */ + suggestions: number; +} + +export async function cleanupPackagePermissions( + ql: any, + packageId: string, + logger?: { info?: (m: string, meta?: any) => void; warn?: (m: string, meta?: any) => void }, +): Promise { + const out: PackagePermissionCleanupOutcome = { sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0 }; + if (!ql || typeof ql.find !== 'function' || typeof ql.delete !== 'function' || !packageId) return out; + + // Provenance-scoped: only the package door's own rows (ADR-0086 D4). + // `managed_by` is filtered in JS — a multi-column where on the readonly + // provenance columns doesn't match through the engine's query layer + // (verified empirically), while the single-column package_id filter does. + const sets = (await tryFind(ql, 'sys_permission_set', { package_id: packageId })) + .filter((r) => r?.managed_by === 'package'); + + // Bindings first — a set row must never outlive its grants in reverse. + for (const set of sets) { + if (!set?.id) continue; + out.positionBindings += await deleteRows( + ql, 'sys_position_permission_set', + await tryFind(ql, 'sys_position_permission_set', { permission_set_id: set.id }), + ); + out.userGrants += await deleteRows( + ql, 'sys_user_permission_set', + await tryFind(ql, 'sys_user_permission_set', { permission_set_id: set.id }), + ); + } + out.sets = await deleteRows(ql, 'sys_permission_set', sets); + + // Suggestion rows in every status — reinstall re-prompts fresh (D5). + out.suggestions = await deleteRows( + ql, 'sys_audience_binding_suggestion', + await tryFind(ql, 'sys_audience_binding_suggestion', { package_id: packageId }), + ); + + if (out.sets + out.positionBindings + out.userGrants + out.suggestions > 0) { + logger?.info?.('[security] package permission rows revoked on uninstall (#2747)', { + packageId, ...out, + }); + } + return out; +} diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 877a24fab0..613079419e 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -26,6 +26,8 @@ export { } from './auto-org-admin-grant.js'; export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; +export { cleanupPackagePermissions } from './cleanup-package-permissions.js'; +export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissions.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; export { appDefaultPermissionSetName } from './app-default-permission-set.js'; export { DelegatedAdminGate, isTenantAdmin } from './delegated-admin-gate.js'; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b15f8ff20b..8bd93612d6 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -17,6 +17,7 @@ import { type SuggestionDeps, type SuggestionListFilter, } from './suggested-audience-bindings.js'; +import { cleanupPackagePermissions } from './cleanup-package-permissions.js'; import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js'; import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; @@ -1003,6 +1004,23 @@ export class SecurityPlugin implements Plugin { }, ); } + // [#2747] Uninstall counterpart of the materializer above: when the + // owning package is uninstalled, revoke its data-plane permission + // rows (package-owned sets + their position/user bindings + the + // package's suggestion rows) so grants die with the package — the + // "no ghost grants" clause of ADR-0090 D5. + if (protocol && typeof protocol.registerUninstallCleanup === 'function') { + protocol.registerUninstallCleanup( + 'security.package-permissions', + async (args: { packageId: string }) => { + const r = await cleanupPackagePermissions(ql, args.packageId, ctx.logger); + return { + success: true, + removed: r.sets + r.positionBindings + r.userGrants + r.suggestions, + }; + }, + ); + } } catch (e) { ctx.logger.warn('[security] permission publish-materializer registration failed', { error: (e as Error).message }); } diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts index abc03ce399..f1da7a372d 100644 Binary files a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts and b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts differ diff --git a/packages/rest/src/package-routes.ts b/packages/rest/src/package-routes.ts index c5c9eba92d..1ac4dcff2a 100644 --- a/packages/rest/src/package-routes.ts +++ b/packages/rest/src/package-routes.ts @@ -9,9 +9,22 @@ import type { PackageService } from '@objectstack/service-package'; export interface PackageRoutesOptions { /** * Protocol service (ObjectStackProtocol) — provides access to in-memory - * SchemaRegistry packages loaded via defineStack()/AppPlugin at boot time. + * SchemaRegistry packages loaded via defineStack()/AppPlugin at boot time, + * and (#2747) the full `deletePackage` uninstall semantics: package + * metadata rows, the durable `sys_packages` record, and the registered + * data-plane cleanups (e.g. plugin-security revoking the package's + * permission sets and bindings). */ - protocol?: { getMetaItems?(req: { type: string }): Promise<{ items: any[] }> }; + protocol?: { + getMetaItems?(req: { type: string }): Promise<{ items: any[] }>; + deletePackage?(req: { packageId: string; actor?: string }): Promise<{ + success: boolean; + deletedCount: number; + failedCount: number; + failed: Array<{ type: string; name: string; error: string; code?: string }>; + cleanups: Array<{ name: string; success: boolean; removed: number; error?: string }>; + }>; + }; } /** @@ -161,6 +174,31 @@ export function registerPackageRoutes( const packageId = req.params.id; const version = req.query?.version; + // [#2747] A FULL uninstall (no version pin) goes through + // protocol.deletePackage — one uninstall semantic, not three dialects: + // it removes the package's metadata rows, drops the durable + // sys_packages record, and runs the registered data-plane cleanups + // (plugin-security revokes the package's permission sets/bindings — + // no ghost grants). A version-scoped delete keeps the narrow durable + // registry semantics, as does a deployment without the protocol. + if (!version && typeof options.protocol?.deletePackage === 'function') { + const result = await options.protocol.deletePackage({ packageId }); + // Zero metadata rows is still a successful uninstall (e.g. a + // runtime-registered package that never published metadata) — + // only per-item failures make it a failure. + if (result.failedCount === 0) { + res.json({ + success: true, + message: `Deleted ${packageId}`, + deletedCount: result.deletedCount, + cleanups: result.cleanups, + }); + return; + } + res.status(400).json({ success: false, failed: result.failed, cleanups: result.cleanups }); + return; + } + const result = await packageService.delete(packageId, version); if (result.success) {