Skip to content

Commit 3a0aa91

Browse files
fix(cloud-connection)!: install-local's cloud branch refuses a package declaring a bare name another package holds (#31388)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XTVkGrf2CjQTF79hBERgqiJ)_ domain:server#5 · os-project-manager · session_01FEkQ5ktJb5rQ5n2GUXSQMR Fixes #31296 Clause-②: no (narrowing) — a cloud-branch install that answered success (`hotLoaded: false`) is now refused 422; one contract-review-tier review owed before the queue Metadata-protocol: none ## 维护者速读 - 改了什么:`POST /api/v1/marketplace/install-local` 的云端分支(从控制面下载的包)在注册被「同名只能一个包持有」规则(#30803 系列:flow、webhook、dataset 等)拒绝时,现在和内联分支一样直接回 `422 PLUGIN_REGISTER_FAILED`,消息点名两个持有者和建议的改名,不写安装记录、不注册。 - 为什么改:之前云端分支只认「环境已持有的安全目录名」这一种拒绝,其余都走宽容路径:写安装记录、回 200 和 `hotLoaded: false`。下次重启时这个包在封印之前就被恢复,冷启动只告警一次就接受了冲突,同名的使用方按注册顺序拿到其中一个包的定义。 - 风险与回滚:只收窄一个入口的一种拒绝;其它注册失败照旧走宽容路径,环境持有的目录名照旧 422。识别靠错误自身的 code + name,不列类型,#30803 后续新增的类型自动覆盖。回滚即还原本 PR。 - 先看:`packages/cloud-connection/src/marketplace-install-local-plugin.ts` 新函数 `refusesBareNameHeldElsewhere` 和 catch 分支里那一行 `if (...)`;然后 `marketplace-install-local-bare-name-conflict.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-install-local-bare-name-conflict.test.ts`,预期 4 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, the cloud-fetched branch of `POST /api/v1/marketplace/install-local` (a `packageId` + `versionId` body): 1. **One new predicate, `refusesBareNameHeldElsewhere(err)`.** It recognises the registry's bare-name refusal (`BareNameConflictError`, `registry.ts` in `@objectstack/objectql`) by the error's own brand: the code it shares with its siblings (`NAMESPACE_CONFLICT_CODE`, already imported here) plus its `name`. This is the code + name pair `isGuestOrganizationUnresolvedError` (`@objectstack/core`) and the `DuplicateRecordError` readers (types, metadata-protocol) use for an unexported class. It never reads the error's types or holders: which types the rule covers and who holds a name stay the registry's. 2. **The register catch asks it beside #25366's predicate.** When either answers yes, the cloud branch answers `422 PLUGIN_REGISTER_FAILED` with the registry's sentence (both holders, the name, the rename), before the ledger write and before anything else of this door's is written. The registry's package door already refused before its own writes. 3. **Doc comments.** The module header's install paragraph, the import comment and the catch's comment say why the lenient answer is wrong here: the ledger entry is rehydrated at the next boot before the cold boot seals the holders, so the boot admits the clash with one warning (`BARE_NAME_COLD_BOOT_MODE = 'warn'`). Not changed: any other register failure on the cloud branch keeps #22695's lenient answer (`200`, `hotLoaded: false`, `hotLoadError`); the inline branch; the environment-held security catalog refusal. **Parallel work.** The `#30803 PR 4` thread (agent + skill) adds two more types to the same one-holder list. This predicate does not enumerate types, so those types are refused here without a change once they land. ## Tests New `packages/cloud-connection/src/marketplace-install-local-bare-name-conflict.test.ts`, over a real `ObjectKernel` + `ObjectQLPlugin` booted to `kernel:bootstrapped` (holders sealed), the holding package installed through the same door, the cloud leg a stubbed `fetch`: - a fresh cloud-branch install of a package declaring a flow name another package holds: 422 `PLUGIN_REGISTER_FAILED` in the declared envelope (`BaseResponseSchema`, `ApiErrorSchema`, `envelopeViolations` empty), the message naming both packages, the flow and the rename `routing_assign_technician`; the package not registered; the ledger equal to before; the holder's flow still the holder's; - the same for an upgrade over an installed version 1: version 1 still registered and ledgered; - CONTROL: an environment-held security catalog name keeps its 422 naming the environment catalog (the full matrix stays in `marketplace-install-local-catalog-name-collision.test.ts`); - CONTROL: the same package declaring a flow name nobody holds installs, `200`, `hotLoaded: true`, registered and ledgered. The lenient answer for a non-registry register failure stays pinned by `marketplace-install-local-hot-loaded.test.ts`. <details><summary>Red on main (the new test run against the unfixed plugin at base d0512c2, before the fix was written)</summary> ``` × a fresh install is refused: 422 naming both holders and the rename, nothing registered or ledgered × an upgrade is refused: 422, the installed version still registered and ledgered AssertionError: {"success":true,"data":{"manifestId":"com.acme.routing","version":"1.0.0","versionId":"v1-held",…,"hotLoaded":false,"hotLoadError":"Bare-name conflict: package \"com.acme.routing\" cannot register a name another holder already holds — package \"com.acme.routing\" declares the flow \"assign_technician\", already held by package \"com.acme.dispatch\" — rename it to \"routing_assign_technician\" in \"com.acme.routing\". …"}}: expected 200 to be 422 Tests 2 failed | 2 passed (4) ``` </details> <details><summary>Ablation (fix committed, predicate removed from the catch, restored)</summary> `node scripts/ablation-replace.mjs --file packages/cloud-connection/src/marketplace-install-local-plugin.ts --anchor "if (refusesEnvironmentHeldCatalogName(err) || refusesBareNameHeldElsewhere(err)) {" --replacement "if (refusesEnvironmentHeldCatalogName(err)) {" -- …vitest run src/marketplace-install-local-bare-name-conflict.test.ts` ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob 09ac8e8 -> 2268f74afe54 × a fresh install is refused: 422 naming both holders and the rename, nothing registered or ledgered × an upgrade is refused: 422, the installed version still registered and ledgered Tests 2 failed | 2 passed (4) ablation-replace: ok restored: blob == HEAD (09ac8e8) and `git diff HEAD` is empty ``` The suite imports the plugin from `src/` relatively, so the mutation needs no build. </details> ## Verification All on head `7b59e006` (the fix, the changeset, and one merge of `origin/main` 8b90442), exit codes captured before any pipe: - **Derived gate union**, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 69 families, every one exit 0. `--ran`: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)". Four first answered a prerequisite and were rerun once it held: `check-plugin-teardown-shape --self-test` (3, shallow clone; 0 after `git fetch --depth=1 origin 621a487`), `check:dts-closure` (1) and `check:dual-build-cjs-loads` (3), both 0 after `pnpm turbo run build --concurrency=2` (VERDICT command-exit 0), and `check:type-check-debt` (3, lock queue behind the dogfood run; 0 on rerun). - **Package**: `pnpm --filter @objectstack/cloud-connection test`: Test Files 74 passed, Tests 973 passed (VERDICT 0). `pnpm --filter @objectstack/cloud-connection typecheck` (src and `tsconfig.test.json`, which lists the new test file): exit 0. - **Lint**: `eslint --no-inline-config` on the two touched `.ts` files: exit 0. - **Full dogfood battery** (`(narrowing)` arm), `pnpm --filter @objectstack/dogfood test` after the `@objectstack/dogfood^...` closure build, under the verify lock: VERDICT command-exit 0, Test Files 437 passed / 3 skipped (440), Tests 3618 passed / 41 skipped (3659). - CI-only families (Test Core shards, the type-check lanes, Build Docs) are read from this PR's check-runs, not re-run here. ## Acceptance notes - **Security catalog, package or built-in holder (not fixed here).** The same lenient path still takes the security catalog's refusal when the holder is another package or a built-in. Measured with this PR's harness on a throwaway probe: a cloud-branch install of a package shipping a permission set another installed package ships answers `200`, `hotLoaded: false`, not registered, ledgered. Read from code: the package door's security catalog check has no seal, so the rehydrate at the next boot is refused the same way and the package never loads. Outside this card's claimed predicate; reported to the seat for filing. - **Old ledger entries.** An entry an earlier build ledgered this way keeps loading at boot beside the cold boot's warning; this PR refuses new ones only. - **Governed surface.** None: the diff is one source file, one test file and one changeset. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5ktJb5rQ5n2GUXSQMR --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5ktJb5rQ5n2GUXSQMR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4a3b186 commit 3a0aa91

3 files changed

Lines changed: 356 additions & 5 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
---
4+
5+
fix(cloud-connection)!: install-local's cloud branch refuses a package declaring a bare name another package holds (#31296)
6+
7+
Clause-②: no (narrowing) — `POST /api/v1/marketplace/install-local` with a cloud-fetched package (`packageId` + `versionId`) whose register the registry refuses for a bare-name clash answers `422 PLUGIN_REGISTER_FAILED` where it answered `200` with `hotLoaded: false` and wrote the ledger entry. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- 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, for one registry refusal the inline branch of the same door already answers 422, and its remedy is the rename the refusal names, so there is nothing for `objectstack migrate meta` to rewrite. A ledger entry an earlier build wrote this way keeps loading at boot, beside the cold boot's warning. -->
11+
12+
**BREAKING** for a cloud-branch install that relied on the lenient answer for a bare-name clash.
13+
14+
- **Before.** The cloud branch refused only the security catalog's environment-held refusal (#25366). A package declaring an item of a bare-name type (a flow, a webhook, a connector, an email template, a dataset, a mapping, a picklist, an API, an object-less action) under a name another installed package or the platform holds was refused by the registry and then ledgered anyway, answering `200` with `hotLoaded: false`. At the next restart the entry loaded before the cold boot sealed the holders, so the boot admitted the clash with one warning, and every consumer resolving the name was served one of the two packages' items by registration order.
15+
- **Now.** That refusal is answered `422 PLUGIN_REGISTER_FAILED` with the registry's sentence naming both holders and the rename, as the inline branch answers it. Nothing is registered or written, and an installed earlier version stays registered and ledgered. The door recognises the registry's refusal by its own brand, never by a list of types, so a type the rule covers later is refused the same way.
16+
- **Unchanged.** Any other register failure on the cloud branch keeps the lenient answer (`200`, `hotLoaded: false`, `hotLoadError`). An environment-held security catalog name is still refused `422`.
17+
18+
**The remedy.** Rename the item in the incoming package (the refusal names the namespaced rename), rename it in the package that holds it, or uninstall that package, then install again.
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #31296 — the cloud-fetched branch of `POST /api/v1/marketplace/install-local`
5+
* refuses a package declaring a bare name another package holds (the #30803
6+
* series: a flow, a webhook, a dataset, …), the way the inline branch does,
7+
* before anything is written.
8+
*
9+
* ## What was wrong
10+
*
11+
* The registry's package door refuses such a package ahead of every one of its
12+
* writes (`BareNameConflictError`, `bare-name-one-holder.ts` in
13+
* `@objectstack/objectql`). The inline branch answered that refusal `422`. The
14+
* cloud-fetched branch recognised only the security catalog's
15+
* environment-held refusal (#25366) and took #22695's lenient path for this
16+
* one: it wrote the ledger entry and answered `200` with `hotLoaded: false`,
17+
* "loads at the next restart". At that restart the entry is rehydrated BEFORE
18+
* the cold boot seals the holders, so the boot admits the clash with one
19+
* warning, and every consumer resolving the name is served one of the two
20+
* packages' items by registration order — the condition the rule refuses.
21+
*
22+
* ## What is real here
23+
*
24+
* The judge is real: an `ObjectKernel` with `ObjectQLPlugin` supplies the
25+
* `manifest` service over the real `SchemaRegistry`, booted to
26+
* `kernel:bootstrapped`, so the holders are sealed and the refusal under test
27+
* is the one production raises. The holding package is installed through the
28+
* same door. The side readers that run after the ledger write (binder,
29+
* translations, seeds) are recorders; they are not what is pinned. The cloud
30+
* leg is a stubbed `fetch`.
31+
*
32+
* ## What these cases pin
33+
*
34+
* - a fresh cloud-fetched install and a cloud-fetched upgrade, each declaring
35+
* a flow name another package holds: `422 PLUGIN_REGISTER_FAILED` in the
36+
* declared envelope, both holders and the rename named, the registry and
37+
* the ledger exactly as before the call, the holder's flow still the
38+
* holder's;
39+
* - CONTROL, an environment-held security catalog name: the same `422` it
40+
* answered before (#25366; its full matrix lives in
41+
* `marketplace-install-local-catalog-name-collision.test.ts`);
42+
* - CONTROL, the same package declaring a flow name nobody holds: `200`,
43+
* `hotLoaded: true`, registered and ledgered.
44+
*
45+
* A register failure that is NOT a registry refusal keeps #22695's lenient
46+
* answer; `marketplace-install-local-hot-loaded.test.ts` pins that.
47+
*/
48+
49+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
50+
import { mkdtempSync, rmSync } from 'node:fs';
51+
import { tmpdir } from 'node:os';
52+
import { join } from 'node:path';
53+
import { BaseResponseSchema, ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api';
54+
// The plugin reaches `@objectstack/runtime` through a lazy `import()`; its first
55+
// load is paid here, at module top, never inside a clocked `it`.
56+
import '@objectstack/runtime';
57+
import { ObjectKernel } from '@objectstack/core';
58+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
59+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
60+
import { LocalManifestSource } from './local-manifest-source.js';
61+
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
62+
63+
type Bag = Record<string, any>;
64+
type Handler = (c: any) => Promise<any>;
65+
66+
const ROUTE = '/api/v1/marketplace/install-local';
67+
const CLOUD_URL = 'http://cloud.test';
68+
69+
/** The package already holding the flow name, installed first. */
70+
const HOLDER_ID = 'com.acme.dispatch';
71+
/** The package the cloud-fetched install asks for. */
72+
const INCOMING_ID = 'com.acme.routing';
73+
/** The flow name both declare. */
74+
const HELD_FLOW = 'assign_technician';
75+
/** CONTROL: a flow name nobody holds. */
76+
const FREE_FLOW = 'reassign_technician';
77+
78+
const flow = (name: string, tag: string): Bag => ({
79+
name,
80+
label: `${tag} flow`,
81+
type: 'autolaunched',
82+
status: 'active',
83+
nodes: [{ id: 'start', type: 'start', label: 'Start' }],
84+
edges: [],
85+
});
86+
87+
/** A package version whose namespace is its id's last segment, so the refusal's rename is `NAMESPACE_NAME`. */
88+
const packageVersion = (id: string, version: string, flows: string[], extra: Bag = {}): Bag => {
89+
const ns = id.split('.').pop()!;
90+
return {
91+
id,
92+
name: ns,
93+
namespace: ns,
94+
type: 'app',
95+
version,
96+
flows: flows.map((name) => flow(name, ns)),
97+
...extra,
98+
};
99+
};
100+
101+
let dir: string;
102+
const kernels: ObjectKernel[] = [];
103+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-bare-name-conflict-')); });
104+
afterEach(async () => {
105+
while (kernels.length) {
106+
const k = kernels.pop()!;
107+
if (k.getState() === 'running') await k.shutdown();
108+
}
109+
rmSync(dir, { recursive: true, force: true });
110+
vi.unstubAllGlobals();
111+
vi.unstubAllEnvs();
112+
vi.restoreAllMocks();
113+
});
114+
115+
/**
116+
* Serve package versions from the cloud leg, by the `versionId` the install
117+
* asks for; the public fast-path is off and a service key is set, so the
118+
* credential read never reaches the developer's own on-disk binding.
119+
*/
120+
function serveVersions(versions: Record<string, Bag>) {
121+
vi.stubEnv('OS_MARKETPLACE_PUBLIC_BASE_URL', 'off');
122+
vi.stubEnv('OS_CLOUD_API_KEY', 'svc-test-key');
123+
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
124+
const versionId = decodeURIComponent(String(url).split('/versions/')[1]?.split('/')[0] ?? '');
125+
const manifest = versions[versionId];
126+
if (!manifest) return new Response('not found', { status: 404 });
127+
return new Response(
128+
JSON.stringify({ data: { manifest, version: manifest.version, version_id: versionId } }),
129+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
130+
);
131+
}));
132+
}
133+
134+
/** The plugin over a REAL engine's `manifest` service and registry, booted (holders sealed) to `kernel:ready`. */
135+
async function bootPlugin() {
136+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false } as never);
137+
kernels.push(kernel);
138+
await kernel.use(new ObjectQLPlugin());
139+
await kernel.bootstrap();
140+
const ql = kernel.getService<ObjectQL>('objectql');
141+
ql.registry.logLevel = 'silent';
142+
143+
const routes = new Map<string, Handler>();
144+
const rawApp = {
145+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
146+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
147+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
148+
};
149+
const hooks = new Map<string, () => Promise<void>>();
150+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
151+
const services: Record<string, unknown> = {
152+
manifest: kernel.getService('manifest'),
153+
auth: installerAuthService(),
154+
objectql: withInstallerGrants({
155+
convertPackageBodyForward: (body: unknown) => ql.convertPackageBodyForward(body),
156+
registry: ql.registry,
157+
syncSchemas: async () => undefined,
158+
}),
159+
};
160+
const ctx = {
161+
hook: (e: string, h: () => Promise<void>) => hooks.set(e, h),
162+
getService: (name: string) => {
163+
if (name === 'http-server') return { getRawApp: () => rawApp };
164+
if (!(name in services)) throw new Error(`no ${name}`);
165+
return services[name];
166+
},
167+
logger,
168+
};
169+
170+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: CLOUD_URL, storageDir: dir });
171+
const p = plugin as unknown as Record<string, unknown>;
172+
p.bindArtifactHandlers = async () => undefined;
173+
p.applySideEffects = async () => ({ translationsLoaded: 0, seeded: { mode: 'none' } });
174+
175+
await plugin.start(ctx as never);
176+
await hooks.get('kernel:ready')?.();
177+
178+
const install = async (body: Bag) => {
179+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
180+
return routes.get(`POST ${ROUTE}`)!({
181+
req: {
182+
url: `http://localhost:3000${ROUTE}`,
183+
raw: new Request(`http://localhost:3000${ROUTE}`),
184+
json: async () => body,
185+
param: () => undefined,
186+
},
187+
json,
188+
});
189+
};
190+
/** The cloud-fetched branch: a `packageId` + `versionId` body, the manifest downloaded from the control plane. */
191+
const installFromCloud = (versionId: string) => install({ packageId: 'pkg_acme_routing', versionId });
192+
/** The holder, through the same door's inline branch. */
193+
const installHolder = async () => {
194+
const res = await install({ manifest: packageVersion(HOLDER_ID, '1.0.0', [HELD_FLOW]) });
195+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
196+
};
197+
return { ql, install, installFromCloud, installHolder };
198+
}
199+
200+
/** Every ledger entry, read off disk. */
201+
const ledger = () => {
202+
const source = new LocalManifestSource(dir);
203+
return { holder: source.read(HOLDER_ID).entry, incoming: source.read(INCOMING_ID).entry };
204+
};
205+
206+
/** The holder's flow is still the holder's: the registry serves its item under the bare name. */
207+
function expectHolderFlowKept(ql: ObjectQL) {
208+
expect(ql.registry.getItem<Bag>('flow', HELD_FLOW)?.label).toBe('dispatch flow');
209+
}
210+
211+
function expectBareNameRefusal(res: any) {
212+
expect(res.status, JSON.stringify(res.payload)).toBe(422);
213+
expect(BaseResponseSchema.safeParse(res.payload).success).toBe(true);
214+
expect(envelopeViolations(res.payload)).toEqual([]);
215+
expect(ApiErrorSchema.safeParse(res.payload.error).success).toBe(true);
216+
expect(res.payload.success).toBe(false);
217+
expect(res.payload.error.code).toBe('PLUGIN_REGISTER_FAILED');
218+
// The registry's own sentence: both holders, the name, and the rename.
219+
expect(res.payload.error.message).toContain(`package "${INCOMING_ID}"`);
220+
expect(res.payload.error.message).toContain(`package "${HOLDER_ID}"`);
221+
expect(res.payload.error.message).toContain(`"${HELD_FLOW}"`);
222+
expect(res.payload.error.message).toContain(`"routing_${HELD_FLOW}"`);
223+
}
224+
225+
describe('install-local cloud branch refuses a package declaring a bare name another package holds, before any write', () => {
226+
const versions = {
227+
'v1-free': packageVersion(INCOMING_ID, '1.0.0', [FREE_FLOW]),
228+
'v1-held': packageVersion(INCOMING_ID, '1.0.0', [HELD_FLOW]),
229+
'v2-held': packageVersion(INCOMING_ID, '2.0.0', [FREE_FLOW, HELD_FLOW]),
230+
};
231+
232+
it('a fresh install is refused: 422 naming both holders and the rename, nothing registered or ledgered', async () => {
233+
serveVersions(versions);
234+
const { ql, installFromCloud, installHolder } = await bootPlugin();
235+
await installHolder();
236+
const before = ledger();
237+
238+
const res = await installFromCloud('v1-held');
239+
240+
expectBareNameRefusal(res);
241+
expect(res.payload.data).toBeUndefined();
242+
expect(ql.registry.getPackage(INCOMING_ID)).toBeUndefined();
243+
expect(ledger()).toEqual(before);
244+
expect(ledger().incoming).toBeNull();
245+
expectHolderFlowKept(ql);
246+
});
247+
248+
it('an upgrade is refused: 422, the installed version still registered and ledgered', async () => {
249+
serveVersions(versions);
250+
const { ql, installFromCloud, installHolder } = await bootPlugin();
251+
await installHolder();
252+
const first = await installFromCloud('v1-free');
253+
expect(first.status, JSON.stringify(first.payload)).toBe(200);
254+
expect(first.payload.data.hotLoaded).toBe(true);
255+
const before = ledger();
256+
257+
const res = await installFromCloud('v2-held');
258+
259+
expectBareNameRefusal(res);
260+
expect(ql.registry.getPackage(INCOMING_ID)?.manifest?.version).toBe('1.0.0');
261+
expect(ledger()).toEqual(before);
262+
expect(ledger().incoming?.version).toBe('1.0.0');
263+
expectHolderFlowKept(ql);
264+
});
265+
266+
it('CONTROL — an environment-held security catalog name keeps its answer: 422 naming the environment catalog', async () => {
267+
serveVersions({
268+
'v1-cloned': packageVersion(INCOMING_ID, '1.0.0', [], {
269+
permissions: [{ name: 'acme_dispatcher_lite', label: 'acme_dispatcher_lite', objects: {} }],
270+
}),
271+
});
272+
const { ql, installFromCloud } = await bootPlugin();
273+
// The environment's item, as `sys_metadata` hydration registers it: the bare slot, no package.
274+
ql.registry.registerItem('permission', { name: 'acme_dispatcher_lite', label: 'customer clone', objects: {} });
275+
276+
const res = await installFromCloud('v1-cloned');
277+
278+
expect(res.status, JSON.stringify(res.payload)).toBe(422);
279+
expect(res.payload.error.code).toBe('PLUGIN_REGISTER_FAILED');
280+
expect(res.payload.error.message).toContain('the environment catalog');
281+
expect(ql.registry.getPackage(INCOMING_ID)).toBeUndefined();
282+
expect(ledger().incoming).toBeNull();
283+
});
284+
285+
it('CONTROL — the same package declaring a flow name nobody holds installs: 200, hot-loaded, ledgered', async () => {
286+
serveVersions(versions);
287+
const { ql, installFromCloud, installHolder } = await bootPlugin();
288+
await installHolder();
289+
290+
const res = await installFromCloud('v1-free');
291+
292+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
293+
expect(res.payload.data.hotLoaded).toBe(true);
294+
expect(res.payload.data.hotLoadError).toBeUndefined();
295+
expect(ql.registry.getPackage(INCOMING_ID)?.manifest?.version).toBe('1.0.0');
296+
expect(ledger().incoming?.version).toBe('1.0.0');
297+
expect(ql.registry.getItem<Bag>('flow', FREE_FLOW)?.label).toBe('routing flow');
298+
expectHolderFlowKept(ql);
299+
});
300+
});

0 commit comments

Comments
 (0)