@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,144 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Default datasource SecretBinder — persists a runtime datasource's cleartext
5
+ * credential into the `sys_secret` cipher store and returns an opaque
6
+ * `credentialsRef` handle (ADR-0015 Addendum, security invariant).
7
+ *
8
+ * Mirrors the SettingsService Phase-3 split: the cleartext is wrapped by an
9
+ * {@link ICryptoProvider} into a {@link CryptoHandle}, the ciphertext lands in a
10
+ * `sys_secret` row keyed by `handle.id`, and only the handle id (wrapped as
11
+ * `sys_secret:<id>`) is ever stored on the datasource artefact. Cleartext never
12
+ * touches metadata.
13
+ *
14
+ * This is the dev/self-host wiring; production hosts swap the
15
+ * `LocalCryptoProvider` for a KMS-backed `ICryptoProvider` and pass it here.
16
+ */
17
+
18
+ import type { CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts';
19
+
20
+ /** Prefix used to recognise a datasource credential handle. */
21
+ const REF_PREFIX = 'sys_secret:';
22
+
23
+ /** A persisted `sys_secret` row (subset used to reconstruct a {@link CryptoHandle}). */
24
+ interface SecretRow {
25
+ id: string;
26
+ namespace: string;
27
+ key: string;
28
+ kms_key_id: string;
29
+ alg: string;
30
+ version: number;
31
+ ciphertext: string;
32
+ }
33
+
34
+ /** Minimal data-engine surface used to read/write the `sys_secret` store. */
35
+ export interface SecretStoreEngineLike {
36
+ insert(object: string, data: Record<string, unknown>, options?: unknown): Promise<unknown>;
37
+ delete(object: string, options: { where: Record<string, unknown> }): Promise<unknown>;
38
+ /**
39
+ * Read `sys_secret` rows for the `resolve()` path. Optional so existing
40
+ * callers that only bind/unbind keep working; `resolve()` no-ops when absent.
41
+ * Mirrors `IDataEngine.find` — returns an array (or `{ data: [...] }`).
42
+ */
43
+ find?(object: string, query: Record<string, unknown>): Promise<unknown>;
44
+ }
45
+
46
+ export interface DatasourceSecretBinderDeps {
47
+ /** Data engine (ObjectQL) used to persist the `sys_secret` row. */
48
+ engine: SecretStoreEngineLike;
49
+ /** Crypto provider that wraps cleartext into a {@link CryptoHandle}. */
50
+ cryptoProvider: ICryptoProvider;
51
+ /** Settings namespace recorded on the secret row (default `'datasource'`). */
52
+ namespace?: string;
53
+ }
54
+
55
+ export interface DatasourceSecretBinder {
56
+ bind(input: { value: string; namespace?: string; key?: string }, hint: { name: string }): Promise<string>;
57
+ unbind(credentialsRef: string): Promise<void>;
58
+ /**
59
+ * Dereference a `credentialsRef` back to its cleartext credential by reading
60
+ * the `sys_secret` row and decrypting it. Used at boot to rebuild a runtime
61
+ * datasource's live connection pool (the cleartext is never persisted, so it
62
+ * must be recovered from the cipher store). Returns `undefined` when the ref
63
+ * isn't ours, the row is gone, the engine can't read, or decryption fails
64
+ * (e.g. an ephemeral dev key changed across restarts) — callers degrade to
65
+ * skipping that pool rather than crashing boot.
66
+ */
67
+ resolve(credentialsRef: string): Promise<string | undefined>;
68
+ }
69
+
70
+ /** Build a `credentialsRef` from a crypto handle id. */
71
+ export function toCredentialsRef(handleId: string): string {
72
+ return `${REF_PREFIX}${handleId}`;
73
+ }
74
+
75
+ /** Extract the `sys_secret` handle id from a credentialsRef, if it is one. */
76
+ export function parseCredentialsRef(ref: string): string | undefined {
77
+ return ref?.startsWith(REF_PREFIX) ? ref.slice(REF_PREFIX.length) : undefined;
78
+ }
79
+
80
+ /**
81
+ * Create the default datasource secret binder. Persists into `sys_secret` via
82
+ * the data engine and never returns or logs the cleartext.
83
+ */
84
+ export function createDatasourceSecretBinder(deps: DatasourceSecretBinderDeps): DatasourceSecretBinder {
85
+ const { engine, cryptoProvider } = deps;
86
+ const defaultNamespace = deps.namespace ?? 'datasource';
87
+
88
+ return {
89
+ async bind(input, hint) {
90
+ const namespace = input.namespace ?? defaultNamespace;
91
+ const key = input.key ?? hint.name;
92
+ const handle: CryptoHandle = await cryptoProvider.encrypt(input.value, { namespace, key });
93
+ await engine.insert('sys_secret', {
94
+ id: handle.id,
95
+ namespace,
96
+ key,
97
+ kms_key_id: handle.kmsKeyId,
98
+ alg: handle.alg,
99
+ version: handle.version,
100
+ ciphertext: handle.ciphertext,
101
+ });
102
+ return toCredentialsRef(handle.id);
103
+ },
104
+
105
+ async unbind(credentialsRef) {
106
+ const id = parseCredentialsRef(credentialsRef);
107
+ if (!id) return; // not ours (or already cleared) — nothing to do
108
+ await engine.delete('sys_secret', { where: { id } });
109
+ },
110
+
111
+ async resolve(credentialsRef) {
112
+ const id = parseCredentialsRef(credentialsRef);
113
+ if (!id || typeof engine.find !== 'function') return undefined;
114
+ try {
115
+ const result = await engine.find('sys_secret', {
116
+ where: { id },
117
+ limit: 1,
118
+ // Secrets are scoped through their owning datasource artefact, so
119
+ // skip the tenant-audit warning (mirrors SettingsService's store).
120
+ bypassTenantAudit: true,
121
+ });
122
+ const rows = (Array.isArray(result) ? result : (result as { data?: unknown[] })?.data) ?? [];
123
+ const row = rows[0] as SecretRow | undefined;
124
+ if (!row?.ciphertext) return undefined;
125
+ // Reconstruct the handle and decrypt under the same (namespace,key)
126
+ // AAD the row was sealed with — a mismatch fails authentication.
127
+ return await cryptoProvider.decrypt(
128
+ {
129
+ id: row.id,
130
+ kmsKeyId: row.kms_key_id,
131
+ alg: row.alg,
132
+ version: row.version,
133
+ ciphertext: row.ciphertext,
134
+ },
135
+ { namespace: row.namespace, key: row.key },
136
+ );
137
+ } catch {
138
+ // Missing row / unreadable engine / decrypt failure (e.g. rotated dev
139
+ // key) — never block boot; the pool is simply not rehydrated.
140
+ return undefined;
141
+ }
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,185 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Default (dev/self-host) implementation of {@link IDatasourceDriverFactory}.
5
+ *
6
+ * The framework ships no universal "driver-by-id" registry — concrete drivers
7
+ * are constructed by the host stack (ADR-0015 Addendum §3.5). This factory is
8
+ * the host-side glue that lets the runtime-datasource lifecycle
9
+ * (`IDatasourceAdminService`) build a live driver from an *unsaved* draft so it
10
+ * can probe a connection before "Save" and hot-register a pool afterwards.
11
+ *
12
+ * Supported driver ids map onto the same open-core drivers the standalone
13
+ * stack auto-detects:
14
+ * - `postgres` / `pg` / `postgresql` → `@objectstack/driver-sql` (client `pg`)
15
+ * - `sqlite` / `sqlite3` → `@objectstack/driver-sql` (better-sqlite3)
16
+ * - `mongodb` / `mongo` → `@objectstack/driver-mongodb` (peer dep)
17
+ * - `memory` / `inmemory` → `@objectstack/driver-memory`
18
+ *
19
+ * Anything else returns `supports() === false`, so the admin service degrades
20
+ * gracefully (testConnection → `{ ok: false }`, create skips hot pool reg).
21
+ *
22
+ * SECURITY: the cleartext `spec.secret` is used only to open the connection and
23
+ * is never persisted or logged here.
24
+ */
25
+
26
+ import type {
27
+ IDatasourceDriverFactory,
28
+ DatasourceConnectionSpec,
29
+ DatasourceDriverHandle,
30
+ } from './contracts/index.js';
31
+
32
+ type ResolvedKind = 'postgres' | 'sqlite' | 'mongodb' | 'memory';
33
+
34
+ const DRIVER_ID_ALIASES: Record<string, ResolvedKind> = {
35
+ postgres: 'postgres',
36
+ postgresql: 'postgres',
37
+ pg: 'postgres',
38
+ sqlite: 'sqlite',
39
+ sqlite3: 'sqlite',
40
+ 'better-sqlite3': 'sqlite',
41
+ mongodb: 'mongodb',
42
+ mongo: 'mongodb',
43
+ memory: 'memory',
44
+ inmemory: 'memory',
45
+ 'in-memory': 'memory',
46
+ };
47
+
48
+ function resolveKind(driverId: string): ResolvedKind | undefined {
49
+ return DRIVER_ID_ALIASES[String(driverId ?? '').toLowerCase()];
50
+ }
51
+
52
+ /**
53
+ * Wrap a concrete engine driver in a probe handle. `ping`/`checkHealth` reuse
54
+ * the driver's own health check; `driver` is the escape hatch the admin service
55
+ * hands to `registerDriver()`.
56
+ */
57
+ function toHandle(driver: any, serverVersion?: () => Promise<string | undefined>): DatasourceDriverHandle {
58
+ return {
59
+ connect: typeof driver?.connect === 'function' ? () => driver.connect() : undefined,
60
+ disconnect: typeof driver?.disconnect === 'function' ? () => driver.disconnect() : undefined,
61
+ checkHealth: typeof driver?.checkHealth === 'function' ? () => driver.checkHealth() : undefined,
62
+ ping: typeof driver?.checkHealth === 'function' ? () => driver.checkHealth() : undefined,
63
+ ...(serverVersion ? { serverVersion } : {}),
64
+ driver,
65
+ };
66
+ }
67
+
68
+ /** Build the Knex `connection` for a SQL driver from a spec's config + secret. */
69
+ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'better-sqlite3'): unknown {
70
+ const cfg = (spec.config ?? {}) as Record<string, unknown>;
71
+
72
+ if (client === 'better-sqlite3') {
73
+ const filename =
74
+ (cfg.filename as string | undefined) ??
75
+ (cfg.file as string | undefined) ??
76
+ (cfg.database as string | undefined) ??
77
+ ':memory:';
78
+ return { filename };
79
+ }
80
+
81
+ // pg — accept either a connection string (`url`/`connectionString`) or
82
+ // discrete fields. The secret is the password and is never part of `config`.
83
+ const url = (cfg.url as string | undefined) ?? (cfg.connectionString as string | undefined);
84
+ if (url) {
85
+ // For a DSN, a separately-supplied secret overrides the embedded password.
86
+ return spec.secret ? { connectionString: url, password: spec.secret } : { connectionString: url };
87
+ }
88
+ return {
89
+ host: cfg.host,
90
+ port: cfg.port,
91
+ database: cfg.database,
92
+ user: cfg.user ?? cfg.username,
93
+ ...(spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}),
94
+ ...(cfg.ssl != null ? { ssl: cfg.ssl } : {}),
95
+ };
96
+ }
97
+
98
+ /** Build a mongodb connection URL from a spec's config + secret. */
99
+ function buildMongoUrl(spec: DatasourceConnectionSpec): string {
100
+ const cfg = (spec.config ?? {}) as Record<string, unknown>;
101
+ const explicit = (cfg.url as string | undefined) ?? (cfg.uri as string | undefined);
102
+ if (explicit) return explicit;
103
+ const host = (cfg.host as string | undefined) ?? 'localhost';
104
+ const port = (cfg.port as number | string | undefined) ?? 27017;
105
+ const db = (cfg.database as string | undefined) ?? '';
106
+ const user = (cfg.user as string | undefined) ?? (cfg.username as string | undefined);
107
+ const auth = user ? `${encodeURIComponent(user)}:${encodeURIComponent(spec.secret ?? '')}@` : '';
108
+ return `mongodb://${auth}${host}:${port}/${db}`;
109
+ }
110
+
111
+ /**
112
+ * Create the default datasource driver factory. Driver packages are imported
113
+ * lazily so a host that never builds (e.g.) a mongo connection doesn't pay for
114
+ * the mongo SDK.
115
+ */
116
+ export function createDefaultDatasourceDriverFactory(): IDatasourceDriverFactory {
117
+ return {
118
+ supports(driverId: string): boolean {
119
+ return resolveKind(driverId) !== undefined;
120
+ },
121
+
122
+ async create(spec: DatasourceConnectionSpec): Promise<DatasourceDriverHandle> {
123
+ const kind = resolveKind(spec.driver);
124
+ if (!kind) {
125
+ throw new Error(`Unsupported driver id '${spec.driver}'.`);
126
+ }
127
+
128
+ const schemaMode = (spec.external as { schemaMode?: string } | undefined)?.schemaMode
129
+ ?? ((spec.config as Record<string, unknown> | undefined)?.schemaMode as string | undefined);
130
+
131
+ if (kind === 'postgres') {
132
+ const { SqlDriver } = await import('@objectstack/driver-sql');
133
+ const driver = new SqlDriver({
134
+ client: 'pg',
135
+ connection: buildSqlConnection(spec, 'pg') as any,
136
+ pool: { min: 0, max: 5 },
137
+ ...(schemaMode ? { schemaMode: schemaMode as any } : {}),
138
+ } as any);
139
+ return toHandle(driver, () => sqlServerVersion(driver, 'pg'));
140
+ }
141
+
142
+ if (kind === 'sqlite') {
143
+ const { SqlDriver } = await import('@objectstack/driver-sql');
144
+ const driver = new SqlDriver({
145
+ client: 'better-sqlite3',
146
+ connection: buildSqlConnection(spec, 'better-sqlite3') as any,
147
+ useNullAsDefault: true,
148
+ ...(schemaMode ? { schemaMode: schemaMode as any } : {}),
149
+ } as any);
150
+ return toHandle(driver, () => sqlServerVersion(driver, 'sqlite'));
151
+ }
152
+
153
+ if (kind === 'mongodb') {
154
+ let MongoDBDriver: any;
155
+ try {
156
+ ({ MongoDBDriver } = await import('@objectstack/driver-mongodb' as any));
157
+ } catch (err: any) {
158
+ throw new Error(
159
+ `mongodb driver requested but @objectstack/driver-mongodb is not installed (${err?.message ?? err}).`,
160
+ );
161
+ }
162
+ const driver = new MongoDBDriver({ url: buildMongoUrl(spec) });
163
+ return toHandle(driver);
164
+ }
165
+
166
+ // memory
167
+ const { InMemoryDriver } = await import('@objectstack/driver-memory');
168
+ return toHandle(new InMemoryDriver());
169
+ },
170
+ };
171
+ }
172
+
173
+ /** Best-effort server version via a raw query; swallows everything. */
174
+ async function sqlServerVersion(driver: any, client: 'pg' | 'sqlite'): Promise<string | undefined> {
175
+ if (typeof driver?.execute !== 'function') return undefined;
176
+ try {
177
+ const sql = client === 'pg' ? 'SELECT version() AS v' : 'SELECT sqlite_version() AS v';
178
+ const rows: any = await driver.execute(sql);
179
+ const first = Array.isArray(rows) ? rows[0] : Array.isArray(rows?.rows) ? rows.rows[0] : rows;
180
+ const v = first?.v ?? first?.version ?? first?.['sqlite_version()'];
181
+ return typeof v === 'string' ? v : undefined;
182
+ } catch {
183
+ return undefined;
184
+ }
185
+ }