Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@objectstack/service-analytics": "workspace:*",
"@objectstack/service-automation": "workspace:*",
"@objectstack/service-cache": "workspace:*",
"@objectstack/service-datasource-admin": "workspace:*",
"@objectstack/service-external-datasource": "workspace:*",
"@objectstack/service-feed": "workspace:*",
"@objectstack/service-job": "workspace:*",
Expand Down
133 changes: 122 additions & 11 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1601,14 +1601,15 @@
}
}

// Shared dev crypto provider for ALL of sys_secret (datasource creds
// below + secret fields after start). One instance ⇒ one key, so every
// encrypted secret decrypts under the same provider. Created lazily by
// whichever block runs first.
let sharedCryptoProvider: any = undefined;

// ── External Datasource Federation (ADR-0015) ─────────────────
// Federation (introspect / draft / import / validate of external
// tables) ships in the open framework. The runtime-UI datasource
// *lifecycle* (ADR-0015 Addendum — the "Add Datasource" wizard backend:
// create / update / remove runtime datasources) was extracted into the
// private `@objectstack/datasource-admin` package; a private host wires
// its `DatasourceAdminServicePlugin` + routes after the data/metadata
// services exist (see that package's README).
// tables) ships in the open framework.
try {
const dsMod: any = await import('@objectstack/service-external-datasource');
const { ExternalDatasourceServicePlugin } = dsMod;
Expand Down Expand Up @@ -1636,7 +1637,114 @@
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('Cannot find module') && !msg.includes('ERR_MODULE_NOT_FOUND')) {
console.error(`[Datasource] runtime-UI lifecycle wiring failed: ${msg}`);
console.error(`[Datasource] federation wiring failed: ${msg}`);
}
}

// ── Runtime Datasource Admin (ADR-0015 Addendum) ──────────────
// The "Add Datasource" wizard backend: list / test / create / update /
// remove datasources defined in the UI at runtime. This is open-source
// *mechanism* (`@objectstack/service-datasource-admin`); the tier line
// falls on which ICryptoProvider / driver factory a host injects, not on
// whether the UI can manage datasources. Mounted by default so a
// self-host runtime is a complete low-code platform out of the box.
//
// Credentials are bound through the SAME crypto provider used for
// `secret` fields below (`sharedCryptoProvider`), so every secret in
// `sys_secret` (settings, secret fields, datasource creds) shares one
// key. Wired BEFORE runtime.start() so the plugin's kernel:ready boot
// rehydration (which decrypts persisted creds) has its binder ready.
try {
const adminMod: any = await import('@objectstack/service-datasource-admin');
const {
DatasourceAdminServicePlugin,
createDefaultDatasourceDriverFactory,
createDatasourceSecretBinder,
registerDatasourceAdminRoutes,
} = adminMod;

if (
DatasourceAdminServicePlugin &&
!hasPluginMatching(['service-datasource-admin', 'DatasourceAdminServicePlugin'])
) {
// Lazy data-engine surface for the secret store (resolved per call
// so it works whether the engine is registered as 'data' or
// 'objectql', and regardless of init ordering).
const resolveEngine = (): any =>
kernel.getService?.('data') ?? kernel.getService?.('objectql');
const lazySecretEngine = {
insert: (o: string, d: any, opt?: any) => resolveEngine()?.insert(o, d, opt),
delete: (o: string, opt?: any) => resolveEngine()?.delete(o, opt),
find: (o: string, q?: any) => resolveEngine()?.find(o, q),
};

// Fail-closed binder over the shared dev crypto provider. If the
// provider can't be created, leave `secrets` undefined — the plugin
// then rejects secret-bearing create/update instead of storing
// cleartext (by design).
let secrets: any = undefined;
try {
const { InMemoryCryptoProvider } = await import(
/* webpackIgnore: true */ '@objectstack/service-settings'
);
sharedCryptoProvider = sharedCryptoProvider ?? new InMemoryCryptoProvider();

Check warning

Code scanning / CodeQL

Useless conditional Warning

This use of variable 'sharedCryptoProvider' always evaluates to false.
secrets = createDatasourceSecretBinder({
engine: lazySecretEngine,
cryptoProvider: sharedCryptoProvider,
});
} catch (cryptoErr: any) {
console.warn(
chalk.yellow(
` ⚠ datasource admin: no CryptoProvider (${cryptoErr?.message ?? cryptoErr}); secret-bearing datasource create/update will fail closed`,
),
);
}

await kernel.use(
new DatasourceAdminServicePlugin({
driverFactory: createDefaultDatasourceDriverFactory(),
secrets,
}),
);
trackPlugin('DatasourceAdminServicePlugin');

// REST routes under /api/v1/datasources. Registered via a tiny
// plugin so it resolves http.server during init (same pattern as
// the hostname guard above).
const adminRoutePlugin: any = {
name: 'com.objectstack.cli.datasource-admin-routes',
version: '1.0.0',
init: async (ctx: any) => {
try {
const httpServer: any =
ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
if (!httpServer || typeof httpServer.get !== 'function') {
ctx.logger?.warn?.(
'[datasource-admin] http.server unavailable; REST routes not installed',
);
return;
}
registerDatasourceAdminRoutes(httpServer, ctx, '/api/v1');
} catch (routeErr: any) {
ctx.logger?.warn?.(
`[datasource-admin] route registration failed: ${routeErr?.message ?? routeErr}`,
);
}
},
};
await kernel.use(adminRoutePlugin);
trackPlugin('DatasourceAdminRoutes');

if (isDev) {
console.log(
chalk.dim(' ↪ datasource admin: runtime UI lifecycle wired (/api/v1/datasources)'),
);
}
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('Cannot find module') && !msg.includes('ERR_MODULE_NOT_FOUND')) {
console.error(`[Datasource] runtime-UI admin wiring failed: ${msg}`);
}
}

Expand Down Expand Up @@ -1695,10 +1803,13 @@
const dataEngine: any =
kernel.getService?.('data') ?? kernel.getService?.('objectql');
if (dataEngine && typeof dataEngine.setCryptoProvider === 'function') {
const { InMemoryCryptoProvider } = await import(
/* webpackIgnore: true */ '@objectstack/service-settings'
);
dataEngine.setCryptoProvider(new InMemoryCryptoProvider());
if (!sharedCryptoProvider) {
const { InMemoryCryptoProvider } = await import(
/* webpackIgnore: true */ '@objectstack/service-settings'
);
sharedCryptoProvider = new InMemoryCryptoProvider();
}
dataEngine.setCryptoProvider(sharedCryptoProvider);
if (isDev) {
console.log(
chalk.dim(
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-datasource-admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"devDependencies": {
"@objectstack/driver-memory": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@objectstack/plugin-hono-server": "workspace:*",
"@types/node": "^25.9.1",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi } from 'vitest';
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
import { registerDatasourceAdminRoutes } from '../admin-routes.js';

/**
* End-to-end routing test against the REAL `HonoHttpServer` adapter — the same
* IHttpServer implementation `os serve` mounts. We exercise the routes via
* `getRawApp().fetch(...)` (no socket bind needed), proving the wiring path the
* serve composition root relies on: routes mount on `IHttpServer` and dispatch
* to whatever object the plugin context resolves for the `datasource-admin`
* service.
*/

const json = (path: string, init?: RequestInit) =>
new Request(`http://local${path}`, {
...init,
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
});

function mount(svc: unknown) {
const server = new HonoHttpServer(0);
const ctx = { getService: vi.fn().mockReturnValue(svc) } as any;
registerDatasourceAdminRoutes(server, ctx, '/api/v1');
return server.getRawApp();
}

describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
it('GET /api/v1/datasources returns the service listing', async () => {
const listDatasources = vi.fn().mockResolvedValue([
{ name: 'pg', origin: 'runtime', health: 'ok' },
]);
const app = mount({ listDatasources });

const res = await app.fetch(json('/api/v1/datasources'));

expect(res.status).toBe(200);
expect(await res.json()).toEqual({
datasources: [{ name: 'pg', origin: 'runtime', health: 'ok' }],
});
expect(listDatasources).toHaveBeenCalledOnce();
});

it('POST /api/v1/datasources/test splits the inline secret out of the draft', async () => {
const testConnection = vi.fn().mockResolvedValue({ ok: true });
const app = mount({ testConnection });

const res = await app.fetch(
json('/api/v1/datasources/test', {
method: 'POST',
body: JSON.stringify({ name: 'pg', driver: 'postgres', secret: 's3cr3t' }),
}),
);

expect(res.status).toBe(200);
expect(await res.json()).toEqual({ result: { ok: true } });
// draft must NOT carry the secret; secret is normalised to { value }.
expect(testConnection).toHaveBeenCalledWith(
{ name: 'pg', driver: 'postgres' },
{ value: 's3cr3t' },
);
});

it('POST /api/v1/datasources creates a runtime datasource (201)', async () => {
const createDatasource = vi.fn().mockResolvedValue({ name: 'pg', origin: 'runtime' });
const app = mount({ createDatasource });

const res = await app.fetch(
json('/api/v1/datasources', {
method: 'POST',
body: JSON.stringify({ name: 'pg', driver: 'postgres' }),
}),
);

expect(res.status).toBe(201);
expect(await res.json()).toEqual({ datasource: { name: 'pg', origin: 'runtime' } });
});

it('degrades to 503 when the datasource-admin service is not wired', async () => {
const app = mount(undefined);

const res = await app.fetch(json('/api/v1/datasources'));

expect(res.status).toBe(503);
expect(await res.json()).toEqual({ error: 'datasource_admin_unavailable' });
});

it('surfaces lifecycle errors as 400 with the service message', async () => {
const createDatasource = vi.fn().mockRejectedValue(new Error('duplicate name'));
const app = mount({ createDatasource });

const res = await app.fetch(
json('/api/v1/datasources', {
method: 'POST',
body: JSON.stringify({ name: 'pg', driver: 'postgres' }),
}),
);

expect(res.status).toBe(400);
expect(await res.json()).toEqual({
error: 'datasource_admin_error',
message: 'duplicate name',
});
});
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading