@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.
- package/.turbo/turbo-build.log +28 -0
- package/CHANGELOG.md +94 -0
- package/LICENSE +202 -0
- package/LICENSE.apache +202 -0
- package/README.md +50 -0
- package/dist/contracts/index.cjs +1 -0
- package/dist/contracts/index.cjs.map +1 -0
- package/dist/contracts/index.d.cts +178 -0
- package/dist/contracts/index.d.ts +178 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/index.cjs +995 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +414 -0
- package/dist/index.d.ts +414 -0
- package/dist/index.js +995 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
- package/src/__tests__/admin-routes.test.ts +106 -0
- package/src/__tests__/datasource-admin-plugin.test.ts +231 -0
- package/src/__tests__/datasource-admin-service.test.ts +288 -0
- package/src/__tests__/datasource-secret-binder.test.ts +101 -0
- package/src/__tests__/external-datasource-service.test.ts +360 -0
- package/src/admin-routes.ts +117 -0
- package/src/contracts/datasource-admin-service.ts +119 -0
- package/src/contracts/datasource-driver-factory.ts +77 -0
- package/src/contracts/index.ts +18 -0
- package/src/datasource-admin-plugin.ts +362 -0
- package/src/datasource-admin-service.ts +297 -0
- package/src/datasource-secret-binder.ts +144 -0
- package/src/default-datasource-driver-factory.ts +185 -0
- package/src/external-datasource-service.ts +456 -0
- package/src/index.ts +73 -0
- package/src/logger.ts +11 -0
- package/src/plugin.ts +119 -0
- package/tsconfig.json +17 -0
- 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
|
+
}
|