From e8f7f5f30bfcacdb43074b8800988efb0382274f Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:52:09 +0800 Subject: [PATCH 1/2] chore: bump objectui to fdd083657e2d feat(studio): AI-draft review/diff mode in the object designer (v1) (#1456) objectui@fdd083657e2da9832059492d4c88e818a5990a8d --- .objectui-sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.objectui-sha b/.objectui-sha index 170033d29..029f0c740 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -1508a8d32b881335f5820dd47fbb91470c13cbad +fdd083657e2da9832059492d4c88e818a5990a8d From 9a6c84d4c16c13f55aac33ce406dddb9d1c06bd2 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:35:47 +0800 Subject: [PATCH 2/2] feat(service-datasource-admin): open-source the runtime datasource admin mechanism (ADR-0015 Addendum) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the runtime UI-created datasource lifecycle (list/test/create/update/ remove) out of the private cloud repo into the open framework as @objectstack/service-datasource-admin, sitting next to its federation sibling @objectstack/service-external-datasource. This package is mechanism only: credential storage is delegated to a host-provided SecretBinder over any ICryptoProvider (framework default: InMemoryCryptoProvider), and drivers to a swappable factory. The tier line falls on which crypto provider / driver factory a host injects — a neutral technical seam — so a managed credential vault + multi-tenant overlay can be layered on by a private host without forking. Consistent with the connector / outbox / approval tiering principle: charge for credential custody + multi-tenant ops + scale, not for the ability to manage a datasource. - workspace:* deps, Apache-2.0, exports . and ./contracts - serve is NOT wired to mount this by default yet (OSS-edition identity TBD) - 29 tests pass; typecheck + build (incl. dts) clean Co-Authored-By: Claude Opus 4.8 --- .../service-datasource-admin/README.md | 85 ++++ .../service-datasource-admin/package.json | 62 +++ .../__tests__/datasource-admin-plugin.test.ts | 231 +++++++++++ .../datasource-admin-service.test.ts | 288 ++++++++++++++ .../datasource-secret-binder.test.ts | 101 +++++ .../src/admin-routes.ts | 117 ++++++ .../src/contracts/datasource-admin-service.ts | 119 ++++++ .../contracts/datasource-driver-factory.ts | 77 ++++ .../src/contracts/index.ts | 18 + .../src/datasource-admin-plugin.ts | 362 ++++++++++++++++++ .../src/datasource-admin-service.ts | 297 ++++++++++++++ .../src/datasource-secret-binder.ts | 144 +++++++ .../src/default-datasource-driver-factory.ts | 185 +++++++++ .../service-datasource-admin/src/index.ts | 58 +++ .../service-datasource-admin/src/logger.ts | 11 + .../service-datasource-admin/tsconfig.json | 10 + .../service-datasource-admin/tsup.config.ts | 14 + pnpm-lock.yaml | 31 ++ 18 files changed, 2210 insertions(+) create mode 100644 packages/services/service-datasource-admin/README.md create mode 100644 packages/services/service-datasource-admin/package.json create mode 100644 packages/services/service-datasource-admin/src/__tests__/datasource-admin-plugin.test.ts create mode 100644 packages/services/service-datasource-admin/src/__tests__/datasource-admin-service.test.ts create mode 100644 packages/services/service-datasource-admin/src/__tests__/datasource-secret-binder.test.ts create mode 100644 packages/services/service-datasource-admin/src/admin-routes.ts create mode 100644 packages/services/service-datasource-admin/src/contracts/datasource-admin-service.ts create mode 100644 packages/services/service-datasource-admin/src/contracts/datasource-driver-factory.ts create mode 100644 packages/services/service-datasource-admin/src/contracts/index.ts create mode 100644 packages/services/service-datasource-admin/src/datasource-admin-plugin.ts create mode 100644 packages/services/service-datasource-admin/src/datasource-admin-service.ts create mode 100644 packages/services/service-datasource-admin/src/datasource-secret-binder.ts create mode 100644 packages/services/service-datasource-admin/src/default-datasource-driver-factory.ts create mode 100644 packages/services/service-datasource-admin/src/index.ts create mode 100644 packages/services/service-datasource-admin/src/logger.ts create mode 100644 packages/services/service-datasource-admin/tsconfig.json create mode 100644 packages/services/service-datasource-admin/tsup.config.ts diff --git a/packages/services/service-datasource-admin/README.md b/packages/services/service-datasource-admin/README.md new file mode 100644 index 000000000..668bba2c8 --- /dev/null +++ b/packages/services/service-datasource-admin/README.md @@ -0,0 +1,85 @@ +# @objectstack/service-datasource-admin + +Runtime **UI-created datasource lifecycle** — the "Add Datasource" wizard backend +from **ADR-0015 Addendum**. This package is the open-source **mechanism**: +list / test-connection / create / update / remove datasources that an admin +defines *in the UI* at runtime (as opposed to code-defined `*.datasource.ts`). + +It deliberately ships **no managed credential vault and no multi-tenant overlay**. +Both are injected through stable seams, so a private host can layer enterprise +behaviour on without forking: + +- **credentials** → a host-provided `SecretBinder` over any `ICryptoProvider`. + The framework default uses `InMemoryCryptoProvider` (dev / self-host, single + node). A managed host swaps in a KMS/Vault-backed `ICryptoProvider` for + rotation, per-tenant isolation and compliance. +- **drivers** → a swappable `IDatasourceDriverFactory`. The default factory + covers `postgres` / `sqlite` / `mongodb` / `memory`; a host can register a + factory that adds premium drivers (Salesforce / SAP / Oracle / …). + +The tier line therefore falls on *which `ICryptoProvider` / driver factory you +inject* — a neutral, technical seam — not on whether the UI can manage +datasources at all. + +## Scope + +Two orthogonal axes of "datasource": + +| Axis | `origin: 'code'` | `origin: 'runtime'` | +|------|------------------|---------------------| +| Defined by | `*.datasource.ts` (GitOps) | the UI wizard, at runtime | +| Mutable at runtime | no (read-only) | yes | +| Stored in | code / artefacts | `sys_metadata` + secret in `sys_secret` | + +**This package owns only the `origin: 'runtime'` lifecycle.** *Federation* +(introspect / draft / import / validate of external tables, ADR-0015 main body) +lives in `@objectstack/service-external-datasource`. + +## Contents + +- `DatasourceAdminService` + `DatasourceAdminServicePlugin` — registers the + `'datasource-admin'` kernel service. +- `createDefaultDatasourceDriverFactory` — postgres / sqlite / mongodb / memory + factory used to probe a connection and hot-register a pool. +- `createDatasourceSecretBinder` — fail-closed `sys_secret` binder over an + `ICryptoProvider`; only an opaque `credentialsRef` is ever persisted, never + cleartext. With no binder wired, secret-bearing create/update throws. +- `registerDatasourceAdminRoutes` — REST routes under `/api/v1/datasources`. +- `contracts/` — `IDatasourceAdminService`, `IDatasourceDriverFactory` + DTOs. + +## Host wiring + +```ts +import { + DatasourceAdminServicePlugin, + createDefaultDatasourceDriverFactory, + createDatasourceSecretBinder, + registerDatasourceAdminRoutes, +} from '@objectstack/service-datasource-admin'; +import { InMemoryCryptoProvider } from '@objectstack/service-settings'; + +// 1. secret binder (fail-closed: no crypto provider ⇒ secret-bearing +// create/update throws instead of persisting cleartext). +// A managed host swaps InMemoryCryptoProvider for a KMS/Vault provider. +const cryptoProvider = new InMemoryCryptoProvider(); +const lazyEngine = { + insert: (o, d, opt) => kernel.getService('data').insert(o, d, opt), + delete: (o, opt) => kernel.getService('data').delete(o, opt), + find: (o, q) => kernel.getService('data').find(o, q), +}; +const secrets = createDatasourceSecretBinder({ engine: lazyEngine, cryptoProvider }); + +// 2. plugin (driverFactory + secrets are the enterprise injection points) +await kernel.use( + new DatasourceAdminServicePlugin({ + driverFactory: createDefaultDatasourceDriverFactory(), + secrets, + }), +); + +// 3. REST routes (mount alongside the federation routes) +registerDatasourceAdminRoutes(httpServer, pluginContext, '/api/v1'); +``` + +`@objectstack/core` and `@objectstack/spec` are required deps; the driver +packages are optional peers (imported lazily only for the drivers you use). diff --git a/packages/services/service-datasource-admin/package.json b/packages/services/service-datasource-admin/package.json new file mode 100644 index 000000000..834865c4e --- /dev/null +++ b/packages/services/service-datasource-admin/package.json @@ -0,0 +1,62 @@ +{ + "name": "@objectstack/service-datasource-admin", + "version": "7.5.0", + "license": "Apache-2.0", + "description": "Runtime UI-created datasource lifecycle (ADR-0015 Addendum) — list/test/create/update/remove datasources defined in the UI. Mechanism only: credential storage is delegated to a host-provided SecretBinder (CryptoProvider) and drivers to a swappable factory, so a managed credential vault / multi-tenant overlay can be layered on without forking.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./contracts": { + "types": "./dist/contracts/index.d.ts", + "import": "./dist/contracts/index.js", + "require": "./dist/contracts/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "peerDependencies": { + "@objectstack/driver-memory": "workspace:*", + "@objectstack/driver-mongodb": "workspace:*", + "@objectstack/driver-sql": "workspace:*" + }, + "peerDependenciesMeta": { + "@objectstack/driver-sql": { "optional": true }, + "@objectstack/driver-mongodb": { "optional": true }, + "@objectstack/driver-memory": { "optional": true } + }, + "devDependencies": { + "@objectstack/driver-memory": "workspace:*", + "@objectstack/driver-sql": "workspace:*", + "@types/node": "^25.9.1", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vitest": "^4.1.8" + }, + "keywords": [ + "objectstack", + "datasource", + "datasource-admin", + "runtime-datasource" + ], + "author": "ObjectStack", + "repository": { + "type": "git", + "url": "https://github.com/objectstack-ai/framework.git", + "directory": "packages/services/service-datasource-admin" + } +} diff --git a/packages/services/service-datasource-admin/src/__tests__/datasource-admin-plugin.test.ts b/packages/services/service-datasource-admin/src/__tests__/datasource-admin-plugin.test.ts new file mode 100644 index 000000000..dc72b050c --- /dev/null +++ b/packages/services/service-datasource-admin/src/__tests__/datasource-admin-plugin.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import type { IDatasourceAdminService, IDatasourceDriverFactory } from '../contracts/index.js'; +import { + DatasourceAdminServicePlugin, + type DatasourceAdminServicePluginOptions, +} from '../datasource-admin-plugin.js'; + +/** + * Minimal PluginContext + in-memory metadata service. Boots the plugin and + * returns the registered `datasource-admin` service so we can exercise the + * plugin's glue (probe via factory, fail-closed secret) end to end. + */ +async function boot(opts: DatasourceAdminServicePluginOptions & { + services?: Record; +} = {}) { + const registry = new Map>(); + const metadata = { + get: async (t: string, n: string) => registry.get(t)?.get(n), + list: async (t: string) => [...(registry.get(t)?.values() ?? [])], + register: async (t: string, n: string, d: unknown) => { + if (!registry.has(t)) registry.set(t, new Map()); + registry.get(t)!.set(n, d); + }, + unregister: async (t: string, n: string) => { + registry.get(t)?.delete(n); + }, + listObjects: async () => [...(registry.get('object')?.values() ?? [])], + }; + + const services: Record = { metadata, ...(opts.services ?? {}) }; + let registered: IDatasourceAdminService | undefined; + const ctx: any = { + getService: (name: string) => { + if (name in services) return services[name]; + throw new Error(`no service ${name}`); + }, + registerService: (name: string, svc: unknown) => { + if (name === 'datasource-admin') registered = svc as IDatasourceAdminService; + }, + trigger: async () => {}, + logger: { warn() {}, info() {} }, + }; + + const { services: _omit, ...pluginOpts } = opts; + const plugin = new DatasourceAdminServicePlugin(pluginOpts); + await plugin.init(ctx); + return { service: registered!, registry, metadata, plugin, ctx }; +} + +/** A driver factory whose handle records connect/ping/disconnect calls. */ +function fakeFactory(over?: Partial & { onProbe?: () => void }): IDatasourceDriverFactory { + return { + supports: (id: string) => id === 'postgres', + create: async (spec) => ({ + connect: async () => {}, + ping: async () => { + over?.onProbe?.(); + // expose the secret the factory received for assertions + (globalThis as any).__lastProbeSecret = spec.secret; + }, + disconnect: async () => {}, + serverVersion: async () => 'PostgreSQL 16.1', + }), + ...over, + }; +} + +describe('DatasourceAdminServicePlugin: probe', () => { + it('tests a connection through the driver factory (latency + version)', async () => { + const { service } = await boot({ + driverFactory: fakeFactory(), + }); + const res = await service.testConnection( + { name: 'reporting', driver: 'postgres', config: { host: 'db' } }, + { value: 's3cret' }, + ); + expect(res.ok).toBe(true); + expect(res.serverVersion).toBe('PostgreSQL 16.1'); + expect(typeof res.latencyMs).toBe('number'); + expect((globalThis as any).__lastProbeSecret).toBe('s3cret'); + }); + + it('returns ok:false when no factory supports the driver', async () => { + const { service } = await boot({ driverFactory: fakeFactory() }); + const res = await service.testConnection({ name: 'x', driver: 'oracle', config: {} }); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/no driver factory supports/i); + }); + + it('returns ok:false when no factory is registered at all', async () => { + const { service } = await boot(); + const res = await service.testConnection({ name: 'x', driver: 'postgres', config: {} }); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/no driver factory is registered/i); + }); +}); + +describe('DatasourceAdminServicePlugin: secret fail-closed', () => { + it('refuses to create a secret-bearing datasource without a secret binder', async () => { + const { service, registry } = await boot({ driverFactory: fakeFactory() }); + await expect( + service.createDatasource({ name: 'reporting', driver: 'postgres', config: {} }, { value: 'pw' }), + ).rejects.toThrow(/no secret store configured/i); + // nothing persisted + expect(registry.get('datasource')?.size ?? 0).toBe(0); + }); + + it('persists a credentialsRef (not cleartext) when a binder is wired', async () => { + const bound: string[] = []; + const { service, registry } = await boot({ + driverFactory: fakeFactory(), + secrets: { + bind: async (input, hint) => { + bound.push(input.value); + return `sys_secret://datasource/${hint.name}#1`; + }, + }, + }); + await service.createDatasource({ name: 'reporting', driver: 'postgres', config: {} }, { value: 'pw' }); + const rec = registry.get('datasource')?.get('reporting') as any; + expect(rec.origin).toBe('runtime'); + expect(rec.external?.credentialsRef).toBe('sys_secret://datasource/reporting#1'); + expect(JSON.stringify(rec)).not.toContain('pw'); + expect(bound).toEqual(['pw']); + }); +}); + +describe('DatasourceAdminServicePlugin: boot rehydration', () => { + /** Fake engine ('data') that records hot-registered drivers. */ + function fakeEngine() { + const drivers: any[] = []; + return { + drivers, + registerDriver: (d: any) => drivers.push(d), + registerDatasourceDef: () => {}, + getDriverByName: (n: string) => drivers.find((d) => d.name === n), + }; + } + + /** Factory that records the spec (incl. resolved secret) of each create(). */ + function recordingFactory() { + const specs: any[] = []; + const factory: IDatasourceDriverFactory = { + supports: (id: string) => id === 'postgres', + create: async (spec) => { + specs.push(spec); + return { connect: async () => {}, disconnect: async () => {} }; + }, + }; + return { factory, specs }; + } + + it('rebuilds runtime pools at start(), decrypting the credentialsRef', async () => { + const engine = fakeEngine(); + const { factory, specs } = recordingFactory(); + const resolved: string[] = []; + + const { plugin, ctx, registry } = await boot({ + driverFactory: factory, + services: { data: engine }, + secrets: { + bind: async () => 'sys_secret:abc', + resolve: async (ref) => { + resolved.push(ref); + return ref === 'sys_secret:abc' ? 'super-secret-pw' : undefined; + }, + }, + }); + + // Simulate a persisted (DB-backed) runtime datasource that survived a restart. + registry.set( + 'datasource', + new Map([ + ['crm_primary', { name: 'crm_primary', driver: 'sqlite', origin: 'code' }], + [ + 'reporting', + { + name: 'reporting', + driver: 'postgres', + origin: 'runtime', + active: true, + config: { host: 'db' }, + external: { credentialsRef: 'sys_secret:abc' }, + }, + ], + [ + 'archived', + { name: 'archived', driver: 'postgres', origin: 'runtime', active: false }, + ], + ]), + ); + + await plugin.start(ctx); + + // Only the active runtime datasource is rehydrated — not the code one, not the inactive one. + expect(engine.drivers.map((d) => d.name)).toEqual(['reporting']); + // The credentialsRef was dereferenced and the cleartext handed to the factory. + expect(resolved).toEqual(['sys_secret:abc']); + expect(specs).toHaveLength(1); + expect(specs[0].secret).toBe('super-secret-pw'); + expect(specs[0].name).toBe('reporting'); + }); + + it('does not block boot when nothing is persisted (dev: in-memory store)', async () => { + const engine = fakeEngine(); + const { factory } = recordingFactory(); + const { plugin, ctx } = await boot({ driverFactory: factory, services: { data: engine } }); + await expect(plugin.start(ctx)).resolves.toBeUndefined(); + expect(engine.drivers).toHaveLength(0); + }); +}); + +describe('DatasourceAdminServicePlugin: persistence + bound count', () => { + it('lists code (artefact) + runtime records with origin, blocks remove while bound', async () => { + const { service, registry } = await boot({ driverFactory: fakeFactory() }); + // seed an artefact (code) datasource lacking explicit origin + registry.set('datasource', new Map([['crm_primary', { name: 'crm_primary', driver: 'sqlite' }]])); + // seed an object bound to a runtime datasource + registry.set('object', new Map([['lead', { name: 'lead', datasource: 'reporting' }]])); + + await service.createDatasource({ name: 'reporting', driver: 'postgres', config: {} }); + + const list = await service.listDatasources(); + expect(list.find((d) => d.name === 'crm_primary')?.origin).toBe('code'); + expect(list.find((d) => d.name === 'reporting')?.origin).toBe('runtime'); + + await expect(service.removeDatasource('reporting')).rejects.toThrow(/1 object\(s\)/); + }); +}); diff --git a/packages/services/service-datasource-admin/src/__tests__/datasource-admin-service.test.ts b/packages/services/service-datasource-admin/src/__tests__/datasource-admin-service.test.ts new file mode 100644 index 000000000..c987b167b --- /dev/null +++ b/packages/services/service-datasource-admin/src/__tests__/datasource-admin-service.test.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + DatasourceAdminService, + type DatasourceAdminServiceConfig, + type StoredDatasource, + type ProbeInput, +} from '../datasource-admin-service.js'; + +/** + * In-memory harness: an editable record store + secret store, with probe and + * bound-object count stubbable per test. Records what was probed/written so + * tests can assert credentials never leak into the persisted record. + */ +function makeHarness(opts?: { + seed?: StoredDatasource[]; + probe?: (input: ProbeInput) => Promise<{ ok: boolean; error?: string; latencyMs?: number }>; + boundCounts?: Record; +}) { + // Flat list, not a name-keyed map: in production `listDatasourceRecords` + // merges artefact (code) records with runtime-store records, so the same + // name can legitimately appear twice (a runtime row shadowed by a code one). + const records: StoredDatasource[] = (opts?.seed ?? []).map((r) => ({ ...r })); + /** Resolve the effective record for a name (code wins over runtime). */ + const findEffective = (n: string) => + records.find((r) => r.name === n && r.origin !== 'runtime') ?? + records.find((r) => r.name === n); + + const secrets = new Map(); + let secretSeq = 0; + const probed: ProbeInput[] = []; + const registered: string[] = []; + const unregistered: string[] = []; + const removedSecrets: string[] = []; + + const config: DatasourceAdminServiceConfig = { + probe: async (input) => { + probed.push(input); + return (opts?.probe ?? (async () => ({ ok: true, latencyMs: 3 })))(input); + }, + listDatasourceRecords: async () => records.map((r) => ({ ...r })), + getDatasourceRecord: async (n) => { + const r = findEffective(n); + return r ? { ...r } : undefined; + }, + putDatasourceRecord: async (record) => { + const idx = records.findIndex((r) => r.name === record.name && r.origin === 'runtime'); + if (idx >= 0) records[idx] = { ...record }; + else records.push({ ...record }); + }, + deleteDatasourceRecord: async (n) => { + const idx = records.findIndex((r) => r.name === n && r.origin === 'runtime'); + if (idx >= 0) records.splice(idx, 1); + }, + writeSecret: async (input, hint) => { + const ref = `sys_secret://datasource/${input.key ?? hint.name}#${++secretSeq}`; + secrets.set(ref, { value: input.value, namespace: input.namespace, key: input.key }); + return ref; + }, + removeSecret: async (ref) => { + removedSecrets.push(ref); + secrets.delete(ref); + }, + countBoundObjects: async (n) => opts?.boundCounts?.[n] ?? 0, + registerPool: (record) => { + registered.push(record.name); + }, + unregisterPool: (name) => { + unregistered.push(name); + }, + }; + + const service = new DatasourceAdminService(config); + // Thin accessor over the flat record list, runtime-preferring (tests assert + // on the persisted runtime row, e.g. after create/update). + const store = { + get: (n: string) => + records.find((r) => r.name === n && r.origin === 'runtime') ?? + records.find((r) => r.name === n), + has: (n: string) => records.some((r) => r.name === n), + get size() { + return records.length; + }, + }; + return { service, store, secrets, probed, registered, unregistered, removedSecrets }; +} + +describe('listDatasources', () => { + it('reports origin + dedupes by name (code wins, flags shadowed runtime)', async () => { + const { service } = makeHarness({ + seed: [ + { name: 'crm_primary', driver: 'sqlite', origin: 'code', definedIn: '@example/crm' }, + { name: 'crm_primary', driver: 'postgres', origin: 'runtime' }, + { name: 'reporting', driver: 'postgres', schemaMode: 'external', origin: 'runtime' }, + ], + }); + + const list = await service.listDatasources(); + const crm = list.find((d) => d.name === 'crm_primary')!; + const reporting = list.find((d) => d.name === 'reporting')!; + + expect(list).toHaveLength(2); + expect(crm.origin).toBe('code'); + expect(crm.driver).toBe('sqlite'); // code wins over the runtime row + expect(crm.definedIn).toBe('@example/crm'); + expect(crm.conflictsWithCode).toBe(true); + expect(reporting.origin).toBe('runtime'); + expect(reporting.schemaMode).toBe('external'); + expect(reporting.conflictsWithCode).toBeUndefined(); + }); +}); + +describe('testConnection', () => { + it('probes with the cleartext secret without persisting anything', async () => { + const { service, store, probed } = makeHarness(); + const res = await service.testConnection( + { name: 'tmp', driver: 'postgres', config: { host: 'db.internal' } }, + { value: 's3cret' }, + ); + expect(res.ok).toBe(true); + expect(probed[0].secret).toBe('s3cret'); + expect(store.size).toBe(0); // nothing saved + }); + + it('returns ok:false when no driver is supplied', async () => { + const { service } = makeHarness(); + const res = await service.testConnection({ name: 'x', driver: '' }); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/driver is required/i); + }); + + it('captures a thrown probe error as ok:false', async () => { + const { service } = makeHarness({ + probe: async () => { + throw new Error('ECONNREFUSED'); + }, + }); + const res = await service.testConnection({ name: 'x', driver: 'postgres' }); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/ECONNREFUSED/); + }); +}); + +describe('createDatasource', () => { + it('persists a runtime record and stores the secret as an opaque ref only', async () => { + const { service, store, secrets } = makeHarness(); + const summary = await service.createDatasource( + { + name: 'reporting', + driver: 'postgres', + schemaMode: 'external', + config: { host: 'db.internal', database: 'analytics' }, + external: { allowWrites: false }, + }, + { value: 'postgres://user:pw@db.internal/analytics' }, + ); + + expect(summary.origin).toBe('runtime'); + const rec = store.get('reporting')!; + expect(rec.origin).toBe('runtime'); + // credential is referenced, never inlined + expect(rec.external?.credentialsRef).toBeTruthy(); + expect(JSON.stringify(rec)).not.toContain('postgres://'); + expect(JSON.stringify(rec)).not.toContain('pw@'); + expect(secrets.size).toBe(1); + }); + + it('hot-registers the pool after create', async () => { + const { service, registered } = makeHarness(); + await service.createDatasource({ name: 'reporting', driver: 'postgres' }); + expect(registered).toContain('reporting'); + }); + + it('rejects a name owned by a code-defined datasource', async () => { + const { service } = makeHarness({ + seed: [{ name: 'crm_primary', driver: 'sqlite', origin: 'code' }], + }); + await expect( + service.createDatasource({ name: 'crm_primary', driver: 'postgres' }), + ).rejects.toThrow(/code-defined/i); + }); + + it('rejects a duplicate runtime name', async () => { + const { service } = makeHarness({ + seed: [{ name: 'reporting', driver: 'postgres', origin: 'runtime' }], + }); + await expect( + service.createDatasource({ name: 'reporting', driver: 'postgres' }), + ).rejects.toThrow(/already exists/i); + }); + + it('rejects an invalid name', async () => { + const { service } = makeHarness(); + await expect( + service.createDatasource({ name: 'Bad-Name', driver: 'postgres' }), + ).rejects.toThrow(/must match/i); + }); +}); + +describe('updateDatasource', () => { + it('patches a runtime record and rewraps the secret, removing the old ref', async () => { + const { service, store, secrets, removedSecrets } = makeHarness({ + seed: [ + { + name: 'reporting', + driver: 'postgres', + origin: 'runtime', + external: { credentialsRef: 'sys_secret://datasource/reporting#0' }, + }, + ], + }); + secrets.set('sys_secret://datasource/reporting#0', { value: 'old' }); + + const summary = await service.updateDatasource( + 'reporting', + { label: 'Reporting DB', active: false }, + { value: 'new-pw' }, + ); + + expect(summary.label).toBe('Reporting DB'); + expect(summary.active).toBe(false); + const rec = store.get('reporting')!; + expect(rec.external?.credentialsRef).not.toBe('sys_secret://datasource/reporting#0'); + expect(removedSecrets).toContain('sys_secret://datasource/reporting#0'); + }); + + it('preserves the existing credentialsRef when external is patched without a new secret', async () => { + const ref = 'sys_secret://datasource/reporting#0'; + const { service, store } = makeHarness({ + seed: [ + { name: 'reporting', driver: 'postgres', origin: 'runtime', external: { credentialsRef: ref } }, + ], + }); + await service.updateDatasource('reporting', { external: { allowWrites: true } }); + expect(store.get('reporting')!.external?.credentialsRef).toBe(ref); + }); + + it('rejects editing a code-defined datasource', async () => { + const { service } = makeHarness({ + seed: [{ name: 'crm_primary', driver: 'sqlite', origin: 'code' }], + }); + await expect( + service.updateDatasource('crm_primary', { label: 'x' }), + ).rejects.toThrow(/code-defined/i); + }); + + it('rejects updating a missing datasource', async () => { + const { service } = makeHarness(); + await expect(service.updateDatasource('nope', { label: 'x' })).rejects.toThrow(/not found/i); + }); +}); + +describe('removeDatasource', () => { + it('removes a runtime record, its secret, and the pool', async () => { + const ref = 'sys_secret://datasource/reporting#0'; + const { service, store, removedSecrets, unregistered } = makeHarness({ + seed: [ + { name: 'reporting', driver: 'postgres', origin: 'runtime', external: { credentialsRef: ref } }, + ], + }); + await service.removeDatasource('reporting'); + expect(store.has('reporting')).toBe(false); + expect(removedSecrets).toContain(ref); + expect(unregistered).toContain('reporting'); + }); + + it('refuses to remove while objects are still bound', async () => { + const { service, store } = makeHarness({ + seed: [{ name: 'reporting', driver: 'postgres', origin: 'runtime' }], + boundCounts: { reporting: 3 }, + }); + await expect(service.removeDatasource('reporting')).rejects.toThrow(/3 object\(s\)/); + expect(store.has('reporting')).toBe(true); + }); + + it('refuses to remove a code-defined datasource', async () => { + const { service } = makeHarness({ + seed: [{ name: 'crm_primary', driver: 'sqlite', origin: 'code' }], + }); + await expect(service.removeDatasource('crm_primary')).rejects.toThrow(/code-defined/i); + }); + + it('rejects removing a missing datasource', async () => { + const { service } = makeHarness(); + await expect(service.removeDatasource('nope')).rejects.toThrow(/not found/i); + }); +}); diff --git a/packages/services/service-datasource-admin/src/__tests__/datasource-secret-binder.test.ts b/packages/services/service-datasource-admin/src/__tests__/datasource-secret-binder.test.ts new file mode 100644 index 000000000..e88905562 --- /dev/null +++ b/packages/services/service-datasource-admin/src/__tests__/datasource-secret-binder.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; +import { + createDatasourceSecretBinder, + parseCredentialsRef, + toCredentialsRef, + type SecretStoreEngineLike, +} from '../datasource-secret-binder.js'; + +/** + * Minimal AAD-binding crypto fake: ciphertext = base64(`${ns}|${key}::${plain}`). + * decrypt() verifies the (namespace,key) AAD matches what encrypt() sealed — + * mirroring InMemoryCryptoProvider's guarantee without pulling in node:crypto. + */ +function fakeCrypto(): ICryptoProvider { + return { + async encrypt(plain: string, ctx: CryptoContext): Promise { + return { + id: 'sec_' + ctx.key, + kmsKeyId: 'local:test:v1', + alg: 'aes-256-gcm', + version: 1, + ciphertext: Buffer.from(`${ctx.namespace}|${ctx.key}::${plain}`, 'utf8').toString('base64'), + }; + }, + async decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise { + const raw = Buffer.from(handle.ciphertext, 'base64').toString('utf8'); + const [aad, plain] = raw.split('::'); + if (aad !== `${ctx.namespace}|${ctx.key}`) throw new Error('AAD mismatch'); + return plain; + }, + async rotateKey(handle: CryptoHandle): Promise { + return handle; + }, + digest: (plain: string) => 'sha256:' + plain, + }; +} + +/** In-memory `sys_secret` store backing the engine surface. */ +function fakeEngine(): SecretStoreEngineLike & { rows: Map } { + const rows = new Map(); + return { + rows, + async insert(_object, data) { + rows.set(String(data.id), data); + return data; + }, + async delete(_object, options) { + rows.delete(String(options.where.id)); + return undefined; + }, + async find(_object, query) { + const id = String((query.where as any)?.id); + const row = rows.get(id); + return row ? [row] : []; + }, + }; +} + +describe('createDatasourceSecretBinder', () => { + it('round-trips: bind → credentialsRef → resolve back to cleartext', async () => { + const engine = fakeEngine(); + const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto() }); + + const ref = await binder.bind({ value: 'super-secret-pw' }, { name: 'reporting' }); + expect(ref).toBe(toCredentialsRef('sec_reporting')); + + // The persisted row holds only ciphertext — never the cleartext. + const row = engine.rows.get('sec_reporting'); + expect(row.namespace).toBe('datasource'); + expect(row.key).toBe('reporting'); + expect(JSON.stringify(row)).not.toContain('super-secret-pw'); + + expect(await binder.resolve(ref)).toBe('super-secret-pw'); + }); + + it('resolve() returns undefined after unbind (row gone)', async () => { + const engine = fakeEngine(); + const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto() }); + const ref = await binder.bind({ value: 'pw' }, { name: 'ds1' }); + await binder.unbind(ref); + expect(await binder.resolve(ref)).toBeUndefined(); + }); + + it('resolve() returns undefined for a foreign / non-sys_secret ref', async () => { + const engine = fakeEngine(); + const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto() }); + expect(parseCredentialsRef('vault://other/handle')).toBeUndefined(); + expect(await binder.resolve('vault://other/handle')).toBeUndefined(); + }); + + it('resolve() degrades to undefined when the engine cannot read', async () => { + const engine = fakeEngine(); + delete (engine as any).find; // older engine surface without a read path + const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto() }); + const ref = await binder.bind({ value: 'pw' }, { name: 'ds1' }); + expect(await binder.resolve(ref)).toBeUndefined(); + }); +}); diff --git a/packages/services/service-datasource-admin/src/admin-routes.ts b/packages/services/service-datasource-admin/src/admin-routes.ts new file mode 100644 index 000000000..8bac1551e --- /dev/null +++ b/packages/services/service-datasource-admin/src/admin-routes.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { PluginContext } from '@objectstack/core'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +/** + * Datasource lifecycle REST routes (ADR-0015 Addendum §3.5). + * + * Mounted under `/api/v1/datasources` and served by the `datasource-admin` + * service. Every route degrades gracefully + * (`503 datasource_admin_unavailable`) when the service is not wired in, and + * lifecycle/validation failures surface as `400` with the service's message. + * + * GET /datasources → listDatasources (provenance + health) + * POST /datasources/test → testConnection (no persistence) + * POST /datasources → createDatasource (origin: 'runtime') + * PATCH /datasources/:name → updateDatasource (runtime only) + * DELETE /datasources/:name → removeDatasource (runtime only) + * + * Request bodies carry the connection draft inline with an optional cleartext + * `secret` field; the route splits `secret` out so it never reaches the draft + * the service persists. + */ +export function registerDatasourceAdminRoutes( + server: IHttpServer, + ctx: PluginContext, + basePath = '/api/v1', +): void { + const root = `${basePath}/datasources`; + + const adminService = (): any => { + try { + return ctx.getService('datasource-admin'); + } catch { + return undefined; + } + }; + + const unavailable = (res: any) => + res.status(503).json({ error: 'datasource_admin_unavailable' }); + + const badRequest = (res: any, err: unknown) => + res.status(400).json({ error: 'datasource_admin_error', message: err instanceof Error ? err.message : String(err) }); + + /** Split an inline `{ secret, ...draft }` body into (draft, secret). */ + const splitSecret = (body: any): { draft: any; secret: any } => { + const { secret, ...draft } = (body as Record) ?? {}; + // Accept either a bare string or a `{ value, namespace?, key? }` object. + const normalised = + secret == null + ? undefined + : typeof secret === 'string' + ? { value: secret } + : secret; + return { draft, secret: normalised }; + }; + + // List all datasources with provenance + health. + server.get(root, async (_req: any, res: any) => { + const svc = adminService(); + if (!svc?.listDatasources) return unavailable(res); + const datasources = await svc.listDatasources(); + res.json({ datasources }); + }); + + // Probe a connection without persisting anything. Registered before the + // `:name` routes so the literal `test` segment is never captured as a name. + server.post(`${root}/test`, async (req: any, res: any) => { + const svc = adminService(); + if (!svc?.testConnection) return unavailable(res); + const { draft, secret } = splitSecret(req.body); + try { + const result = await svc.testConnection(draft, secret); + res.json({ result }); + } catch (err) { + badRequest(res, err); + } + }); + + // Create a runtime datasource. + server.post(root, async (req: any, res: any) => { + const svc = adminService(); + if (!svc?.createDatasource) return unavailable(res); + const { draft, secret } = splitSecret(req.body); + try { + const datasource = await svc.createDatasource(draft, secret); + res.status(201).json({ datasource }); + } catch (err) { + badRequest(res, err); + } + }); + + // Patch a runtime datasource. + server.patch(`${root}/:name`, async (req: any, res: any) => { + const svc = adminService(); + if (!svc?.updateDatasource) return unavailable(res); + const { draft, secret } = splitSecret(req.body); + try { + const datasource = await svc.updateDatasource(req.params.name, draft, secret); + res.json({ datasource }); + } catch (err) { + badRequest(res, err); + } + }); + + // Remove a runtime datasource. + server.delete(`${root}/:name`, async (req: any, res: any) => { + const svc = adminService(); + if (!svc?.removeDatasource) return unavailable(res); + try { + await svc.removeDatasource(req.params.name); + res.status(204).end(); + } catch (err) { + badRequest(res, err); + } + }); +} diff --git a/packages/services/service-datasource-admin/src/contracts/datasource-admin-service.ts b/packages/services/service-datasource-admin/src/contracts/datasource-admin-service.ts new file mode 100644 index 000000000..5614d351f --- /dev/null +++ b/packages/services/service-datasource-admin/src/contracts/datasource-admin-service.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IDatasourceAdminService — runtime datasource lifecycle contract + * (ADR-0015 Addendum: Runtime UI-Created Datasources). + * + * Where {@link IExternalDatasourceService} covers *federation* (introspection, + * object drafting, schema validation) of datasources that already exist, this + * service covers their *lifecycle*: testing a connection before saving, + * creating / updating / removing a **runtime** datasource (`origin: 'runtime'`), + * and listing all datasources with their provenance + health. + * + * Code-defined datasources (`origin: 'code'`, authored as `*.datasource.ts`) + * are read-only here: `updateDatasource` / `removeDatasource` reject them, and + * a runtime datasource never shadows a code one of the same name (code wins). + * + * Credentials are never persisted in cleartext: callers pass a {@link SecretInput} + * separately from the connection `config`; the implementation encrypts it into + * the secret store (`sys_secret`) and persists only an opaque `credentialsRef`. + */ + +/** Provenance of a datasource definition. */ +export type DatasourceOrigin = 'code' | 'runtime'; + +/** + * A cleartext secret (password or full connection string) supplied for a + * create/update/test call. Never persisted as-is — encrypted into the secret + * store, with only the returned handle (`credentialsRef`) kept on the record. + */ +export interface SecretInput { + /** The cleartext value to encrypt (e.g. password or connection string). */ + value: string; + /** Optional secret-store namespace (defaults to `'datasource'`). */ + namespace?: string; + /** Optional secret-store key (defaults to the datasource name). */ + key?: string; +} + +/** + * The connection definition a caller supplies to test/create/update. A subset + * of `Datasource` — server-managed fields (`origin`) are never accepted from + * the client. + */ +export interface DatasourceDraft { + name: string; + label?: string; + driver: string; + schemaMode?: 'managed' | 'external' | 'validate-only'; + /** Driver-specific connection config (host, port, database, …). No secrets. */ + config?: Record; + /** External federation settings (required when schemaMode != 'managed'). */ + external?: Record; + pool?: Record; + active?: boolean; +} + +/** Result of probing a connection (live driver connect + cheap round-trip). */ +export interface TestConnectionResult { + ok: boolean; + /** Round-trip latency of the probe, when the connection succeeded. */ + latencyMs?: number; + /** Driver-reported server version, when available. */ + serverVersion?: string; + /** Human-readable failure reason, when `ok === false`. */ + error?: string; +} + +/** A datasource with its provenance and current health (no secrets). */ +export interface DatasourceSummary { + name: string; + label?: string; + driver: string; + schemaMode: 'managed' | 'external' | 'validate-only'; + origin: DatasourceOrigin; + active: boolean; + /** Validation health: `unvalidated` until the first validate/test runs. */ + status: 'ok' | 'error' | 'unvalidated'; + /** Package id that defines a code-origin datasource (omitted for runtime). */ + definedIn?: string; + /** True when a runtime row is shadowed by a code definition of the same name. */ + conflictsWithCode?: boolean; +} + +/** + * Runtime datasource lifecycle service. Registered into the kernel as the + * `'datasource-admin'` service; consumed by the REST layer and Studio wizard. + */ +export interface IDatasourceAdminService { + /** List every datasource (code + runtime) with provenance and health. */ + listDatasources(): Promise; + + /** + * Probe a connection without persisting anything. Accepts an unsaved draft + * so the wizard can validate credentials before "Save". + */ + testConnection(input: DatasourceDraft, secret?: SecretInput): Promise; + + /** + * Persist a new runtime datasource (`origin: 'runtime'`, environment-scoped). + * Rejects when a code-defined datasource of the same name exists. + */ + createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise; + + /** + * Patch an existing runtime datasource. Rejects for code-defined datasources. + * Passing `secret` re-wraps the stored credential. + */ + updateDatasource( + name: string, + patch: Partial, + secret?: SecretInput, + ): Promise; + + /** + * Remove a runtime datasource. Rejects for code-defined ones and while + * objects are still bound to it. + */ + removeDatasource(name: string): Promise; +} diff --git a/packages/services/service-datasource-admin/src/contracts/datasource-driver-factory.ts b/packages/services/service-datasource-admin/src/contracts/datasource-driver-factory.ts new file mode 100644 index 000000000..aa17c05c0 --- /dev/null +++ b/packages/services/service-datasource-admin/src/contracts/datasource-driver-factory.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * IDatasourceDriverFactory — host-provided capability that builds a live driver + * from a connection spec (ADR-0015 Addendum §3.5). + * + * The framework deliberately ships no universal "driver-by-id" registry — + * concrete drivers (`SqlDriver`, `MongoDBDriver`, `TursoDriver`, …) are + * constructed by the host stack and registered as live connections. The + * runtime-datasource lifecycle (`IDatasourceAdminService`) needs to build a + * driver from an *unsaved* draft — to probe a connection before "Save", and to + * hot-register a pool after create/update — so the host exposes this factory + * as the `'datasource-driver-factory'` service. + * + * When no factory is registered, or none `supports()` a given driver id, the + * admin service degrades gracefully: `testConnection` returns + * `{ ok: false, error }` and create/update skip hot pool registration (the + * driver is picked up on the next boot instead). + * + * Security: the cleartext `secret` on {@link DatasourceConnectionSpec} is used + * only to open the live connection. Factories MUST NOT persist or log it. + */ + +/** Everything needed to construct one live driver connection. */ +export interface DatasourceConnectionSpec { + /** Datasource name, when building for an existing/named datasource. */ + name?: string; + /** Driver id (e.g. `'postgres'`, `'sqlite'`, `'mongodb'`). */ + driver: string; + /** Driver-specific connection config (host, port, database, …). No secrets. */ + config: Record; + /** Cleartext secret (password / DSN) injected for this connection only. */ + secret?: string; + /** External federation settings (timeouts, allowed schemas, …). */ + external?: Record; + /** Connection pool settings. */ + pool?: Record; +} + +/** + * A live (or lazily-connecting) driver handle. Intentionally structural and + * fully optional so any concrete driver satisfies it — the admin service uses + * whatever capabilities are present and skips the rest. + */ +export interface DatasourceDriverHandle { + /** Open the connection / pool. */ + connect?(): Promise; + /** Close the connection / pool. */ + disconnect?(): Promise; + /** Cheap liveness round-trip (preferred for probes). */ + ping?(): Promise; + /** Introspect the live schema (fallback probe when `ping` is absent). */ + introspectSchema?(): Promise; + /** Liveness check on the underlying engine driver (probe fallback). */ + checkHealth?(): Promise; + /** Driver-reported server version, when available. */ + serverVersion?(): Promise; + /** + * Escape hatch: the concrete engine driver to hand to + * `IDataEngine.registerDriver()` when hot-registering a pool. When present + * the admin service registers *this* (whose `.name` must equal the + * datasource name for routing) instead of the handle itself; absent ⇒ the + * handle is assumed to be the driver. Never serialized. + */ + driver?: unknown; +} + +/** Host-provided factory that builds drivers from connection specs. */ +export interface IDatasourceDriverFactory { + /** True if this factory can build a driver for the given driver id. */ + supports(driverId: string): boolean; + /** + * Build a driver instance for the spec. Implementations may return a + * not-yet-connected handle; the caller calls `connect()` when needed. + */ + create(spec: DatasourceConnectionSpec): Promise | DatasourceDriverHandle; +} diff --git a/packages/services/service-datasource-admin/src/contracts/index.ts b/packages/services/service-datasource-admin/src/contracts/index.ts new file mode 100644 index 000000000..1f4ed2c76 --- /dev/null +++ b/packages/services/service-datasource-admin/src/contracts/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// Datasource lifecycle + driver-factory contracts (ADR-0015 Addendum). +// Moved out of `@objectstack/spec` so the open framework no longer ships them. +export type { + DatasourceOrigin, + SecretInput, + DatasourceDraft, + TestConnectionResult, + DatasourceSummary, + IDatasourceAdminService, +} from './datasource-admin-service.js'; + +export type { + DatasourceConnectionSpec, + DatasourceDriverHandle, + IDatasourceDriverFactory, +} from './datasource-driver-factory.js'; diff --git a/packages/services/service-datasource-admin/src/datasource-admin-plugin.ts b/packages/services/service-datasource-admin/src/datasource-admin-plugin.ts new file mode 100644 index 000000000..625339815 --- /dev/null +++ b/packages/services/service-datasource-admin/src/datasource-admin-plugin.ts @@ -0,0 +1,362 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { registerMetadataTypeActions } from '@objectstack/spec/kernel'; +import type { + IDatasourceDriverFactory, + DatasourceConnectionSpec, + TestConnectionResult, +} from './contracts/index.js'; +import { + DatasourceAdminService, + type DatasourceAdminServiceConfig, + type StoredDatasource, + type ProbeInput, +} from './datasource-admin-service.js'; +import type { Logger } from './logger.js'; + +/** + * Minimal metadata-service surface used for datasource persistence + the + * bound-object count. Kept structural so the plugin doesn't hard-depend on the + * concrete `MetadataManager`. + */ +interface MetadataServiceLike { + get: (type: string, name: string) => Promise; + list: (type: string) => Promise; + register: (type: string, name: string, data: unknown) => Promise; + unregister: (type: string, name: string) => Promise; + listObjects?: () => Promise; +} + +/** Engine surface used for hot pool (de)registration. */ +interface DataEngineLike { + registerDriver?: (driver: unknown, isDefault?: boolean) => void; + registerDatasourceDef?: (def: { name: string; schemaMode?: string; external?: { allowWrites?: boolean } }) => void; + getDriverByName?: (name: string) => unknown; +} + +/** + * Host-provided secret binding. Encrypts a cleartext credential into the secret + * store and returns an opaque `credentialsRef`; `unbind` deletes it. Wired by + * the stack that owns the `ICryptoProvider` + `sys_secret` store. When absent, + * the plugin fails *closed*: creating/updating a datasource *with* a secret + * throws rather than risk persisting cleartext. + */ +export interface SecretBinder { + bind: (input: { value: string; namespace?: string; key?: string }, hint: { name: string }) => Promise; + unbind?: (credentialsRef: string) => Promise; + /** + * Dereference a `credentialsRef` back to cleartext for opening a live + * connection (boot rehydration + hot pool registration). Optional: when + * absent, pools for secret-bearing datasources are built without the + * credential (fine for credential-less drivers like sqlite/memory). + */ + resolve?: (credentialsRef: string) => Promise; +} + +export interface DatasourceAdminServicePluginOptions { + /** Secret binding backed by the host's crypto provider + `sys_secret`. */ + secrets?: SecretBinder; + /** Override the driver factory (defaults to the `'datasource-driver-factory'` service). */ + driverFactory?: IDatasourceDriverFactory; + logger?: Logger; +} + +/** + * DatasourceAdminServicePlugin — registers `IDatasourceAdminService` into the + * kernel as the `'datasource-admin'` service (ADR-0015 Addendum). + * + * Bridges the decoupled {@link DatasourceAdminService} to live infrastructure: + * - persistence + bound-object count via the `'metadata'` service + * (`register`/`unregister` write through to the runtime DB loader), + * - connection probe + hot pool (de)registration via the + * `'datasource-driver-factory'` capability and the `'data'` engine, + * - secret encryption via a host-provided {@link SecretBinder} (fail-closed). + * + * Every dependency degrades gracefully: a missing driver factory turns + * `testConnection` into a clear `{ ok: false }` and skips hot pool registration + * (the driver is picked up at next boot); a missing secret binder makes + * secret-bearing create/update fail loudly instead of leaking cleartext. + */ +export class DatasourceAdminServicePlugin implements Plugin { + name = 'com.objectstack.service-datasource-admin'; + version = '1.0.0'; + type = 'standard' as const; + dependencies: string[] = []; + + private service?: DatasourceAdminService; + private config?: DatasourceAdminServiceConfig; + private readonly options: DatasourceAdminServicePluginOptions; + + constructor(options: DatasourceAdminServicePluginOptions = {}) { + this.options = options; + } + + async init(ctx: PluginContext): Promise { + const logger = this.options.logger; + + // Contribute the metadata-admin "Test connection" type-level action, + // co-located with the route handler that serves it + // (`POST /api/v1/datasources/:name/test`, see admin-routes.ts). The + // open-source framework deliberately ships no declarative datasource + // action, so the button is emitted by `/api/v1/meta` only when this + // backend plugin is installed — never advertising a route the host + // can't serve. `${ctx.recordId}` resolves to the datasource's name. + registerMetadataTypeActions('datasource', [ + { + name: 'test_connection', + label: 'Test connection', + icon: 'plug-zap', + type: 'api', + target: '/api/v1/datasources/${ctx.recordId}/test', + method: 'POST', + variant: 'secondary', + refreshAfter: false, + locations: ['record_header', 'list_item'], + }, + ] as any); + + // Resolve infra services lazily, per call — `init()` may run before the + // `data` / `metadata` plugins have registered their services (plugin start + // order is dependency- not registration-driven), and admin requests only + // arrive long after the full boot completes. + const metadataOf = (): MetadataServiceLike | undefined => + safeGetService(ctx, 'metadata'); + const engineOf = (): DataEngineLike | undefined => + safeGetService(ctx, 'data'); + + const factory = (): IDatasourceDriverFactory | undefined => + this.options.driverFactory ?? safeGetService(ctx, 'datasource-driver-factory'); + + const config: DatasourceAdminServiceConfig = { + probe: (input) => this.probe(factory(), input), + + listDatasourceRecords: async () => { + const rows = ((await metadataOf()?.list('datasource')) ?? []) as StoredDatasource[]; + // Artefact-loaded rows may omit `origin`; treat them as code-defined. + return rows.map((r) => ({ ...r, origin: r.origin ?? 'code' })); + }, + + getDatasourceRecord: async (name) => { + const row = (await metadataOf()?.get('datasource', name)) as StoredDatasource | undefined; + return row ? { ...row, origin: row.origin ?? 'code' } : undefined; + }, + + putDatasourceRecord: async (record) => { + const metadata = metadataOf(); + if (!metadata?.register) { + throw new Error('Metadata service is unavailable; cannot persist datasource.'); + } + await metadata.register('datasource', record.name, record); + }, + + deleteDatasourceRecord: async (name) => { + const metadata = metadataOf(); + if (!metadata?.unregister) { + throw new Error('Metadata service is unavailable; cannot remove datasource.'); + } + await metadata.unregister('datasource', name); + }, + + writeSecret: async (input, hint) => { + const binder = this.options.secrets; + if (!binder?.bind) { + throw new Error( + 'No secret store configured: refusing to persist a datasource credential in cleartext. ' + + 'Wire a SecretBinder (CryptoProvider + sys_secret) into DatasourceAdminServicePlugin.', + ); + } + return binder.bind(input, hint); + }, + + removeSecret: async (ref) => { + await this.options.secrets?.unbind?.(ref); + }, + + countBoundObjects: async (datasource) => { + const metadata = metadataOf(); + const objects = ((await metadata?.listObjects?.()) ?? + (await metadata?.list('object')) ?? + []) as Array<{ datasource?: string }>; + return objects.filter((o) => o?.datasource === datasource).length; + }, + + registerPool: async (record) => { + const f = factory(); + const engine = engineOf(); + if (!f || !engine?.registerDriver || !f.supports(record.driver)) return; + // Recover the cleartext credential from `sys_secret` so the pool opens + // with the real password. The cleartext is never persisted on the + // record (only `credentialsRef`), so it must be dereferenced here — + // both on create/update and on boot rehydration. Credential-less + // drivers (sqlite/memory) simply have no ref and skip this. + const credentialsRef = record.external?.credentialsRef; + const secret = credentialsRef ? await this.options.secrets?.resolve?.(credentialsRef) : undefined; + const handle = await f.create({ ...this.toSpec(record), ...(secret ? { secret } : {}) }); + if (typeof handle?.connect === 'function') await handle.connect(); + // The engine routes a datasource to a driver by `driver.name === ` + // (see ObjectQL engine.getDriver). Prefer the factory's underlying engine + // driver (the `driver` escape hatch); fall back to the handle itself. Stamp + // the name so routing resolves to this pool. + const engineDriver = (handle.driver ?? handle) as { name?: string }; + try { + engineDriver.name = record.name; + } catch { + /* frozen driver — registration may still work if name already matches */ + } + engine.registerDriver(engineDriver); + engine.registerDatasourceDef?.({ + name: record.name, + schemaMode: record.schemaMode, + external: record.external as { allowWrites?: boolean } | undefined, + }); + }, + + unregisterPool: async (name) => { + const driver = engineOf()?.getDriverByName?.(name) as { disconnect?: () => Promise } | undefined; + if (typeof driver?.disconnect === 'function') await driver.disconnect(); + }, + + logger, + }; + + this.config = config; + this.service = new DatasourceAdminService(config); + ctx.registerService('datasource-admin', this.service); + } + + async start(ctx: PluginContext): Promise { + // Rebuild live connection pools for persisted runtime datasources before + // announcing readiness — a node restart otherwise leaves UI-created + // datasources with a record but no open pool until the next write. + await this.rehydratePools(); + if (this.service) await ctx.trigger('datasource-admin:ready', this.service); + } + + /** + * Boot-time rehydration: list persisted runtime datasources and re-register + * each one's connection pool (driver build → connect → registerDriver), + * decrypting its `sys_secret` credential on the way via the configured + * `registerPool` (which resolves `credentialsRef`). Code-defined datasources + * are owned by the host stack's own boot path and skipped here. Entirely + * best-effort: a missing factory/engine, an unpersisted dev store (nothing + * to rehydrate), or a single failing pool never blocks boot. + */ + private async rehydratePools(): Promise { + const cfg = this.config; + if (!cfg?.registerPool || !cfg.listDatasourceRecords) return; + + let records: StoredDatasource[]; + try { + records = await cfg.listDatasourceRecords(); + } catch (err) { + this.options.logger?.warn?.('datasource rehydrate: listing records failed', err); + return; + } + + const runtime = records.filter((r) => r.origin === 'runtime' && (r.active ?? true)); + if (runtime.length === 0) return; + + let registered = 0; + for (const record of runtime) { + try { + await cfg.registerPool(record); + registered++; + } catch (err) { + this.options.logger?.warn?.(`datasource rehydrate: pool '${record.name}' failed`, err); + } + } + this.options.logger?.info?.( + `Rehydrated ${registered}/${runtime.length} runtime datasource pool(s) on boot`, + ); + } + + async destroy(): Promise { + this.service = undefined; + } + + // --- internals ----------------------------------------------------------- + + private toSpec(record: StoredDatasource): DatasourceConnectionSpec { + return { + name: record.name, + driver: record.driver, + config: record.config ?? {}, + external: record.external, + pool: record.pool, + }; + } + + /** Probe a connection via the driver factory: build → connect → ping → close. */ + private async probe( + factory: IDatasourceDriverFactory | undefined, + input: ProbeInput, + ): Promise { + if (!factory) { + return { ok: false, error: 'No driver factory is registered to test connections.' }; + } + if (!factory.supports(input.driver)) { + return { ok: false, error: `No driver factory supports driver '${input.driver}'.` }; + } + + let driver: any; + try { + driver = await factory.create({ + driver: input.driver, + config: input.config, + secret: input.secret, + external: input.external, + }); + } catch (err) { + return { ok: false, error: `Failed to build driver: ${errMsg(err)}` }; + } + + const startedAt = monotonicNow(); + try { + if (typeof driver?.connect === 'function') await driver.connect(); + // Prefer a cheap ping; fall back to the engine driver's health check, then + // a schema introspection round-trip — whichever the handle exposes. + if (typeof driver?.ping === 'function') await driver.ping(); + else if (typeof driver?.checkHealth === 'function') await driver.checkHealth(); + else if (typeof driver?.introspectSchema === 'function') await driver.introspectSchema(); + const latencyMs = elapsedSince(startedAt); + let serverVersion: string | undefined; + try { + serverVersion = typeof driver?.serverVersion === 'function' ? await driver.serverVersion() : undefined; + } catch { + /* version is best-effort */ + } + return { ok: true, latencyMs, ...(serverVersion ? { serverVersion } : {}) }; + } catch (err) { + return { ok: false, error: errMsg(err) }; + } finally { + try { + if (typeof driver?.disconnect === 'function') await driver.disconnect(); + } catch { + /* best-effort teardown */ + } + } + } +} + +function safeGetService(ctx: PluginContext, name: string): T | undefined { + try { + return ctx.getService(name); + } catch { + return undefined; + } +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** Monotonic clock when available (avoids wall-clock skew); falls back to 0. */ +function monotonicNow(): number { + const perf = (globalThis as { performance?: { now?: () => number } }).performance; + return typeof perf?.now === 'function' ? perf.now() : 0; +} + +function elapsedSince(startedAt: number): number { + return Math.max(0, Math.round(monotonicNow() - startedAt)); +} diff --git a/packages/services/service-datasource-admin/src/datasource-admin-service.ts b/packages/services/service-datasource-admin/src/datasource-admin-service.ts new file mode 100644 index 000000000..dc9626184 --- /dev/null +++ b/packages/services/service-datasource-admin/src/datasource-admin-service.ts @@ -0,0 +1,297 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * DatasourceAdminService — implements {@link IDatasourceAdminService} + * (ADR-0015 Addendum) on top of injected persistence + secret + driver probe + * callbacks. + * + * Like its federation sibling `ExternalDatasourceService`, this service is + * intentionally decoupled from the kernel: every side effect (connection probe, + * metadata read/write, secret write, bound-object count, hot pool (de)register) + * is injected via {@link DatasourceAdminServiceConfig}, so the lifecycle rules + * (origin gating, secret indirection, removal safety) are pure and unit-testable. + * + * Invariants enforced here, independent of the wiring: + * - Code-defined datasources (`origin: 'code'`) are read-only — update/remove + * reject them, and create refuses a name a code datasource already owns. + * - A runtime datasource never shadows a code one (code wins on collision). + * - Credentials never persist in cleartext: the cleartext {@link SecretInput} + * transits create/update/test only; create/update write it to the secret + * store and persist only the returned `credentialsRef`. + * - Removal is refused while objects are still bound to the datasource. + */ + +import type { + IDatasourceAdminService, + DatasourceDraft, + SecretInput, + TestConnectionResult, + DatasourceSummary, +} from './contracts/index.js'; +import type { Logger } from './logger.js'; + +/** Datasource name rule (mirrors `DatasourceSchema.name`). */ +const NAME_RE = /^[a-z_][a-z0-9_]*$/; + +/** + * A persisted datasource record (subset of `Datasource`). `origin` distinguishes + * code-defined from runtime; `external.credentialsRef` is the opaque secret + * handle — never a cleartext credential. + */ +export interface StoredDatasource { + name: string; + label?: string; + driver: string; + schemaMode?: 'managed' | 'external' | 'validate-only'; + config?: Record; + external?: (Record & { credentialsRef?: string }) | undefined; + pool?: Record; + active?: boolean; + origin?: 'code' | 'runtime'; + /** Package that defines a code-origin datasource, when known. */ + definedIn?: string; +} + +/** What a connection probe needs (cleartext secret is transient, never stored). */ +export interface ProbeInput { + driver: string; + config: Record; + /** Cleartext secret used for this probe only (e.g. password / DSN). */ + secret?: string; + external?: Record; + timeoutMs?: number; +} + +/** + * Injected dependencies. The plugin supplies real implementations backed by the + * driver registry, `IMetadataService` (runtime store), and the secret store; + * tests supply fakes. + */ +export interface DatasourceAdminServiceConfig { + /** Probe a connection live (driver connect + cheap round-trip). */ + probe: (input: ProbeInput) => Promise; + /** Read every datasource record (code + runtime). */ + listDatasourceRecords: () => Promise; + /** Read one datasource record by name. */ + getDatasourceRecord: (name: string) => Promise; + /** Persist a runtime datasource record into the runtime metadata store. */ + putDatasourceRecord: (record: StoredDatasource) => Promise; + /** Remove a runtime datasource record from the runtime metadata store. */ + deleteDatasourceRecord: (name: string) => Promise; + /** Encrypt + store a secret, returning an opaque `credentialsRef`. */ + writeSecret: (input: SecretInput, hint: { name: string }) => Promise; + /** Best-effort delete of a stored secret by ref (cleanup on remove/rewrap). */ + removeSecret?: (credentialsRef: string) => Promise; + /** Count objects bound to a datasource (removal blocked while > 0). */ + countBoundObjects: (datasource: string) => Promise; + /** Hot-(re)register a runtime datasource's connection pool after write. */ + registerPool?: (record: StoredDatasource) => Promise | void; + /** Tear down a runtime datasource's pool on remove. */ + unregisterPool?: (name: string) => Promise | void; + logger?: Logger; +} + +export class DatasourceAdminService implements IDatasourceAdminService { + constructor(private readonly config: DatasourceAdminServiceConfig) {} + + private get logger(): Logger | undefined { + return this.config.logger; + } + + async listDatasources(): Promise { + const records = await this.config.listDatasourceRecords(); + + // Group by name; code wins on collision, and a shadowed runtime row marks + // the effective (code) entry as conflicting. + const byName = new Map(); + for (const rec of records) { + const slot = byName.get(rec.name) ?? {}; + if (rec.origin === 'runtime') slot.runtime = rec; + else slot.code = rec; + byName.set(rec.name, slot); + } + + const summaries: DatasourceSummary[] = []; + for (const [name, slot] of byName) { + const effective = slot.code ?? slot.runtime; + if (!effective) continue; + summaries.push({ + name, + label: effective.label, + driver: effective.driver, + schemaMode: effective.schemaMode ?? 'managed', + origin: slot.code ? 'code' : 'runtime', + active: effective.active ?? true, + status: 'unvalidated', + ...(slot.code?.definedIn ? { definedIn: slot.code.definedIn } : {}), + ...(slot.code && slot.runtime ? { conflictsWithCode: true } : {}), + }); + } + return summaries; + } + + async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise { + if (!input?.driver) { + return { ok: false, error: 'A driver is required to test a connection.' }; + } + const queryTimeoutMs = (input.external as { queryTimeoutMs?: number } | undefined)?.queryTimeoutMs; + try { + return await this.config.probe({ + driver: input.driver, + config: input.config ?? {}, + secret: secret?.value, + external: input.external, + ...(typeof queryTimeoutMs === 'number' ? { timeoutMs: queryTimeoutMs } : {}), + }); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + async createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise { + this.assertValidName(input?.name); + if (!input.driver) throw new Error('A driver is required to create a datasource.'); + + const existing = await this.config.getDatasourceRecord(input.name); + if (existing) { + if (existing.origin === 'code' || existing.origin === undefined) { + throw new Error( + `Cannot create datasource '${input.name}': a code-defined datasource owns this name (read-only).`, + ); + } + throw new Error(`Datasource '${input.name}' already exists.`); + } + + const record: StoredDatasource = { + ...this.toRecord(input), + origin: 'runtime', + }; + + if (secret) { + const credentialsRef = await this.config.writeSecret(secret, { name: input.name }); + record.external = { ...(record.external ?? {}), credentialsRef }; + } + + await this.config.putDatasourceRecord(record); + await this.tryRegisterPool(record); + return this.toSummary(record); + } + + async updateDatasource( + name: string, + patch: Partial, + secret?: SecretInput, + ): Promise { + const existing = await this.config.getDatasourceRecord(name); + if (!existing) throw new Error(`Datasource '${name}' not found.`); + if (existing.origin !== 'runtime') { + throw new Error(`Datasource '${name}' is code-defined and cannot be edited at runtime.`); + } + + // Merge patch over the existing record; `name`/`origin` are never patched. + const merged: StoredDatasource = { + ...existing, + ...(patch.label !== undefined ? { label: patch.label } : {}), + ...(patch.driver !== undefined ? { driver: patch.driver } : {}), + ...(patch.schemaMode !== undefined ? { schemaMode: patch.schemaMode } : {}), + ...(patch.config !== undefined ? { config: patch.config } : {}), + ...(patch.pool !== undefined ? { pool: patch.pool } : {}), + ...(patch.active !== undefined ? { active: patch.active } : {}), + name: existing.name, + origin: 'runtime', + }; + if (patch.external !== undefined) { + // Preserve the existing credentialsRef unless a new secret rewraps it. + merged.external = { ...patch.external, credentialsRef: existing.external?.credentialsRef }; + } + + if (secret) { + const prevRef = existing.external?.credentialsRef; + const credentialsRef = await this.config.writeSecret(secret, { name }); + merged.external = { ...(merged.external ?? {}), credentialsRef }; + if (prevRef && prevRef !== credentialsRef) await this.tryRemoveSecret(prevRef); + } + + await this.config.putDatasourceRecord(merged); + await this.tryRegisterPool(merged); + return this.toSummary(merged); + } + + async removeDatasource(name: string): Promise { + const existing = await this.config.getDatasourceRecord(name); + if (!existing) throw new Error(`Datasource '${name}' not found.`); + if (existing.origin !== 'runtime') { + throw new Error(`Datasource '${name}' is code-defined and cannot be removed at runtime.`); + } + + const bound = await this.config.countBoundObjects(name); + if (bound > 0) { + throw new Error( + `Cannot remove datasource '${name}': ${bound} object(s) are still bound to it.`, + ); + } + + await this.config.deleteDatasourceRecord(name); + if (existing.external?.credentialsRef) await this.tryRemoveSecret(existing.external.credentialsRef); + await this.tryUnregisterPool(name); + } + + // --- internals ----------------------------------------------------------- + + private assertValidName(name: string | undefined): void { + if (!name || !NAME_RE.test(name)) { + throw new Error( + `Invalid datasource name '${name ?? ''}': must match /^[a-z_][a-z0-9_]*$/.`, + ); + } + } + + private toRecord(input: DatasourceDraft): StoredDatasource { + return { + name: input.name, + ...(input.label !== undefined ? { label: input.label } : {}), + driver: input.driver, + ...(input.schemaMode !== undefined ? { schemaMode: input.schemaMode } : {}), + ...(input.config !== undefined ? { config: input.config } : {}), + ...(input.external !== undefined ? { external: input.external } : {}), + ...(input.pool !== undefined ? { pool: input.pool } : {}), + ...(input.active !== undefined ? { active: input.active } : {}), + }; + } + + private toSummary(record: StoredDatasource): DatasourceSummary { + return { + name: record.name, + label: record.label, + driver: record.driver, + schemaMode: record.schemaMode ?? 'managed', + origin: record.origin ?? 'runtime', + active: record.active ?? true, + status: 'unvalidated', + }; + } + + private async tryRegisterPool(record: StoredDatasource): Promise { + try { + await this.config.registerPool?.(record); + } catch (err) { + this.logger?.warn(`registerPool('${record.name}') failed`, err); + } + } + + private async tryUnregisterPool(name: string): Promise { + try { + await this.config.unregisterPool?.(name); + } catch (err) { + this.logger?.warn(`unregisterPool('${name}') failed`, err); + } + } + + private async tryRemoveSecret(credentialsRef: string): Promise { + try { + await this.config.removeSecret?.(credentialsRef); + } catch (err) { + this.logger?.warn(`removeSecret('${credentialsRef}') failed`, err); + } + } +} diff --git a/packages/services/service-datasource-admin/src/datasource-secret-binder.ts b/packages/services/service-datasource-admin/src/datasource-secret-binder.ts new file mode 100644 index 000000000..7b4f87581 --- /dev/null +++ b/packages/services/service-datasource-admin/src/datasource-secret-binder.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Default datasource SecretBinder — persists a runtime datasource's cleartext + * credential into the `sys_secret` cipher store and returns an opaque + * `credentialsRef` handle (ADR-0015 Addendum, security invariant). + * + * Mirrors the SettingsService Phase-3 split: the cleartext is wrapped by an + * {@link ICryptoProvider} into a {@link CryptoHandle}, the ciphertext lands in a + * `sys_secret` row keyed by `handle.id`, and only the handle id (wrapped as + * `sys_secret:`) is ever stored on the datasource artefact. Cleartext never + * touches metadata. + * + * This is the dev/self-host wiring; production hosts swap the + * `InMemoryCryptoProvider` for a KMS-backed `ICryptoProvider` and pass it here. + */ + +import type { CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; + +/** Prefix used to recognise a datasource credential handle. */ +const REF_PREFIX = 'sys_secret:'; + +/** A persisted `sys_secret` row (subset used to reconstruct a {@link CryptoHandle}). */ +interface SecretRow { + id: string; + namespace: string; + key: string; + kms_key_id: string; + alg: string; + version: number; + ciphertext: string; +} + +/** Minimal data-engine surface used to read/write the `sys_secret` store. */ +export interface SecretStoreEngineLike { + insert(object: string, data: Record, options?: unknown): Promise; + delete(object: string, options: { where: Record }): Promise; + /** + * Read `sys_secret` rows for the `resolve()` path. Optional so existing + * callers that only bind/unbind keep working; `resolve()` no-ops when absent. + * Mirrors `IDataEngine.find` — returns an array (or `{ data: [...] }`). + */ + find?(object: string, query: Record): Promise; +} + +export interface DatasourceSecretBinderDeps { + /** Data engine (ObjectQL) used to persist the `sys_secret` row. */ + engine: SecretStoreEngineLike; + /** Crypto provider that wraps cleartext into a {@link CryptoHandle}. */ + cryptoProvider: ICryptoProvider; + /** Settings namespace recorded on the secret row (default `'datasource'`). */ + namespace?: string; +} + +export interface DatasourceSecretBinder { + bind(input: { value: string; namespace?: string; key?: string }, hint: { name: string }): Promise; + unbind(credentialsRef: string): Promise; + /** + * Dereference a `credentialsRef` back to its cleartext credential by reading + * the `sys_secret` row and decrypting it. Used at boot to rebuild a runtime + * datasource's live connection pool (the cleartext is never persisted, so it + * must be recovered from the cipher store). Returns `undefined` when the ref + * isn't ours, the row is gone, the engine can't read, or decryption fails + * (e.g. an ephemeral dev key changed across restarts) — callers degrade to + * skipping that pool rather than crashing boot. + */ + resolve(credentialsRef: string): Promise; +} + +/** Build a `credentialsRef` from a crypto handle id. */ +export function toCredentialsRef(handleId: string): string { + return `${REF_PREFIX}${handleId}`; +} + +/** Extract the `sys_secret` handle id from a credentialsRef, if it is one. */ +export function parseCredentialsRef(ref: string): string | undefined { + return ref?.startsWith(REF_PREFIX) ? ref.slice(REF_PREFIX.length) : undefined; +} + +/** + * Create the default datasource secret binder. Persists into `sys_secret` via + * the data engine and never returns or logs the cleartext. + */ +export function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps): DatasourceSecretBinder { + const { engine, cryptoProvider } = deps; + const defaultNamespace = deps.namespace ?? 'datasource'; + + return { + async bind(input, hint) { + const namespace = input.namespace ?? defaultNamespace; + const key = input.key ?? hint.name; + const handle: CryptoHandle = await cryptoProvider.encrypt(input.value, { namespace, key }); + await engine.insert('sys_secret', { + id: handle.id, + namespace, + key, + kms_key_id: handle.kmsKeyId, + alg: handle.alg, + version: handle.version, + ciphertext: handle.ciphertext, + }); + return toCredentialsRef(handle.id); + }, + + async unbind(credentialsRef) { + const id = parseCredentialsRef(credentialsRef); + if (!id) return; // not ours (or already cleared) — nothing to do + await engine.delete('sys_secret', { where: { id } }); + }, + + async resolve(credentialsRef) { + const id = parseCredentialsRef(credentialsRef); + if (!id || typeof engine.find !== 'function') return undefined; + try { + const result = await engine.find('sys_secret', { + where: { id }, + limit: 1, + // Secrets are scoped through their owning datasource artefact, so + // skip the tenant-audit warning (mirrors SettingsService's store). + bypassTenantAudit: true, + }); + const rows = (Array.isArray(result) ? result : (result as { data?: unknown[] })?.data) ?? []; + const row = rows[0] as SecretRow | undefined; + if (!row?.ciphertext) return undefined; + // Reconstruct the handle and decrypt under the same (namespace,key) + // AAD the row was sealed with — a mismatch fails authentication. + return await cryptoProvider.decrypt( + { + id: row.id, + kmsKeyId: row.kms_key_id, + alg: row.alg, + version: row.version, + ciphertext: row.ciphertext, + }, + { namespace: row.namespace, key: row.key }, + ); + } catch { + // Missing row / unreadable engine / decrypt failure (e.g. rotated dev + // key) — never block boot; the pool is simply not rehydrated. + return undefined; + } + }, + }; +} diff --git a/packages/services/service-datasource-admin/src/default-datasource-driver-factory.ts b/packages/services/service-datasource-admin/src/default-datasource-driver-factory.ts new file mode 100644 index 000000000..7d0ba35af --- /dev/null +++ b/packages/services/service-datasource-admin/src/default-datasource-driver-factory.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Default (dev/self-host) implementation of {@link IDatasourceDriverFactory}. + * + * The framework ships no universal "driver-by-id" registry — concrete drivers + * are constructed by the host stack (ADR-0015 Addendum §3.5). This factory is + * the host-side glue that lets the runtime-datasource lifecycle + * (`IDatasourceAdminService`) build a live driver from an *unsaved* draft so it + * can probe a connection before "Save" and hot-register a pool afterwards. + * + * Supported driver ids map onto the same open-core drivers the standalone + * stack auto-detects: + * - `postgres` / `pg` / `postgresql` → `@objectstack/driver-sql` (client `pg`) + * - `sqlite` / `sqlite3` → `@objectstack/driver-sql` (better-sqlite3) + * - `mongodb` / `mongo` → `@objectstack/driver-mongodb` (peer dep) + * - `memory` / `inmemory` → `@objectstack/driver-memory` + * + * Anything else returns `supports() === false`, so the admin service degrades + * gracefully (testConnection → `{ ok: false }`, create skips hot pool reg). + * + * SECURITY: the cleartext `spec.secret` is used only to open the connection and + * is never persisted or logged here. + */ + +import type { + IDatasourceDriverFactory, + DatasourceConnectionSpec, + DatasourceDriverHandle, +} from './contracts/index.js'; + +type ResolvedKind = 'postgres' | 'sqlite' | 'mongodb' | 'memory'; + +const DRIVER_ID_ALIASES: Record = { + postgres: 'postgres', + postgresql: 'postgres', + pg: 'postgres', + sqlite: 'sqlite', + sqlite3: 'sqlite', + 'better-sqlite3': 'sqlite', + mongodb: 'mongodb', + mongo: 'mongodb', + memory: 'memory', + inmemory: 'memory', + 'in-memory': 'memory', +}; + +function resolveKind(driverId: string): ResolvedKind | undefined { + return DRIVER_ID_ALIASES[String(driverId ?? '').toLowerCase()]; +} + +/** + * Wrap a concrete engine driver in a probe handle. `ping`/`checkHealth` reuse + * the driver's own health check; `driver` is the escape hatch the admin service + * hands to `registerDriver()`. + */ +function toHandle(driver: any, serverVersion?: () => Promise): DatasourceDriverHandle { + return { + connect: typeof driver?.connect === 'function' ? () => driver.connect() : undefined, + disconnect: typeof driver?.disconnect === 'function' ? () => driver.disconnect() : undefined, + checkHealth: typeof driver?.checkHealth === 'function' ? () => driver.checkHealth() : undefined, + ping: typeof driver?.checkHealth === 'function' ? () => driver.checkHealth() : undefined, + ...(serverVersion ? { serverVersion } : {}), + driver, + }; +} + +/** Build the Knex `connection` for a SQL driver from a spec's config + secret. */ +function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'better-sqlite3'): unknown { + const cfg = (spec.config ?? {}) as Record; + + if (client === 'better-sqlite3') { + const filename = + (cfg.filename as string | undefined) ?? + (cfg.file as string | undefined) ?? + (cfg.database as string | undefined) ?? + ':memory:'; + return { filename }; + } + + // pg — accept either a connection string (`url`/`connectionString`) or + // discrete fields. The secret is the password and is never part of `config`. + const url = (cfg.url as string | undefined) ?? (cfg.connectionString as string | undefined); + if (url) { + // For a DSN, a separately-supplied secret overrides the embedded password. + return spec.secret ? { connectionString: url, password: spec.secret } : { connectionString: url }; + } + return { + host: cfg.host, + port: cfg.port, + database: cfg.database, + user: cfg.user ?? cfg.username, + ...(spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}), + ...(cfg.ssl != null ? { ssl: cfg.ssl } : {}), + }; +} + +/** Build a mongodb connection URL from a spec's config + secret. */ +function buildMongoUrl(spec: DatasourceConnectionSpec): string { + const cfg = (spec.config ?? {}) as Record; + const explicit = (cfg.url as string | undefined) ?? (cfg.uri as string | undefined); + if (explicit) return explicit; + const host = (cfg.host as string | undefined) ?? 'localhost'; + const port = (cfg.port as number | string | undefined) ?? 27017; + const db = (cfg.database as string | undefined) ?? ''; + const user = (cfg.user as string | undefined) ?? (cfg.username as string | undefined); + const auth = user ? `${encodeURIComponent(user)}:${encodeURIComponent(spec.secret ?? '')}@` : ''; + return `mongodb://${auth}${host}:${port}/${db}`; +} + +/** + * Create the default datasource driver factory. Driver packages are imported + * lazily so a host that never builds (e.g.) a mongo connection doesn't pay for + * the mongo SDK. + */ +export function createDefaultDatasourceDriverFactory(): IDatasourceDriverFactory { + return { + supports(driverId: string): boolean { + return resolveKind(driverId) !== undefined; + }, + + async create(spec: DatasourceConnectionSpec): Promise { + const kind = resolveKind(spec.driver); + if (!kind) { + throw new Error(`Unsupported driver id '${spec.driver}'.`); + } + + const schemaMode = (spec.external as { schemaMode?: string } | undefined)?.schemaMode + ?? ((spec.config as Record | undefined)?.schemaMode as string | undefined); + + if (kind === 'postgres') { + const { SqlDriver } = await import('@objectstack/driver-sql'); + const driver = new SqlDriver({ + client: 'pg', + connection: buildSqlConnection(spec, 'pg') as any, + pool: { min: 0, max: 5 }, + ...(schemaMode ? { schemaMode: schemaMode as any } : {}), + } as any); + return toHandle(driver, () => sqlServerVersion(driver, 'pg')); + } + + if (kind === 'sqlite') { + const { SqlDriver } = await import('@objectstack/driver-sql'); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: buildSqlConnection(spec, 'better-sqlite3') as any, + useNullAsDefault: true, + ...(schemaMode ? { schemaMode: schemaMode as any } : {}), + } as any); + return toHandle(driver, () => sqlServerVersion(driver, 'sqlite')); + } + + if (kind === 'mongodb') { + let MongoDBDriver: any; + try { + ({ MongoDBDriver } = await import('@objectstack/driver-mongodb' as any)); + } catch (err: any) { + throw new Error( + `mongodb driver requested but @objectstack/driver-mongodb is not installed (${err?.message ?? err}).`, + ); + } + const driver = new MongoDBDriver({ url: buildMongoUrl(spec) }); + return toHandle(driver); + } + + // memory + const { InMemoryDriver } = await import('@objectstack/driver-memory'); + return toHandle(new InMemoryDriver()); + }, + }; +} + +/** Best-effort server version via a raw query; swallows everything. */ +async function sqlServerVersion(driver: any, client: 'pg' | 'sqlite'): Promise { + if (typeof driver?.execute !== 'function') return undefined; + try { + const sql = client === 'pg' ? 'SELECT version() AS v' : 'SELECT sqlite_version() AS v'; + const rows: any = await driver.execute(sql); + const first = Array.isArray(rows) ? rows[0] : Array.isArray(rows?.rows) ? rows.rows[0] : rows; + const v = first?.v ?? first?.version ?? first?.['sqlite_version()']; + return typeof v === 'string' ? v : undefined; + } catch { + return undefined; + } +} diff --git a/packages/services/service-datasource-admin/src/index.ts b/packages/services/service-datasource-admin/src/index.ts new file mode 100644 index 000000000..33eb41cf6 --- /dev/null +++ b/packages/services/service-datasource-admin/src/index.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/service-datasource-admin — runtime UI-created datasource + * lifecycle (ADR-0015 Addendum). Open-source mechanism: list / test / create / + * update / remove datasources defined in the UI. Credential storage is + * delegated to a host-provided {@link SecretBinder} (over an `ICryptoProvider`) + * and drivers to a swappable factory, so a managed credential vault / + * multi-tenant overlay can be layered on by a private host without forking. + * See README for host wiring. + */ + +// Contracts (the canonical datasource-admin DTOs; re-exported here). +export type { + DatasourceOrigin, + SecretInput, + DatasourceDraft, + TestConnectionResult, + DatasourceSummary, + IDatasourceAdminService, + DatasourceConnectionSpec, + DatasourceDriverHandle, + IDatasourceDriverFactory, +} from './contracts/index.js'; + +// Decoupled lifecycle service + injected-config shape. +export { DatasourceAdminService } from './datasource-admin-service.js'; +export type { + DatasourceAdminServiceConfig, + StoredDatasource, + ProbeInput, +} from './datasource-admin-service.js'; + +// Kernel plugin (registers the `'datasource-admin'` service). +export { DatasourceAdminServicePlugin } from './datasource-admin-plugin.js'; +export type { + DatasourceAdminServicePluginOptions, + SecretBinder, +} from './datasource-admin-plugin.js'; + +// Host glue: dev driver factory + fail-closed secret binder. +export { createDefaultDatasourceDriverFactory } from './default-datasource-driver-factory.js'; +export { + createDatasourceSecretBinder, + toCredentialsRef, + parseCredentialsRef, +} from './datasource-secret-binder.js'; +export type { + DatasourceSecretBinder, + DatasourceSecretBinderDeps, + SecretStoreEngineLike, +} from './datasource-secret-binder.js'; + +// REST routes. +export { registerDatasourceAdminRoutes } from './admin-routes.js'; + +// Inlined Logger surface (severs dependency on the federation service). +export type { Logger } from './logger.js'; diff --git a/packages/services/service-datasource-admin/src/logger.ts b/packages/services/service-datasource-admin/src/logger.ts new file mode 100644 index 000000000..2b38044c6 --- /dev/null +++ b/packages/services/service-datasource-admin/src/logger.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Minimal logger surface. Inlined here (rather than imported from the + * federation service) so this package has no dependency on + * `@objectstack/service-external-datasource`. + */ +export interface Logger { + warn: (message: string, meta?: unknown) => void; + info?: (message: string, meta?: unknown) => void; +} diff --git a/packages/services/service-datasource-admin/tsconfig.json b/packages/services/service-datasource-admin/tsconfig.json new file mode 100644 index 000000000..b25dd285c --- /dev/null +++ b/packages/services/service-datasource-admin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/services/service-datasource-admin/tsup.config.ts b/packages/services/service-datasource-admin/tsup.config.ts new file mode 100644 index 000000000..b2fbddc4c --- /dev/null +++ b/packages/services/service-datasource-admin/tsup.config.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts', 'src/contracts/index.ts'], + splitting: true, + sourcemap: true, + clean: true, + dts: true, + format: ['esm', 'cjs'], + target: 'es2020', + external: ['vitest'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7983fc32..1b281ec24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1820,6 +1820,37 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/services/service-datasource-admin: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/driver-mongodb': + specifier: workspace:* + version: link:../../plugins/driver-mongodb + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@objectstack/driver-memory': + specifier: workspace:* + version: link:../../plugins/driver-memory + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../../plugins/driver-sql + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/services/service-external-datasource: dependencies: '@objectstack/core':