@objectstack/service-datasource 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +28 -0
  2. package/CHANGELOG.md +94 -0
  3. package/LICENSE +202 -0
  4. package/LICENSE.apache +202 -0
  5. package/README.md +50 -0
  6. package/dist/contracts/index.cjs +1 -0
  7. package/dist/contracts/index.cjs.map +1 -0
  8. package/dist/contracts/index.d.cts +178 -0
  9. package/dist/contracts/index.d.ts +178 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/index.js.map +1 -0
  12. package/dist/index.cjs +995 -0
  13. package/dist/index.cjs.map +1 -0
  14. package/dist/index.d.cts +414 -0
  15. package/dist/index.d.ts +414 -0
  16. package/dist/index.js +995 -0
  17. package/dist/index.js.map +1 -0
  18. package/package.json +61 -0
  19. package/src/__tests__/admin-routes.test.ts +106 -0
  20. package/src/__tests__/datasource-admin-plugin.test.ts +231 -0
  21. package/src/__tests__/datasource-admin-service.test.ts +288 -0
  22. package/src/__tests__/datasource-secret-binder.test.ts +101 -0
  23. package/src/__tests__/external-datasource-service.test.ts +360 -0
  24. package/src/admin-routes.ts +117 -0
  25. package/src/contracts/datasource-admin-service.ts +119 -0
  26. package/src/contracts/datasource-driver-factory.ts +77 -0
  27. package/src/contracts/index.ts +18 -0
  28. package/src/datasource-admin-plugin.ts +362 -0
  29. package/src/datasource-admin-service.ts +297 -0
  30. package/src/datasource-secret-binder.ts +144 -0
  31. package/src/default-datasource-driver-factory.ts +185 -0
  32. package/src/external-datasource-service.ts +456 -0
  33. package/src/index.ts +73 -0
  34. package/src/logger.ts +11 -0
  35. package/src/plugin.ts +119 -0
  36. package/tsconfig.json +17 -0
  37. package/tsup.config.ts +19 -0
@@ -0,0 +1,288 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect } from 'vitest';
4
+ import {
5
+ DatasourceAdminService,
6
+ type DatasourceAdminServiceConfig,
7
+ type StoredDatasource,
8
+ type ProbeInput,
9
+ } from '../datasource-admin-service.js';
10
+
11
+ /**
12
+ * In-memory harness: an editable record store + secret store, with probe and
13
+ * bound-object count stubbable per test. Records what was probed/written so
14
+ * tests can assert credentials never leak into the persisted record.
15
+ */
16
+ function makeHarness(opts?: {
17
+ seed?: StoredDatasource[];
18
+ probe?: (input: ProbeInput) => Promise<{ ok: boolean; error?: string; latencyMs?: number }>;
19
+ boundCounts?: Record<string, number>;
20
+ }) {
21
+ // Flat list, not a name-keyed map: in production `listDatasourceRecords`
22
+ // merges artefact (code) records with runtime-store records, so the same
23
+ // name can legitimately appear twice (a runtime row shadowed by a code one).
24
+ const records: StoredDatasource[] = (opts?.seed ?? []).map((r) => ({ ...r }));
25
+ /** Resolve the effective record for a name (code wins over runtime). */
26
+ const findEffective = (n: string) =>
27
+ records.find((r) => r.name === n && r.origin !== 'runtime') ??
28
+ records.find((r) => r.name === n);
29
+
30
+ const secrets = new Map<string, { value: string; namespace?: string; key?: string }>();
31
+ let secretSeq = 0;
32
+ const probed: ProbeInput[] = [];
33
+ const registered: string[] = [];
34
+ const unregistered: string[] = [];
35
+ const removedSecrets: string[] = [];
36
+
37
+ const config: DatasourceAdminServiceConfig = {
38
+ probe: async (input) => {
39
+ probed.push(input);
40
+ return (opts?.probe ?? (async () => ({ ok: true, latencyMs: 3 })))(input);
41
+ },
42
+ listDatasourceRecords: async () => records.map((r) => ({ ...r })),
43
+ getDatasourceRecord: async (n) => {
44
+ const r = findEffective(n);
45
+ return r ? { ...r } : undefined;
46
+ },
47
+ putDatasourceRecord: async (record) => {
48
+ const idx = records.findIndex((r) => r.name === record.name && r.origin === 'runtime');
49
+ if (idx >= 0) records[idx] = { ...record };
50
+ else records.push({ ...record });
51
+ },
52
+ deleteDatasourceRecord: async (n) => {
53
+ const idx = records.findIndex((r) => r.name === n && r.origin === 'runtime');
54
+ if (idx >= 0) records.splice(idx, 1);
55
+ },
56
+ writeSecret: async (input, hint) => {
57
+ const ref = `sys_secret://datasource/${input.key ?? hint.name}#${++secretSeq}`;
58
+ secrets.set(ref, { value: input.value, namespace: input.namespace, key: input.key });
59
+ return ref;
60
+ },
61
+ removeSecret: async (ref) => {
62
+ removedSecrets.push(ref);
63
+ secrets.delete(ref);
64
+ },
65
+ countBoundObjects: async (n) => opts?.boundCounts?.[n] ?? 0,
66
+ registerPool: (record) => {
67
+ registered.push(record.name);
68
+ },
69
+ unregisterPool: (name) => {
70
+ unregistered.push(name);
71
+ },
72
+ };
73
+
74
+ const service = new DatasourceAdminService(config);
75
+ // Thin accessor over the flat record list, runtime-preferring (tests assert
76
+ // on the persisted runtime row, e.g. after create/update).
77
+ const store = {
78
+ get: (n: string) =>
79
+ records.find((r) => r.name === n && r.origin === 'runtime') ??
80
+ records.find((r) => r.name === n),
81
+ has: (n: string) => records.some((r) => r.name === n),
82
+ get size() {
83
+ return records.length;
84
+ },
85
+ };
86
+ return { service, store, secrets, probed, registered, unregistered, removedSecrets };
87
+ }
88
+
89
+ describe('listDatasources', () => {
90
+ it('reports origin + dedupes by name (code wins, flags shadowed runtime)', async () => {
91
+ const { service } = makeHarness({
92
+ seed: [
93
+ { name: 'crm_primary', driver: 'sqlite', origin: 'code', definedIn: '@example/crm' },
94
+ { name: 'crm_primary', driver: 'postgres', origin: 'runtime' },
95
+ { name: 'reporting', driver: 'postgres', schemaMode: 'external', origin: 'runtime' },
96
+ ],
97
+ });
98
+
99
+ const list = await service.listDatasources();
100
+ const crm = list.find((d) => d.name === 'crm_primary')!;
101
+ const reporting = list.find((d) => d.name === 'reporting')!;
102
+
103
+ expect(list).toHaveLength(2);
104
+ expect(crm.origin).toBe('code');
105
+ expect(crm.driver).toBe('sqlite'); // code wins over the runtime row
106
+ expect(crm.definedIn).toBe('@example/crm');
107
+ expect(crm.conflictsWithCode).toBe(true);
108
+ expect(reporting.origin).toBe('runtime');
109
+ expect(reporting.schemaMode).toBe('external');
110
+ expect(reporting.conflictsWithCode).toBeUndefined();
111
+ });
112
+ });
113
+
114
+ describe('testConnection', () => {
115
+ it('probes with the cleartext secret without persisting anything', async () => {
116
+ const { service, store, probed } = makeHarness();
117
+ const res = await service.testConnection(
118
+ { name: 'tmp', driver: 'postgres', config: { host: 'db.internal' } },
119
+ { value: 's3cret' },
120
+ );
121
+ expect(res.ok).toBe(true);
122
+ expect(probed[0].secret).toBe('s3cret');
123
+ expect(store.size).toBe(0); // nothing saved
124
+ });
125
+
126
+ it('returns ok:false when no driver is supplied', async () => {
127
+ const { service } = makeHarness();
128
+ const res = await service.testConnection({ name: 'x', driver: '' });
129
+ expect(res.ok).toBe(false);
130
+ expect(res.error).toMatch(/driver is required/i);
131
+ });
132
+
133
+ it('captures a thrown probe error as ok:false', async () => {
134
+ const { service } = makeHarness({
135
+ probe: async () => {
136
+ throw new Error('ECONNREFUSED');
137
+ },
138
+ });
139
+ const res = await service.testConnection({ name: 'x', driver: 'postgres' });
140
+ expect(res.ok).toBe(false);
141
+ expect(res.error).toMatch(/ECONNREFUSED/);
142
+ });
143
+ });
144
+
145
+ describe('createDatasource', () => {
146
+ it('persists a runtime record and stores the secret as an opaque ref only', async () => {
147
+ const { service, store, secrets } = makeHarness();
148
+ const summary = await service.createDatasource(
149
+ {
150
+ name: 'reporting',
151
+ driver: 'postgres',
152
+ schemaMode: 'external',
153
+ config: { host: 'db.internal', database: 'analytics' },
154
+ external: { allowWrites: false },
155
+ },
156
+ { value: 'postgres://user:pw@db.internal/analytics' },
157
+ );
158
+
159
+ expect(summary.origin).toBe('runtime');
160
+ const rec = store.get('reporting')!;
161
+ expect(rec.origin).toBe('runtime');
162
+ // credential is referenced, never inlined
163
+ expect(rec.external?.credentialsRef).toBeTruthy();
164
+ expect(JSON.stringify(rec)).not.toContain('postgres://');
165
+ expect(JSON.stringify(rec)).not.toContain('pw@');
166
+ expect(secrets.size).toBe(1);
167
+ });
168
+
169
+ it('hot-registers the pool after create', async () => {
170
+ const { service, registered } = makeHarness();
171
+ await service.createDatasource({ name: 'reporting', driver: 'postgres' });
172
+ expect(registered).toContain('reporting');
173
+ });
174
+
175
+ it('rejects a name owned by a code-defined datasource', async () => {
176
+ const { service } = makeHarness({
177
+ seed: [{ name: 'crm_primary', driver: 'sqlite', origin: 'code' }],
178
+ });
179
+ await expect(
180
+ service.createDatasource({ name: 'crm_primary', driver: 'postgres' }),
181
+ ).rejects.toThrow(/code-defined/i);
182
+ });
183
+
184
+ it('rejects a duplicate runtime name', async () => {
185
+ const { service } = makeHarness({
186
+ seed: [{ name: 'reporting', driver: 'postgres', origin: 'runtime' }],
187
+ });
188
+ await expect(
189
+ service.createDatasource({ name: 'reporting', driver: 'postgres' }),
190
+ ).rejects.toThrow(/already exists/i);
191
+ });
192
+
193
+ it('rejects an invalid name', async () => {
194
+ const { service } = makeHarness();
195
+ await expect(
196
+ service.createDatasource({ name: 'Bad-Name', driver: 'postgres' }),
197
+ ).rejects.toThrow(/must match/i);
198
+ });
199
+ });
200
+
201
+ describe('updateDatasource', () => {
202
+ it('patches a runtime record and rewraps the secret, removing the old ref', async () => {
203
+ const { service, store, secrets, removedSecrets } = makeHarness({
204
+ seed: [
205
+ {
206
+ name: 'reporting',
207
+ driver: 'postgres',
208
+ origin: 'runtime',
209
+ external: { credentialsRef: 'sys_secret://datasource/reporting#0' },
210
+ },
211
+ ],
212
+ });
213
+ secrets.set('sys_secret://datasource/reporting#0', { value: 'old' });
214
+
215
+ const summary = await service.updateDatasource(
216
+ 'reporting',
217
+ { label: 'Reporting DB', active: false },
218
+ { value: 'new-pw' },
219
+ );
220
+
221
+ expect(summary.label).toBe('Reporting DB');
222
+ expect(summary.active).toBe(false);
223
+ const rec = store.get('reporting')!;
224
+ expect(rec.external?.credentialsRef).not.toBe('sys_secret://datasource/reporting#0');
225
+ expect(removedSecrets).toContain('sys_secret://datasource/reporting#0');
226
+ });
227
+
228
+ it('preserves the existing credentialsRef when external is patched without a new secret', async () => {
229
+ const ref = 'sys_secret://datasource/reporting#0';
230
+ const { service, store } = makeHarness({
231
+ seed: [
232
+ { name: 'reporting', driver: 'postgres', origin: 'runtime', external: { credentialsRef: ref } },
233
+ ],
234
+ });
235
+ await service.updateDatasource('reporting', { external: { allowWrites: true } });
236
+ expect(store.get('reporting')!.external?.credentialsRef).toBe(ref);
237
+ });
238
+
239
+ it('rejects editing a code-defined datasource', async () => {
240
+ const { service } = makeHarness({
241
+ seed: [{ name: 'crm_primary', driver: 'sqlite', origin: 'code' }],
242
+ });
243
+ await expect(
244
+ service.updateDatasource('crm_primary', { label: 'x' }),
245
+ ).rejects.toThrow(/code-defined/i);
246
+ });
247
+
248
+ it('rejects updating a missing datasource', async () => {
249
+ const { service } = makeHarness();
250
+ await expect(service.updateDatasource('nope', { label: 'x' })).rejects.toThrow(/not found/i);
251
+ });
252
+ });
253
+
254
+ describe('removeDatasource', () => {
255
+ it('removes a runtime record, its secret, and the pool', async () => {
256
+ const ref = 'sys_secret://datasource/reporting#0';
257
+ const { service, store, removedSecrets, unregistered } = makeHarness({
258
+ seed: [
259
+ { name: 'reporting', driver: 'postgres', origin: 'runtime', external: { credentialsRef: ref } },
260
+ ],
261
+ });
262
+ await service.removeDatasource('reporting');
263
+ expect(store.has('reporting')).toBe(false);
264
+ expect(removedSecrets).toContain(ref);
265
+ expect(unregistered).toContain('reporting');
266
+ });
267
+
268
+ it('refuses to remove while objects are still bound', async () => {
269
+ const { service, store } = makeHarness({
270
+ seed: [{ name: 'reporting', driver: 'postgres', origin: 'runtime' }],
271
+ boundCounts: { reporting: 3 },
272
+ });
273
+ await expect(service.removeDatasource('reporting')).rejects.toThrow(/3 object\(s\)/);
274
+ expect(store.has('reporting')).toBe(true);
275
+ });
276
+
277
+ it('refuses to remove a code-defined datasource', async () => {
278
+ const { service } = makeHarness({
279
+ seed: [{ name: 'crm_primary', driver: 'sqlite', origin: 'code' }],
280
+ });
281
+ await expect(service.removeDatasource('crm_primary')).rejects.toThrow(/code-defined/i);
282
+ });
283
+
284
+ it('rejects removing a missing datasource', async () => {
285
+ const { service } = makeHarness();
286
+ await expect(service.removeDatasource('nope')).rejects.toThrow(/not found/i);
287
+ });
288
+ });
@@ -0,0 +1,101 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect } from 'vitest';
4
+ import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts';
5
+ import {
6
+ createDatasourceSecretBinder,
7
+ parseCredentialsRef,
8
+ toCredentialsRef,
9
+ type SecretStoreEngineLike,
10
+ } from '../datasource-secret-binder.js';
11
+
12
+ /**
13
+ * Minimal AAD-binding crypto fake: ciphertext = base64(`${ns}|${key}::${plain}`).
14
+ * decrypt() verifies the (namespace,key) AAD matches what encrypt() sealed —
15
+ * mirroring InMemoryCryptoProvider's guarantee without pulling in node:crypto.
16
+ */
17
+ function fakeCrypto(): ICryptoProvider {
18
+ return {
19
+ async encrypt(plain: string, ctx: CryptoContext): Promise<CryptoHandle> {
20
+ return {
21
+ id: 'sec_' + ctx.key,
22
+ kmsKeyId: 'local:test:v1',
23
+ alg: 'aes-256-gcm',
24
+ version: 1,
25
+ ciphertext: Buffer.from(`${ctx.namespace}|${ctx.key}::${plain}`, 'utf8').toString('base64'),
26
+ };
27
+ },
28
+ async decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise<string> {
29
+ const raw = Buffer.from(handle.ciphertext, 'base64').toString('utf8');
30
+ const [aad, plain] = raw.split('::');
31
+ if (aad !== `${ctx.namespace}|${ctx.key}`) throw new Error('AAD mismatch');
32
+ return plain;
33
+ },
34
+ async rotateKey(handle: CryptoHandle): Promise<CryptoHandle> {
35
+ return handle;
36
+ },
37
+ digest: (plain: string) => 'sha256:' + plain,
38
+ };
39
+ }
40
+
41
+ /** In-memory `sys_secret` store backing the engine surface. */
42
+ function fakeEngine(): SecretStoreEngineLike & { rows: Map<string, any> } {
43
+ const rows = new Map<string, any>();
44
+ return {
45
+ rows,
46
+ async insert(_object, data) {
47
+ rows.set(String(data.id), data);
48
+ return data;
49
+ },
50
+ async delete(_object, options) {
51
+ rows.delete(String(options.where.id));
52
+ return undefined;
53
+ },
54
+ async find(_object, query) {
55
+ const id = String((query.where as any)?.id);
56
+ const row = rows.get(id);
57
+ return row ? [row] : [];
58
+ },
59
+ };
60
+ }
61
+
62
+ describe('createDatasourceSecretBinder', () => {
63
+ it('round-trips: bind → credentialsRef → resolve back to cleartext', async () => {
64
+ const engine = fakeEngine();
65
+ const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto() });
66
+
67
+ const ref = await binder.bind({ value: 'super-secret-pw' }, { name: 'reporting' });
68
+ expect(ref).toBe(toCredentialsRef('sec_reporting'));
69
+
70
+ // The persisted row holds only ciphertext — never the cleartext.
71
+ const row = engine.rows.get('sec_reporting');
72
+ expect(row.namespace).toBe('datasource');
73
+ expect(row.key).toBe('reporting');
74
+ expect(JSON.stringify(row)).not.toContain('super-secret-pw');
75
+
76
+ expect(await binder.resolve(ref)).toBe('super-secret-pw');
77
+ });
78
+
79
+ it('resolve() returns undefined after unbind (row gone)', async () => {
80
+ const engine = fakeEngine();
81
+ const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto() });
82
+ const ref = await binder.bind({ value: 'pw' }, { name: 'ds1' });
83
+ await binder.unbind(ref);
84
+ expect(await binder.resolve(ref)).toBeUndefined();
85
+ });
86
+
87
+ it('resolve() returns undefined for a foreign / non-sys_secret ref', async () => {
88
+ const engine = fakeEngine();
89
+ const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto() });
90
+ expect(parseCredentialsRef('vault://other/handle')).toBeUndefined();
91
+ expect(await binder.resolve('vault://other/handle')).toBeUndefined();
92
+ });
93
+
94
+ it('resolve() degrades to undefined when the engine cannot read', async () => {
95
+ const engine = fakeEngine();
96
+ delete (engine as any).find; // older engine surface without a read path
97
+ const binder = createDatasourceSecretBinder({ engine, cryptoProvider: fakeCrypto() });
98
+ const ref = await binder.bind({ value: 'pw' }, { name: 'ds1' });
99
+ expect(await binder.resolve(ref)).toBeUndefined();
100
+ });
101
+ });