diff --git a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts index 1d2c4e3615..91265762c9 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts @@ -76,6 +76,21 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => { expect(generateObjectDraft).toHaveBeenCalledWith('demo_ext', 'customers', {}); }); + it('GET /api/v1/datasources/:name returns the credential-stripped detail (404 when unknown)', async () => { + const getDatasource = vi.fn(async (name: string) => + name === 'demo_ext' + ? { name: 'demo_ext', driver: 'sqlite', schemaMode: 'managed', config: { filename: '/tmp/x.db' }, active: true, origin: 'runtime', hasSecret: false } + : undefined, + ); + const app = mount({ getDatasource }); + const ok = await app.fetch(json('/api/v1/datasources/demo_ext')); + expect(ok.status).toBe(200); + expect((await ok.json()).datasource.config.filename).toBe('/tmp/x.db'); + const missing = await app.fetch(json('/api/v1/datasources/nope')); + expect(missing.status).toBe(404); + expect(getDatasource).toHaveBeenCalledWith('demo_ext'); + }); + it('POST /api/v1/datasources/:name/test probes a saved datasource by name', async () => { const testConnection = vi.fn().mockResolvedValue({ ok: true, latencyMs: 7, tableCount: 2 }); const app = mount({ testConnection }); diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts index c987b167b8..29e8723d00 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts @@ -286,3 +286,33 @@ describe('removeDatasource', () => { await expect(service.removeDatasource('nope')).rejects.toThrow(/not found/i); }); }); + +describe('getDatasource', () => { + it('returns config + hasSecret with the credentialsRef stripped', async () => { + const { service } = makeHarness({ + seed: [ + { + name: 'pg', + driver: 'postgres', + origin: 'runtime', + config: { host: 'db', port: 5432, database: 'app' }, + external: { credentialsRef: 'sys_secret://datasource/pg#1' }, + }, + ], + }); + const ds = await service.getDatasource('pg'); + expect(ds).toMatchObject({ name: 'pg', driver: 'postgres', origin: 'runtime', hasSecret: true }); + expect(ds!.config).toEqual({ host: 'db', port: 5432, database: 'app' }); + // The opaque credential handle must never be returned. + expect(JSON.stringify(ds)).not.toContain('sys_secret'); + expect(JSON.stringify(ds)).not.toContain('credentialsRef'); + }); + + it('reports hasSecret:false and returns undefined for unknown names', async () => { + const { service } = makeHarness({ + seed: [{ name: 'lite', driver: 'sqlite', origin: 'runtime', config: { filename: '/tmp/a.db' } }], + }); + expect((await service.getDatasource('lite'))!.hasSecret).toBe(false); + expect(await service.getDatasource('missing')).toBeUndefined(); + }); +}); diff --git a/packages/services/service-datasource/src/admin-routes.ts b/packages/services/service-datasource/src/admin-routes.ts index 105971e891..2b8b2eb7fc 100644 --- a/packages/services/service-datasource/src/admin-routes.ts +++ b/packages/services/service-datasource/src/admin-routes.ts @@ -99,6 +99,21 @@ export function registerDatasourceAdminRoutes( // `datasource` `test_connection` action). Distinct from `POST /datasources/test` // which probes an unsaved draft carried inline. Registered before the generic // `:name` mutation routes. + // Read one datasource's full detail for the edit form (credential stripped; + // `config` is non-sensitive, plus a `hasSecret` flag). Registered after the + // static `/drivers` route so that literal segment is never captured as a name. + server.get(`${root}/:name`, async (req: any, res: any) => { + const svc = adminService(); + if (!svc?.getDatasource) return unavailable(res); + try { + const datasource = await svc.getDatasource(req.params.name); + if (!datasource) return res.status(404).json({ error: 'not_found' }); + res.json({ datasource }); + } catch (err) { + badRequest(res, err); + } + }); + server.post(`${root}/:name/test`, async (req: any, res: any) => { const svc = externalService(); if (!svc?.testConnection) return unavailable(res); diff --git a/packages/services/service-datasource/src/datasource-admin-service.ts b/packages/services/service-datasource/src/datasource-admin-service.ts index dc9626184e..d8573f3fdb 100644 --- a/packages/services/service-datasource/src/datasource-admin-service.ts +++ b/packages/services/service-datasource/src/datasource-admin-service.ts @@ -130,6 +130,36 @@ export class DatasourceAdminService implements IDatasourceAdminService { return summaries; } + /** + * Read one datasource's full detail for editing, with the credential stripped. + * Returns `config` (non-sensitive — credentials live in `sys_secret`, never in + * config), `origin`, and a `hasSecret` flag so the UI can show "leave blank to + * keep" without ever receiving the `credentialsRef` or any cleartext. Returns + * `undefined` when the name is unknown. + */ + async getDatasource(name: string): Promise< + | (Pick & { + origin: 'code' | 'runtime'; + hasSecret: boolean; + }) + | undefined + > { + const rec = await this.config.getDatasourceRecord(name); + if (!rec) return undefined; + const hasSecret = Boolean(rec.external?.credentialsRef); + return { + name: rec.name, + label: rec.label, + driver: rec.driver, + schemaMode: rec.schemaMode ?? 'managed', + config: rec.config ?? {}, + active: rec.active ?? true, + origin: rec.origin === 'runtime' ? 'runtime' : 'code', + hasSecret, + ...(rec.definedIn ? { definedIn: rec.definedIn } : {}), + }; + } + async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise { if (!input?.driver) { return { ok: false, error: 'A driver is required to test a connection.' };