Skip to content

Commit d1c01ff

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(runtime): DELETE /packages/:id refuses an org-less uninstall before it touches the registry (#20492) (#20514)
Fixes #20492 Clause-②: no ## H0 first: who reaches this door with no organization (measured before any edit) Measured on unmodified `origin/main` `4a1df19656`. The probe drove `DELETE /packages/:id` through `dispatch()` with real identity resolution (`resolveRequestScope` → `resolveExecutionContext` → `resolveAuthzContext`) under an `isolated` posture. It used a registry double that really uninstalls and a protocol double that refuses an org-less `deletePackage` the way the real one does. The probe was throwaway and is not committed. | Arm | Answer | Passes `requireManageMetadata` | Package in the live registry afterwards | |---|---|---|---| | (a1) removed from `org_alpha`, was its org admin (the auto-grant is revoked on removal), still a member of `org_beta` | 403 | no | kept | | (a1') same, but the `organization_admin` auto-grant row survived | 403 | no | kept | | **(a2)** removed from `org_alpha`, held an operator-authored `manage_metadata` set granted **scoped to `org_alpha`**, and that row survived the removal | 400 `TENANT_SCOPE_REQUIRED` | **yes** | **removed** | | **(a2b)** same as (a2), with no membership left anywhere | 400 | **yes** | **removed** | | (a3) removed from `org_alpha`, holds an unscoped `manage_metadata` set (the #20491 rig's shape; a platform-level author) | 400 | yes | removed | | (b1) fresh sign-up with no organization and no grant rows (the implicit `everyone` only) | 403 | no | kept | | (b2) fresh sign-up with no organization; `org_alpha`'s `everyone` position is bound to a `manage_metadata` set (not a default binding) | 400 | yes, but only in the fixture (see notes) | removed | | control: a current `org_alpha` member holding the alpha-scoped set | 200 | yes | removed, as asked | **Readout.** Under the shipped default grants, both populations are refused 403. `organization_admin` withholds `manage_metadata`, and the baseline bound to `everyone` refuses high-privilege bits. **(a2) passes, though.** When the resolver drops the stale claim, it re-resolves grants with no tenant. `resolveUserAuthzGrants`'s permission-set filter keeps every org-scoped grant when no tenant is active, so a grant scoped to the organization the person left still confers `manage_metadata`. On unmodified `main`, that person took the package out of the running process and got a 400 back. That is the triage's stated p0 trigger. After this PR the `DELETE` door changes nothing for them. They still pass the capability gate, which lives in `packages/core` and is outside this card; it is listed below for the seat. ## What changed `DELETE /packages/:id` (`packages/runtime/src/domains/packages.ts`) now resolves the caller's organization once, after `requireManageMetadata` and `requireWritablePackage`. If a persisted delete will run (`protocol.deletePackage` is present) and there is no organization, the door refuses `400 TENANT_SCOPE_REQUIRED` **before** `registry.uninstallPackage(id)`. The refusal comes from a new `requireUninstallOrganizationScope` guard. The same organization value is then handed to `deletePackage`, so the door's check and the protocol's cannot disagree. There is no compensating re-install. The protocol keeps its own refusal as the second line. - **H1 holds.** `deletePackage` refuses (a) `organizationId` together with `allTenants: true`, and (b) neither of them. The door never sends `allTenants`, so (a) is unreachable from it and (b) is exactly "no organization". Nothing about the package or its rows enters the condition. The producer's declared request type says the same thing: "Omitted together with `allTenants` ⇒ refused". The door therefore mirrors all of it up front. It asks only when the persisted half will run: with no `deletePackage` there is no refusal to mirror, and the in-memory uninstall proceeds as before. - **Accept set unchanged (`Clause-②: no`).** Every request refused now was refused before, with the same code and status, and the rest are answered as before. A read-only package still gets `422 WRITABLE_PACKAGE_REQUIRED` first. An unknown id with no organization was 400 before and is 400 now. - **Envelope and message.** The envelope is `code: TENANT_SCOPE_REQUIRED`, `httpStatus: 400`, `details.packageId`. The sentence is the door's own, for the same reason `requireWritablePackage`'s is: the protocol's remedy ("pass organizationId … or allTenants: true") names request keys an HTTP caller cannot send. The new text tells the caller to select an organization they belong to, and says that nothing changed. - **No `isSystem` bypass.** The protocol refuses an org-less uninstall whoever asks, so the mirror does too. ## Pins (H2): `packages-uninstall-refuse-before-mutate.test.ts` This file reuses the #20491 rig: `dispatch()` with real identity resolution under `isolated`, and one shared permission set, so only the organization separates the arms. It adds a **real** `SchemaRegistry` holding the package and one object it owns, plus a protocol double that keeps the stored rows and records every `deletePackage` request. - For each refused population (the removed member whose claim is dropped, and the caller who never selected an organization), there are two pins: - **the answer:** `400 TENANT_SCOPE_REQUIRED` (code plus `httpStatus`), and `deletePackage` is never asked. - **unchanged afterwards:** `GET /packages/:id` answers 200, `GET /packages` still lists it, the registry still holds the package and its object, and the stored rows are untouched. - One pin checks that the rig really drops the removed member's claim. - **Control:** a current member gets 200. The package and its object leave the registry, `GET` answers 404, and `deletePackage` receives `{ packageId, organizationId: org_alpha }` and removes the rows. - **The mirror's reach:** a host with no persisted half still uninstalls an org-less caller, answering 200. An `isSystem` caller with no organization is refused 400 and the registry keeps the package. ## Ablation (H3) The check was moved back after `uninstallPackage` through `node scripts/ablation-replace.mjs`, in WRAP mode with its own restore, plus a script-level `trap` restore against the absolute path. - **Landing proof.** The anchor went from 1 hit to 0, the replacement from 0 to 1, and the blob from `86b509c9da22` to `7ff97007f460`. An on-disk order check printed `uninstallPackage at line 2034, scope guard at line 2036 -> MUTATED (uninstall first)`. - **Resolution path.** The subject is imported by relative path (`../http-dispatcher.js` → `src`), so the mutated source is what ran and no `dist/` leg applies. - **Result on `src/domains/packages*`:** 3 failed and 435 passed (438 total). Exactly the refused-caller "unchanged" pins went red: both "afterwards … untouched" pins, plus the `isSystem` pin's registry assertion, which is the same fact for the third refused caller. The "answer" pins stayed green, because the door still refuses before `deletePackage` in that position. That is the expected direction: the ablation separates the response from the side effect, which is this card's whole point. - **Restore proven twice.** The tool reported `blob after restore 86b509c == HEAD`, `git diff HEAD empty`. The trap reported `RESTORE PROVEN: blob 86b509c… == HEAD`. `git status --porcelain` was empty afterwards. ## Fixture triage (five existing files) Four fixtures sent their allow-path `DELETE` callers with **no organization** to a protocol double that accepted it. The real protocol refuses that request, so these fixtures could not happen for real, and they went red once the door refused first. Each one now **acts in an organization**. That is a spelling fix: none of those files is about organization scope. - `packages-capability-gate.test.ts`: the `DELETE /:id` case alone carries `tenantId` for its allow-path callers. - `packages-read-delete-response-conformance.test.ts`: the session names `org_acme`, backed by a `sys_member` row. - `packages-readonly-gate.test.ts`: `admin()` acts in `org_acme`. - `packages-uninstall-envelope.test.ts`: `authed()` acts in `org_acme`. The fifth file, `packages-vetted-org-source.test.ts` (#20477's pin), is **re-judged**. Its generic "the protocol is handed no organization" loop excludes the uninstall door, because that door now refuses before any protocol call. The door's dedicated pin now also asserts `state.calls` is empty. The registry half is left to the new file, because this rig's registry cannot uninstall. ## Verification (final head `eca5d389a3`, after merging `origin/main` `9449512a31`) - **Build:** `turbo run build --filter='@objectstack/runtime...'` (30/30), then `--filter='./packages/*' --filter='./packages/*/*'` (71/71) for the gates that read every `dist/`. - **`@objectstack/runtime` tests:** `vitest run --project local` gave 286 files and 4170 passed, 1 skipped. `--project repo` gave 3 files and 718 passed. - **`@objectstack/runtime` typecheck:** `tsc --noEmit` passed. `check:test-typecheck` was OK with the debt ledger unchanged. The touched test files are in the `tsconfig.test.json` program (`--listFilesOnly`) with 0 errors. - **`dispatch-gates --repo objectstack-ai/objectstack --commands`:** 61 derived commands, all exit 0. Reconciled with `--ran` (exit codes recorded): `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN`. - `check:dual-build-cjs-loads` and `check:type-check-debt` first exited 3 (`PREREQUISITE NOT MET`, no `dist/`). They were re-run after the full build and exited 0. - **Roster gates whose rosters sit under touched directories:** `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:route-ledger-census` and `check:error-status-conformance` all exited 0. - **`node scripts/check-issue-citations.mjs --base origin/main`:** exit 0. 3 citations, all resolve. - **`pnpm lint`** (the full `eslint . --no-inline-config`) exited 0 in 30s. - **Not run locally:** `packages/objectql/**`, `packages/metadata-protocol/**` and `packages/core/**` are untouched and read-only for this card. `@objectstack/runtime`'s public surface is byte-unchanged (no export, no spec key, no wire shape), so no import-side sweep is owed. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. NODE_OPTIONS=--max-old-space-size=4096 pnpm exec turbo run build --filter='@objectstack/runtime^...' --concurrency=2 --output-logs=errors-only NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 [src/domains/zz-h0-probe-20492.test.ts | src/domains/packages … | the whole project] bash ablate.sh (the H3 ablation above) NODE_OPTIONS=--max-old-space-size=4096 pnpm exec turbo run build --filter='@objectstack/runtime...' --concurrency=2 --output-logs=errors-only NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 && … --project repo --maxWorkers=2 && pnpm --filter @objectstack/runtime typecheck NODE_OPTIONS=--max-old-space-size=4096 pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 --output-logs=errors-only ## Patch round 1 (head `cadfe0b7ae`, after merging `origin/main` `9e9bb46417`) *Added by the `domain:cli` seat from the dev's round-1 report on #20492; the dev writes the body once.* - **Why:** `Lint & Repo Gates` went red on `eca5d389a3` in `check:error-code-provenance`. The door's new refusal stamps `metadata-protocol`'s wire code `TENANT_SCOPE_REQUIRED` from `@objectstack/runtime`, and the gate asks for a decision on the record. - **What:** ONE `PROVENANCE_WAIVERS` entry in `packages/spec/src/api/error-code-ledger.zod.ts` (`package: '@objectstack/runtime'`, `code: 'TENANT_SCOPE_REQUIRED'`, `registeredUnder: '@objectstack/metadata-protocol'`), and an `'@objectstack/spec': patch` line in this PR's changeset. Nothing else under `packages/spec/**` changed; the registered union and `ErrorCode` are unchanged. The entry rides this PR because the gate holds waivers live in both directions (claim `5878081156`, amended in place). - **Verification at `cadfe0b7ae`:** - `check:error-code-provenance` is green: 330 stamp sites, 313 listed, 17 waived, 10 waivers all live. - `dispatch-gates --commands` derives 87 families now that spec paths apply, and all 87 ran. - `check:merge-driver` and the `check:generated` aggregate fail on this host alone (the global pnpm v11 shim rejects `pnpm -s`), and identically on a clean `origin/main`. All 15 `check:generated` members pass run one by one. - The spec liveness test's JSON truncation reproduces on a clean `origin/main` on this host. - CI: 35/35 completed, 0 red. - **Declared narrowing, UNLOCKED as above:** this round's commands are the full turbo build, the spec and runtime test and typecheck runs, and the gate derivation. ## Acceptance notes - **(Finding for the seat, not fixed here: `packages/core`, read-only for this card.)** When the session claim is dropped (#15409 ruling B), the grants are re-resolved with no tenant. `resolveUserAuthzGrants` keeps every org-scoped `sys_user_permission_set` grant when no tenant is active (`!(org && tenantId && org !== tenantId)`). So a grant scoped to the organization the person was removed from still confers its capabilities. H0 (a2) and (a2b) measured this at `dispatch()`: `requireManageMetadata` passes. `Seam: core resolveUserAuthzGrants (permission-set filter) → runtime: every capability gate reading executionContext.systemPermissions`. The other env-wide `/packages` doors this population now reaches with no organization (`PATCH /:id/disable` and `/enable`, `POST /packages`, `PATCH /:id`) are NOT MEASURED. No cleanup of custom org-scoped grants on member removal was found in plugin-security, organizations or plugin-auth (the org-admin auto-grant is the only reconciled one). That was not exhaustively measured. - **(b2) is fixture-level.** The probe's `find` ignores the context argument, so it cannot say whether the real driver scopes the `sys_position` read for a tenant-less system context. It is recorded as an inference and NOT MEASURED against a real driver. - **Not in this card, per the triage:** a persistence failure part-way through `deletePackage` (not a refusal) still runs after the registry uninstall and answers `400 PACKAGE_DELETE_PARTIAL` with `registryRemoved: true`. - **Pre-existing:** `packages-capability-gate`, `packages-uninstall-envelope` and `packages-readonly-gate` let an allowed uninstall reach `setPackageDisabled` without redirecting `OS_HOME`, so they write a state file under the real ObjectStack home. This was already true before this PR; the fixture change keeps their allow-path exactly where it was. Carrier: none. - **The #20477 changeset** (`.changeset/20477-dispatcher-vetted-org-source.md`, not yet released) says the removed member's uninstall "is refused `400 TENANT_SCOPE_REQUIRED` and deletes nothing". Before this PR that was true of the stored rows only, not the registry. It becomes wholly true when this lands, so both entries can ship in one release unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb194c7 commit d1c01ff

9 files changed

Lines changed: 458 additions & 15 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(runtime): `DELETE /packages/:id` refuses an uninstall that names no organization before it touches the running registry (#20492)
7+
8+
Clause-②: no
9+
10+
A caller holding `manage_metadata` with no active organization — a member removed from an organization whose session still names it, or a caller who never selected one — sent `DELETE /api/v1/packages/:id` and was answered `400 TENANT_SCOPE_REQUIRED`. The dispatcher had already run the registry uninstall by then, so the package and every object it registers had left the running process for everyone it serves, while its stored rows still said it was installed. The state lasted until a restart re-seeded the registry.
11+
12+
The door now asks the persisted delete's organization-scope question first, from the same organization value it hands `deletePackage`, and only when a persisted delete will run. The same refusal (`400 TENANT_SCOPE_REQUIRED`) now arrives before anything changes: the package stays served, listed and registered, and its stored rows are untouched. The refusal's message names what an HTTP caller can do, which is to select an organization they are a member of and retry.
13+
14+
- **Unchanged:** a caller acting in an organization uninstalls exactly as before. A read-only package is still refused `422 WRITABLE_PACKAGE_REQUIRED` first. A host with no persisted delete (no `deletePackage` on its `protocol` service) still uninstalls from the registry alone, because there is no refusal to mirror there. The protocol keeps its own refusal as a second line.
15+
16+
- **`@objectstack/spec`:** `PROVENANCE_WAIVERS` (the error-code ledger) gains one entry: `@objectstack/runtime` stamps `TENANT_SCOPE_REQUIRED`, which stays registered under `@objectstack/metadata-protocol`. The door mirrors `deletePackage`'s refusal and does not emit a second vocabulary. The registered code union and `ErrorCode` are unchanged.

‎packages/runtime/src/domains/packages-capability-gate.test.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ const ctx = (executionContext?: any): any => ({
4141
const anon = () => ctx();
4242
/** Identity resolved but sessionless (no `userId`) — also anonymous. */
4343
const anonResolved = () => ctx({ isSystem: false, positions: [], permissions: [], systemPermissions: [] });
44-
/** Authenticated, holding exactly `caps`. */
45-
const authed = (caps: string[] = []) => ctx({ userId: 'u_portal', isSystem: false, systemPermissions: caps });
44+
/** Authenticated, holding exactly `caps` — in `tenantId` when one is given. */
45+
const authed = (caps: string[] = [], tenantId?: string) =>
46+
ctx({ userId: 'u_portal', isSystem: false, systemPermissions: caps, ...(tenantId ? { tenantId } : {}) });
4647
/** Engine self-invocation — never settable from the wire. */
47-
const system = () => ctx({ isSystem: true });
48+
const system = (tenantId?: string) => ctx({ isSystem: true, ...(tenantId ? { tenantId } : {}) });
4849

4950
// ── fake kernel ──────────────────────────────────────────────────────────────
5051
function make(overrides: { protocol?: any; metadata?: any; registry?: any } = {}) {
@@ -140,7 +141,12 @@ describe('/packages — anonymous-deny floor (#7033/#7023)', () => {
140141
// 2. Write gate — `manage_metadata` on every state-changing route
141142
// ══════════════════════════════════════════════════════════════════════════════
142143

143-
type WriteCase = { name: string; path: string; method: string; body?: any; query?: any; target: (p: any, r: any) => any };
144+
type WriteCase = {
145+
name: string; path: string; method: string; body?: any; query?: any;
146+
/** The organization the ALLOW-path callers act in, for a route that refuses a caller with none. */
147+
tenantId?: string;
148+
target: (p: any, r: any) => any;
149+
};
144150
const WRITE_ROUTES: WriteCase[] = [
145151
// `overwrite` so the allow-path clears the 409 duplicate guard (the shared
146152
// registry double answers `getPackage` truthy for any id); the write gate
@@ -158,7 +164,11 @@ const WRITE_ROUTES: WriteCase[] = [
158164
{ name: 'POST /:id/adopt-orphans', path: '/pkg-a/adopt-orphans', method: 'POST', target: (p) => p.reassignOrphanedMetadata },
159165
{ name: 'POST /:id/duplicate', path: '/pkg-a/duplicate', method: 'POST', body: { targetPackageId: 'pkg-b' }, target: (p) => p.duplicatePackage },
160166
{ name: 'PATCH /:id (manifest)', path: '/pkg-a', method: 'PATCH', body: { name: 'renamed' }, target: (p) => p.updatePackage },
161-
{ name: 'DELETE /:id', path: '/pkg-a', method: 'DELETE', target: (p) => p.deletePackage },
167+
// [#20492] The uninstall's allow-path acts in an organization: the door
168+
// refuses one that names none before anything else runs, as the persisted
169+
// delete itself does, so an org-less caller never reaches the target.
170+
// Pinned in `packages-uninstall-refuse-before-mutate.test.ts`.
171+
{ name: 'DELETE /:id', path: '/pkg-a', method: 'DELETE', tenantId: 'org_a', target: (p) => p.deletePackage },
162172
];
163173

164174
describe('/packages — write gate: every state-changing route demands `manage_metadata`', () => {
@@ -182,7 +192,7 @@ describe('/packages — write gate: every state-changing route demands `manage_m
182192
it(`lets a manage_metadata caller through on ${wc.name}`, async () => {
183193
const protocol = fullProtocol();
184194
const { dispatcher, registry } = make({ protocol });
185-
const r = await dispatcher.handlePackages(wc.path, wc.method, wc.body ?? {}, wc.query ?? {}, authed(['manage_metadata']));
195+
const r = await dispatcher.handlePackages(wc.path, wc.method, wc.body ?? {}, wc.query ?? {}, authed(['manage_metadata'], wc.tenantId));
186196
expect(r.response?.status).not.toBe(403);
187197
expect(r.response?.status).not.toBe(401);
188198
expect(wc.target(protocol, registry)).toHaveBeenCalled();
@@ -191,7 +201,7 @@ describe('/packages — write gate: every state-changing route demands `manage_m
191201
it(`lets an isSystem caller through on ${wc.name}`, async () => {
192202
const protocol = fullProtocol();
193203
const { dispatcher, registry } = make({ protocol });
194-
const r = await dispatcher.handlePackages(wc.path, wc.method, wc.body ?? {}, wc.query ?? {}, system());
204+
const r = await dispatcher.handlePackages(wc.path, wc.method, wc.body ?? {}, wc.query ?? {}, system(wc.tenantId));
195205
expect(r.response?.status).not.toBe(403);
196206
expect(wc.target(protocol, registry)).toHaveBeenCalled();
197207
});

‎packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,17 @@ const CODE_PKG = {
110110
name: 'Code Defined', objects: [{ name: 'code_lead', fields: { title: { type: 'text' } } }],
111111
};
112112

113+
/**
114+
* [#20492] The admin's session carries an organization, backed by a
115+
* membership: `DELETE /packages/:id` refuses an uninstall that names none
116+
* before anything else runs (as the persisted delete itself does), and the
117+
* rows under test here are the ones a completed uninstall serves.
118+
*/
119+
const ORG = 'org_acme';
120+
113121
/** The permission store the shared authz resolver reads, in its shipped shapes. */
114122
const TABLES: Record<string, any[]> = {
123+
sys_member: [{ user_id: 'u_admin', organization_id: ORG, role: 'member' }],
115124
sys_user: [{ id: 'u_admin', email: 'u_admin@example.com' }],
116125
sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg' }],
117126
sys_permission_set: [
@@ -161,7 +170,7 @@ function dispatcher(manifests: any[], protocol?: unknown): HttpDispatcher {
161170
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
162171
},
163172
};
164-
const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' } }) } };
173+
const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' }, session: { activeOrganizationId: ORG } }) } };
165174
const services: Record<string, unknown> = { objectql: ql, auth, ...(protocol ? { protocol } : {}) };
166175
return new HttpDispatcher({
167176
getState: () => 'running',

‎packages/runtime/src/domains/packages-readonly-gate.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,16 @@ function make() {
129129
return { dispatcher: new HttpDispatcher(kernel), registry, protocol };
130130
}
131131

132-
/** Authorized under #7033 — holds the write capability on every call below. */
132+
/**
133+
* Authorized under #7033 — holds the write capability on every call below.
134+
* [#20492] Acting in the organization that owns the writable base: an
135+
* uninstall that names no organization is refused before the registry is
136+
* touched, so an org-less admin would never reach the allowed delete below.
137+
*/
133138
const admin = (): any => ({
134139
request: {},
135140
environmentId: 'pkg-readonly-gate-test',
136-
executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['manage_metadata'] },
141+
executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['manage_metadata'], tenantId: 'org_acme' },
137142
});
138143
/** Engine self-invocation. Read-only is about the package, not the caller. */
139144
const system = (): any => ({

‎packages/runtime/src/domains/packages-uninstall-envelope.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,13 @@
6666
import { describe, it, expect, vi } from 'vitest';
6767
import { HttpDispatcher } from '../http-dispatcher.js';
6868

69+
// [#20492] The caller acts in an organization: an uninstall that names none is
70+
// refused before the registry is touched (as the persisted delete itself
71+
// does), so it would never reach the persisted outcomes this file pins.
6972
const authed = (caps: string[] = ['manage_metadata']): any => ({
7073
request: {},
7174
environmentId: 'platform',
72-
executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: caps },
75+
executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: caps, tenantId: 'org_acme' },
7376
});
7477

7578
function make(deletePackageResult: any, opts: { registryRemoved?: boolean } = {}) {

0 commit comments

Comments
 (0)