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
5 changes: 5 additions & 0 deletions .changeset/connection-credential-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/cloud-connection": minor
---

Self-hosted binding becomes consumable (cloud ADR-0008 consumption side): `ConnectionCredentialStore` persists the one-time `oscc_` runtime bearer the bind flow returns (0600, env-local); all control-plane forwards fall back to it when no `OS_CLOUD_API_KEY` is set; new `POST /cloud-connection/unbind` revokes + clears; install-local's catalog fetch presents the credential so org/private packages resolve. The binding Setup UI ships WITH the plugin as SDUI metadata (`cloud_connection_settings` page + Setup-nav contribution, ADR-0029 K2) — the console only registers the `cloud-connection:panel` widget.
5 changes: 3 additions & 2 deletions packages/cloud-connection/src/cloud-connection-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ afterEach(() => {
});

describe('CloudConnectionPlugin — mounting', () => {
it('mounts all 7 routes on kernel:ready', async () => {
it('mounts all 8 routes on kernel:ready', async () => {
const rawApp = makeRawApp();
const { ctx, fireKernelReady } = makeCtx({ rawApp });
const plugin = new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test' });
Expand All @@ -79,12 +79,13 @@ describe('CloudConnectionPlugin — mounting', () => {
'GET /api/v1/cloud-connection/status',
'POST /api/v1/cloud-connection/bind/start',
'POST /api/v1/cloud-connection/bind/poll',
'POST /api/v1/cloud-connection/unbind',
'POST /api/v1/cloud-connection/install',
'GET /api/v1/cloud-connection/installation',
'GET /api/v1/cloud-connection/installed',
'GET /api/v1/cloud-connection/org-packages',
]));
expect(keys).toHaveLength(7);
expect(keys).toHaveLength(8);
});

it('warns and mounts nothing without an http-server', async () => {
Expand Down
129 changes: 110 additions & 19 deletions packages/cloud-connection/src/cloud-connection-plugin.ts

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions packages/cloud-connection/src/cloud-connection-ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Cloud Connection — SDUI surface (metadata, not React).
*
* The binding UI ships WITH the plugin as page + navigation metadata
* (ADR-0029 K2: capability plugins contribute their own Setup entries;
* cloud ADR-0008 / console-SDUI direction: new console surfaces are
* metadata-first). The only React involved is the registered
* `cloud-connection:panel` widget — the device-code state machine — which
* the console registers once as a reusable primitive; everything else
* (page shell, nav placement, labels) is server-driven and can evolve
* without a console release.
*/

import type { Page } from '@objectstack/spec/ui';

/** Setup page hosting the binding panel widget. */
export const CloudConnectionSettingsPage: Page = {
name: 'cloud_connection_settings',
label: 'Cloud Connection',
type: 'app',
template: 'default',
kind: 'full',
isDefault: false,
regions: [
{
name: 'header',
width: 'full',
components: [
{
type: 'page:header',
properties: {
title: 'Cloud Connection',
subtitle:
'Connect this runtime to an ObjectStack control plane to browse your '
+ 'organization\'s private packages and install them here.',
icon: 'cloud',
},
},
],
},
{
name: 'main',
width: 'large',
components: [
{
// Registered console widget — the RFC 8628 device-code
// state machine (status → start → user-code display →
// poll → bound / disconnect). Talks to the same-origin
// /api/v1/cloud-connection/* routes this plugin mounts.
type: 'cloud-connection:panel',
properties: {},
},
],
},
],
};

/** Setup-nav contribution: System group, after the marketplace entries. */
export const CLOUD_CONNECTION_NAV_CONTRIBUTIONS = [
{
app: 'setup',
group: 'group_apps',
priority: 200,
items: [
{
id: 'nav_cloud_connection',
type: 'page',
pageName: 'cloud_connection_settings',
label: 'Cloud Connection',
icon: 'cloud',
},
],
},
];

/** Manifest bundle the plugin registers so the page + nav reach the kernel. */
export const CLOUD_CONNECTION_UI_BUNDLE = {
id: 'com.objectstack.cloud-connection.ui',
namespace: 'sys',
version: '0.1.0',
type: 'plugin',
scope: 'system',
name: 'Cloud Connection UI',
description: 'Setup page + navigation for binding this runtime to a control plane.',
pages: [CloudConnectionSettingsPage],
navigationContributions: CLOUD_CONNECTION_NAV_CONTRIBUTIONS,
};
165 changes: 165 additions & 0 deletions packages/cloud-connection/src/connection-credential-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ConnectionCredentialStore + the plugin's credential behavior
* (cloud ADR-0008 consumption side):
* - store round-trip / clear / corrupt tolerance / 0600 mode
* - bind/poll persists the one-time runtime_token and STRIPS it from
* the browser-facing response
* - forwards fall back to the stored bearer when no service key is set
* - unbind revokes (best-effort) and clears the local credential
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { ConnectionCredentialStore } from './connection-credential-store.js';
import { CloudConnectionPlugin } from './cloud-connection-plugin.js';

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

const CRED = { runtimeToken: 'oscc_abc', environmentId: 'env_1', controlPlaneUrl: 'http://cloud.test' };

describe('ConnectionCredentialStore', () => {
it('round-trips, clears, and tolerates corrupt files', () => {
const store = new ConnectionCredentialStore(join(dir, 'cc.json'));
expect(store.read()).toBeNull();
store.write(CRED);
expect(store.read()?.runtimeToken).toBe('oscc_abc');
expect(store.clear()).toBe(true);
expect(store.clear()).toBe(false);

writeFileSync(store.path, '{nope', 'utf8');
expect(store.read()).toBeNull();
});

it('writes the secret 0600', () => {
const store = new ConnectionCredentialStore(join(dir, 'cc.json'));
store.write(CRED);
const mode = statSync(store.path).mode & 0o777;
expect(mode).toBe(0o600);
});
});

// ── plugin harness ──────────────────────────────────────────────────────
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),
};
}
const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId } } : null) } });
function makeCtx(rawApp: any, services: Record<string, any> = {}) {
const hooks = new Map<string, any>();
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(url: string, body?: any) {
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
return { req: { url, raw: new Request(url), json: async () => body ?? {} }, json };
}

describe('CloudConnectionPlugin credential behavior', () => {
it('bind/poll persists runtime_token to the store and strips it from the response', async () => {
const credPath = join(dir, 'cc.json');
const rawApp = makeRawApp();
const { ctx, fire } = makeCtx(rawApp, { auth: sessionAuth('admin'), manifest: { register: vi.fn() } });
vi.stubGlobal('fetch', vi.fn(async (url: any) => {
const u = String(url);
if (u.includes('/auth/device/token')) {
return new Response(JSON.stringify({ access_token: 'op-token' }), { status: 200 });
}
if (u.includes('/cloud-connection/bind')) {
return new Response(JSON.stringify({ success: true, data: { bound: true, connection: { organization_id: 'org_x' }, runtime_token: 'oscc_minted' } }), { status: 200 });
}
throw new Error(`unexpected fetch ${u}`);
}));
await new CloudConnectionPlugin({
singleEnvironment: true, environmentId: 'env_1',
controlPlaneUrl: 'http://cloud.test', credentialPath: credPath,
}).start(ctx as any);
await fire();

const res = await rawApp.routes.get('POST /api/v1/cloud-connection/bind/poll')!(
makeC('http://localhost:3000/x', { device_code: 'dc' }),
);
expect(res.payload.success).toBe(true);
expect(JSON.stringify(res.payload)).not.toContain('oscc_minted');
const stored = new ConnectionCredentialStore(credPath).read();
expect(stored?.runtimeToken).toBe('oscc_minted');
expect(stored?.environmentId).toBe('env_1');
});

it('forwards use the stored bearer when no service key is configured', async () => {
const credPath = join(dir, 'cc.json');
new ConnectionCredentialStore(credPath).write({ runtimeToken: 'oscc_stored', environmentId: 'env_1' });
const rawApp = makeRawApp();
const { ctx, fire } = makeCtx(rawApp, { auth: sessionAuth('admin'), manifest: { register: vi.fn() } });
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ success: true, data: { items: [] } }), { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
await new CloudConnectionPlugin({
singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: '', credentialPath: credPath,
}).start(ctx as any);
await fire();

// env id resolves from the STORE (no OS_ENVIRONMENT_ID, no config id).
const res = await rawApp.routes.get('GET /api/v1/cloud-connection/org-packages')!(
makeC('http://localhost:3000/api/v1/cloud-connection/org-packages'),
);
expect(res.payload.success).toBe(true);
const [, init] = fetchSpy.mock.calls[0]!;
expect((init as any).headers.Authorization).toBe('Bearer oscc_stored');
});

it('unbind revokes against the control plane and clears the local credential', async () => {
const credPath = join(dir, 'cc.json');
const store = new ConnectionCredentialStore(credPath);
store.write({ runtimeToken: 'oscc_stored', environmentId: 'env_1' });
const rawApp = makeRawApp();
const { ctx, fire } = makeCtx(rawApp, { auth: sessionAuth('admin'), manifest: { register: vi.fn() } });
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ success: true }), { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
await new CloudConnectionPlugin({
singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: '', credentialPath: credPath,
}).start(ctx as any);
await fire();

const res = await rawApp.routes.get('POST /api/v1/cloud-connection/unbind')!(
makeC('http://localhost:3000/x'),
);
expect(res.payload.data).toEqual({ environmentId: 'env_1', revoked: true, cleared: true });
expect(String(fetchSpy.mock.calls[0]![0])).toContain('/cloud-connection/revoke');
expect((fetchSpy.mock.calls[0]![1] as any).headers.Authorization).toBe('Bearer oscc_stored');
expect(store.read()).toBeNull();
});

it('registers the SDUI bundle (page + nav) with the manifest service', async () => {
const register = vi.fn();
const rawApp = makeRawApp();
const { ctx, fire } = makeCtx(rawApp, { manifest: { register } });
await new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', credentialPath: join(dir, 'cc.json') }).start(ctx as any);
await fire();
expect(register).toHaveBeenCalledWith(expect.objectContaining({
id: 'com.objectstack.cloud-connection.ui',
pages: [expect.objectContaining({ name: 'cloud_connection_settings' })],
navigationContributions: [expect.objectContaining({ app: 'setup' })],
}));
});
});
79 changes: 79 additions & 0 deletions packages/cloud-connection/src/connection-credential-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ConnectionCredentialStore — where a SELF-HOSTED runtime keeps the
* credential it received at bind time (cloud ADR-0008 consumption side).
*
* Cloud-hosted runtimes authenticate to the control plane with an
* env→cloud service key (`OS_CLOUD_API_KEY`, injected by the cloud).
* Self-hosted runtimes have no such key: their identity ceremony is the
* RFC 8628 device-code bind, whose response carries a one-time
* `runtime_token` (`oscc_…`). This store persists that bearer — plus the
* environment id the binding established — under the runtime's own
* working directory, next to the LocalManifestSource ledger:
*
* <cwd>/.objectstack/cloud-connection.json
*
* Like everything on the runtime's serving path, reads are local file
* operations: presenting the credential is how the runtime reaches the
* control plane for org-scoped catalog/install calls, but nothing at
* boot or serve time DEPENDS on those calls succeeding.
*
* Treat the file as a secret (it is written 0600).
*/

import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';

/** Persisted binding credential + context. */
export interface StoredConnectionCredential {
/** The `oscc_…` runtime bearer returned ONCE by the bind route. */
runtimeToken: string;
/** Control-plane environment id this runtime is bound as. */
environmentId: string;
/** Control-plane base URL the binding was made against. */
controlPlaneUrl?: string;
organizationId?: string;
accountEmail?: string;
boundAt?: string;
}

/** Default store location, relative to the runtime's working directory. */
export const DEFAULT_CONNECTION_CREDENTIAL_PATH = '.objectstack/cloud-connection.json';

export class ConnectionCredentialStore {
/** Resolved file path. */
readonly path: string;

constructor(path?: string) {
this.path = path
? resolve(path)
: resolve(process.cwd(), DEFAULT_CONNECTION_CREDENTIAL_PATH);
}

/** Read the stored credential; null when absent or unreadable. */
read(): StoredConnectionCredential | null {
if (!existsSync(this.path)) return null;
try {
const parsed = JSON.parse(readFileSync(this.path, 'utf8'));
if (!parsed || typeof parsed.runtimeToken !== 'string' || !parsed.runtimeToken) return null;
if (typeof parsed.environmentId !== 'string' || !parsed.environmentId) return null;
return parsed as StoredConnectionCredential;
} catch {
return null;
}
}

/** Persist (replace) the credential. Written 0600 — it is a secret. */
write(credential: StoredConnectionCredential): void {
mkdirSync(dirname(this.path), { recursive: true });
writeFileSync(this.path, JSON.stringify(credential, null, 2), { encoding: 'utf8', mode: 0o600 });
}

/** Remove the credential (unbind). Returns false when nothing was stored. */
clear(): boolean {
if (!existsSync(this.path)) return false;
unlinkSync(this.path);
return true;
}
}
5 changes: 5 additions & 0 deletions packages/cloud-connection/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,8 @@ export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-conn
export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js';
export { RuntimeConfigPlugin } from './runtime-config-plugin.js';
export type { RuntimeConfigPluginConfig, RuntimeConfigPlanFeatures } from './runtime-config-plugin.js';
// ADR-0008 consumption side — the self-hosted credential ledger (bind
// persists the oscc_ bearer here; forwards present it to the control plane).
export { ConnectionCredentialStore, DEFAULT_CONNECTION_CREDENTIAL_PATH } from './connection-credential-store.js';
export type { StoredConnectionCredential } from './connection-credential-store.js';
export { CloudConnectionSettingsPage, CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js';
Loading
Loading