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
80 changes: 80 additions & 0 deletions packages/runtime/src/cloud/marketplace-proxy-browse-only.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* MarketplaceProxyPlugin is a BROWSE-ONLY mechanism (ADR §5.2): it forwards
* GET/HEAD marketplace reads and must NOT own install *policy*. Non-GET
* requests pass through (`next()`) so a host-supplied install route (mounted
* via the createObjectOSStack `extraPlugins` seam) can claim them — instead of
* the old 405 "install via cloud" dead-end (framework#1548).
*/

import { describe, it, expect } from 'vitest';
import { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js';

/** Drive the plugin's kernel:ready wiring and capture the registered handler. */
async function captureHandler(plugin: MarketplaceProxyPlugin) {
let handler: any;
let readyFn: any;
const rawApp = {
all: (_path: string, h: any) => { handler = h; },
get: () => {}, post: () => {}, head: () => {},
};
const ctx: any = {
hook: (ev: string, fn: any) => { if (ev === 'kernel:ready') readyFn = fn; },
getService: (name: string) => (name === 'http-server' ? { getRawApp: () => rawApp } : undefined),
logger: { info() {}, warn() {}, error() {} },
};
await plugin.start(ctx);
await readyFn();
return handler;
}

function fakeCtx(method: string, path: string) {
return {
req: {
url: `http://env.test${path}`,
method,
header: () => undefined,
raw: {},
},
json: (body: any, status: number) => ({ __status: status, __body: body }),
};
}

describe('MarketplaceProxyPlugin — browse-only (no install dead-end)', () => {
it('passes through a non-GET marketplace request (next()) instead of 405', async () => {
const plugin = new MarketplaceProxyPlugin({ controlPlaneUrl: 'http://cloud.test', cacheDisabled: true });
const handler = await captureHandler(plugin);
expect(typeof handler).toBe('function');

let nextCalled = false;
const result = await handler(
fakeCtx('POST', '/api/v1/marketplace/packages/pkg_1/install'),
async () => { nextCalled = true; return 'PASSED_THROUGH'; },
);

// The handler delegates to the next route rather than emitting a 405.
expect(nextCalled).toBe(true);
expect(result).toBe('PASSED_THROUGH');
});

it('still passes install-local through (owned by the local install plugin)', async () => {
const plugin = new MarketplaceProxyPlugin({ controlPlaneUrl: 'http://cloud.test', cacheDisabled: true });
const handler = await captureHandler(plugin);

let nextCalled = false;
await handler(
fakeCtx('POST', '/api/v1/marketplace/install-local'),
async () => { nextCalled = true; return 'LOCAL'; },
);
expect(nextCalled).toBe(true);
});

it('503s when no control plane is configured (unchanged behaviour)', async () => {
const plugin = new MarketplaceProxyPlugin({ controlPlaneUrl: 'off', cacheDisabled: true });
const handler = await captureHandler(plugin);
const result: any = await handler(fakeCtx('GET', '/api/v1/marketplace/packages'), async () => 'NEXT');
expect(result.__status).toBe(503);
expect(result.__body?.error?.code).toBe('marketplace_unavailable');
});
});
24 changes: 13 additions & 11 deletions packages/runtime/src/cloud/marketplace-proxy-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,18 +232,20 @@ export class MarketplaceProxyPlugin implements Plugin {
// Preserve the full /api/v1/marketplace/... path on cloud.
const target = `${cloudUrl}${incomingUrl.pathname}${incomingUrl.search}`;

// Forward only safe, idempotent methods. We intentionally
// do NOT proxy POST / PUT / DELETE here — those would
// need credentialled cloud auth which the tenant runtime
// does not carry.
// Browse-only mechanism: this plugin forwards only safe,
// idempotent GET/HEAD (it carries no credentialled cloud
// auth). It does NOT own install *policy* — that is a host
// concern (ObjectStack Cloud supplies a credentialled
// install route via the `extraPlugins` seam; see ADR
// docs/design/cloud-account-binding-marketplace-install.md
// §5.2). So instead of dead-ending non-GET with a 405
// "install via cloud", we PASS THROUGH: a host-supplied
// handler mounted after this plugin can claim the request;
// if none does, the app returns its normal 404. This
// removes the browse-only install dead-end (framework#1548)
// without this plugin pretending to know install policy.
if (method !== 'GET' && method !== 'HEAD') {
return c.json({
success: false,
error: {
code: 'marketplace_method_not_allowed',
message: `Marketplace proxy only forwards GET/HEAD; install via cloud.`,
},
}, 405);
return next();
}

// Cache lookup. Key includes accept-language because
Expand Down
Loading