Skip to content

Commit c213ed8

Browse files
xuyushun441-syshotlongclaude
authored
fix(cloud-connection): don't refuse upgrading an orphaned marketplace install (#2174)
`findConflict` classified any manifest present in the engine registry but absent from the on-disk ledger as `user-code`, and the install endpoint then refused the upgrade with "manifest_id … is already defined by this runtime's local code. Refusing to overwrite" (and told the user to edit objectstack.config.ts — where it isn't). But a marketplace package is hot-registered AND recorded in the ledger. If the ledger entry goes missing or out of sync — a half-finished upgrade that left a `.bak`, a manual cleanup, a corrupted file — the manifest stays registered while `ledger.has()` returns false. The package is now an ORPHANED marketplace install, not local code, yet every reinstall/upgrade was blocked with a misleading error. (Hit live: a stale `app.objectstack.template.project.json.bak` made the showcase refuse to install the fixed version.) Disambiguate by source instead of by "is it registered": - Snapshot the manifest ids already in the engine registry at `kernel:ready`, BEFORE rehydrating the ledger — those are genuine AppPlugin/config code. - findConflict: ledger entry ⇒ 'marketplace'; in the boot snapshot ⇒ 'user-code' (still protected); registered but in neither ⇒ orphaned marketplace install ⇒ 'marketplace' (allow the upgrade to overwrite) rather than 'user-code'. Adds `marketplace-install-local-conflict.test.ts` (3 cases: real user code refused, orphaned install allowed, fresh install clean). Co-authored-by: Jack Zhuang <zhuangjianguo@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 987854b commit c213ed8

2 files changed

Lines changed: 176 additions & 6 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* findConflict: an ORPHANED marketplace install (registered in the engine but
5+
* with no ledger entry on disk — e.g. a half-finished upgrade left the entry as
6+
* a `.bak`) must NOT be mistaken for user/config code. Previously such a state
7+
* returned 'user-code' and the install endpoint refused the upgrade with a
8+
* misleading "defined by this runtime's local code" 409. It now resolves to
9+
* 'marketplace' so the upgrade can overwrite it. Genuine user code (present in
10+
* the registry at boot, before rehydrate) is still protected.
11+
*/
12+
13+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
14+
import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
15+
import { join } from 'node:path';
16+
import { tmpdir } from 'node:os';
17+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
18+
19+
type Handler = (c: any) => Promise<any>;
20+
21+
function makeRawApp() {
22+
const routes = new Map<string, Handler>();
23+
return {
24+
routes,
25+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
26+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
27+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
28+
};
29+
}
30+
31+
/**
32+
* ctx whose `objectql` exposes a registry backed by `registry` (an array). The
33+
* `manifest.register()` mock pushes into it, so hot-registering a manifest makes
34+
* the engine "know" it — exactly what findConflict reads via getAllPackages().
35+
*/
36+
function makeCtx(rawApp: any, registry: any[]) {
37+
const hooks = new Map<string, any>();
38+
const services: Record<string, any> = {
39+
manifest: { register: (m: any) => registry.push({ manifest: m }) },
40+
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
41+
objectql: { syncSchemas: async () => undefined, registry: { getAllPackages: () => registry } },
42+
metadata: {},
43+
};
44+
return {
45+
ctx: {
46+
hook: (e: string, h: any) => hooks.set(e, h),
47+
getService: (name: string) => {
48+
if (name === 'http-server') return { getRawApp: () => rawApp };
49+
const svc = services[name];
50+
if (svc === undefined) throw new Error(`no ${name}`);
51+
return svc;
52+
},
53+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
54+
},
55+
fire: async () => { await hooks.get('kernel:ready')?.(); },
56+
};
57+
}
58+
59+
function makeC(body: any) {
60+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
61+
return { req: { url: 'http://localhost:3000/x', raw: new Request('http://localhost:3000/x'), json: async () => body, param: () => undefined }, json };
62+
}
63+
64+
let dir: string;
65+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-conflict-')); });
66+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
67+
68+
const MANIFEST = (id: string, version = '1.0.0') => ({ id, version, objects: [] });
69+
70+
describe('findConflict — orphaned ledger vs real user code', () => {
71+
it('refuses to overwrite genuine user/config code (registered at boot)', async () => {
72+
const rawApp = makeRawApp();
73+
// AppPlugin-style local code present in the registry BEFORE kernel:ready.
74+
const registry: any[] = [{ manifest: { id: 'app.user.foo', objects: [] } }];
75+
const { ctx, fire } = makeCtx(rawApp, registry);
76+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
77+
await plugin.start(ctx as any);
78+
await fire(); // captures bootUserCodeIds = { app.user.foo }
79+
80+
const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
81+
makeC({ manifest: MANIFEST('app.user.foo') }),
82+
);
83+
expect(res.status).toBe(409);
84+
expect(res.payload?.error?.code).toBe('manifest_conflict');
85+
});
86+
87+
it('allows upgrading an orphaned marketplace install (registered, ledger entry gone)', async () => {
88+
const rawApp = makeRawApp();
89+
const registry: any[] = []; // empty at boot → empty user-code snapshot
90+
const { ctx, fire } = makeCtx(rawApp, registry);
91+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
92+
await plugin.start(ctx as any);
93+
await fire();
94+
95+
const install = rawApp.routes.get('POST /api/v1/marketplace/install-local')!;
96+
97+
// First install: succeeds, writes a ledger entry, registers the manifest.
98+
const first = await install(makeC({ manifest: MANIFEST('app.mk.bar', '1.0.0') }));
99+
expect(first.payload?.success).toBe(true);
100+
expect(registry.some((p) => p.manifest.id === 'app.mk.bar')).toBe(true);
101+
102+
// Orphan it: drop the ledger file but leave it registered in the engine.
103+
for (const f of readdirSync(dir)) rmSync(join(dir, f));
104+
105+
// Re-install (upgrade): must NOT be refused as "user-code".
106+
const second = await install(makeC({ manifest: MANIFEST('app.mk.bar', '1.0.1') }));
107+
expect(second.status).not.toBe(409);
108+
expect(second.payload?.success).toBe(true);
109+
expect(second.payload?.error?.code).not.toBe('manifest_conflict');
110+
});
111+
112+
it('still treats a fresh, never-seen manifest as a clean install', async () => {
113+
const rawApp = makeRawApp();
114+
const registry: any[] = [];
115+
const { ctx, fire } = makeCtx(rawApp, registry);
116+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
117+
await plugin.start(ctx as any);
118+
await fire();
119+
120+
const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
121+
makeC({ manifest: MANIFEST('app.brand.new') }),
122+
);
123+
expect(res.payload?.success).toBe(true);
124+
});
125+
});

packages/cloud-connection/src/marketplace-install-local-plugin.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js';
5151

5252
const ROUTE_BASE = '/api/v1/marketplace/install-local';
5353

54+
/** Best-effort manifest id from a registry package entry (shape varies). */
55+
function manifestIdOf(p: any): string | undefined {
56+
return p?.manifest?.id ?? p?.id ?? p?.manifest?.name ?? undefined;
57+
}
58+
5459
export interface MarketplaceInstallLocalPluginConfig {
5560
/** Cloud control-plane base URL. When unset, falls back to OS_CLOUD_URL
5661
* and then to the public ObjectStack cloud so a fresh `objectstack dev`
@@ -75,6 +80,14 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
7580
private readonly ledger: LocalManifestSource;
7681
private readonly storageDir: string;
7782
private readonly credentials: ConnectionCredentialStore;
83+
/**
84+
* Manifest ids already present in the engine registry at `kernel:ready`,
85+
* BEFORE this plugin rehydrates its own ledger. These are genuine
86+
* user/config-defined apps (AppPlugin from objectstack.config.ts). Used by
87+
* findConflict to tell real local code apart from an orphaned marketplace
88+
* install whose ledger entry went missing.
89+
*/
90+
private readonly bootUserCodeIds = new Set<string>();
7891

7992
constructor(config: MarketplaceInstallLocalPluginConfig = {}) {
8093
this.cloudUrl = resolveCloudUrl(config.controlPlaneUrl);
@@ -89,6 +102,13 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
89102

90103
start = async (ctx: PluginContext): Promise<void> => {
91104
ctx.hook('kernel:ready', async () => {
105+
// Snapshot the manifest ids the engine already knows about BEFORE
106+
// we register anything or rehydrate the ledger — by now AppPlugin
107+
// has loaded objectstack.config.ts, so whatever is registered here
108+
// is genuine local/user code. findConflict uses this to avoid
109+
// misreading an orphaned marketplace install as user code.
110+
this.captureBootUserCodeIds(ctx);
111+
92112
// Plugin-owned Setup nav (cloud ADR-0009): "Installed Apps"
93113
// ships WITH the local-install capability.
94114
try {
@@ -454,22 +474,47 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
454474
* (refuse to avoid silently overwriting authored code)
455475
*/
456476
private findConflict = (ctx: PluginContext, manifestId: string): 'none' | 'marketplace' | 'user-code' => {
457-
// First check: do we already have a marketplace install entry?
477+
// 1. A live ledger entry is the authoritative "we installed this" record.
458478
if (this.ledger.has(manifestId)) {
459479
return 'marketplace';
460480
}
461-
// Then check: is the manifest_id already in the engine's registry?
481+
// 2. Present in the engine registry AND captured at boot before we
482+
// rehydrated — genuine user/config code. Refuse to overwrite.
483+
if (this.bootUserCodeIds.has(manifestId)) {
484+
return 'user-code';
485+
}
486+
// 3. Registered, but neither in the ledger nor in the boot snapshot.
487+
// This is an ORPHANED marketplace install — its ledger entry was
488+
// lost/renamed/corrupted (e.g. a half-finished upgrade left a
489+
// `.bak`). It is NOT user code, so treat it as a marketplace package
490+
// and let the upgrade overwrite it, rather than refusing with a
491+
// misleading "defined by this runtime's local code" error.
462492
try {
463493
const ql: any = ctx.getService('objectql');
464494
const packages: any[] = ql?.registry?.getAllPackages?.() ?? [];
465-
const hit = packages.find((p: any) =>
466-
(p?.manifest?.id ?? p?.id ?? p?.manifest?.name) === manifestId,
467-
);
468-
if (hit) return 'user-code';
495+
if (packages.some((p: any) => manifestIdOf(p) === manifestId)) {
496+
return 'marketplace';
497+
}
469498
} catch { /* objectql not registered yet — treat as fresh */ }
470499
return 'none';
471500
};
472501

502+
/**
503+
* Record the manifest ids the engine registry already holds, called once at
504+
* `kernel:ready` before rehydrate. Best-effort: a missing/empty registry
505+
* just yields an empty snapshot (every later install is treated as fresh).
506+
*/
507+
private captureBootUserCodeIds = (ctx: PluginContext): void => {
508+
try {
509+
const ql: any = ctx.getService('objectql');
510+
const packages: any[] = ql?.registry?.getAllPackages?.() ?? [];
511+
for (const p of packages) {
512+
const id = manifestIdOf(p);
513+
if (id) this.bootUserCodeIds.add(id);
514+
}
515+
} catch { /* objectql not ready — leave snapshot empty */ }
516+
};
517+
473518
/**
474519
* Pull a userId out of the request's better-auth session, if any.
475520
* Returns null when there is no signed-in user. v1 does not check

0 commit comments

Comments
 (0)