Skip to content

Commit 8d9e8b9

Browse files
fix(cloud-connection)!: refuse a cloud-fetched marketplace install whose catalog name an environment-authored item holds (#25435)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu6LTunNmT4vzpPoisQo1ZXX)_ Fixes #25366 Clause-②: no (narrowing) Metadata-protocol: none Claim 6364974343 (`domain:services` seat 1, #6021). The branch is the harness-pinned `claude/project-thread-gzt6ro`. It names no issue, so the claim is the identity record for it. ## What changed **`cloud-connection`, step 3 of `POST /api/v1/marketplace/install-local`.** When the registry refuses the package because an environment-authored item already holds a position, permission set or capability name the package ships, the cloud-fetched branch now refuses `422 PLUGIN_REGISTER_FAILED` before the ledger write. The answer carries the registry's own sentence, which names each colliding name and its holder. The inline (file-import) branch already answered exactly this. Before, the cloud branch answered `200` with `hotLoaded: false`, wrote the ledger entry, and said the package would load at the next restart. - **One shared pre-write check.** The registry's package door (`SchemaRegistry.installPackage` → `refuseSecurityCatalogNameConflicts`) already runs ahead of every registry mutation, for every door that installs a package. The door asks that refusal rather than keeping a second reading of who holds a name. `refusesEnvironmentHeldCatalogName` recognises it by `NAMESPACE_CONFLICT_CODE` plus a `conflicts` entry whose holder is `environment`. The engine keeps the error class unexported, so this is a code comparison and not `instanceof`. - **Narrow on purpose.** Every other register failure on the cloud branch keeps its lenient answer. A name another package or a built-in holds is not judged here. - ⛔ Engine lane untouched: the refusal, its message and the cold-boot check in `packages/objectql` stay as they are. ## Every install door, measured | door | before | after | |---|---|---| | marketplace install-local, inline `manifest` | 422, nothing written | unchanged | | marketplace install-local, cloud-fetched, fresh | 200 `hotLoaded:false`, ledger written | **422, nothing written** | | marketplace install-local, cloud-fetched, upgrade | 200 `hotLoaded:false`, ledger overwritten | **422, earlier version stays** | | `POST /api/v1/packages`, fresh and `overwrite` | 400 `VALIDATION_ERROR` at the body parse, naming `positions` / `capabilities` / `permissions` | unchanged, now pinned | | `protocol.installPackage` callers (duplicate, AI blueprint) | same strict parse | unchanged | | unmanaged install | the import verb's own collision refusal | unchanged, out of scope | Boot-time plugin registrations are not install doors. ## The premise, measured The card says the next cold boot is refused. A real restart shows something different. The boot runs. The marketplace rehydrate, at `kernel:ready`, after the engine's own cold-boot check, logs `rehydrate failed for com.acme.field-service` with the security-catalog sentence. The package is then not loaded at all. On an upgrade its earlier version does not load either, because the ledger entry that named that version was overwritten. The deployment does not stop, but the package's apps, objects and permission sets disappear while `GET /api/v1/marketplace/install-local` still lists the package. The fix is the same either way. ## R1–R4 My reading of R1–R4, taken from #24446 U2 practice: - **R1:** a dev-server run under `single` that drives the door. - **R4:** a production `objectstack serve` run under walled `isolated` and `group` that drives the door. - **R2 and R3:** the package tests and the dispatch gates. The test manifests are served by a local stub control plane (`OS_CLOUD_URL` pointed at it, `OS_MARKETPLACE_PUBLIC_BASE_URL=off`). Package `com.acme.field-service`: - v1 ships `rone_dispatcher`; - v2-collides ships `rone_dispatcher` and `rone_dispatcher_lite`; - v2-free ships `rone_dispatcher` and `rone_auditor`. The environment's clone is `PUT /api/v1/meta/permission/rone_dispatcher_lite`, which answers "Saved permission … (env-wide)". **R1, `single` (`pnpm dev` on the showcase, persistent SQLite).** The ablated leg (guard disabled with `ablation-replace`, dist rebuilt; esbuild drops the dead branch, and the dist has no `existingHolder`): 1. v1 installs `200`. 2. Clone `200`. 3. v2-collides answers `200 hotLoaded:false` ("registers it again at its next restart"), and the ledger reads 2.0.0. 4. A restart logs `rehydrate failed`. `rone_dispatcher` answers `404`, and `/api/v1/packages` has no row for the package. Then the two remedies, each across a real restart: - **Re-install v1:** `200 hotLoaded:true`, and it loads again after a restart. - **Rename the clone:** author `rone_dispatcher_lite_custom`, delete `rone_dispatcher_lite`, then restart. v2 loads, `rone_dispatcher_lite` is the vendor's at 2.0.0, and the clone lives on under its new name. The fixed leg (restored, blob == HEAD, dist rebuilt), on a fresh database: 1. v1 `200`, then clone `200`. 2. v2-collides answers `422 PLUGIN_REGISTER_FAILED` with the sentence. v1 stays loaded and the ledger stays at 1.0.0. 3. A restart loads v1, with no `rehydrate failed`. 4. CONTROL: v2-free answers `200 hotLoaded:true, upgradedFrom` set, and `rone_auditor` is at 2.0.0. **R4, production `objectstack serve` of the built showcase.** Settings: `NODE_ENV=production`, persistent SQLite, `OS_PLATFORM_OWNER_EMAIL` set to the operator, `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, and `OS_SECRET_KEY`. Each banner prints `Tenancy: isolated` or `Tenancy: group`. The run needed temporary changes, all reverted afterwards: `@objectstack/organizations` declared in the showcase's `package.json` and linked into its `node_modules` (no lockfile change, compared byte for byte), plus the test databases and ledgers under scratch. The tree is clean. Under both `isolated` and `group`, the operator saw the same sequence: 1. v1 `200`, then clone `200`. 2. v2-collides answers `422 PLUGIN_REGISTER_FAILED`. v1 stays and the ledger stays at 1.0.0. 3. A restart loads v1, with no `rehydrate failed`. 4. CONTROL: v2-free `200`, and `rone_auditor` is at 2.0.0. A walled tenant administrator is stopped earlier by the unchanged ADR-0131 D12 refusal (`403`), so the operator is the only caller who reaches this step. ## Tests - **`cloud-connection/src/marketplace-install-local-catalog-name-collision.test.ts`** (18). It runs over a real `ObjectKernel` with `ObjectQLPlugin` and the kernel's real `manifest` service. The cases cover permission set, position and capability, on both the inline and the cloud-fetched branch: - a fresh install is refused with nothing registered and no ledger entry; - an upgrade is refused and v1 stays registered and ledgered; - CONTROL: a non-colliding v2 installs. - Each refusal is a valid `BaseResponseSchema` envelope naming the name and "the environment catalog". The environment item is kept. - Ablation (guard disabled) turns the 6 cloud-branch refusal cases red, with main's `200 hotLoaded:false` answer. The inline cases stay green. - **`runtime/src/package-door-security-catalog-collection-refused.test.ts`** (9). It runs the real `HttpDispatcher.handlePackages` over a real `SchemaRegistry` holding the environment's item. For each type, a fresh install and an overwrite carrying the collection are refused `400` naming the key, and nothing is installed or the earlier version stands. CONTROL: the same manifests without the collection install `201`. `POST /api/v1/packages` gets no check of its own, because it cannot carry a catalog name. - The cloud-connection suite: 56 files and 722 tests pass. Typecheck passes for cloud-connection and runtime. eslint on the touched files passes. ## Acceptance notes - **Out of scope, engine lane (not measured as a defect):** `engine.registerApp` sets `this.manifests` and the datasource definitions before `installPackage` runs its refusal. So a refused install may leave a manifest entry in the engine's own map. Not touched here. - **Out of scope, observation:** `os migrate security-catalog-overlays`, named by the engine's cold-boot remedy, boots the stack composition, which likely does not include the marketplace ledger. It may therefore list nothing for a marketplace package. Not measured. - A name held by another package or a built-in still takes the lenient cloud answer. That is outside this card. - The changeset carries the operator remedy for a deployment that already took such an upgrade. Both remedy steps were measured in R1 across a real restart. The proof readings are in the os-dev-report on #25366: the dispatch gates, `check-governed-merges --branch` (0 of 4 paths governed, 480 lines), R1, R4 and the dogfood battery. --- _Generated by [Claude Code](https://claude.ai/code/session_01ENyzeNx2dXdHp37P4Zdp2d)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ENyzeNx2dXdHp37P4Zdp2d --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b3f3298 commit 8d9e8b9

4 files changed

Lines changed: 479 additions & 1 deletion

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
---
4+
5+
fix(cloud-connection)!: a cloud-fetched marketplace install whose package ships a position, permission set or capability name an environment-authored item holds is refused before any write, as the file-import install already was
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type, flag or error code is added, removed or renamed: the refusal reuses the door's own `PLUGIN_REGISTER_FAILED` code and the registry's existing security-catalog sentence. What narrows is one install door's answer for a package the registry already refused to load; the remedy for a deployment that already took such an install is an operator one, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this door (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** (the install door's accept set narrows), shipped as `patch`: it refuses only installs that could never load.
13+
14+
`POST /api/v1/marketplace/install-local` fetching a package from the marketplace (`packageId` plus `versionId`) used to answer `200` with `hotLoaded: false` when the registry refused the package because an item authored in this system already holds a security catalog name the package ships, for example a customer's clone of a vendor permission set saved under a name the vendor's next version also uses. The door wrote the package to its install ledger anyway and said it would load at the next restart. It never could: the environment's item is loaded at every boot, so the restart met the same refusal. Measured on a real restart, the boot runs, logs `rehydrate failed for <package id>` with the security-catalog sentence, and the package is not loaded at all. On an upgrade, not even its earlier version loads, because the ledger entry that named that version was overwritten.
15+
16+
- **Now:** that install is refused `422 PLUGIN_REGISTER_FAILED` before anything is registered or written, with the registry's sentence naming each colliding name and its holder. The earlier version, on an upgrade, stays installed and loaded. This is the answer an install from an imported file (`manifest` in the body) already gave.
17+
- **Unchanged:** an install with no such collision, fresh or upgrade. A cloud-fetched install the registry refuses for any other reason keeps its lenient `200` answer. A name another package or a built-in holds is not part of this change. `POST /api/v1/packages` cannot ship a catalog name at all: its body parse refuses a `positions`, `capabilities` or permission-set list before any write.
18+
- **Who sees it:** under `single`, the administrator who installs packages. Under `group` and `isolated`, the platform operator, who alone reaches marketplace installs.
19+
20+
**If a deployment already took such an install.** Its boot log carries `rehydrate failed for <package id>` with `Security catalog name conflict`, and the package's apps, objects and permission sets are missing although `GET /api/v1/marketplace/install-local` lists it. Choose one of these:
21+
22+
- To stay on the earlier version, install that version again through the same door (`POST /api/v1/marketplace/install-local` with its `versionId`). It loads at once, and its ledger entry replaces the one that cannot load, so it also loads after a restart.
23+
- To take the new version, first move the environment's item off the name. Save it again under a new name, point every assignment that named the old name at the new one, then delete the item under the old name. Then restart, or install the new version again. Only after that does the old name belong to the package, so an assignment left on the old name grants the package's definition from then on.
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25366 — `POST /api/v1/marketplace/install-local` refuses a package that
5+
* ships a position, permission set or capability name an ENVIRONMENT-authored
6+
* item already holds, on both branches, before anything is written.
7+
*
8+
* ## What was wrong
9+
*
10+
* The registry's package door (`SchemaRegistry.installPackage`) refuses such a
11+
* package ahead of every one of its writes (`SecurityCatalogNameConflictError`,
12+
* ADR-0048 §3.4 narrowed; ruling Q4 = A on #15196). The inline branch answered
13+
* that refusal `422`. The cloud-fetched branch took its lenient path instead:
14+
* it wrote the ledger entry and answered `200` with `hotLoaded: false`, "loads
15+
* at the next restart". It never does. The environment's item is loaded from
16+
* `sys_metadata` at every boot, so every later load of that entry is refused
17+
* the same way. The reach is ordinary: a customer clones a vendor permission
18+
* set or position under a name, and a later version of the vendor's package
19+
* ships that name.
20+
*
21+
* ## What is real here
22+
*
23+
* The judge is real: an `ObjectKernel` with `ObjectQLPlugin` supplies the
24+
* `manifest` service over the real `SchemaRegistry`, so the refusal under test
25+
* is the one production raises, and "nothing registered" is read off that
26+
* registry. The environment's item is registered the way `sys_metadata`
27+
* hydration registers it: into the bare slot, with no package. The side
28+
* readers that run after the ledger write (binder, translations, seeds) are
29+
* recorders; they are not what is pinned. The cloud leg is a stubbed `fetch`.
30+
*
31+
* ## What these cases pin
32+
*
33+
* - each catalog type × each branch × a fresh install and an upgrade over an
34+
* installed version: `422 PLUGIN_REGISTER_FAILED` in the declared envelope,
35+
* the colliding name and its environment holder named, the registry and
36+
* the ledger exactly as before the call, and the environment's item still
37+
* the environment's;
38+
* - CONTROL, the same flows shipping a name nobody holds: `200`,
39+
* `hotLoaded: true`, the new version registered and ledgered.
40+
*
41+
* A capability is code-only (`allowRuntimeCreate: false`), so no environment
42+
* save produces one; its case pins that the door judges the refusal, not the
43+
* type.
44+
*/
45+
46+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
47+
import { mkdtempSync, rmSync } from 'node:fs';
48+
import { tmpdir } from 'node:os';
49+
import { join } from 'node:path';
50+
import { BaseResponseSchema, ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api';
51+
// The plugin reaches `@objectstack/runtime` through a lazy `import()`; its first
52+
// load is paid here, at module top, never inside a clocked `it`.
53+
import '@objectstack/runtime';
54+
import { ObjectKernel } from '@objectstack/core';
55+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
56+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
57+
import { LocalManifestSource } from './local-manifest-source.js';
58+
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
59+
60+
type Bag = Record<string, any>;
61+
type Handler = (c: any) => Promise<any>;
62+
63+
const ROUTE = '/api/v1/marketplace/install-local';
64+
const APP_ID = 'com.acme.field-service';
65+
const CLOUD_URL = 'http://cloud.test';
66+
67+
/** One catalog type: its registry type, its manifest collection, and the names a case uses. */
68+
interface CatalogCase {
69+
readonly type: 'permission' | 'position' | 'capability';
70+
readonly collection: 'permissions' | 'positions' | 'capabilities';
71+
/** What version 1 ships. */
72+
readonly shipped: string;
73+
/** The environment's item — a customer's clone — and the name version 2 also ships. */
74+
readonly cloned: string;
75+
/** CONTROL: a name version 2 adds that nobody holds. */
76+
readonly free: string;
77+
}
78+
79+
const CASES: readonly CatalogCase[] = [
80+
{ type: 'permission', collection: 'permissions', shipped: 'acme_dispatcher', cloned: 'acme_dispatcher_lite', free: 'acme_auditor' },
81+
{ type: 'position', collection: 'positions', shipped: 'acme_technician', cloned: 'acme_lead_technician', free: 'acme_planner' },
82+
{ type: 'capability', collection: 'capabilities', shipped: 'acme_export_jobs', cloned: 'acme_close_jobs', free: 'acme_reopen_jobs' },
83+
];
84+
85+
const item = (k: CatalogCase, name: string): Bag =>
86+
k.type === 'permission' ? { name, label: name, objects: {} } : { name, label: name };
87+
88+
/** A package version shipping `names` in `k`'s collection. */
89+
const packageVersion = (k: CatalogCase, version: string, names: string[]): Bag => ({
90+
id: APP_ID,
91+
name: 'acme_field_service',
92+
type: 'app',
93+
version,
94+
[k.collection]: names.map((name) => item(k, name)),
95+
});
96+
97+
let dir: string;
98+
const kernels: ObjectKernel[] = [];
99+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-catalog-collision-')); });
100+
afterEach(async () => {
101+
while (kernels.length) {
102+
const k = kernels.pop()!;
103+
if (k.getState() === 'running') await k.shutdown();
104+
}
105+
rmSync(dir, { recursive: true, force: true });
106+
vi.unstubAllGlobals();
107+
vi.unstubAllEnvs();
108+
vi.restoreAllMocks();
109+
});
110+
111+
/**
112+
* Serve package versions from the cloud leg, by the `versionId` the install
113+
* asks for; the public fast-path is off and a service key is set, so the
114+
* credential read never reaches the developer's own on-disk binding.
115+
*/
116+
function serveVersions(versions: Record<string, Bag>) {
117+
vi.stubEnv('OS_MARKETPLACE_PUBLIC_BASE_URL', 'off');
118+
vi.stubEnv('OS_CLOUD_API_KEY', 'svc-test-key');
119+
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
120+
const versionId = decodeURIComponent(String(url).split('/versions/')[1]?.split('/')[0] ?? '');
121+
const manifest = versions[versionId];
122+
if (!manifest) return new Response('not found', { status: 404 });
123+
return new Response(
124+
JSON.stringify({ data: { manifest, version: manifest.version, version_id: versionId } }),
125+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
126+
);
127+
}));
128+
}
129+
130+
/** The plugin over a REAL engine's `manifest` service and registry, booted to `kernel:ready`. */
131+
async function bootPlugin(controlPlaneUrl: string) {
132+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false } as never);
133+
kernels.push(kernel);
134+
await kernel.use(new ObjectQLPlugin());
135+
await kernel.bootstrap();
136+
const ql = kernel.getService<ObjectQL>('objectql');
137+
ql.registry.logLevel = 'silent';
138+
139+
const routes = new Map<string, Handler>();
140+
const rawApp = {
141+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
142+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
143+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
144+
};
145+
const hooks = new Map<string, () => Promise<void>>();
146+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
147+
const services: Record<string, unknown> = {
148+
manifest: kernel.getService('manifest'),
149+
auth: installerAuthService(),
150+
objectql: withInstallerGrants({
151+
convertPackageBodyForward: (body: unknown) => ql.convertPackageBodyForward(body),
152+
registry: ql.registry,
153+
syncSchemas: async () => undefined,
154+
}),
155+
};
156+
const ctx = {
157+
hook: (e: string, h: () => Promise<void>) => hooks.set(e, h),
158+
getService: (name: string) => {
159+
if (name === 'http-server') return { getRawApp: () => rawApp };
160+
if (!(name in services)) throw new Error(`no ${name}`);
161+
return services[name];
162+
},
163+
logger,
164+
};
165+
166+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl, storageDir: dir });
167+
const p = plugin as unknown as Record<string, unknown>;
168+
p.bindArtifactHandlers = async () => undefined;
169+
p.applySideEffects = async () => ({ translationsLoaded: 0, seeded: { mode: 'none' } });
170+
171+
await plugin.start(ctx as never);
172+
await hooks.get('kernel:ready')?.();
173+
174+
const install = async (body: Bag) => {
175+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
176+
return routes.get(`POST ${ROUTE}`)!({
177+
req: {
178+
url: `http://localhost:3000${ROUTE}`,
179+
raw: new Request(`http://localhost:3000${ROUTE}`),
180+
json: async () => body,
181+
param: () => undefined,
182+
},
183+
json,
184+
});
185+
};
186+
return { ql, install };
187+
}
188+
189+
/** The two branches, each asked for one version by the shape that branch's body takes. */
190+
const BRANCHES = [
191+
{
192+
branch: 'inline',
193+
controlPlaneUrl: 'off',
194+
serve: (_versions: Record<string, Bag>) => undefined,
195+
body: (versions: Record<string, Bag>, versionId: string) => ({ manifest: versions[versionId] }),
196+
},
197+
{
198+
branch: 'cloud-fetched',
199+
controlPlaneUrl: CLOUD_URL,
200+
serve: (versions: Record<string, Bag>) => serveVersions(versions),
201+
body: (_versions: Record<string, Bag>, versionId: string) => ({ packageId: 'pkg_acme_field_service', versionId }),
202+
},
203+
] as const;
204+
205+
/** The environment's item under `name`, as `sys_metadata` hydration registers it: the bare slot, no package. */
206+
function authorEnvironmentItem(ql: ObjectQL, k: CatalogCase, name: string) {
207+
ql.registry.registerItem(k.type, { ...item(k, name), label: `${name} (customer clone)` });
208+
}
209+
210+
function expectRefusal(res: any, k: CatalogCase) {
211+
expect(res.status, JSON.stringify(res.payload)).toBe(422);
212+
expect(BaseResponseSchema.safeParse(res.payload).success).toBe(true);
213+
expect(envelopeViolations(res.payload)).toEqual([]);
214+
expect(ApiErrorSchema.safeParse(res.payload.error).success).toBe(true);
215+
expect(res.payload.success).toBe(false);
216+
expect(res.payload.error.code).toBe('PLUGIN_REGISTER_FAILED');
217+
// The registry's own sentence: the colliding name, and the environment as its holder.
218+
expect(res.payload.error.message).toContain(`"${k.cloned}"`);
219+
expect(res.payload.error.message).toContain('the environment catalog');
220+
}
221+
222+
/** The environment's item is untouched: still in the bare slot, bound to no package. */
223+
function expectEnvironmentItemKept(ql: ObjectQL, k: CatalogCase) {
224+
const kept = ql.registry.getItem<Bag>(k.type, k.cloned);
225+
expect(kept?.label).toBe(`${k.cloned} (customer clone)`);
226+
expect(kept?._packageId).toBeUndefined();
227+
}
228+
229+
describe('install-local refuses a package whose catalog name an environment-authored item holds, before any write', () => {
230+
for (const b of BRANCHES) {
231+
for (const k of CASES) {
232+
describe(`${b.branch} branch · ${k.type}`, () => {
233+
const versions = {
234+
'v1': packageVersion(k, '1.0.0', [k.shipped]),
235+
'v2-collides': packageVersion(k, '2.0.0', [k.shipped, k.cloned]),
236+
'v2-free': packageVersion(k, '2.0.0', [k.shipped, k.free]),
237+
};
238+
239+
it('a fresh install is refused: 422, nothing registered, no ledger entry', async () => {
240+
b.serve(versions);
241+
const { ql, install } = await bootPlugin(b.controlPlaneUrl);
242+
authorEnvironmentItem(ql, k, k.cloned);
243+
244+
const res = await install(b.body(versions, 'v2-collides'));
245+
246+
expectRefusal(res, k);
247+
expect(ql.registry.getPackage(APP_ID)).toBeUndefined();
248+
expect(new LocalManifestSource(dir).read(APP_ID).entry).toBeNull();
249+
expectEnvironmentItemKept(ql, k);
250+
});
251+
252+
it('an upgrade is refused: 422, the installed version still registered and ledgered', async () => {
253+
b.serve(versions);
254+
const { ql, install } = await bootPlugin(b.controlPlaneUrl);
255+
const first = await install(b.body(versions, 'v1'));
256+
expect(first.status, JSON.stringify(first.payload)).toBe(200);
257+
// The customer clones under a name version 1 does not ship.
258+
authorEnvironmentItem(ql, k, k.cloned);
259+
260+
const res = await install(b.body(versions, 'v2-collides'));
261+
262+
expectRefusal(res, k);
263+
expect(ql.registry.getPackage(APP_ID)?.manifest?.version).toBe('1.0.0');
264+
expect(new LocalManifestSource(dir).read(APP_ID).entry?.version).toBe('1.0.0');
265+
expectEnvironmentItemKept(ql, k);
266+
});
267+
268+
it('CONTROL — an upgrade shipping a name nobody holds installs unchanged: 200, hot-loaded, ledgered', async () => {
269+
b.serve(versions);
270+
const { ql, install } = await bootPlugin(b.controlPlaneUrl);
271+
expect((await install(b.body(versions, 'v1'))).status).toBe(200);
272+
authorEnvironmentItem(ql, k, k.cloned);
273+
274+
const res = await install(b.body(versions, 'v2-free'));
275+
276+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
277+
expect(res.payload.data.hotLoaded).toBe(true);
278+
expect(res.payload.data.upgradedFrom).toBe('previous-marketplace-version');
279+
expect(ql.registry.getPackage(APP_ID)?.manifest?.version).toBe('2.0.0');
280+
expect(new LocalManifestSource(dir).read(APP_ID).entry?.version).toBe('2.0.0');
281+
expectEnvironmentItemKept(ql, k);
282+
});
283+
});
284+
}
285+
}
286+
});

0 commit comments

Comments
 (0)