@fadhilp/stateql 0.7.0 → 0.8.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/README.md +27 -14
- package/dist/src/cli.js +8 -3
- package/dist/src/connection.d.ts +3 -2
- package/dist/src/connection.js +14 -3
- package/dist/src/index.d.ts +1 -1
- package/dist/src/migrations.js +75 -1
- package/dist/src/response-data.js +1 -0
- package/dist/src/stateql.js +60 -27
- package/dist/src/store.d.ts +4 -0
- package/dist/src/store.js +8 -6
- package/dist/src/types.d.ts +13 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,12 +76,13 @@ its cache entry is valid. Use `--cache bypass` when a fresh read is required.
|
|
|
76
76
|
|
|
77
77
|
## Connections and profiles
|
|
78
78
|
|
|
79
|
-
A connection accepts exactly one source: a direct target, `--env`,
|
|
80
|
-
`--profile`.
|
|
79
|
+
A connection accepts exactly one source: a direct target, `--env`,
|
|
80
|
+
`--credential-ref`, or `--profile`.
|
|
81
81
|
|
|
82
82
|
```bash
|
|
83
83
|
stql connect <sqlite-path|postgres-url|mysql-url|mongodb-url> [--name NAME] [--read-write]
|
|
84
84
|
stql connect --env ENV [--name NAME] [--read-write]
|
|
85
|
+
stql connect --credential-ref REF [--name NAME] [--read-write]
|
|
85
86
|
stql connect --profile NAME
|
|
86
87
|
stql disconnect
|
|
87
88
|
stql status
|
|
@@ -113,19 +114,26 @@ connection metadata.
|
|
|
113
114
|
|
|
114
115
|
### Local profiles
|
|
115
116
|
|
|
116
|
-
Profiles store connection
|
|
117
|
-
|
|
118
|
-
with other StateQL
|
|
117
|
+
Profiles store exactly one connection target, environment-variable name, or
|
|
118
|
+
opaque credential reference together with read-only policy. Credential values
|
|
119
|
+
are never stored. Profiles persist under `STQL_HOME` with other StateQL
|
|
120
|
+
metadata, and list/show responses include `credential_ref` when configured.
|
|
119
121
|
|
|
120
122
|
```bash
|
|
121
123
|
stql profile add local ./app.sqlite --read-write
|
|
122
124
|
stql profile add production --env PROD_DATABASE_URL --read-only
|
|
125
|
+
stql profile add hosted --credential-ref 'vault://team/app' --read-only
|
|
123
126
|
stql profile list
|
|
124
127
|
stql profile show production
|
|
125
128
|
stql connect local
|
|
126
129
|
stql connect --profile production
|
|
127
130
|
```
|
|
128
131
|
|
|
132
|
+
Credential references are bounded nonempty opaque strings; StateQL does not
|
|
133
|
+
apply environment-variable syntax or normalization to them. They can only be
|
|
134
|
+
resolved by a trusted host `CredentialResolver`, so the standalone CLI may
|
|
135
|
+
store them in profiles but cannot connect with them.
|
|
136
|
+
|
|
129
137
|
A bare connection target matching a profile name resolves to that profile;
|
|
130
138
|
otherwise it remains a path or database URL.
|
|
131
139
|
|
|
@@ -464,8 +472,9 @@ for user confirmation before changing membership or the shared connection.
|
|
|
464
472
|
|
|
465
473
|
### Harness credential resolution
|
|
466
474
|
|
|
467
|
-
Library integrations can resolve
|
|
468
|
-
trusted approval or secret-storage layer
|
|
475
|
+
Library integrations can resolve environment-variable names or opaque
|
|
476
|
+
credential references through a trusted approval or secret-storage layer
|
|
477
|
+
instead of mutating `process.env`:
|
|
469
478
|
|
|
470
479
|
```ts
|
|
471
480
|
import {
|
|
@@ -479,6 +488,7 @@ async function resolveCredential(
|
|
|
479
488
|
): Promise<string | undefined> {
|
|
480
489
|
const approved = await credentialBroker.request({
|
|
481
490
|
reference: request.reference,
|
|
491
|
+
source: request.source ?? "secret_env",
|
|
482
492
|
actor: request.actorId,
|
|
483
493
|
session: request.session.id,
|
|
484
494
|
operation: request.operation,
|
|
@@ -496,13 +506,16 @@ const stateql = StateQL.forActor({
|
|
|
496
506
|
});
|
|
497
507
|
```
|
|
498
508
|
|
|
499
|
-
When no custom resolver is configured, StateQL reads
|
|
500
|
-
`process.env
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
509
|
+
When no custom resolver is configured, StateQL reads only `secret_env`
|
|
510
|
+
references from `process.env`; `credential_ref` never falls back to the
|
|
511
|
+
environment. A configured resolver is authoritative for both sources: returning
|
|
512
|
+
`undefined` produces `CREDENTIAL_UNAVAILABLE` and never falls back to the
|
|
513
|
+
process environment. Resolver requests include `source` (`secret_env` or
|
|
514
|
+
`credential_ref`) while retaining `reference`; source may be omitted only on
|
|
515
|
+
legacy secret-environment request objects. Resolvers may throw
|
|
516
|
+
`CredentialResolutionError` with `denied`, `cancelled`, `timeout`, or
|
|
517
|
+
`unavailable` to produce controlled, secret-free failures. Unknown resolver
|
|
518
|
+
errors are replaced with a generic `CREDENTIAL_RESOLUTION_FAILED` response.
|
|
506
519
|
|
|
507
520
|
StateQL calls the resolver only immediately before database access, after SQL
|
|
508
521
|
safety and duplicate checks. Requests contain actor and session identity, the
|
package/dist/src/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ const parsed = parseArgs({
|
|
|
13
13
|
name: { type: "string" },
|
|
14
14
|
profile: { type: "string" },
|
|
15
15
|
env: { type: "string" },
|
|
16
|
+
"credential-ref": { type: "string" },
|
|
16
17
|
"read-only": { type: "boolean" },
|
|
17
18
|
"read-write": { type: "boolean" },
|
|
18
19
|
params: { type: "string" },
|
|
@@ -143,6 +144,7 @@ async function dispatch() {
|
|
|
143
144
|
return stateql.connect(subcommand, {
|
|
144
145
|
...(values.name ? { name: values.name } : {}),
|
|
145
146
|
...(values.env ? { secretEnv: values.env } : {}),
|
|
147
|
+
...(values["credential-ref"] ? { credentialRef: values["credential-ref"] } : {}),
|
|
146
148
|
...(values.profile ? { profile: values.profile } : {}),
|
|
147
149
|
...(values["read-only"]
|
|
148
150
|
? { readOnly: true }
|
|
@@ -282,6 +284,7 @@ async function dispatchProfile(action, args) {
|
|
|
282
284
|
}
|
|
283
285
|
return stateql.addProfile(requireValue(args[0], "profile name"), args[1], {
|
|
284
286
|
...(values.env ? { secretEnv: values.env } : {}),
|
|
287
|
+
...(values["credential-ref"] ? { credentialRef: values["credential-ref"] } : {}),
|
|
285
288
|
readOnly: !values["read-write"],
|
|
286
289
|
});
|
|
287
290
|
case "list":
|
|
@@ -576,9 +579,10 @@ function helpText() {
|
|
|
576
579
|
Usage: stql <command> [arguments] [options]
|
|
577
580
|
|
|
578
581
|
Commands:
|
|
579
|
-
connect
|
|
580
|
-
|
|
581
|
-
|
|
582
|
+
connect TARGET | --env ENV | --credential-ref REF | --profile NAME
|
|
583
|
+
disconnect, status
|
|
584
|
+
profile add NAME [TARGET | --env ENV | --credential-ref REF]
|
|
585
|
+
profile list|show|remove
|
|
582
586
|
query, filter, exec, show, rows, count, columns, export
|
|
583
587
|
mongo query|exec|plan '<EJSON command>'
|
|
584
588
|
alias set
|
|
@@ -590,6 +594,7 @@ Commands:
|
|
|
590
594
|
|
|
591
595
|
SQL parameters: --params JSON, repeated --param VALUE, or --params-file FILE.
|
|
592
596
|
Deadline: --timeout-ms N (default: 30000). Ctrl+C cancels database work.
|
|
597
|
+
Credential refs require a trusted host CredentialResolver; the standalone CLI cannot resolve them.
|
|
593
598
|
State: --max-state-bytes N, --cache-ttl-seconds N, --result-ttl-seconds N.
|
|
594
599
|
Output: --output agent|json|jsonl|text|silent (default: agent).
|
|
595
600
|
Batch/pipe accept JSON array files or JSONL streams. Stop on first error.`;
|
package/dist/src/connection.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ConnectionRecord } from "./store.js";
|
|
2
|
-
import type { Driver, StateConfidence } from "./types.js";
|
|
2
|
+
import type { CredentialSource, Driver, StateConfidence } from "./types.js";
|
|
3
3
|
export declare function databaseIdentity(connection: ConnectionRecord): unknown;
|
|
4
4
|
export declare function detectDriver(target: string): Driver;
|
|
5
5
|
export declare function mongoDatabaseName(target: string): string;
|
|
6
|
-
export declare function credentialSource(value: string, expectedDriver?: Driver): {
|
|
6
|
+
export declare function credentialSource(value: string, expectedDriver?: Driver, referenceSource?: CredentialSource): {
|
|
7
7
|
driver: Driver;
|
|
8
8
|
source: string;
|
|
9
9
|
};
|
|
@@ -13,3 +13,4 @@ export declare function version(connection: ConnectionRecord): string;
|
|
|
13
13
|
export declare function confidence(connection: ConnectionRecord): StateConfidence;
|
|
14
14
|
export declare function validateProfileName(name: string): void;
|
|
15
15
|
export declare function isEnvironmentName(name: string): boolean;
|
|
16
|
+
export declare function validateCredentialRef(reference: unknown): asserts reference is string;
|
package/dist/src/connection.js
CHANGED
|
@@ -6,6 +6,7 @@ export function databaseIdentity(connection) {
|
|
|
6
6
|
database: connection.database_name,
|
|
7
7
|
source: connection.source,
|
|
8
8
|
secretEnvironment: connection.secret_env,
|
|
9
|
+
credentialReference: connection.credential_ref,
|
|
9
10
|
};
|
|
10
11
|
}
|
|
11
12
|
export function detectDriver(target) {
|
|
@@ -43,11 +44,14 @@ export function mongoDatabaseName(target) {
|
|
|
43
44
|
}
|
|
44
45
|
throw new StateQLError("INVALID_COMMAND", "MongoDB URL must include an explicit database name.");
|
|
45
46
|
}
|
|
46
|
-
export function credentialSource(value, expectedDriver) {
|
|
47
|
+
export function credentialSource(value, expectedDriver, referenceSource = "secret_env") {
|
|
48
|
+
const sourceLabel = referenceSource === "credential_ref"
|
|
49
|
+
? "Credential reference"
|
|
50
|
+
: "Secret environment variable";
|
|
47
51
|
const explicitSqlite = /^sqlite:(?!\/\/)/i.test(value);
|
|
48
52
|
const driver = explicitSqlite ? "sqlite" : detectDriver(value);
|
|
49
53
|
if (driver === "sqlite" && (!explicitSqlite || value.length === 7)) {
|
|
50
|
-
throw new StateQLError("INVALID_COMMAND",
|
|
54
|
+
throw new StateQLError("INVALID_COMMAND", `${sourceLabel} must contain a complete PostgreSQL/MySQL URL or an explicit sqlite: source; MongoDB URLs are also supported.`, {
|
|
51
55
|
suggestedAction: "Store the full database URL, or prefix an SQLite path with sqlite:.",
|
|
52
56
|
});
|
|
53
57
|
}
|
|
@@ -58,7 +62,7 @@ export function credentialSource(value, expectedDriver) {
|
|
|
58
62
|
throw new Error();
|
|
59
63
|
}
|
|
60
64
|
catch {
|
|
61
|
-
throw new StateQLError("INVALID_COMMAND",
|
|
65
|
+
throw new StateQLError("INVALID_COMMAND", `${sourceLabel} must contain a valid database URL.`);
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
if (expectedDriver && driver !== expectedDriver) {
|
|
@@ -101,3 +105,10 @@ export function validateProfileName(name) {
|
|
|
101
105
|
export function isEnvironmentName(name) {
|
|
102
106
|
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
|
|
103
107
|
}
|
|
108
|
+
export function validateCredentialRef(reference) {
|
|
109
|
+
if (typeof reference === "string" &&
|
|
110
|
+
reference.trim().length > 0 &&
|
|
111
|
+
reference.length <= 1_024)
|
|
112
|
+
return;
|
|
113
|
+
throw new StateQLError("INVALID_COMMAND", "Credential reference must be a nonempty string of at most 1024 characters.");
|
|
114
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { StateQL } from "./stateql.js";
|
|
2
2
|
export { CredentialResolutionError, StateQLError, exitCodeFor, } from "./errors.js";
|
|
3
3
|
export type { CredentialResolutionFailure } from "./errors.js";
|
|
4
|
-
export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CapabilitiesData, CommandExecutionContext, CommandOrigin, CloseSessionData, Column, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, CredentialAccess, CredentialOperation, CredentialRequest, CredentialResolver, DisconnectData, DoctorData, Driver, ExecData, ExecOptions, ExecutionOptions, ExportData, Failure, FilterOptions, HistoryData, HistoryEntry, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfilesData, PurgeData, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
|
|
4
|
+
export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CapabilitiesData, CommandExecutionContext, CommandOrigin, CloseSessionData, Column, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, CredentialAccess, CredentialOperation, CredentialRequest, CredentialSource, CredentialResolver, DisconnectData, DoctorData, Driver, ExecData, ExecOptions, ExecutionOptions, ExportData, Failure, FilterOptions, HistoryData, HistoryEntry, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfilesData, PurgeData, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
|
package/dist/src/migrations.js
CHANGED
|
@@ -75,6 +75,11 @@ const MIGRATIONS = [
|
|
|
75
75
|
requireIndexes(db, ["history_session_origin"]);
|
|
76
76
|
},
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
name: "credential_refs_v1",
|
|
80
|
+
apply: migrateCredentialRefs,
|
|
81
|
+
validate: validateCredentialRefs,
|
|
82
|
+
},
|
|
78
83
|
];
|
|
79
84
|
export function runMigrations(db, now) {
|
|
80
85
|
db.exec(`
|
|
@@ -130,10 +135,15 @@ function createInitialSchema(db) {
|
|
|
130
135
|
name TEXT PRIMARY KEY,
|
|
131
136
|
target TEXT,
|
|
132
137
|
secret_env TEXT,
|
|
138
|
+
credential_ref TEXT,
|
|
133
139
|
read_only INTEGER NOT NULL,
|
|
134
140
|
created_at TEXT NOT NULL,
|
|
135
141
|
updated_at TEXT NOT NULL,
|
|
136
|
-
CHECK(
|
|
142
|
+
CHECK (
|
|
143
|
+
(target IS NOT NULL) +
|
|
144
|
+
(secret_env IS NOT NULL) +
|
|
145
|
+
(credential_ref IS NOT NULL) = 1
|
|
146
|
+
)
|
|
137
147
|
);
|
|
138
148
|
CREATE TABLE IF NOT EXISTS connections (
|
|
139
149
|
id TEXT PRIMARY KEY,
|
|
@@ -143,6 +153,7 @@ function createInitialSchema(db) {
|
|
|
143
153
|
database_name TEXT NOT NULL,
|
|
144
154
|
source TEXT NOT NULL,
|
|
145
155
|
secret_env TEXT,
|
|
156
|
+
credential_ref TEXT,
|
|
146
157
|
read_only INTEGER NOT NULL,
|
|
147
158
|
version INTEGER NOT NULL,
|
|
148
159
|
created_at TEXT NOT NULL,
|
|
@@ -301,6 +312,69 @@ function validateSharedSessionActors(db) {
|
|
|
301
312
|
throw new Error(`State migration left ${table}.${column} empty.`);
|
|
302
313
|
}
|
|
303
314
|
}
|
|
315
|
+
function migrateCredentialRefs(db) {
|
|
316
|
+
const profileColumns = new Set(db.prepare("PRAGMA table_info(profiles)").all().map((column) => column.name));
|
|
317
|
+
const hasCredentialRef = profileColumns.has("credential_ref");
|
|
318
|
+
if (!profileCredentialRefSchemaCurrent(db)) {
|
|
319
|
+
const missingSources = db.prepare(`SELECT COUNT(*) AS count FROM profiles
|
|
320
|
+
WHERE target IS NULL AND secret_env IS NULL${hasCredentialRef ? " AND credential_ref IS NULL" : ""}`).get();
|
|
321
|
+
if (missingSources.count) {
|
|
322
|
+
throw new Error("State migration found a profile without a connection source.");
|
|
323
|
+
}
|
|
324
|
+
const legacyCredentialRef = hasCredentialRef ? "credential_ref" : "NULL";
|
|
325
|
+
db.exec(`
|
|
326
|
+
ALTER TABLE profiles RENAME TO profiles_legacy_credential_refs_v1;
|
|
327
|
+
CREATE TABLE profiles (
|
|
328
|
+
name TEXT PRIMARY KEY,
|
|
329
|
+
target TEXT,
|
|
330
|
+
secret_env TEXT,
|
|
331
|
+
credential_ref TEXT,
|
|
332
|
+
read_only INTEGER NOT NULL,
|
|
333
|
+
created_at TEXT NOT NULL,
|
|
334
|
+
updated_at TEXT NOT NULL,
|
|
335
|
+
CHECK (
|
|
336
|
+
(target IS NOT NULL) +
|
|
337
|
+
(secret_env IS NOT NULL) +
|
|
338
|
+
(credential_ref IS NOT NULL) = 1
|
|
339
|
+
)
|
|
340
|
+
);
|
|
341
|
+
INSERT INTO profiles
|
|
342
|
+
(name, target, secret_env, credential_ref, read_only, created_at, updated_at)
|
|
343
|
+
SELECT
|
|
344
|
+
name,
|
|
345
|
+
CASE
|
|
346
|
+
WHEN ${legacyCredentialRef} IS NULL AND secret_env IS NULL THEN target
|
|
347
|
+
ELSE NULL
|
|
348
|
+
END,
|
|
349
|
+
CASE WHEN ${legacyCredentialRef} IS NULL THEN secret_env ELSE NULL END,
|
|
350
|
+
${legacyCredentialRef},
|
|
351
|
+
read_only,
|
|
352
|
+
created_at,
|
|
353
|
+
updated_at
|
|
354
|
+
FROM profiles_legacy_credential_refs_v1;
|
|
355
|
+
DROP TABLE profiles_legacy_credential_refs_v1;
|
|
356
|
+
`);
|
|
357
|
+
}
|
|
358
|
+
addColumn(db, "connections", "credential_ref", "TEXT");
|
|
359
|
+
}
|
|
360
|
+
function validateCredentialRefs(db) {
|
|
361
|
+
requireColumns(db, "profiles", ["credential_ref"]);
|
|
362
|
+
requireColumns(db, "connections", ["credential_ref"]);
|
|
363
|
+
if (!profileCredentialRefSchemaCurrent(db)) {
|
|
364
|
+
throw new Error("State migration did not enforce exactly one profile source.");
|
|
365
|
+
}
|
|
366
|
+
const invalid = db.prepare(`SELECT COUNT(*) AS count FROM profiles
|
|
367
|
+
WHERE (target IS NOT NULL) +
|
|
368
|
+
(secret_env IS NOT NULL) +
|
|
369
|
+
(credential_ref IS NOT NULL) != 1`).get();
|
|
370
|
+
if (invalid.count) {
|
|
371
|
+
throw new Error("State migration left a profile with ambiguous connection sources.");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function profileCredentialRefSchemaCurrent(db) {
|
|
375
|
+
const row = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'profiles'").get();
|
|
376
|
+
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));
|
|
377
|
+
}
|
|
304
378
|
function addColumn(db, table, column, definition) {
|
|
305
379
|
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
306
380
|
if (!columns.some((candidate) => candidate.name === column)) {
|
package/dist/src/stateql.js
CHANGED
|
@@ -3,7 +3,7 @@ import { writeFileSync } from "node:fs";
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
4
|
import { env } from "node:process";
|
|
5
5
|
import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
|
|
6
|
-
import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, mongoDatabaseName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
|
|
6
|
+
import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, mongoDatabaseName, normalizeSqliteSource, validateCredentialRef, validateProfileName, version, } from "./connection.js";
|
|
7
7
|
import { asStateQLError, CredentialResolutionError, StateQLError, } from "./errors.js";
|
|
8
8
|
import { analyzeMongoWriteSafety, deserializeMongoWriteCommand, serializeMongoCommand, validateMongoReadCommand, validateMongoWriteCommand, MongoAdapter, } from "./mongodb.js";
|
|
9
9
|
import { filterMaterializedRows, prepareFilterStatement, validateFilterParameters, } from "./filter.js";
|
|
@@ -90,12 +90,16 @@ export class StateQL {
|
|
|
90
90
|
if (session.active_transaction_id) {
|
|
91
91
|
throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before connecting again.");
|
|
92
92
|
}
|
|
93
|
-
const sourceCount = [
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
const sourceCount = [
|
|
94
|
+
target,
|
|
95
|
+
options.profile,
|
|
96
|
+
options.secretEnv,
|
|
97
|
+
options.credentialRef,
|
|
98
|
+
].filter((value) => value !== undefined).length;
|
|
99
|
+
if (sourceCount !== 1) {
|
|
100
|
+
throw new StateQLError("INVALID_COMMAND", "Use exactly one connection target, profile, secret environment variable, or credential reference.");
|
|
97
101
|
}
|
|
98
|
-
const implicitProfile = !options.profile && !options.secretEnv && target
|
|
102
|
+
const implicitProfile = !options.profile && !options.secretEnv && !options.credentialRef && target
|
|
99
103
|
? this.store.getProfile(target)
|
|
100
104
|
: undefined;
|
|
101
105
|
const profile = options.profile
|
|
@@ -104,16 +108,28 @@ export class StateQL {
|
|
|
104
108
|
if (options.profile && !profile) {
|
|
105
109
|
throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${options.profile}" was not found.`, { suggestedAction: "Run stql profile list." });
|
|
106
110
|
}
|
|
111
|
+
if (profile &&
|
|
112
|
+
[profile.target, profile.secret_env, profile.credential_ref]
|
|
113
|
+
.filter((value) => value !== null).length !== 1) {
|
|
114
|
+
throw new StateQLError("STATE_CORRUPTED", `Profile "${profile.name}" does not have exactly one connection source.`);
|
|
115
|
+
}
|
|
107
116
|
const resolvedTarget = profile?.target ?? target;
|
|
108
117
|
const secretEnv = options.secretEnv ?? profile?.secret_env ?? undefined;
|
|
109
|
-
|
|
118
|
+
const credentialRef = options.credentialRef ?? profile?.credential_ref ?? undefined;
|
|
119
|
+
if (secretEnv !== undefined && !isEnvironmentName(secretEnv)) {
|
|
110
120
|
throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
|
|
111
121
|
}
|
|
122
|
+
if (credentialRef !== undefined)
|
|
123
|
+
validateCredentialRef(credentialRef);
|
|
124
|
+
const credentialReference = secretEnv ?? credentialRef;
|
|
125
|
+
const credentialReferenceSource = secretEnv !== undefined
|
|
126
|
+
? "secret_env"
|
|
127
|
+
: credentialRef !== undefined ? "credential_ref" : undefined;
|
|
112
128
|
const readOnly = options.readOnly ??
|
|
113
129
|
(profile ? Boolean(profile.read_only) : true);
|
|
114
130
|
const context = this.executionContext(options);
|
|
115
|
-
const secret =
|
|
116
|
-
? await this.resolveCredential(
|
|
131
|
+
const secret = credentialReference && credentialReferenceSource
|
|
132
|
+
? await this.resolveCredential(credentialReference, credentialReferenceSource, session, "connect", readOnly ? "read" : "write", context, {
|
|
117
133
|
...(profile ? { profile: { name: profile.name } } : {}),
|
|
118
134
|
requestedReadOnly: readOnly,
|
|
119
135
|
})
|
|
@@ -121,23 +137,23 @@ export class StateQL {
|
|
|
121
137
|
if (!secret) {
|
|
122
138
|
throw new StateQLError("INVALID_COMMAND", "Connection target is required.");
|
|
123
139
|
}
|
|
124
|
-
const resolvedSource =
|
|
125
|
-
? credentialSource(secret)
|
|
140
|
+
const resolvedSource = credentialReferenceSource
|
|
141
|
+
? credentialSource(secret, undefined, credentialReferenceSource)
|
|
126
142
|
: { driver: detectDriver(secret), source: secret };
|
|
127
143
|
const { driver } = resolvedSource;
|
|
128
144
|
if (driver !== "sqlite" &&
|
|
129
|
-
!
|
|
145
|
+
!credentialReference &&
|
|
130
146
|
databaseUrlHasSecret(secret)) {
|
|
131
|
-
throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`, {
|
|
132
|
-
suggestedAction: "
|
|
147
|
+
throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`, {
|
|
148
|
+
suggestedAction: "Use an environment variable or trusted host credential reference.",
|
|
133
149
|
});
|
|
134
150
|
}
|
|
135
|
-
const adapterSource =
|
|
151
|
+
const adapterSource = credentialReferenceSource
|
|
136
152
|
? resolvedSource.source
|
|
137
153
|
: driver === "sqlite" ? normalizeSqliteSource(secret) : secret;
|
|
138
154
|
const source = driver === "sqlite"
|
|
139
155
|
? adapterSource
|
|
140
|
-
:
|
|
156
|
+
: credentialReferenceSource
|
|
141
157
|
? redact(secret)
|
|
142
158
|
: adapterSource;
|
|
143
159
|
const databaseName = driver === "sqlite"
|
|
@@ -153,6 +169,7 @@ export class StateQL {
|
|
|
153
169
|
database_name: databaseName,
|
|
154
170
|
source,
|
|
155
171
|
secret_env: secretEnv ?? null,
|
|
172
|
+
credential_ref: credentialRef ?? null,
|
|
156
173
|
read_only: readOnly ? 1 : 0,
|
|
157
174
|
version: 0,
|
|
158
175
|
created_at: this.now().toISOString(),
|
|
@@ -180,6 +197,7 @@ export class StateQL {
|
|
|
180
197
|
databaseName,
|
|
181
198
|
source,
|
|
182
199
|
...(secretEnv ? { secretEnv } : {}),
|
|
200
|
+
...(credentialRef ? { credentialRef } : {}),
|
|
183
201
|
readOnly,
|
|
184
202
|
});
|
|
185
203
|
if (!connection) {
|
|
@@ -206,20 +224,25 @@ export class StateQL {
|
|
|
206
224
|
async addProfile(name, target, options = {}) {
|
|
207
225
|
return this.run("profile.add", async () => {
|
|
208
226
|
validateProfileName(name);
|
|
209
|
-
|
|
210
|
-
|
|
227
|
+
const sourceCount = [target, options.secretEnv, options.credentialRef]
|
|
228
|
+
.filter((value) => value !== undefined).length;
|
|
229
|
+
if (sourceCount !== 1 || target === "") {
|
|
230
|
+
throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target, secret environment variable, or credential reference.");
|
|
211
231
|
}
|
|
212
232
|
if (this.store.getProfile(name)) {
|
|
213
233
|
throw new StateQLError("INVALID_COMMAND", `Profile "${name}" already exists.`);
|
|
214
234
|
}
|
|
215
|
-
if (options.secretEnv && !isEnvironmentName(options.secretEnv)) {
|
|
235
|
+
if (options.secretEnv !== undefined && !isEnvironmentName(options.secretEnv)) {
|
|
216
236
|
throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
|
|
217
237
|
}
|
|
238
|
+
if (options.credentialRef !== undefined) {
|
|
239
|
+
validateCredentialRef(options.credentialRef);
|
|
240
|
+
}
|
|
218
241
|
let storedTarget = target;
|
|
219
242
|
if (target) {
|
|
220
243
|
const driver = detectDriver(target);
|
|
221
244
|
if (driver !== "sqlite" && databaseUrlHasSecret(target)) {
|
|
222
|
-
throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`);
|
|
245
|
+
throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`);
|
|
223
246
|
}
|
|
224
247
|
if (driver === "sqlite")
|
|
225
248
|
storedTarget = normalizeSqliteSource(target);
|
|
@@ -228,6 +251,7 @@ export class StateQL {
|
|
|
228
251
|
name,
|
|
229
252
|
target: storedTarget,
|
|
230
253
|
secretEnv: options.secretEnv,
|
|
254
|
+
credentialRef: options.credentialRef,
|
|
231
255
|
readOnly: options.readOnly ?? true,
|
|
232
256
|
});
|
|
233
257
|
return {
|
|
@@ -1387,6 +1411,7 @@ export class StateQL {
|
|
|
1387
1411
|
name: command.name,
|
|
1388
1412
|
readOnly: command.read_only,
|
|
1389
1413
|
secretEnv: command.secret_env,
|
|
1414
|
+
credentialRef: command.credential_ref,
|
|
1390
1415
|
profile: command.profile,
|
|
1391
1416
|
timeoutMs: command.timeout_ms,
|
|
1392
1417
|
});
|
|
@@ -1398,6 +1423,7 @@ export class StateQL {
|
|
|
1398
1423
|
return this.addProfile(batchString(command.name, "name"), command.target, {
|
|
1399
1424
|
readOnly: command.read_only ?? true,
|
|
1400
1425
|
secretEnv: command.secret_env,
|
|
1426
|
+
credentialRef: command.credential_ref,
|
|
1401
1427
|
});
|
|
1402
1428
|
case "profile.list":
|
|
1403
1429
|
return this.listProfiles();
|
|
@@ -2022,9 +2048,13 @@ export class StateQL {
|
|
|
2022
2048
|
throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
|
|
2023
2049
|
}
|
|
2024
2050
|
async resolveConnectionSource(connection, session, operation, access, context) {
|
|
2025
|
-
|
|
2051
|
+
const reference = connection.secret_env ?? connection.credential_ref;
|
|
2052
|
+
if (!reference)
|
|
2026
2053
|
return connection.source;
|
|
2027
|
-
const
|
|
2054
|
+
const source = connection.secret_env
|
|
2055
|
+
? "secret_env"
|
|
2056
|
+
: "credential_ref";
|
|
2057
|
+
const value = await this.resolveCredential(reference, source, session, operation, access, context, {
|
|
2028
2058
|
connection: {
|
|
2029
2059
|
id: connection.id,
|
|
2030
2060
|
name: connection.name,
|
|
@@ -2033,9 +2063,9 @@ export class StateQL {
|
|
|
2033
2063
|
readOnly: Boolean(connection.read_only),
|
|
2034
2064
|
},
|
|
2035
2065
|
});
|
|
2036
|
-
return credentialSource(value, connection.driver).source;
|
|
2066
|
+
return credentialSource(value, connection.driver, source).source;
|
|
2037
2067
|
}
|
|
2038
|
-
async resolveCredential(reference, session, operation, access, context, details = {}) {
|
|
2068
|
+
async resolveCredential(reference, source, session, operation, access, context, details = {}) {
|
|
2039
2069
|
const resolver = this.credentialResolver;
|
|
2040
2070
|
if (!resolver) {
|
|
2041
2071
|
if (context.signal?.aborted) {
|
|
@@ -2044,13 +2074,16 @@ export class StateQL {
|
|
|
2044
2074
|
if (context.deadline <= Date.now()) {
|
|
2045
2075
|
throw credentialStateQLError(reference, new CredentialResolutionError("timeout"));
|
|
2046
2076
|
}
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2077
|
+
if (source === "secret_env") {
|
|
2078
|
+
const value = env[reference];
|
|
2079
|
+
if (value)
|
|
2080
|
+
return value;
|
|
2081
|
+
}
|
|
2050
2082
|
throw credentialStateQLError(reference, new CredentialResolutionError("unavailable"));
|
|
2051
2083
|
}
|
|
2052
2084
|
const request = {
|
|
2053
2085
|
reference,
|
|
2086
|
+
source,
|
|
2054
2087
|
actorId: this.actorId,
|
|
2055
2088
|
session: { id: session.id, name: session.name },
|
|
2056
2089
|
operation,
|
package/dist/src/store.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface ConnectionRecord {
|
|
|
17
17
|
database_name: string;
|
|
18
18
|
source: string;
|
|
19
19
|
secret_env: string | null;
|
|
20
|
+
credential_ref: string | null;
|
|
20
21
|
read_only: number;
|
|
21
22
|
version: number;
|
|
22
23
|
created_at: string;
|
|
@@ -25,6 +26,7 @@ export interface ProfileRecord {
|
|
|
25
26
|
name: string;
|
|
26
27
|
target: string | null;
|
|
27
28
|
secret_env: string | null;
|
|
29
|
+
credential_ref: string | null;
|
|
28
30
|
read_only: number;
|
|
29
31
|
created_at: string;
|
|
30
32
|
updated_at: string;
|
|
@@ -136,6 +138,7 @@ export declare class StateStore {
|
|
|
136
138
|
name: string;
|
|
137
139
|
target?: string;
|
|
138
140
|
secretEnv?: string;
|
|
141
|
+
credentialRef?: string;
|
|
139
142
|
readOnly: boolean;
|
|
140
143
|
}): ProfileRecord;
|
|
141
144
|
getProfile(name: string): ProfileRecord | undefined;
|
|
@@ -149,6 +152,7 @@ export declare class StateStore {
|
|
|
149
152
|
databaseName: string;
|
|
150
153
|
source: string;
|
|
151
154
|
secretEnv?: string;
|
|
155
|
+
credentialRef?: string;
|
|
152
156
|
readOnly: boolean;
|
|
153
157
|
}): ConnectionRecord | undefined;
|
|
154
158
|
getConnection(id: string): ConnectionRecord | undefined;
|
package/dist/src/store.js
CHANGED
|
@@ -235,9 +235,9 @@ export class StateStore {
|
|
|
235
235
|
const timestamp = this.now().toISOString();
|
|
236
236
|
this.db
|
|
237
237
|
.prepare(`INSERT INTO profiles
|
|
238
|
-
(name, target, secret_env, read_only, created_at, updated_at)
|
|
239
|
-
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
240
|
-
.run(input.name, input.target ?? null, input.secretEnv ?? null, input.readOnly ? 1 : 0, timestamp, timestamp);
|
|
238
|
+
(name, target, secret_env, credential_ref, read_only, created_at, updated_at)
|
|
239
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
240
|
+
.run(input.name, input.target ?? null, input.secretEnv ?? null, input.credentialRef ?? null, input.readOnly ? 1 : 0, timestamp, timestamp);
|
|
241
241
|
return this.getProfile(input.name);
|
|
242
242
|
}
|
|
243
243
|
getProfile(name) {
|
|
@@ -274,9 +274,9 @@ export class StateStore {
|
|
|
274
274
|
this.db
|
|
275
275
|
.prepare(`INSERT INTO connections
|
|
276
276
|
(id, session_id, name, driver, database_name, source, secret_env,
|
|
277
|
-
read_only, version, created_at)
|
|
278
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
|
|
279
|
-
.run(id, input.sessionId, input.name, input.driver, input.databaseName, input.source, input.secretEnv ?? null, input.readOnly ? 1 : 0, timestamp);
|
|
277
|
+
credential_ref, read_only, version, created_at)
|
|
278
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
|
|
279
|
+
.run(id, input.sessionId, input.name, input.driver, input.databaseName, input.source, input.secretEnv ?? null, input.credentialRef ?? null, input.readOnly ? 1 : 0, timestamp);
|
|
280
280
|
this.db
|
|
281
281
|
.prepare(`UPDATE sessions
|
|
282
282
|
SET active_connection_id = ?, updated_at = ?
|
|
@@ -415,6 +415,8 @@ export class StateStore {
|
|
|
415
415
|
AND previous_connection.source = current_connection.source
|
|
416
416
|
AND COALESCE(previous_connection.secret_env, '') =
|
|
417
417
|
COALESCE(current_connection.secret_env, '')
|
|
418
|
+
AND COALESCE(previous_connection.credential_ref, '') =
|
|
419
|
+
COALESCE(current_connection.credential_ref, '')
|
|
418
420
|
AND previous_connection.database_name = current_connection.database_name
|
|
419
421
|
ORDER BY operations.created_at DESC LIMIT 1`)
|
|
420
422
|
.get(input.connectionId, input.idempotencyKey);
|
package/dist/src/types.d.ts
CHANGED
|
@@ -8,7 +8,8 @@ export interface CommandExecutionContext {
|
|
|
8
8
|
}
|
|
9
9
|
export type CredentialAccess = "read" | "write";
|
|
10
10
|
export type CredentialOperation = "connect" | "query" | "inspect" | "plan" | "exec" | "apply" | "transaction.commit";
|
|
11
|
-
export
|
|
11
|
+
export type CredentialSource = "secret_env" | "credential_ref";
|
|
12
|
+
interface CredentialRequestBase {
|
|
12
13
|
reference: string;
|
|
13
14
|
actorId: string;
|
|
14
15
|
session: {
|
|
@@ -30,6 +31,12 @@ export interface CredentialRequest {
|
|
|
30
31
|
readOnly: boolean;
|
|
31
32
|
};
|
|
32
33
|
}
|
|
34
|
+
/** Omitted source remains backward-compatible with legacy secret_env requests. */
|
|
35
|
+
export type CredentialRequest = CredentialRequestBase & ({
|
|
36
|
+
source?: "secret_env";
|
|
37
|
+
} | {
|
|
38
|
+
source: "credential_ref";
|
|
39
|
+
});
|
|
33
40
|
export type CredentialResolver = (request: CredentialRequest) => string | undefined | Promise<string | undefined>;
|
|
34
41
|
export type StateConfidence = "authoritative" | "transaction_snapshot" | "database_reported" | "local" | "ttl_based" | "unknown";
|
|
35
42
|
export interface Warning {
|
|
@@ -239,10 +246,12 @@ export interface ConnectOptions extends ExecutionOptions {
|
|
|
239
246
|
readOnly?: boolean;
|
|
240
247
|
secretEnv?: string;
|
|
241
248
|
profile?: string;
|
|
249
|
+
credentialRef?: string;
|
|
242
250
|
}
|
|
243
251
|
export interface ProfileOptions {
|
|
244
252
|
readOnly?: boolean;
|
|
245
253
|
secretEnv?: string;
|
|
254
|
+
credentialRef?: string;
|
|
246
255
|
}
|
|
247
256
|
export interface RowsOptions {
|
|
248
257
|
offset?: number;
|
|
@@ -273,6 +282,7 @@ export interface BatchCommand {
|
|
|
273
282
|
cache?: "auto" | "bypass" | "require";
|
|
274
283
|
read_only?: boolean;
|
|
275
284
|
secret_env?: string;
|
|
285
|
+
credential_ref?: string;
|
|
276
286
|
profile?: string;
|
|
277
287
|
replay?: boolean;
|
|
278
288
|
idempotency_key?: string;
|
|
@@ -311,6 +321,7 @@ export interface ProfileData {
|
|
|
311
321
|
profile: string;
|
|
312
322
|
target: string | null;
|
|
313
323
|
secret_env: string | null;
|
|
324
|
+
credential_ref: string | null;
|
|
314
325
|
read_only: boolean;
|
|
315
326
|
}
|
|
316
327
|
export interface ProfilesData {
|
|
@@ -534,3 +545,4 @@ export interface CloseSessionData {
|
|
|
534
545
|
session_id: string;
|
|
535
546
|
state: string;
|
|
536
547
|
}
|
|
548
|
+
export {};
|