From a60df9e3fdaf9980aa0b2798eac2990f112a7d9f Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:41:14 +0800 Subject: [PATCH] feat(cli): mount runtime datasource admin in `serve` by default (ADR-0015 Addendum) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire `@objectstack/service-datasource-admin` into the `serve` composition root so a self-host runtime is a complete low-code platform out of the box — the "Add Datasource" wizard (list/test/create/update/remove + REST routes under /api/v1/datasources) works without code or redeploy. Mechanism is open; the tier line stays on which ICryptoProvider / driver factory a host injects, not on whether the UI can manage datasources. Details: - A single shared crypto provider now backs ALL of sys_secret (datasource creds + secret fields). One instance ⇒ one key, so everything decrypts consistently. The datasource secret binder is wired BEFORE runtime.start() (its kernel:ready boot rehydration decrypts persisted creds); the post-start secret-field wiring reuses the same instance. - Fail-closed preserved: if no crypto provider can be created, `secrets` is left undefined and secret-bearing create/update rejects instead of storing cleartext. - REST routes registered via a tiny init-time plugin that resolves http.server (same pattern as the hostname guard). Graceful skip if the package or http.server is absent. - New @objectstack/service-datasource-admin dep on the cli package. Tests: - Adds admin-routes integration test against the REAL HonoHttpServer adapter (list / test-secret-split / create-201 / 503-unavailable / 400-error). 34 pass (was 29); cli typecheck + build green. Co-Authored-By: Claude Opus 4.8 --- packages/cli/package.json | 1 + packages/cli/src/commands/serve.ts | 133 ++++++++++++++++-- .../service-datasource-admin/package.json | 1 + .../src/__tests__/admin-routes.test.ts | 106 ++++++++++++++ pnpm-lock.yaml | 6 + 5 files changed, 236 insertions(+), 11 deletions(-) create mode 100644 packages/services/service-datasource-admin/src/__tests__/admin-routes.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 4bd91e828..ec8f8b966 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -70,6 +70,7 @@ "@objectstack/service-analytics": "workspace:*", "@objectstack/service-automation": "workspace:*", "@objectstack/service-cache": "workspace:*", + "@objectstack/service-datasource-admin": "workspace:*", "@objectstack/service-external-datasource": "workspace:*", "@objectstack/service-feed": "workspace:*", "@objectstack/service-job": "workspace:*", diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2a9bfca53..7ef7bdc97 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1601,14 +1601,15 @@ export default class Serve extends Command { } } + // Shared dev crypto provider for ALL of sys_secret (datasource creds + // below + secret fields after start). One instance ⇒ one key, so every + // encrypted secret decrypts under the same provider. Created lazily by + // whichever block runs first. + let sharedCryptoProvider: any = undefined; + // ── External Datasource Federation (ADR-0015) ───────────────── // Federation (introspect / draft / import / validate of external - // tables) ships in the open framework. The runtime-UI datasource - // *lifecycle* (ADR-0015 Addendum — the "Add Datasource" wizard backend: - // create / update / remove runtime datasources) was extracted into the - // private `@objectstack/datasource-admin` package; a private host wires - // its `DatasourceAdminServicePlugin` + routes after the data/metadata - // services exist (see that package's README). + // tables) ships in the open framework. try { const dsMod: any = await import('@objectstack/service-external-datasource'); const { ExternalDatasourceServicePlugin } = dsMod; @@ -1636,7 +1637,114 @@ export default class Serve extends Command { } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (!msg.includes('Cannot find module') && !msg.includes('ERR_MODULE_NOT_FOUND')) { - console.error(`[Datasource] runtime-UI lifecycle wiring failed: ${msg}`); + console.error(`[Datasource] federation wiring failed: ${msg}`); + } + } + + // ── Runtime Datasource Admin (ADR-0015 Addendum) ────────────── + // The "Add Datasource" wizard backend: list / test / create / update / + // remove datasources defined in the UI at runtime. This is open-source + // *mechanism* (`@objectstack/service-datasource-admin`); the tier line + // falls on which ICryptoProvider / driver factory a host injects, not on + // whether the UI can manage datasources. Mounted by default so a + // self-host runtime is a complete low-code platform out of the box. + // + // Credentials are bound through the SAME crypto provider used for + // `secret` fields below (`sharedCryptoProvider`), so every secret in + // `sys_secret` (settings, secret fields, datasource creds) shares one + // key. Wired BEFORE runtime.start() so the plugin's kernel:ready boot + // rehydration (which decrypts persisted creds) has its binder ready. + try { + const adminMod: any = await import('@objectstack/service-datasource-admin'); + const { + DatasourceAdminServicePlugin, + createDefaultDatasourceDriverFactory, + createDatasourceSecretBinder, + registerDatasourceAdminRoutes, + } = adminMod; + + if ( + DatasourceAdminServicePlugin && + !hasPluginMatching(['service-datasource-admin', 'DatasourceAdminServicePlugin']) + ) { + // Lazy data-engine surface for the secret store (resolved per call + // so it works whether the engine is registered as 'data' or + // 'objectql', and regardless of init ordering). + const resolveEngine = (): any => + kernel.getService?.('data') ?? kernel.getService?.('objectql'); + const lazySecretEngine = { + insert: (o: string, d: any, opt?: any) => resolveEngine()?.insert(o, d, opt), + delete: (o: string, opt?: any) => resolveEngine()?.delete(o, opt), + find: (o: string, q?: any) => resolveEngine()?.find(o, q), + }; + + // Fail-closed binder over the shared dev crypto provider. If the + // provider can't be created, leave `secrets` undefined — the plugin + // then rejects secret-bearing create/update instead of storing + // cleartext (by design). + let secrets: any = undefined; + try { + const { InMemoryCryptoProvider } = await import( + /* webpackIgnore: true */ '@objectstack/service-settings' + ); + sharedCryptoProvider = sharedCryptoProvider ?? new InMemoryCryptoProvider(); + secrets = createDatasourceSecretBinder({ + engine: lazySecretEngine, + cryptoProvider: sharedCryptoProvider, + }); + } catch (cryptoErr: any) { + console.warn( + chalk.yellow( + ` ⚠ datasource admin: no CryptoProvider (${cryptoErr?.message ?? cryptoErr}); secret-bearing datasource create/update will fail closed`, + ), + ); + } + + await kernel.use( + new DatasourceAdminServicePlugin({ + driverFactory: createDefaultDatasourceDriverFactory(), + secrets, + }), + ); + trackPlugin('DatasourceAdminServicePlugin'); + + // REST routes under /api/v1/datasources. Registered via a tiny + // plugin so it resolves http.server during init (same pattern as + // the hostname guard above). + const adminRoutePlugin: any = { + name: 'com.objectstack.cli.datasource-admin-routes', + version: '1.0.0', + init: async (ctx: any) => { + try { + const httpServer: any = + ctx.getService?.('http.server') ?? ctx.getService?.('http-server'); + if (!httpServer || typeof httpServer.get !== 'function') { + ctx.logger?.warn?.( + '[datasource-admin] http.server unavailable; REST routes not installed', + ); + return; + } + registerDatasourceAdminRoutes(httpServer, ctx, '/api/v1'); + } catch (routeErr: any) { + ctx.logger?.warn?.( + `[datasource-admin] route registration failed: ${routeErr?.message ?? routeErr}`, + ); + } + }, + }; + await kernel.use(adminRoutePlugin); + trackPlugin('DatasourceAdminRoutes'); + + if (isDev) { + console.log( + chalk.dim(' ↪ datasource admin: runtime UI lifecycle wired (/api/v1/datasources)'), + ); + } + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('Cannot find module') && !msg.includes('ERR_MODULE_NOT_FOUND')) { + console.error(`[Datasource] runtime-UI admin wiring failed: ${msg}`); } } @@ -1695,10 +1803,13 @@ export default class Serve extends Command { const dataEngine: any = kernel.getService?.('data') ?? kernel.getService?.('objectql'); if (dataEngine && typeof dataEngine.setCryptoProvider === 'function') { - const { InMemoryCryptoProvider } = await import( - /* webpackIgnore: true */ '@objectstack/service-settings' - ); - dataEngine.setCryptoProvider(new InMemoryCryptoProvider()); + if (!sharedCryptoProvider) { + const { InMemoryCryptoProvider } = await import( + /* webpackIgnore: true */ '@objectstack/service-settings' + ); + sharedCryptoProvider = new InMemoryCryptoProvider(); + } + dataEngine.setCryptoProvider(sharedCryptoProvider); if (isDev) { console.log( chalk.dim( diff --git a/packages/services/service-datasource-admin/package.json b/packages/services/service-datasource-admin/package.json index 834865c4e..acb86e7df 100644 --- a/packages/services/service-datasource-admin/package.json +++ b/packages/services/service-datasource-admin/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@objectstack/driver-memory": "workspace:*", "@objectstack/driver-sql": "workspace:*", + "@objectstack/plugin-hono-server": "workspace:*", "@types/node": "^25.9.1", "tsup": "^8.5.1", "typescript": "^6.0.3", diff --git a/packages/services/service-datasource-admin/src/__tests__/admin-routes.test.ts b/packages/services/service-datasource-admin/src/__tests__/admin-routes.test.ts new file mode 100644 index 000000000..fafa8c3a6 --- /dev/null +++ b/packages/services/service-datasource-admin/src/__tests__/admin-routes.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +import { registerDatasourceAdminRoutes } from '../admin-routes.js'; + +/** + * End-to-end routing test against the REAL `HonoHttpServer` adapter — the same + * IHttpServer implementation `os serve` mounts. We exercise the routes via + * `getRawApp().fetch(...)` (no socket bind needed), proving the wiring path the + * serve composition root relies on: routes mount on `IHttpServer` and dispatch + * to whatever object the plugin context resolves for the `datasource-admin` + * service. + */ + +const json = (path: string, init?: RequestInit) => + new Request(`http://local${path}`, { + ...init, + headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }, + }); + +function mount(svc: unknown) { + const server = new HonoHttpServer(0); + const ctx = { getService: vi.fn().mockReturnValue(svc) } as any; + registerDatasourceAdminRoutes(server, ctx, '/api/v1'); + return server.getRawApp(); +} + +describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => { + it('GET /api/v1/datasources returns the service listing', async () => { + const listDatasources = vi.fn().mockResolvedValue([ + { name: 'pg', origin: 'runtime', health: 'ok' }, + ]); + const app = mount({ listDatasources }); + + const res = await app.fetch(json('/api/v1/datasources')); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + datasources: [{ name: 'pg', origin: 'runtime', health: 'ok' }], + }); + expect(listDatasources).toHaveBeenCalledOnce(); + }); + + it('POST /api/v1/datasources/test splits the inline secret out of the draft', async () => { + const testConnection = vi.fn().mockResolvedValue({ ok: true }); + const app = mount({ testConnection }); + + const res = await app.fetch( + json('/api/v1/datasources/test', { + method: 'POST', + body: JSON.stringify({ name: 'pg', driver: 'postgres', secret: 's3cr3t' }), + }), + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ result: { ok: true } }); + // draft must NOT carry the secret; secret is normalised to { value }. + expect(testConnection).toHaveBeenCalledWith( + { name: 'pg', driver: 'postgres' }, + { value: 's3cr3t' }, + ); + }); + + it('POST /api/v1/datasources creates a runtime datasource (201)', async () => { + const createDatasource = vi.fn().mockResolvedValue({ name: 'pg', origin: 'runtime' }); + const app = mount({ createDatasource }); + + const res = await app.fetch( + json('/api/v1/datasources', { + method: 'POST', + body: JSON.stringify({ name: 'pg', driver: 'postgres' }), + }), + ); + + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ datasource: { name: 'pg', origin: 'runtime' } }); + }); + + it('degrades to 503 when the datasource-admin service is not wired', async () => { + const app = mount(undefined); + + const res = await app.fetch(json('/api/v1/datasources')); + + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'datasource_admin_unavailable' }); + }); + + it('surfaces lifecycle errors as 400 with the service message', async () => { + const createDatasource = vi.fn().mockRejectedValue(new Error('duplicate name')); + const app = mount({ createDatasource }); + + const res = await app.fetch( + json('/api/v1/datasources', { + method: 'POST', + body: JSON.stringify({ name: 'pg', driver: 'postgres' }), + }), + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'datasource_admin_error', + message: 'duplicate name', + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b281ec24..8ce2a44c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: '@objectstack/service-cache': specifier: workspace:* version: link:../services/service-cache + '@objectstack/service-datasource-admin': + specifier: workspace:* + version: link:../services/service-datasource-admin '@objectstack/service-external-datasource': specifier: workspace:* version: link:../services/service-external-datasource @@ -1838,6 +1841,9 @@ importers: '@objectstack/driver-sql': specifier: workspace:* version: link:../../plugins/driver-sql + '@objectstack/plugin-hono-server': + specifier: workspace:* + version: link:../../plugins/plugin-hono-server '@types/node': specifier: ^25.9.1 version: 25.9.1