Skip to content
Open
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
64 changes: 64 additions & 0 deletions src/marketplace/__tests__/hub-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { fetchHubCatalog, selectCatalogSource, internalCatalogAuthorized } from '../hub-catalog.js';

const hubOk = { ok: true as const, apps: [{ id: 'documents' }], capabilities: [{ id: 'mib-pos-connector', kind: 'connector', status: 'live' }] };
const hubDown = { ok: false as const, apps: [], capabilities: [], error: 'timeout' };
const local = [{ id: 'documents' }, { id: 'edi-invoices' }];

describe('selectCatalogSource', () => {
it('uses hub apps + capabilities when the hub call succeeds', () => {
const s = selectCatalogSource(local, hubOk);
expect(s.catalog_source).toBe('shre-hub');
expect(s.apps).toEqual(hubOk.apps);
expect(s.capabilities).toHaveLength(1);
});

it('falls back to local rows when the hub is down (roll-back safe)', () => {
const s = selectCatalogSource(local, hubDown);
expect(s.catalog_source).toBe('local');
expect(s.apps).toEqual(local);
expect(s.capabilities).toEqual([]);
});

it('treats an empty hub apps list as an upstream read-back failure and stays local', () => {
const s = selectCatalogSource(local, { ...hubOk, apps: [] });
expect(s.catalog_source).toBe('local');
expect(s.apps).toEqual(local);
// capabilities still usable — the registry half of the hub worked
expect(s.capabilities).toHaveLength(1);
});
});

describe('fetchHubCatalog', () => {
it('returns not-configured without url/token (never throws)', async () => {
expect((await fetchHubCatalog(undefined, undefined)).ok).toBe(false);
expect((await fetchHubCatalog('http://x', undefined)).ok).toBe(false);
});

it('parses a hub catalog response', async () => {
const fake = (async () => new Response(JSON.stringify({ apps: [{ id: 'a' }], capabilities: [{ id: 'c', kind: 'skill', status: 'live' }] }))) as unknown as typeof fetch;
const r = await fetchHubCatalog('http://hub', 'tok', fake);
expect(r.ok).toBe(true);
expect(r.apps).toEqual([{ id: 'a' }]);
expect(r.capabilities[0].id).toBe('c');
});

it('reports non-200s and network errors as honest failures', async () => {
const err = (async () => new Response('nope', { status: 401 })) as unknown as typeof fetch;
expect((await fetchHubCatalog('http://hub', 'tok', err)).error).toContain('401');
const boom = (async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof fetch;
expect((await fetchHubCatalog('http://hub', 'tok', boom)).error).toContain('ECONNREFUSED');
});
});

describe('internalCatalogAuthorized', () => {
it('fails closed when no token is configured', () => {
expect(internalCatalogAuthorized('Bearer x', undefined)).toBe('unconfigured');
});

it('rejects missing/wrong bearers and accepts the right one', () => {
expect(internalCatalogAuthorized(undefined, 'secret')).toBe('unauthorized');
expect(internalCatalogAuthorized('Bearer wrong', 'secret')).toBe('unauthorized');
expect(internalCatalogAuthorized('Bearer secret', 'secret')).toBe('ok');
});
});
73 changes: 73 additions & 0 deletions src/marketplace/hub-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* shre-hub catalog integration (capability-hub mission, Phase 2).
*
* Behind SHRE_HUB_CATALOG_ENABLED the marketplace catalog is served from the
* central hub; AROS stays system-of-record — the hub reads our platform_apps
* back through /api/internal/catalog/* (which never consults the hub, so the
* chain cannot recurse) and adds the fleet capability manifests on top.
* Fallback on any hub failure is the local query, so the flag is
* roll-back-safe by construction.
*/
import { createHash, timingSafeEqual } from 'node:crypto';

export interface HubCapability {
id: string;
kind: string;
status: string;
ui?: { title: string; category: string; summary?: string; icon?: string };
entitlement?: { installable: boolean; requires_terms?: boolean };
[key: string]: unknown;
}

export interface HubCatalogResult {
ok: boolean;
apps: Record<string, unknown>[];
capabilities: HubCapability[];
error?: string;
}

export async function fetchHubCatalog(
baseUrl: string | undefined,
token: string | undefined,
fetchImpl: typeof fetch = fetch,
timeoutMs = 10_000,
): Promise<HubCatalogResult> {
if (!baseUrl || !token) return { ok: false, apps: [], capabilities: [], error: 'hub not configured' };
try {
const res = await fetchImpl(`${baseUrl}/catalog`, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return { ok: false, apps: [], capabilities: [], error: `hub responded ${res.status}` };
const body = (await res.json()) as { apps?: Record<string, unknown>[]; capabilities?: HubCapability[] };
return { ok: true, apps: body.apps ?? [], capabilities: body.capabilities ?? [] };
} catch (e) {
return { ok: false, apps: [], capabilities: [], error: e instanceof Error ? e.message : String(e) };
}
}

// Source selection for the marketplace catalog: hub apps when the hub call
// succeeded AND returned a non-empty list (an empty hub apps list means the
// hub's read-back of OUR data failed upstream — local is more truthful),
// local rows otherwise.
export function selectCatalogSource<T>(
localApps: T[],
hub: HubCatalogResult,
): { apps: T[]; capabilities: HubCapability[]; catalog_source: 'shre-hub' | 'local' } {
if (hub.ok && hub.apps.length > 0) {
return { apps: hub.apps as T[], capabilities: hub.capabilities, catalog_source: 'shre-hub' };
}
return { apps: localApps, capabilities: hub.ok ? hub.capabilities : [], catalog_source: 'local' };
}

// Bearer guard for /api/internal/catalog/* — constant-time, fail-closed.
export function internalCatalogAuthorized(
authorizationHeader: string | undefined,
expectedToken: string | undefined,
): 'ok' | 'unconfigured' | 'unauthorized' {
if (!expectedToken) return 'unconfigured';
const presented = authorizationHeader?.startsWith('Bearer ') ? authorizationHeader.slice(7) : '';
if (!presented) return 'unauthorized';
const digest = (s: string) => createHash('sha256').update(s).digest();
return timingSafeEqual(digest(expectedToken), digest(presented)) ? 'ok' : 'unauthorized';
}
39 changes: 38 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from './billing/stripe.js';
import { handleStripeWebhook } from './billing/webhook.js';
import { publicServiceConfig } from './marketplace/service-config.js';
import { fetchHubCatalog, selectCatalogSource, internalCatalogAuthorized } from './marketplace/hub-catalog.js';
import { provisionLicense } from './billing/license.js';
import { listTasks } from '../tasks/store.js';
import { createSupabaseAdmin, createSupabaseAuthClient } from './supabase.js';
Expand Down Expand Up @@ -342,6 +343,13 @@ const startedAt = new Date().toISOString();
const SHRE_METER_URL = process.env.SHRE_METER_URL || 'http://127.0.0.1:5495';
const SHRE_TASKS_URL = process.env.SHRE_TASKS_URL || 'http://127.0.0.1:5460';
const SHRE_ROUTER_URL = process.env.SHRE_ROUTER_URL || 'http://127.0.0.1:5497';
// Capability-hub (mission P2): marketplace catalog via shre-hub, flag-gated.
// Rollback = unset the flag; entitlement reads/writes never leave AROS.
const SHRE_HUB_CATALOG_ENABLED = process.env.SHRE_HUB_CATALOG_ENABLED === 'true';
const SHRE_HUB_URL = process.env.SHRE_HUB_URL || 'http://127.0.0.1:5537';
const SHRE_HUB_TOKEN = process.env.SHRE_HUB_TOKEN;
// Token the hub presents to /api/internal/catalog/* (endpoints 503 when unset).
const HUB_INTERNAL_TOKEN = process.env.HUB_INTERNAL_TOKEN;
// Router service passport — the launch.sh-managed shre-router enforces
// passport auth on /v1/chat (cost/tenant accounting), but browser requests
// arrive at this proxy with no bearer. Mint a SERVICE passport at boot and
Expand Down Expand Up @@ -2242,18 +2250,45 @@ async function reconcileActivatedAppResources(
if (rows.length) await supabase.from('tenant_resources').upsert(rows, { onConflict: 'tenant_id,kind,name' });
}

// Capability-hub P2: internal read endpoints for shre-hub (system-of-record
// proxy). Serve LOCAL data unconditionally — never consult the hub — so the
// marketplace -> hub -> AROS chain terminates by construction. Service-token
// gated (HUB_INTERNAL_TOKEN), fail-closed, no user auth context.
async function handleInternalCatalog(req: IncomingMessage, res: ServerResponse, resource: 'apps' | 'entitlements'): Promise<void> {
const verdict = internalCatalogAuthorized(req.headers.authorization, HUB_INTERNAL_TOKEN);
if (verdict === 'unconfigured') return json(res, 503, { error: 'internal catalog access not configured (HUB_INTERNAL_TOKEN)' });
if (verdict === 'unauthorized') return json(res, 401, { error: 'Unauthorized' });
const supabase = createSupabaseAdmin();
if (resource === 'apps') {
const { data, error } = await supabase.from('platform_apps').select('*').order('name');
return error ? json(res, 500, { error: error.message }) : json(res, 200, { apps: data || [] });
}
const tenantId = new URL(req.url || '', 'http://localhost').searchParams.get('tenant_id');
if (!tenantId) return json(res, 400, { error: 'tenant_id query param required' });
const { data, error } = await supabase.from('marketplace_app_entitlements').select('app_key,status,source,service_config,enabled_at').eq('tenant_id', tenantId);
if (error) return json(res, 500, { error: error.message });
return json(res, 200, { entitlements: (data || []).map(row => ({ ...row, service_config: publicServiceConfig(row.service_config) })) });
}

async function handlePlatformApps(req: IncomingMessage, res: ServerResponse, appId?: string): Promise<void> {
const auth = await authenticateRequest(req);
if (!auth) return json(res, 401, { error: 'Authentication required' });
const supabase = createSupabaseAdmin();
if (req.method === 'GET') {
const [{ data: apps, error }, { data: grants }] = await Promise.all([supabase.from('platform_apps').select('*').order('name'), supabase.from('marketplace_app_entitlements').select('app_key,status,service_config,role_mapping').eq('tenant_id', auth.tenantId)]);
// Capability-hub P2: catalog via shre-hub when flagged on; grants stay
// local always. Hub failure falls back to the local rows just queried.
const hub = SHRE_HUB_CATALOG_ENABLED
? await fetchHubCatalog(SHRE_HUB_URL, SHRE_HUB_TOKEN)
: { ok: false as const, apps: [], capabilities: [], error: 'flag off' };
if (SHRE_HUB_CATALOG_ENABLED && !hub.ok) console.warn(`[hub-catalog] falling back to local catalog: ${hub.error}`);
const source = selectCatalogSource(apps || [], hub);
// Publish each app's capability bundle so the marketplace can show what
// activation unlocks (skills/agents/tools) before the user commits —
// plus effective_skills: what THIS caller's role bundle actually gets
// (tenant role_mapping override > preset rule > no bundle = none).
const roleMappingByApp = new Map((grants || []).map(g => [g.app_key, (g as { role_mapping?: Record<string, { skills?: string[] }> | null }).role_mapping ?? null]));
return error ? json(res, 500, { error: error.message }) : json(res, 200, { apps: (apps || []).map(app => { const capability = APP_CAPABILITY_BUNDLES[app.id] || null; return { ...app, bundle: capability, effective_skills: capability ? effectiveAppSkills(auth.bundle, capability.skills.map(s => s.name), roleMappingByApp.get(app.id)) : [] }; }), grants: (grants || []).map(({ role_mapping: _rm, service_config, ...rest }) => ({ ...rest, service_config: publicServiceConfig(service_config) })) });
return error ? json(res, 500, { error: error.message }) : json(res, 200, { apps: source.apps.map(app => { const capability = APP_CAPABILITY_BUNDLES[app.id] || null; return { ...app, bundle: capability, effective_skills: capability ? effectiveAppSkills(auth.bundle, capability.skills.map(s => s.name), roleMappingByApp.get(app.id)) : [] }; }), grants: (grants || []).map(({ role_mapping: _rm, service_config, ...rest }) => ({ ...rest, service_config: publicServiceConfig(service_config) })), capabilities: source.capabilities, catalog_source: source.catalog_source });
}
if (!appId) return json(res, 400, { error: 'app id required' });
if (!['owner', 'admin'].includes(auth.role)) return json(res, 403, { error: 'Workspace admin access required' });
Expand Down Expand Up @@ -5662,6 +5697,8 @@ async function handler(req: IncomingMessage, res: ServerResponse): Promise<void>
const workspaceRolesMatch = pathname.match(/^\/api\/workspaces\/([0-9a-f-]+)\/roles$/);
if (workspaceRolesMatch && method === 'GET') return handleWorkspaceMembersCompat(req, res, workspaceRolesMatch[1]);
if (pathname === '/api/apps' && method === 'GET') return handlePlatformApps(req, res);
if (pathname === '/api/internal/catalog/apps' && method === 'GET') return handleInternalCatalog(req, res, 'apps');
if (pathname === '/api/internal/catalog/entitlements' && method === 'GET') return handleInternalCatalog(req, res, 'entitlements');
if (pathname === '/api/app-launch/consume' && method === 'POST') {
if (!rateLimit(req, 30, 60_000)) return json(res, 429, { error: 'Too many launch attempts' });
return handleAppLaunchConsume(req, res);
Expand Down
Loading