Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* findConflict: an ORPHANED marketplace install (registered in the engine but
* with no ledger entry on disk — e.g. a half-finished upgrade left the entry as
* a `.bak`) must NOT be mistaken for user/config code. Previously such a state
* returned 'user-code' and the install endpoint refused the upgrade with a
* misleading "defined by this runtime's local code" 409. It now resolves to
* 'marketplace' so the upgrade can overwrite it. Genuine user code (present in
* the registry at boot, before rehydrate) is still protected.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';

type Handler = (c: any) => Promise<any>;

function makeRawApp() {
const routes = new Map<string, Handler>();
return {
routes,
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
};
}

/**
* ctx whose `objectql` exposes a registry backed by `registry` (an array). The
* `manifest.register()` mock pushes into it, so hot-registering a manifest makes
* the engine "know" it — exactly what findConflict reads via getAllPackages().
*/
function makeCtx(rawApp: any, registry: any[]) {
const hooks = new Map<string, any>();
const services: Record<string, any> = {
manifest: { register: (m: any) => registry.push({ manifest: m }) },
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
objectql: { syncSchemas: async () => undefined, registry: { getAllPackages: () => registry } },
metadata: {},
};
return {
ctx: {
hook: (e: string, h: any) => hooks.set(e, h),
getService: (name: string) => {
if (name === 'http-server') return { getRawApp: () => rawApp };
const svc = services[name];
if (svc === undefined) throw new Error(`no ${name}`);
return svc;
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
},
fire: async () => { await hooks.get('kernel:ready')?.(); },
};
}

function makeC(body: any) {
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
return { req: { url: 'http://localhost:3000/x', raw: new Request('http://localhost:3000/x'), json: async () => body, param: () => undefined }, json };
}

let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-conflict-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });

const MANIFEST = (id: string, version = '1.0.0') => ({ id, version, objects: [] });

describe('findConflict — orphaned ledger vs real user code', () => {
it('refuses to overwrite genuine user/config code (registered at boot)', async () => {
const rawApp = makeRawApp();
// AppPlugin-style local code present in the registry BEFORE kernel:ready.
const registry: any[] = [{ manifest: { id: 'app.user.foo', objects: [] } }];
const { ctx, fire } = makeCtx(rawApp, registry);
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
await plugin.start(ctx as any);
await fire(); // captures bootUserCodeIds = { app.user.foo }

const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
makeC({ manifest: MANIFEST('app.user.foo') }),
);
expect(res.status).toBe(409);
expect(res.payload?.error?.code).toBe('manifest_conflict');
});

it('allows upgrading an orphaned marketplace install (registered, ledger entry gone)', async () => {
const rawApp = makeRawApp();
const registry: any[] = []; // empty at boot → empty user-code snapshot
const { ctx, fire } = makeCtx(rawApp, registry);
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
await plugin.start(ctx as any);
await fire();

const install = rawApp.routes.get('POST /api/v1/marketplace/install-local')!;

// First install: succeeds, writes a ledger entry, registers the manifest.
const first = await install(makeC({ manifest: MANIFEST('app.mk.bar', '1.0.0') }));
expect(first.payload?.success).toBe(true);
expect(registry.some((p) => p.manifest.id === 'app.mk.bar')).toBe(true);

// Orphan it: drop the ledger file but leave it registered in the engine.
for (const f of readdirSync(dir)) rmSync(join(dir, f));

// Re-install (upgrade): must NOT be refused as "user-code".
const second = await install(makeC({ manifest: MANIFEST('app.mk.bar', '1.0.1') }));
expect(second.status).not.toBe(409);
expect(second.payload?.success).toBe(true);
expect(second.payload?.error?.code).not.toBe('manifest_conflict');
});

it('still treats a fresh, never-seen manifest as a clean install', async () => {
const rawApp = makeRawApp();
const registry: any[] = [];
const { ctx, fire } = makeCtx(rawApp, registry);
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
await plugin.start(ctx as any);
await fire();

const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
makeC({ manifest: MANIFEST('app.brand.new') }),
);
expect(res.payload?.success).toBe(true);
});
});
57 changes: 51 additions & 6 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js';

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

/** Best-effort manifest id from a registry package entry (shape varies). */
function manifestIdOf(p: any): string | undefined {
return p?.manifest?.id ?? p?.id ?? p?.manifest?.name ?? undefined;
}

export interface MarketplaceInstallLocalPluginConfig {
/** Cloud control-plane base URL. When unset, falls back to OS_CLOUD_URL
* and then to the public ObjectStack cloud so a fresh `objectstack dev`
Expand All @@ -75,6 +80,14 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
private readonly ledger: LocalManifestSource;
private readonly storageDir: string;
private readonly credentials: ConnectionCredentialStore;
/**
* Manifest ids already present in the engine registry at `kernel:ready`,
* BEFORE this plugin rehydrates its own ledger. These are genuine
* user/config-defined apps (AppPlugin from objectstack.config.ts). Used by
* findConflict to tell real local code apart from an orphaned marketplace
* install whose ledger entry went missing.
*/
private readonly bootUserCodeIds = new Set<string>();

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

start = async (ctx: PluginContext): Promise<void> => {
ctx.hook('kernel:ready', async () => {
// Snapshot the manifest ids the engine already knows about BEFORE
// we register anything or rehydrate the ledger — by now AppPlugin
// has loaded objectstack.config.ts, so whatever is registered here
// is genuine local/user code. findConflict uses this to avoid
// misreading an orphaned marketplace install as user code.
this.captureBootUserCodeIds(ctx);

// Plugin-owned Setup nav (cloud ADR-0009): "Installed Apps"
// ships WITH the local-install capability.
try {
Expand Down Expand Up @@ -454,22 +474,47 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
* (refuse to avoid silently overwriting authored code)
*/
private findConflict = (ctx: PluginContext, manifestId: string): 'none' | 'marketplace' | 'user-code' => {
// First check: do we already have a marketplace install entry?
// 1. A live ledger entry is the authoritative "we installed this" record.
if (this.ledger.has(manifestId)) {
return 'marketplace';
}
// Then check: is the manifest_id already in the engine's registry?
// 2. Present in the engine registry AND captured at boot before we
// rehydrated — genuine user/config code. Refuse to overwrite.
if (this.bootUserCodeIds.has(manifestId)) {
return 'user-code';
}
// 3. Registered, but neither in the ledger nor in the boot snapshot.
// This is an ORPHANED marketplace install — its ledger entry was
// lost/renamed/corrupted (e.g. a half-finished upgrade left a
// `.bak`). It is NOT user code, so treat it as a marketplace package
// and let the upgrade overwrite it, rather than refusing with a
// misleading "defined by this runtime's local code" error.
try {
const ql: any = ctx.getService('objectql');
const packages: any[] = ql?.registry?.getAllPackages?.() ?? [];
const hit = packages.find((p: any) =>
(p?.manifest?.id ?? p?.id ?? p?.manifest?.name) === manifestId,
);
if (hit) return 'user-code';
if (packages.some((p: any) => manifestIdOf(p) === manifestId)) {
return 'marketplace';
}
} catch { /* objectql not registered yet — treat as fresh */ }
return 'none';
};

/**
* Record the manifest ids the engine registry already holds, called once at
* `kernel:ready` before rehydrate. Best-effort: a missing/empty registry
* just yields an empty snapshot (every later install is treated as fresh).
*/
private captureBootUserCodeIds = (ctx: PluginContext): void => {
try {
const ql: any = ctx.getService('objectql');
const packages: any[] = ql?.registry?.getAllPackages?.() ?? [];
for (const p of packages) {
const id = manifestIdOf(p);
if (id) this.bootUserCodeIds.add(id);
}
} catch { /* objectql not ready — leave snapshot empty */ }
};

/**
* Pull a userId out of the request's better-auth session, if any.
* Returns null when there is no signed-in user. v1 does not check
Expand Down