Skip to content

Commit b8e4232

Browse files
os-zhuangclaude
andauthored
feat(cloud-connection): self-hosted credential loop + SDUI binding surface (#1766)
- ConnectionCredentialStore: persists the one-time oscc_ runtime bearer from the bind response (0600 file, env-local); bind/poll stores it and STRIPS it from the browser-facing response; environment id persists with it so OS_ENVIRONMENT_ID is only needed for the first bind. - All control-plane forwards (status/install/installation/installed/ org-packages) resolve their credential per-request: service key (cloud-hosted) → stored bearer (self-hosted). - New POST /cloud-connection/unbind: best-effort control-plane revoke + local credential clear. - install-local catalog fetch presents the credential so a bound runtime resolves its own org/private package manifests. - SDUI surface (ADR-0029 K2): the plugin registers a cloud_connection_settings page + Setup-nav contribution as metadata; the console provides only the cloud-connection:panel widget. - 36/36 package tests (store round-trip/0600, persist+strip, stored- bearer forwards, unbind, bundle registration). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3b2e729 commit b8e4232

8 files changed

Lines changed: 471 additions & 22 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/cloud-connection": minor
3+
---
4+
5+
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.

packages/cloud-connection/src/cloud-connection-plugin.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ afterEach(() => {
6868
});
6969

7070
describe('CloudConnectionPlugin — mounting', () => {
71-
it('mounts all 7 routes on kernel:ready', async () => {
71+
it('mounts all 8 routes on kernel:ready', async () => {
7272
const rawApp = makeRawApp();
7373
const { ctx, fireKernelReady } = makeCtx({ rawApp });
7474
const plugin = new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test' });
@@ -79,12 +79,13 @@ describe('CloudConnectionPlugin — mounting', () => {
7979
'GET /api/v1/cloud-connection/status',
8080
'POST /api/v1/cloud-connection/bind/start',
8181
'POST /api/v1/cloud-connection/bind/poll',
82+
'POST /api/v1/cloud-connection/unbind',
8283
'POST /api/v1/cloud-connection/install',
8384
'GET /api/v1/cloud-connection/installation',
8485
'GET /api/v1/cloud-connection/installed',
8586
'GET /api/v1/cloud-connection/org-packages',
8687
]));
87-
expect(keys).toHaveLength(7);
88+
expect(keys).toHaveLength(8);
8889
});
8990

9091
it('warns and mounts nothing without an http-server', async () => {

packages/cloud-connection/src/cloud-connection-plugin.ts

Lines changed: 110 additions & 19 deletions
Large diffs are not rendered by default.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Cloud Connection — SDUI surface (metadata, not React).
5+
*
6+
* The binding UI ships WITH the plugin as page + navigation metadata
7+
* (ADR-0029 K2: capability plugins contribute their own Setup entries;
8+
* cloud ADR-0008 / console-SDUI direction: new console surfaces are
9+
* metadata-first). The only React involved is the registered
10+
* `cloud-connection:panel` widget — the device-code state machine — which
11+
* the console registers once as a reusable primitive; everything else
12+
* (page shell, nav placement, labels) is server-driven and can evolve
13+
* without a console release.
14+
*/
15+
16+
import type { Page } from '@objectstack/spec/ui';
17+
18+
/** Setup page hosting the binding panel widget. */
19+
export const CloudConnectionSettingsPage: Page = {
20+
name: 'cloud_connection_settings',
21+
label: 'Cloud Connection',
22+
type: 'app',
23+
template: 'default',
24+
kind: 'full',
25+
isDefault: false,
26+
regions: [
27+
{
28+
name: 'header',
29+
width: 'full',
30+
components: [
31+
{
32+
type: 'page:header',
33+
properties: {
34+
title: 'Cloud Connection',
35+
subtitle:
36+
'Connect this runtime to an ObjectStack control plane to browse your '
37+
+ 'organization\'s private packages and install them here.',
38+
icon: 'cloud',
39+
},
40+
},
41+
],
42+
},
43+
{
44+
name: 'main',
45+
width: 'large',
46+
components: [
47+
{
48+
// Registered console widget — the RFC 8628 device-code
49+
// state machine (status → start → user-code display →
50+
// poll → bound / disconnect). Talks to the same-origin
51+
// /api/v1/cloud-connection/* routes this plugin mounts.
52+
type: 'cloud-connection:panel',
53+
properties: {},
54+
},
55+
],
56+
},
57+
],
58+
};
59+
60+
/** Setup-nav contribution: System group, after the marketplace entries. */
61+
export const CLOUD_CONNECTION_NAV_CONTRIBUTIONS = [
62+
{
63+
app: 'setup',
64+
group: 'group_apps',
65+
priority: 200,
66+
items: [
67+
{
68+
id: 'nav_cloud_connection',
69+
type: 'page',
70+
pageName: 'cloud_connection_settings',
71+
label: 'Cloud Connection',
72+
icon: 'cloud',
73+
},
74+
],
75+
},
76+
];
77+
78+
/** Manifest bundle the plugin registers so the page + nav reach the kernel. */
79+
export const CLOUD_CONNECTION_UI_BUNDLE = {
80+
id: 'com.objectstack.cloud-connection.ui',
81+
namespace: 'sys',
82+
version: '0.1.0',
83+
type: 'plugin',
84+
scope: 'system',
85+
name: 'Cloud Connection UI',
86+
description: 'Setup page + navigation for binding this runtime to a control plane.',
87+
pages: [CloudConnectionSettingsPage],
88+
navigationContributions: CLOUD_CONNECTION_NAV_CONTRIBUTIONS,
89+
};
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ConnectionCredentialStore + the plugin's credential behavior
5+
* (cloud ADR-0008 consumption side):
6+
* - store round-trip / clear / corrupt tolerance / 0600 mode
7+
* - bind/poll persists the one-time runtime_token and STRIPS it from
8+
* the browser-facing response
9+
* - forwards fall back to the stored bearer when no service key is set
10+
* - unbind revokes (best-effort) and clears the local credential
11+
*/
12+
13+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
14+
import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
15+
import { join } from 'node:path';
16+
import { tmpdir } from 'node:os';
17+
import { ConnectionCredentialStore } from './connection-credential-store.js';
18+
import { CloudConnectionPlugin } from './cloud-connection-plugin.js';
19+
20+
let dir: string;
21+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'ccs-')); });
22+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.unstubAllGlobals(); });
23+
24+
const CRED = { runtimeToken: 'oscc_abc', environmentId: 'env_1', controlPlaneUrl: 'http://cloud.test' };
25+
26+
describe('ConnectionCredentialStore', () => {
27+
it('round-trips, clears, and tolerates corrupt files', () => {
28+
const store = new ConnectionCredentialStore(join(dir, 'cc.json'));
29+
expect(store.read()).toBeNull();
30+
store.write(CRED);
31+
expect(store.read()?.runtimeToken).toBe('oscc_abc');
32+
expect(store.clear()).toBe(true);
33+
expect(store.clear()).toBe(false);
34+
35+
writeFileSync(store.path, '{nope', 'utf8');
36+
expect(store.read()).toBeNull();
37+
});
38+
39+
it('writes the secret 0600', () => {
40+
const store = new ConnectionCredentialStore(join(dir, 'cc.json'));
41+
store.write(CRED);
42+
const mode = statSync(store.path).mode & 0o777;
43+
expect(mode).toBe(0o600);
44+
});
45+
});
46+
47+
// ── plugin harness ──────────────────────────────────────────────────────
48+
type Handler = (c: any) => Promise<any>;
49+
function makeRawApp() {
50+
const routes = new Map<string, Handler>();
51+
return {
52+
routes,
53+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
54+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
55+
};
56+
}
57+
const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId } } : null) } });
58+
function makeCtx(rawApp: any, services: Record<string, any> = {}) {
59+
const hooks = new Map<string, any>();
60+
return {
61+
ctx: {
62+
hook: (e: string, h: any) => hooks.set(e, h),
63+
getService: (name: string) => {
64+
if (name === 'http-server') return { getRawApp: () => rawApp };
65+
const svc = services[name];
66+
if (svc === undefined) throw new Error(`no ${name}`);
67+
return svc;
68+
},
69+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
70+
},
71+
fire: async () => { await hooks.get('kernel:ready')?.(); },
72+
};
73+
}
74+
function makeC(url: string, body?: any) {
75+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
76+
return { req: { url, raw: new Request(url), json: async () => body ?? {} }, json };
77+
}
78+
79+
describe('CloudConnectionPlugin credential behavior', () => {
80+
it('bind/poll persists runtime_token to the store and strips it from the response', async () => {
81+
const credPath = join(dir, 'cc.json');
82+
const rawApp = makeRawApp();
83+
const { ctx, fire } = makeCtx(rawApp, { auth: sessionAuth('admin'), manifest: { register: vi.fn() } });
84+
vi.stubGlobal('fetch', vi.fn(async (url: any) => {
85+
const u = String(url);
86+
if (u.includes('/auth/device/token')) {
87+
return new Response(JSON.stringify({ access_token: 'op-token' }), { status: 200 });
88+
}
89+
if (u.includes('/cloud-connection/bind')) {
90+
return new Response(JSON.stringify({ success: true, data: { bound: true, connection: { organization_id: 'org_x' }, runtime_token: 'oscc_minted' } }), { status: 200 });
91+
}
92+
throw new Error(`unexpected fetch ${u}`);
93+
}));
94+
await new CloudConnectionPlugin({
95+
singleEnvironment: true, environmentId: 'env_1',
96+
controlPlaneUrl: 'http://cloud.test', credentialPath: credPath,
97+
}).start(ctx as any);
98+
await fire();
99+
100+
const res = await rawApp.routes.get('POST /api/v1/cloud-connection/bind/poll')!(
101+
makeC('http://localhost:3000/x', { device_code: 'dc' }),
102+
);
103+
expect(res.payload.success).toBe(true);
104+
expect(JSON.stringify(res.payload)).not.toContain('oscc_minted');
105+
const stored = new ConnectionCredentialStore(credPath).read();
106+
expect(stored?.runtimeToken).toBe('oscc_minted');
107+
expect(stored?.environmentId).toBe('env_1');
108+
});
109+
110+
it('forwards use the stored bearer when no service key is configured', async () => {
111+
const credPath = join(dir, 'cc.json');
112+
new ConnectionCredentialStore(credPath).write({ runtimeToken: 'oscc_stored', environmentId: 'env_1' });
113+
const rawApp = makeRawApp();
114+
const { ctx, fire } = makeCtx(rawApp, { auth: sessionAuth('admin'), manifest: { register: vi.fn() } });
115+
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ success: true, data: { items: [] } }), { status: 200 }));
116+
vi.stubGlobal('fetch', fetchSpy);
117+
await new CloudConnectionPlugin({
118+
singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: '', credentialPath: credPath,
119+
}).start(ctx as any);
120+
await fire();
121+
122+
// env id resolves from the STORE (no OS_ENVIRONMENT_ID, no config id).
123+
const res = await rawApp.routes.get('GET /api/v1/cloud-connection/org-packages')!(
124+
makeC('http://localhost:3000/api/v1/cloud-connection/org-packages'),
125+
);
126+
expect(res.payload.success).toBe(true);
127+
const [, init] = fetchSpy.mock.calls[0]!;
128+
expect((init as any).headers.Authorization).toBe('Bearer oscc_stored');
129+
});
130+
131+
it('unbind revokes against the control plane and clears the local credential', async () => {
132+
const credPath = join(dir, 'cc.json');
133+
const store = new ConnectionCredentialStore(credPath);
134+
store.write({ runtimeToken: 'oscc_stored', environmentId: 'env_1' });
135+
const rawApp = makeRawApp();
136+
const { ctx, fire } = makeCtx(rawApp, { auth: sessionAuth('admin'), manifest: { register: vi.fn() } });
137+
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ success: true }), { status: 200 }));
138+
vi.stubGlobal('fetch', fetchSpy);
139+
await new CloudConnectionPlugin({
140+
singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: '', credentialPath: credPath,
141+
}).start(ctx as any);
142+
await fire();
143+
144+
const res = await rawApp.routes.get('POST /api/v1/cloud-connection/unbind')!(
145+
makeC('http://localhost:3000/x'),
146+
);
147+
expect(res.payload.data).toEqual({ environmentId: 'env_1', revoked: true, cleared: true });
148+
expect(String(fetchSpy.mock.calls[0]![0])).toContain('/cloud-connection/revoke');
149+
expect((fetchSpy.mock.calls[0]![1] as any).headers.Authorization).toBe('Bearer oscc_stored');
150+
expect(store.read()).toBeNull();
151+
});
152+
153+
it('registers the SDUI bundle (page + nav) with the manifest service', async () => {
154+
const register = vi.fn();
155+
const rawApp = makeRawApp();
156+
const { ctx, fire } = makeCtx(rawApp, { manifest: { register } });
157+
await new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', credentialPath: join(dir, 'cc.json') }).start(ctx as any);
158+
await fire();
159+
expect(register).toHaveBeenCalledWith(expect.objectContaining({
160+
id: 'com.objectstack.cloud-connection.ui',
161+
pages: [expect.objectContaining({ name: 'cloud_connection_settings' })],
162+
navigationContributions: [expect.objectContaining({ app: 'setup' })],
163+
}));
164+
});
165+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ConnectionCredentialStore — where a SELF-HOSTED runtime keeps the
5+
* credential it received at bind time (cloud ADR-0008 consumption side).
6+
*
7+
* Cloud-hosted runtimes authenticate to the control plane with an
8+
* env→cloud service key (`OS_CLOUD_API_KEY`, injected by the cloud).
9+
* Self-hosted runtimes have no such key: their identity ceremony is the
10+
* RFC 8628 device-code bind, whose response carries a one-time
11+
* `runtime_token` (`oscc_…`). This store persists that bearer — plus the
12+
* environment id the binding established — under the runtime's own
13+
* working directory, next to the LocalManifestSource ledger:
14+
*
15+
* <cwd>/.objectstack/cloud-connection.json
16+
*
17+
* Like everything on the runtime's serving path, reads are local file
18+
* operations: presenting the credential is how the runtime reaches the
19+
* control plane for org-scoped catalog/install calls, but nothing at
20+
* boot or serve time DEPENDS on those calls succeeding.
21+
*
22+
* Treat the file as a secret (it is written 0600).
23+
*/
24+
25+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
26+
import { dirname, resolve } from 'node:path';
27+
28+
/** Persisted binding credential + context. */
29+
export interface StoredConnectionCredential {
30+
/** The `oscc_…` runtime bearer returned ONCE by the bind route. */
31+
runtimeToken: string;
32+
/** Control-plane environment id this runtime is bound as. */
33+
environmentId: string;
34+
/** Control-plane base URL the binding was made against. */
35+
controlPlaneUrl?: string;
36+
organizationId?: string;
37+
accountEmail?: string;
38+
boundAt?: string;
39+
}
40+
41+
/** Default store location, relative to the runtime's working directory. */
42+
export const DEFAULT_CONNECTION_CREDENTIAL_PATH = '.objectstack/cloud-connection.json';
43+
44+
export class ConnectionCredentialStore {
45+
/** Resolved file path. */
46+
readonly path: string;
47+
48+
constructor(path?: string) {
49+
this.path = path
50+
? resolve(path)
51+
: resolve(process.cwd(), DEFAULT_CONNECTION_CREDENTIAL_PATH);
52+
}
53+
54+
/** Read the stored credential; null when absent or unreadable. */
55+
read(): StoredConnectionCredential | null {
56+
if (!existsSync(this.path)) return null;
57+
try {
58+
const parsed = JSON.parse(readFileSync(this.path, 'utf8'));
59+
if (!parsed || typeof parsed.runtimeToken !== 'string' || !parsed.runtimeToken) return null;
60+
if (typeof parsed.environmentId !== 'string' || !parsed.environmentId) return null;
61+
return parsed as StoredConnectionCredential;
62+
} catch {
63+
return null;
64+
}
65+
}
66+
67+
/** Persist (replace) the credential. Written 0600 — it is a secret. */
68+
write(credential: StoredConnectionCredential): void {
69+
mkdirSync(dirname(this.path), { recursive: true });
70+
writeFileSync(this.path, JSON.stringify(credential, null, 2), { encoding: 'utf8', mode: 0o600 });
71+
}
72+
73+
/** Remove the credential (unbind). Returns false when nothing was stored. */
74+
clear(): boolean {
75+
if (!existsSync(this.path)) return false;
76+
unlinkSync(this.path);
77+
return true;
78+
}
79+
}

packages/cloud-connection/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,8 @@ export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-conn
4444
export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js';
4545
export { RuntimeConfigPlugin } from './runtime-config-plugin.js';
4646
export type { RuntimeConfigPluginConfig, RuntimeConfigPlanFeatures } from './runtime-config-plugin.js';
47+
// ADR-0008 consumption side — the self-hosted credential ledger (bind
48+
// persists the oscc_ bearer here; forwards present it to the control plane).
49+
export { ConnectionCredentialStore, DEFAULT_CONNECTION_CREDENTIAL_PATH } from './connection-credential-store.js';
50+
export type { StoredConnectionCredential } from './connection-credential-store.js';
51+
export { CloudConnectionSettingsPage, CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js';

0 commit comments

Comments
 (0)