@fadhilp/stateql 0.10.1 → 0.11.1
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/README.md +144 -67
- package/dist/src/connection.d.ts +7 -1
- package/dist/src/connection.js +117 -12
- package/dist/src/index.d.ts +1 -1
- package/dist/src/migrations.js +120 -0
- package/dist/src/response-data.js +1 -0
- package/dist/src/stateql.d.ts +5 -1
- package/dist/src/stateql.js +176 -57
- package/dist/src/store.d.ts +8 -1
- package/dist/src/store.js +144 -66
- package/dist/src/types.d.ts +13 -1
- package/package.json +1 -1
package/dist/src/migrations.js
CHANGED
|
@@ -126,6 +126,11 @@ const MIGRATIONS = [
|
|
|
126
126
|
requireIndexes(db, ["connections_alias"]);
|
|
127
127
|
},
|
|
128
128
|
},
|
|
129
|
+
{
|
|
130
|
+
name: "password_refs_v1",
|
|
131
|
+
apply: migratePasswordRefs,
|
|
132
|
+
validate: validatePasswordRefs,
|
|
133
|
+
},
|
|
129
134
|
];
|
|
130
135
|
export function runMigrations(db, now) {
|
|
131
136
|
db.exec(`
|
|
@@ -364,7 +369,11 @@ function validateSharedSessionActors(db) {
|
|
|
364
369
|
function migrateCredentialRefs(db) {
|
|
365
370
|
const profileColumns = new Set(db.prepare("PRAGMA table_info(profiles)").all().map((column) => column.name));
|
|
366
371
|
const hasCredentialRef = profileColumns.has("credential_ref");
|
|
372
|
+
const hasPasswordRef = profileColumns.has("password_ref");
|
|
367
373
|
if (!profileCredentialRefSchemaCurrent(db)) {
|
|
374
|
+
if (hasPasswordRef) {
|
|
375
|
+
throw new Error("State migration cannot safely rebuild a profile schema that already contains password references.");
|
|
376
|
+
}
|
|
368
377
|
const missingSources = db.prepare(`SELECT COUNT(*) AS count FROM profiles
|
|
369
378
|
WHERE target IS NULL AND secret_env IS NULL${hasCredentialRef ? " AND credential_ref IS NULL" : ""}`).get();
|
|
370
379
|
if (missingSources.count) {
|
|
@@ -424,6 +433,117 @@ function profileCredentialRefSchemaCurrent(db) {
|
|
|
424
433
|
const row = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'profiles'").get();
|
|
425
434
|
return Boolean(row?.sql && /CHECK\s*\(\s*\(target IS NOT NULL\)\s*\+\s*\(secret_env IS NOT NULL\)\s*\+\s*\(credential_ref IS NOT NULL\)\s*=\s*1\s*\)/i.test(row.sql));
|
|
426
435
|
}
|
|
436
|
+
function migratePasswordRefs(db) {
|
|
437
|
+
const profileColumns = tableColumns(db, "profiles");
|
|
438
|
+
const profilePasswordRef = profileColumns.has("password_ref") ? "password_ref" : "NULL";
|
|
439
|
+
if (!profilePasswordRefSchemaCurrent(db)) {
|
|
440
|
+
const invalid = db.prepare(`SELECT COUNT(*) AS count FROM profiles
|
|
441
|
+
WHERE ${profilePasswordRef} IS NOT NULL AND target IS NULL`).get();
|
|
442
|
+
if (invalid.count) {
|
|
443
|
+
throw new Error("State migration found a password reference without a target profile source.");
|
|
444
|
+
}
|
|
445
|
+
db.exec(`
|
|
446
|
+
ALTER TABLE profiles RENAME TO profiles_legacy_password_refs_v1;
|
|
447
|
+
CREATE TABLE profiles (
|
|
448
|
+
name TEXT PRIMARY KEY,
|
|
449
|
+
target TEXT,
|
|
450
|
+
secret_env TEXT,
|
|
451
|
+
credential_ref TEXT,
|
|
452
|
+
password_ref TEXT,
|
|
453
|
+
read_only INTEGER NOT NULL,
|
|
454
|
+
created_at TEXT NOT NULL,
|
|
455
|
+
updated_at TEXT NOT NULL,
|
|
456
|
+
CHECK (
|
|
457
|
+
(target IS NOT NULL) +
|
|
458
|
+
(secret_env IS NOT NULL) +
|
|
459
|
+
(credential_ref IS NOT NULL) = 1
|
|
460
|
+
),
|
|
461
|
+
CHECK (password_ref IS NULL OR target IS NOT NULL)
|
|
462
|
+
);
|
|
463
|
+
INSERT INTO profiles
|
|
464
|
+
(name, target, secret_env, credential_ref, password_ref, read_only, created_at, updated_at)
|
|
465
|
+
SELECT name, target, secret_env, credential_ref, ${profilePasswordRef}, read_only, created_at, updated_at
|
|
466
|
+
FROM profiles_legacy_password_refs_v1;
|
|
467
|
+
DROP TABLE profiles_legacy_password_refs_v1;
|
|
468
|
+
`);
|
|
469
|
+
}
|
|
470
|
+
const connectionColumns = tableColumns(db, "connections");
|
|
471
|
+
const connectionPasswordRef = connectionColumns.has("password_ref") ? "password_ref" : "NULL";
|
|
472
|
+
if (!connectionPasswordRefSchemaCurrent(db)) {
|
|
473
|
+
const invalid = db.prepare(`SELECT COUNT(*) AS count FROM connections
|
|
474
|
+
WHERE ${connectionPasswordRef} IS NOT NULL
|
|
475
|
+
AND (secret_env IS NOT NULL OR credential_ref IS NOT NULL OR
|
|
476
|
+
driver NOT IN ('postgres', 'mysql', 'mongodb', 'redis'))`).get();
|
|
477
|
+
if (invalid.count) {
|
|
478
|
+
throw new Error("State migration found a password reference on a non-target connection source.");
|
|
479
|
+
}
|
|
480
|
+
db.exec(`
|
|
481
|
+
ALTER TABLE connections RENAME TO connections_legacy_password_refs_v1;
|
|
482
|
+
CREATE TABLE connections (
|
|
483
|
+
id TEXT PRIMARY KEY,
|
|
484
|
+
session_id TEXT NOT NULL,
|
|
485
|
+
name TEXT NOT NULL,
|
|
486
|
+
driver TEXT NOT NULL,
|
|
487
|
+
database_name TEXT NOT NULL,
|
|
488
|
+
source TEXT NOT NULL,
|
|
489
|
+
secret_env TEXT,
|
|
490
|
+
credential_ref TEXT,
|
|
491
|
+
password_ref TEXT,
|
|
492
|
+
read_only INTEGER NOT NULL,
|
|
493
|
+
version INTEGER NOT NULL,
|
|
494
|
+
created_at TEXT NOT NULL,
|
|
495
|
+
alias TEXT,
|
|
496
|
+
CHECK (
|
|
497
|
+
password_ref IS NULL OR
|
|
498
|
+
(secret_env IS NULL AND credential_ref IS NULL AND
|
|
499
|
+
driver IN ('postgres', 'mysql', 'mongodb', 'redis'))
|
|
500
|
+
),
|
|
501
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
502
|
+
);
|
|
503
|
+
INSERT INTO connections
|
|
504
|
+
(id, session_id, name, driver, database_name, source, secret_env,
|
|
505
|
+
credential_ref, password_ref, read_only, version, created_at, alias)
|
|
506
|
+
SELECT id, session_id, name, driver, database_name, source, secret_env,
|
|
507
|
+
credential_ref, ${connectionPasswordRef}, read_only, version, created_at, alias
|
|
508
|
+
FROM connections_legacy_password_refs_v1;
|
|
509
|
+
DROP TABLE connections_legacy_password_refs_v1;
|
|
510
|
+
CREATE UNIQUE INDEX connections_alias ON connections(alias);
|
|
511
|
+
`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function validatePasswordRefs(db) {
|
|
515
|
+
requireColumns(db, "profiles", ["password_ref"]);
|
|
516
|
+
requireColumns(db, "connections", ["password_ref"]);
|
|
517
|
+
if (!profilePasswordRefSchemaCurrent(db) || !connectionPasswordRefSchemaCurrent(db)) {
|
|
518
|
+
throw new Error("State migration did not enforce password-reference source constraints.");
|
|
519
|
+
}
|
|
520
|
+
const invalidProfiles = db.prepare(`SELECT COUNT(*) AS count FROM profiles
|
|
521
|
+
WHERE password_ref IS NOT NULL AND target IS NULL`).get();
|
|
522
|
+
const invalidConnections = db.prepare(`SELECT COUNT(*) AS count FROM connections
|
|
523
|
+
WHERE password_ref IS NOT NULL
|
|
524
|
+
AND (secret_env IS NOT NULL OR credential_ref IS NOT NULL OR
|
|
525
|
+
driver NOT IN ('postgres', 'mysql', 'mongodb', 'redis'))`).get();
|
|
526
|
+
if (invalidProfiles.count || invalidConnections.count) {
|
|
527
|
+
throw new Error("State migration left an invalid password-reference source.");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function profilePasswordRefSchemaCurrent(db) {
|
|
531
|
+
const sql = tableSql(db, "profiles");
|
|
532
|
+
return profileCredentialRefSchemaCurrent(db) &&
|
|
533
|
+
/CHECK\s*\(\s*password_ref IS NULL OR target IS NOT NULL\s*\)/i.test(sql);
|
|
534
|
+
}
|
|
535
|
+
function connectionPasswordRefSchemaCurrent(db) {
|
|
536
|
+
return /CHECK\s*\(\s*password_ref IS NULL OR\s*\(secret_env IS NULL AND credential_ref IS NULL AND\s*driver IN \('postgres', 'mysql', 'mongodb', 'redis'\)\)\s*\)/i
|
|
537
|
+
.test(tableSql(db, "connections"));
|
|
538
|
+
}
|
|
539
|
+
function tableSql(db, table) {
|
|
540
|
+
const row = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table);
|
|
541
|
+
return row?.sql ?? "";
|
|
542
|
+
}
|
|
543
|
+
function tableColumns(db, table) {
|
|
544
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all()
|
|
545
|
+
.map((column) => column.name));
|
|
546
|
+
}
|
|
427
547
|
function addColumn(db, table, column, definition) {
|
|
428
548
|
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
429
549
|
if (!columns.some((candidate) => candidate.name === column)) {
|
package/dist/src/stateql.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type TableChange } from "./table-editor.js";
|
|
2
|
-
import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, CommandOrigin, BatchOptions, CapabilitiesData, CatalogObject, DescribeObjectData, ListObjectsData, ListObjectsFilter, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, HistoryOptions, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, ProfileUpdateOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, Column, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StateQLSnapshotOptions, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
|
|
2
|
+
import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, CommandOrigin, BatchOptions, CapabilitiesData, CatalogObject, DescribeObjectData, ListObjectsData, ListObjectsFilter, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, HistoryOptions, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, ProfileUpdateOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, Column, StateQLActorOptions, StateQLOptions, StateQLWorkspaceOptions, StateQLSnapshot, StateQLSnapshotOptions, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
|
|
3
3
|
export declare class StateQL {
|
|
4
|
+
/** Runtime contract marker for passwordRef/password_ref support. */
|
|
5
|
+
static readonly passwordReferenceVersion: 1;
|
|
4
6
|
static forActor(options: StateQLActorOptions): StateQL;
|
|
7
|
+
/** Opens one actor in a named shared workspace for a trusted library host. */
|
|
8
|
+
static forWorkspace(options: StateQLWorkspaceOptions): StateQL;
|
|
5
9
|
private readonly store;
|
|
6
10
|
private readonly sessionName;
|
|
7
11
|
private readonly actorId;
|
package/dist/src/stateql.js
CHANGED
|
@@ -5,7 +5,7 @@ import { writeFileSync } from "node:fs";
|
|
|
5
5
|
import { basename, resolve } from "node:path";
|
|
6
6
|
import { env } from "node:process";
|
|
7
7
|
import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
|
|
8
|
-
import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, mongoDatabaseName, redisDatabaseName, normalizeSqliteSource, validateCredentialRef, validateProfileName, version, } from "./connection.js";
|
|
8
|
+
import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, injectPassword, mongoDatabaseName, redisDatabaseName, normalizeSqliteSource, validateCredentialRef, validatePasswordReferenceTarget, validateProfileName, version, } from "./connection.js";
|
|
9
9
|
import { asStateQLError, CredentialResolutionError, StateQLError, } from "./errors.js";
|
|
10
10
|
import { analyzeMongoWriteSafety, deserializeMongoWriteCommand, serializeMongoCommand, validateMongoReadCommand, validateMongoWriteCommand, MongoAdapter, } from "./mongodb.js";
|
|
11
11
|
import { deserializeRedisCommand, RedisAdapter, serializeRedisCommand, validateRedisReadCommand, validateRedisWriteCommand, } from "./redis.js";
|
|
@@ -17,7 +17,10 @@ import { compactRows, defaultHome, hash, isSqlParameters, parseJson, redact, } f
|
|
|
17
17
|
const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
|
|
18
18
|
const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
|
|
19
19
|
const DEFAULT_CREDENTIAL_RESOLUTION_TIMEOUT_MS = 120_000;
|
|
20
|
+
const WORKSPACE_BOOTSTRAP = Symbol("StateQL.workspaceBootstrap");
|
|
20
21
|
export class StateQL {
|
|
22
|
+
/** Runtime contract marker for passwordRef/password_ref support. */
|
|
23
|
+
static passwordReferenceVersion = 1;
|
|
21
24
|
static forActor(options) {
|
|
22
25
|
if (!options.actor.trim()) {
|
|
23
26
|
throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
|
|
@@ -35,6 +38,21 @@ export class StateQL {
|
|
|
35
38
|
store.close();
|
|
36
39
|
}
|
|
37
40
|
}
|
|
41
|
+
/** Opens one actor in a named shared workspace for a trusted library host. */
|
|
42
|
+
static forWorkspace(options) {
|
|
43
|
+
if (!options.workspace.trim()) {
|
|
44
|
+
throw new StateQLError("INVALID_COMMAND", "Workspace name is required.");
|
|
45
|
+
}
|
|
46
|
+
if (!options.actor.trim()) {
|
|
47
|
+
throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
|
|
48
|
+
}
|
|
49
|
+
const { workspace, ...actorOptions } = options;
|
|
50
|
+
return new StateQL({
|
|
51
|
+
...actorOptions,
|
|
52
|
+
session: workspace,
|
|
53
|
+
[WORKSPACE_BOOTSTRAP]: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
38
56
|
store;
|
|
39
57
|
sessionName;
|
|
40
58
|
actorId;
|
|
@@ -78,7 +96,12 @@ export class StateQL {
|
|
|
78
96
|
}
|
|
79
97
|
const store = new StateStore(options.home ?? defaultHome(), this.now, maxStateBytes);
|
|
80
98
|
try {
|
|
81
|
-
|
|
99
|
+
if (options[WORKSPACE_BOOTSTRAP]) {
|
|
100
|
+
store.bootstrapWorkspace(this.sessionName, this.actorId);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
store.bootstrapSession(this.sessionName, this.actorId, options.actor === undefined);
|
|
104
|
+
}
|
|
82
105
|
}
|
|
83
106
|
catch (error) {
|
|
84
107
|
store.close();
|
|
@@ -112,7 +135,13 @@ export class StateQL {
|
|
|
112
135
|
if (sourceCount !== 1) {
|
|
113
136
|
throw new StateQLError("INVALID_COMMAND", "Use exactly one connection target, profile, secret environment variable, or credential reference.");
|
|
114
137
|
}
|
|
115
|
-
|
|
138
|
+
if (options.passwordRef !== undefined &&
|
|
139
|
+
(target === undefined || options.profile !== undefined ||
|
|
140
|
+
options.secretEnv !== undefined || options.credentialRef !== undefined)) {
|
|
141
|
+
throw new StateQLError("INVALID_COMMAND", "A password reference requires a literal remote connection target.");
|
|
142
|
+
}
|
|
143
|
+
const implicitProfile = !options.profile && !options.secretEnv && !options.credentialRef &&
|
|
144
|
+
options.passwordRef === undefined && target
|
|
116
145
|
? this.store.getProfile(target)
|
|
117
146
|
: undefined;
|
|
118
147
|
const profile = options.profile
|
|
@@ -122,18 +151,27 @@ export class StateQL {
|
|
|
122
151
|
throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${options.profile}" was not found.`, { suggestedAction: "Run stql profile list." });
|
|
123
152
|
}
|
|
124
153
|
if (profile &&
|
|
125
|
-
[profile.target, profile.secret_env, profile.credential_ref]
|
|
126
|
-
.filter((value) => value !== null).length !== 1
|
|
127
|
-
|
|
154
|
+
([profile.target, profile.secret_env, profile.credential_ref]
|
|
155
|
+
.filter((value) => value !== null).length !== 1 ||
|
|
156
|
+
(profile.password_ref !== null && profile.target === null))) {
|
|
157
|
+
throw new StateQLError("STATE_CORRUPTED", `Profile "${profile.name}" has an invalid connection source.`);
|
|
128
158
|
}
|
|
129
159
|
const resolvedTarget = profile?.target ?? target;
|
|
130
160
|
const secretEnv = options.secretEnv ?? profile?.secret_env ?? undefined;
|
|
131
161
|
const credentialRef = options.credentialRef ?? profile?.credential_ref ?? undefined;
|
|
162
|
+
const passwordRef = options.passwordRef ?? profile?.password_ref ?? undefined;
|
|
132
163
|
if (secretEnv !== undefined && !isEnvironmentName(secretEnv)) {
|
|
133
164
|
throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
|
|
134
165
|
}
|
|
135
166
|
if (credentialRef !== undefined)
|
|
136
167
|
validateCredentialRef(credentialRef);
|
|
168
|
+
if (passwordRef !== undefined) {
|
|
169
|
+
validateCredentialRef(passwordRef);
|
|
170
|
+
if (!resolvedTarget) {
|
|
171
|
+
throw new StateQLError("INVALID_COMMAND", "A password reference requires a literal remote connection target.");
|
|
172
|
+
}
|
|
173
|
+
validatePasswordReferenceTarget(resolvedTarget);
|
|
174
|
+
}
|
|
137
175
|
const credentialReference = secretEnv ?? credentialRef;
|
|
138
176
|
const credentialReferenceSource = secretEnv !== undefined
|
|
139
177
|
? "secret_env"
|
|
@@ -141,50 +179,65 @@ export class StateQL {
|
|
|
141
179
|
const readOnly = options.readOnly ??
|
|
142
180
|
(profile ? Boolean(profile.read_only) : true);
|
|
143
181
|
const context = this.executionContext(options);
|
|
144
|
-
|
|
145
|
-
|
|
182
|
+
let adapterSource;
|
|
183
|
+
let driver;
|
|
184
|
+
if (passwordRef !== undefined) {
|
|
185
|
+
const password = await this.resolveCredential(passwordRef, "password_ref", session, "connect", readOnly ? "read" : "write", context, {
|
|
146
186
|
...(profile ? { profile: { name: profile.name } } : {}),
|
|
147
187
|
requestedReadOnly: readOnly,
|
|
148
|
-
})
|
|
149
|
-
: resolvedTarget;
|
|
150
|
-
if (!secret) {
|
|
151
|
-
throw new StateQLError("INVALID_COMMAND", "Connection target is required.");
|
|
152
|
-
}
|
|
153
|
-
const resolvedSource = credentialReferenceSource
|
|
154
|
-
? credentialSource(secret, undefined, credentialReferenceSource)
|
|
155
|
-
: { driver: detectDriver(secret), source: secret };
|
|
156
|
-
const { driver } = resolvedSource;
|
|
157
|
-
if (driver !== "sqlite" &&
|
|
158
|
-
!credentialReference &&
|
|
159
|
-
databaseUrlHasSecret(secret)) {
|
|
160
|
-
throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`, {
|
|
161
|
-
suggestedAction: "Use an environment variable or trusted host credential reference.",
|
|
162
|
-
});
|
|
188
|
+
}, resolvedTarget);
|
|
189
|
+
({ driver, source: adapterSource } = injectPassword(resolvedTarget, password));
|
|
163
190
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
191
|
+
else {
|
|
192
|
+
const secret = credentialReference && credentialReferenceSource
|
|
193
|
+
? await this.resolveCredential(credentialReference, credentialReferenceSource, session, "connect", readOnly ? "read" : "write", context, {
|
|
194
|
+
...(profile ? { profile: { name: profile.name } } : {}),
|
|
195
|
+
requestedReadOnly: readOnly,
|
|
196
|
+
})
|
|
197
|
+
: resolvedTarget;
|
|
198
|
+
if (!secret) {
|
|
199
|
+
throw new StateQLError("INVALID_COMMAND", "Connection target is required.");
|
|
200
|
+
}
|
|
201
|
+
const resolvedSource = credentialReferenceSource
|
|
202
|
+
? credentialSource(secret, undefined, credentialReferenceSource)
|
|
203
|
+
: { driver: detectDriver(secret), source: secret };
|
|
204
|
+
driver = resolvedSource.driver;
|
|
205
|
+
if (driver !== "sqlite" &&
|
|
206
|
+
!credentialReference &&
|
|
207
|
+
databaseUrlHasSecret(secret)) {
|
|
208
|
+
throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`, {
|
|
209
|
+
suggestedAction: "Use an environment variable or trusted host credential reference.",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
adapterSource = credentialReferenceSource
|
|
213
|
+
? resolvedSource.source
|
|
214
|
+
: driver === "sqlite" ? normalizeSqliteSource(secret) : secret;
|
|
215
|
+
}
|
|
216
|
+
const persistedSource = passwordRef !== undefined
|
|
217
|
+
? resolvedTarget
|
|
218
|
+
: driver === "sqlite"
|
|
219
|
+
? adapterSource
|
|
220
|
+
: credentialReferenceSource
|
|
221
|
+
? redact(adapterSource)
|
|
222
|
+
: adapterSource;
|
|
223
|
+
const identitySource = passwordRef !== undefined ? resolvedTarget : adapterSource;
|
|
172
224
|
const databaseName = driver === "sqlite"
|
|
173
225
|
? basename(adapterSource)
|
|
174
226
|
: driver === "mongodb"
|
|
175
|
-
? mongoDatabaseName(
|
|
227
|
+
? mongoDatabaseName(identitySource)
|
|
176
228
|
: driver === "redis"
|
|
177
|
-
? redisDatabaseName(
|
|
178
|
-
: new URL(
|
|
229
|
+
? redisDatabaseName(identitySource)
|
|
230
|
+
: new URL(identitySource).pathname.replace(/^\//, "") || driver;
|
|
179
231
|
const draft = {
|
|
180
232
|
id: "pending",
|
|
181
233
|
session_id: session.id,
|
|
182
234
|
name: options.name ?? profile?.name ?? databaseName,
|
|
183
235
|
driver,
|
|
184
236
|
database_name: databaseName,
|
|
185
|
-
source,
|
|
237
|
+
source: persistedSource,
|
|
186
238
|
secret_env: secretEnv ?? null,
|
|
187
239
|
credential_ref: credentialRef ?? null,
|
|
240
|
+
password_ref: passwordRef ?? null,
|
|
188
241
|
read_only: readOnly ? 1 : 0,
|
|
189
242
|
version: 0,
|
|
190
243
|
created_at: this.now().toISOString(),
|
|
@@ -212,9 +265,10 @@ export class StateQL {
|
|
|
212
265
|
name: draft.name,
|
|
213
266
|
driver,
|
|
214
267
|
databaseName,
|
|
215
|
-
source,
|
|
268
|
+
source: persistedSource,
|
|
216
269
|
...(secretEnv ? { secretEnv } : {}),
|
|
217
270
|
...(credentialRef ? { credentialRef } : {}),
|
|
271
|
+
...(passwordRef !== undefined ? { passwordRef } : {}),
|
|
218
272
|
readOnly,
|
|
219
273
|
});
|
|
220
274
|
if (!connection) {
|
|
@@ -250,12 +304,14 @@ export class StateQL {
|
|
|
250
304
|
target,
|
|
251
305
|
secretEnv: options.secretEnv,
|
|
252
306
|
credentialRef: options.credentialRef,
|
|
307
|
+
passwordRef: options.passwordRef,
|
|
253
308
|
});
|
|
254
309
|
const profile = this.store.addProfile({
|
|
255
310
|
name,
|
|
256
311
|
target: source.target ?? undefined,
|
|
257
312
|
secretEnv: source.secretEnv ?? undefined,
|
|
258
313
|
credentialRef: source.credentialRef ?? undefined,
|
|
314
|
+
passwordRef: source.passwordRef ?? undefined,
|
|
259
315
|
readOnly: options.readOnly ?? true,
|
|
260
316
|
});
|
|
261
317
|
return {
|
|
@@ -272,22 +328,32 @@ export class StateQL {
|
|
|
272
328
|
if (!existing)
|
|
273
329
|
throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${name}" was not found.`);
|
|
274
330
|
if (!changes || typeof changes !== "object" || Array.isArray(changes) ||
|
|
275
|
-
Object.keys(changes).some((key) => !["target", "secretEnv", "credentialRef", "readOnly"].includes(key))) {
|
|
331
|
+
Object.keys(changes).some((key) => !["target", "secretEnv", "credentialRef", "passwordRef", "readOnly"].includes(key))) {
|
|
276
332
|
throw new StateQLError("INVALID_COMMAND", "Profile update contains unknown fields.");
|
|
277
333
|
}
|
|
278
334
|
if (changes.readOnly !== undefined && typeof changes.readOnly !== "boolean")
|
|
279
335
|
throw new StateQLError("INVALID_COMMAND", "Profile readOnly must be boolean.");
|
|
280
336
|
const changesSource = Object.hasOwn(changes, "target") || Object.hasOwn(changes, "secretEnv") || Object.hasOwn(changes, "credentialRef");
|
|
281
|
-
|
|
337
|
+
const changesPassword = Object.hasOwn(changes, "passwordRef");
|
|
338
|
+
if (!changesSource && !changesPassword && changes.readOnly === undefined)
|
|
282
339
|
throw new StateQLError("INVALID_COMMAND", "Profile update has no changes.");
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
340
|
+
const targetSource = changesSource ? changes.target ?? undefined : existing.target ?? undefined;
|
|
341
|
+
const source = validatedProfileSource({
|
|
342
|
+
target: targetSource,
|
|
343
|
+
secretEnv: changesSource ? changes.secretEnv ?? undefined : existing.secret_env ?? undefined,
|
|
344
|
+
credentialRef: changesSource ? changes.credentialRef ?? undefined : existing.credential_ref ?? undefined,
|
|
345
|
+
passwordRef: changesPassword
|
|
346
|
+
? changes.passwordRef ?? undefined
|
|
347
|
+
: !changesSource || targetSource === existing.target
|
|
348
|
+
? existing.password_ref ?? undefined
|
|
349
|
+
: undefined,
|
|
350
|
+
});
|
|
286
351
|
const profile = this.store.updateProfile({
|
|
287
352
|
name,
|
|
288
353
|
target: source.target,
|
|
289
354
|
secretEnv: source.secretEnv,
|
|
290
355
|
credentialRef: source.credentialRef,
|
|
356
|
+
passwordRef: source.passwordRef,
|
|
291
357
|
readOnly: changes.readOnly ?? Boolean(existing.read_only),
|
|
292
358
|
});
|
|
293
359
|
if (!profile)
|
|
@@ -1711,7 +1777,7 @@ export class StateQL {
|
|
|
1711
1777
|
const planParameters = compiled?.params ?? (nativePlan || tableUpdates
|
|
1712
1778
|
? undefined
|
|
1713
1779
|
: parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters));
|
|
1714
|
-
const claimToken = this.store.
|
|
1780
|
+
const claimToken = this.store.randomId("claim");
|
|
1715
1781
|
const claimed = this.store.claimPlan(plan.id, session.id, this.actorId, claimToken);
|
|
1716
1782
|
if (!claimed) {
|
|
1717
1783
|
throw new StateQLError("STALE_PLAN", "Plan is already being applied.");
|
|
@@ -1900,6 +1966,7 @@ export class StateQL {
|
|
|
1900
1966
|
readOnly: command.read_only,
|
|
1901
1967
|
secretEnv: command.secret_env,
|
|
1902
1968
|
credentialRef: command.credential_ref,
|
|
1969
|
+
passwordRef: command.password_ref ?? undefined,
|
|
1903
1970
|
profile: command.profile,
|
|
1904
1971
|
timeoutMs: command.timeout_ms,
|
|
1905
1972
|
});
|
|
@@ -1912,12 +1979,14 @@ export class StateQL {
|
|
|
1912
1979
|
readOnly: command.read_only ?? true,
|
|
1913
1980
|
secretEnv: command.secret_env,
|
|
1914
1981
|
credentialRef: command.credential_ref,
|
|
1982
|
+
passwordRef: command.password_ref ?? undefined,
|
|
1915
1983
|
});
|
|
1916
1984
|
case "profile.update":
|
|
1917
1985
|
return this.updateProfile(batchString(command.name, "name"), {
|
|
1918
1986
|
...(command.target !== undefined ? { target: command.target } : {}),
|
|
1919
1987
|
...(command.secret_env !== undefined ? { secretEnv: command.secret_env } : {}),
|
|
1920
1988
|
...(command.credential_ref !== undefined ? { credentialRef: command.credential_ref } : {}),
|
|
1989
|
+
...(command.password_ref !== undefined ? { passwordRef: command.password_ref } : {}),
|
|
1921
1990
|
...(command.read_only !== undefined ? { readOnly: command.read_only } : {}),
|
|
1922
1991
|
});
|
|
1923
1992
|
case "profile.list":
|
|
@@ -2718,6 +2787,28 @@ export class StateQL {
|
|
|
2718
2787
|
throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
|
|
2719
2788
|
}
|
|
2720
2789
|
async resolveConnectionSource(connection, session, operation, access, context) {
|
|
2790
|
+
const references = [connection.secret_env, connection.credential_ref, connection.password_ref]
|
|
2791
|
+
.filter((value) => value !== null);
|
|
2792
|
+
if (references.length > 1) {
|
|
2793
|
+
throw new StateQLError("STATE_CORRUPTED", "Connection has ambiguous credential references.");
|
|
2794
|
+
}
|
|
2795
|
+
if (connection.password_ref !== null) {
|
|
2796
|
+
validateCredentialRef(connection.password_ref);
|
|
2797
|
+
const driver = validatePasswordReferenceTarget(connection.source);
|
|
2798
|
+
if (driver !== connection.driver) {
|
|
2799
|
+
throw new StateQLError("STATE_CORRUPTED", "Password-reference target driver does not match the stored connection.");
|
|
2800
|
+
}
|
|
2801
|
+
const password = await this.resolveCredential(connection.password_ref, "password_ref", session, operation, access, context, {
|
|
2802
|
+
connection: {
|
|
2803
|
+
id: connection.id,
|
|
2804
|
+
name: connection.name,
|
|
2805
|
+
driver: connection.driver,
|
|
2806
|
+
database: connection.database_name,
|
|
2807
|
+
readOnly: Boolean(connection.read_only),
|
|
2808
|
+
},
|
|
2809
|
+
}, connection.source);
|
|
2810
|
+
return injectPassword(connection.source, password).source;
|
|
2811
|
+
}
|
|
2721
2812
|
const reference = connection.secret_env ?? connection.credential_ref;
|
|
2722
2813
|
if (!reference)
|
|
2723
2814
|
return connection.source;
|
|
@@ -2735,7 +2826,7 @@ export class StateQL {
|
|
|
2735
2826
|
});
|
|
2736
2827
|
return credentialSource(value, connection.driver, source).source;
|
|
2737
2828
|
}
|
|
2738
|
-
async resolveCredential(reference, source, session, operation, access, context, details = {}) {
|
|
2829
|
+
async resolveCredential(reference, source, session, operation, access, context, details = {}, passwordTarget) {
|
|
2739
2830
|
const resolver = this.credentialResolver;
|
|
2740
2831
|
const credentialContext = createAdapterContext(this.credentialTimeoutMs, context.signal);
|
|
2741
2832
|
let value;
|
|
@@ -2750,9 +2841,8 @@ export class StateQL {
|
|
|
2750
2841
|
value = env[reference];
|
|
2751
2842
|
}
|
|
2752
2843
|
else {
|
|
2753
|
-
const
|
|
2844
|
+
const baseRequest = {
|
|
2754
2845
|
reference,
|
|
2755
|
-
source,
|
|
2756
2846
|
actorId: this.actorId,
|
|
2757
2847
|
session: { id: session.id, name: session.name },
|
|
2758
2848
|
operation,
|
|
@@ -2760,6 +2850,9 @@ export class StateQL {
|
|
|
2760
2850
|
...(context.signal ? { signal: context.signal } : {}),
|
|
2761
2851
|
...details,
|
|
2762
2852
|
};
|
|
2853
|
+
const request = source === "password_ref"
|
|
2854
|
+
? { ...baseRequest, source, target: passwordTarget }
|
|
2855
|
+
: { ...baseRequest, source };
|
|
2763
2856
|
try {
|
|
2764
2857
|
value = await resolveCredentialBeforeDeadline(resolver, request, credentialContext);
|
|
2765
2858
|
}
|
|
@@ -2767,7 +2860,7 @@ export class StateQL {
|
|
|
2767
2860
|
throw credentialStateQLError(reference, error);
|
|
2768
2861
|
}
|
|
2769
2862
|
}
|
|
2770
|
-
if (
|
|
2863
|
+
if (value === undefined || (source !== "password_ref" && value === "")) {
|
|
2771
2864
|
throw credentialStateQLError(reference, new CredentialResolutionError("unavailable"));
|
|
2772
2865
|
}
|
|
2773
2866
|
context.deadline = Date.now() + (context.timeoutMs ?? this.timeoutMs);
|
|
@@ -2844,8 +2937,8 @@ export class StateQL {
|
|
|
2844
2937
|
const category = historyCategory(command);
|
|
2845
2938
|
const internal = commandContext?.internal ?? false;
|
|
2846
2939
|
let session = this.store.ensureSession(this.sessionName);
|
|
2847
|
-
const commandId = this.store.nextId("cmd");
|
|
2848
2940
|
if (!this.store.isSessionMember(session.id, this.actorId)) {
|
|
2941
|
+
const commandId = this.store.randomId("cmd");
|
|
2849
2942
|
const error = new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
|
|
2850
2943
|
return {
|
|
2851
2944
|
ok: false,
|
|
@@ -2861,8 +2954,7 @@ export class StateQL {
|
|
|
2861
2954
|
const result = await action(session);
|
|
2862
2955
|
const responseSession = result.session ?? session;
|
|
2863
2956
|
const sqlText = resolveHistorySql(historySql);
|
|
2864
|
-
this.store.addHistory({
|
|
2865
|
-
id: commandId,
|
|
2957
|
+
const history = this.store.addHistory({
|
|
2866
2958
|
sessionId: session.id,
|
|
2867
2959
|
actorId: this.actorId,
|
|
2868
2960
|
origin,
|
|
@@ -2878,7 +2970,7 @@ export class StateQL {
|
|
|
2878
2970
|
});
|
|
2879
2971
|
return {
|
|
2880
2972
|
ok: true,
|
|
2881
|
-
command_id:
|
|
2973
|
+
command_id: history.id,
|
|
2882
2974
|
session_id: responseSession.id,
|
|
2883
2975
|
data: result.data,
|
|
2884
2976
|
warnings: result.warnings ?? [],
|
|
@@ -2896,8 +2988,7 @@ export class StateQL {
|
|
|
2896
2988
|
catch (error) {
|
|
2897
2989
|
const stateqlError = asStateQLError(error);
|
|
2898
2990
|
const sqlText = resolveHistorySql(historySql);
|
|
2899
|
-
this.store.addHistory({
|
|
2900
|
-
id: commandId,
|
|
2991
|
+
const history = this.store.addHistory({
|
|
2901
2992
|
sessionId: session.id,
|
|
2902
2993
|
actorId: this.actorId,
|
|
2903
2994
|
origin,
|
|
@@ -2913,7 +3004,7 @@ export class StateQL {
|
|
|
2913
3004
|
});
|
|
2914
3005
|
return {
|
|
2915
3006
|
ok: false,
|
|
2916
|
-
command_id:
|
|
3007
|
+
command_id: history.id,
|
|
2917
3008
|
session_id: session.id,
|
|
2918
3009
|
error: stateqlError.details,
|
|
2919
3010
|
meta: {
|
|
@@ -3214,7 +3305,23 @@ function credentialStateQLError(reference, error) {
|
|
|
3214
3305
|
return new StateQLError("CREDENTIAL_RESOLUTION_FAILED", `Credential reference "${reference}" could not be resolved.`, { retryable: true });
|
|
3215
3306
|
}
|
|
3216
3307
|
function safeCredentialErrorMessage(error, source) {
|
|
3217
|
-
|
|
3308
|
+
let message = errorMessage(error).split(source).join("[credential redacted]");
|
|
3309
|
+
const authority = /^[a-z][a-z\d+.-]*:\/\/([^/?#]*)/i.exec(source)?.[1];
|
|
3310
|
+
if (authority) {
|
|
3311
|
+
const at = authority.lastIndexOf("@");
|
|
3312
|
+
const colon = at < 0 ? -1 : authority.slice(0, at).indexOf(":");
|
|
3313
|
+
const password = colon < 0 ? "" : authority.slice(colon + 1, at);
|
|
3314
|
+
const secrets = new Set([password]);
|
|
3315
|
+
try {
|
|
3316
|
+
secrets.add(decodeURIComponent(password));
|
|
3317
|
+
}
|
|
3318
|
+
catch { /* malformed values stay encoded */ }
|
|
3319
|
+
for (const secret of secrets) {
|
|
3320
|
+
if (secret)
|
|
3321
|
+
message = message.split(secret).join("[credential redacted]");
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
return redact(message)
|
|
3218
3325
|
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s\/@]+(?::[^\s\/@]*)?@/giu, "$1***@");
|
|
3219
3326
|
}
|
|
3220
3327
|
function executionTimeout(value, name = "timeoutMs") {
|
|
@@ -3236,15 +3343,27 @@ function validatedProfileSource(input) {
|
|
|
3236
3343
|
throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
|
|
3237
3344
|
if (input.credentialRef !== undefined)
|
|
3238
3345
|
validateCredentialRef(input.credentialRef);
|
|
3346
|
+
if (input.passwordRef !== undefined)
|
|
3347
|
+
validateCredentialRef(input.passwordRef);
|
|
3239
3348
|
let target = input.target ?? null;
|
|
3349
|
+
if (input.passwordRef !== undefined && target === null) {
|
|
3350
|
+
throw new StateQLError("INVALID_COMMAND", "A password reference requires a literal remote profile target.");
|
|
3351
|
+
}
|
|
3240
3352
|
if (target) {
|
|
3241
|
-
const driver =
|
|
3242
|
-
|
|
3353
|
+
const driver = input.passwordRef !== undefined
|
|
3354
|
+
? validatePasswordReferenceTarget(target)
|
|
3355
|
+
: detectDriver(target);
|
|
3356
|
+
if (input.passwordRef === undefined && driver !== "sqlite" && databaseUrlHasSecret(target))
|
|
3243
3357
|
throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`);
|
|
3244
3358
|
if (driver === "sqlite")
|
|
3245
3359
|
target = normalizeSqliteSource(target);
|
|
3246
3360
|
}
|
|
3247
|
-
return {
|
|
3361
|
+
return {
|
|
3362
|
+
target,
|
|
3363
|
+
secretEnv: input.secretEnv ?? null,
|
|
3364
|
+
credentialRef: input.credentialRef ?? null,
|
|
3365
|
+
passwordRef: input.passwordRef ?? null,
|
|
3366
|
+
};
|
|
3248
3367
|
}
|
|
3249
3368
|
function historyCategory(command) {
|
|
3250
3369
|
if (["query", "exec", "plan", "apply", "mongo.query", "mongo.exec", "mongo.plan", "redis.query", "redis.exec", "redis.plan", "filter"].includes(command))
|