@fadhilp/stateql 0.7.0 → 0.8.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 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`, or
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 targets, read-only policy, and environment-variable
117
- names. Credential values are never stored. Profiles persist under `STQL_HOME`
118
- with other StateQL metadata.
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
 
@@ -407,6 +415,7 @@ const stateql = StateQL.forActor({
407
415
  home: "./.stql",
408
416
  actor: "pi-session-id",
409
417
  timeoutMs: 30_000,
418
+ credentialTimeoutMs: 120_000,
410
419
  maxResultBytes: 16 * 1024 * 1024,
411
420
  maxStateBytes: 256 * 1024 * 1024,
412
421
  });
@@ -464,8 +473,9 @@ for user confirmation before changing membership or the shared connection.
464
473
 
465
474
  ### Harness credential resolution
466
475
 
467
- Library integrations can resolve a profile's credential reference through a
468
- trusted approval or secret-storage layer instead of mutating `process.env`:
476
+ Library integrations can resolve environment-variable names or opaque
477
+ credential references through a trusted approval or secret-storage layer
478
+ instead of mutating `process.env`:
469
479
 
470
480
  ```ts
471
481
  import {
@@ -479,6 +489,7 @@ async function resolveCredential(
479
489
  ): Promise<string | undefined> {
480
490
  const approved = await credentialBroker.request({
481
491
  reference: request.reference,
492
+ source: request.source ?? "secret_env",
482
493
  actor: request.actorId,
483
494
  session: request.session.id,
484
495
  operation: request.operation,
@@ -496,13 +507,20 @@ const stateql = StateQL.forActor({
496
507
  });
497
508
  ```
498
509
 
499
- When no custom resolver is configured, StateQL reads references from
500
- `process.env`. A configured resolver is authoritative: returning `undefined`
501
- produces `CREDENTIAL_UNAVAILABLE` and never falls back to the process
502
- environment. Resolvers may throw `CredentialResolutionError` with `denied`,
503
- `cancelled`, `timeout`, or `unavailable` to produce controlled, secret-free
504
- failures. Unknown resolver errors are replaced with a generic
505
- `CREDENTIAL_RESOLUTION_FAILED` response.
510
+ Credential resolution has its own two-minute default deadline
511
+ (`credentialTimeoutMs`) and remains cancellable through `request.signal`.
512
+ The database-operation timeout begins after a credential is resolved.
513
+
514
+ When no custom resolver is configured, StateQL reads only `secret_env`
515
+ references from `process.env`; `credential_ref` never falls back to the
516
+ environment. A configured resolver is authoritative for both sources: returning
517
+ `undefined` produces `CREDENTIAL_UNAVAILABLE` and never falls back to the
518
+ process environment. Resolver requests include `source` (`secret_env` or
519
+ `credential_ref`) while retaining `reference`; source may be omitted only on
520
+ legacy secret-environment request objects. Resolvers may throw
521
+ `CredentialResolutionError` with `denied`, `cancelled`, `timeout`, or
522
+ `unavailable` to produce controlled, secret-free failures. Unknown resolver
523
+ errors are replaced with a generic `CREDENTIAL_RESOLUTION_FAILED` response.
506
524
 
507
525
  StateQL calls the resolver only immediately before database access, after SQL
508
526
  safety and duplicate checks. Requests contain actor and session identity, the
@@ -9,6 +9,7 @@ export interface WriteResult {
9
9
  }
10
10
  export interface AdapterContext {
11
11
  deadline: number;
12
+ timeoutMs?: number;
12
13
  signal?: AbortSignal;
13
14
  }
14
15
  export declare class AdapterExecutionError extends Error {
@@ -32,6 +32,7 @@ export class AdapterWriteError extends Error {
32
32
  export function createAdapterContext(timeoutMs, signal) {
33
33
  return {
34
34
  deadline: Date.now() + timeoutMs,
35
+ timeoutMs,
35
36
  ...(signal ? { signal } : {}),
36
37
  };
37
38
  }
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, disconnect, status
580
- profile add|list|show|remove
581
- session start|list|show|summary|close
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.`;
@@ -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;
@@ -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", "Secret environment variable must contain a complete PostgreSQL/MySQL URL or an explicit sqlite: source; MongoDB URLs are also supported.", {
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", "Secret environment variable must contain a valid database URL.");
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
+ }
@@ -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";
@@ -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(target IS NOT NULL OR secret_env IS NOT NULL)
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)) {
@@ -13,6 +13,7 @@ export function profileData(profile) {
13
13
  profile: profile.name,
14
14
  target: profile.target,
15
15
  secret_env: profile.secret_env,
16
+ credential_ref: profile.credential_ref,
16
17
  read_only: Boolean(profile.read_only),
17
18
  };
18
19
  }
@@ -11,6 +11,7 @@ export declare class StateQL {
11
11
  private readonly maxResultRows;
12
12
  private readonly maxResultBytes;
13
13
  private readonly timeoutMs;
14
+ private readonly credentialTimeoutMs;
14
15
  private readonly signal?;
15
16
  private readonly commandContexts;
16
17
  private readonly credentialResolver?;
@@ -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";
@@ -13,6 +13,7 @@ import { StateStore, } from "./store.js";
13
13
  import { compactRows, defaultHome, hash, isSqlParameters, parseJson, redact, } from "./util.js";
14
14
  const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
15
15
  const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
16
+ const DEFAULT_CREDENTIAL_RESOLUTION_TIMEOUT_MS = 120_000;
16
17
  export class StateQL {
17
18
  static forActor(options) {
18
19
  if (!options.actor.trim()) {
@@ -41,6 +42,7 @@ export class StateQL {
41
42
  maxResultRows;
42
43
  maxResultBytes;
43
44
  timeoutMs;
45
+ credentialTimeoutMs;
44
46
  signal;
45
47
  commandContexts = new AsyncLocalStorage();
46
48
  credentialResolver;
@@ -60,6 +62,7 @@ export class StateQL {
60
62
  this.maxResultRows = positiveInteger(options.maxResultRows ?? 10_000, "maxResultRows");
61
63
  this.maxResultBytes = positiveInteger(options.maxResultBytes ?? 16 * 1024 * 1024, "maxResultBytes");
62
64
  this.timeoutMs = executionTimeout(options.timeoutMs ?? 30_000);
65
+ this.credentialTimeoutMs = executionTimeout(options.credentialTimeoutMs ?? DEFAULT_CREDENTIAL_RESOLUTION_TIMEOUT_MS, "credentialTimeoutMs");
63
66
  const maxStateBytes = positiveInteger(options.maxStateBytes ?? 256 * 1024 * 1024, "maxStateBytes");
64
67
  this.signal = options.signal;
65
68
  this.credentialResolver = options.credentialResolver;
@@ -90,12 +93,16 @@ export class StateQL {
90
93
  if (session.active_transaction_id) {
91
94
  throw new StateQLError("TRANSACTION_FAILED", "Commit or roll back the active transaction before connecting again.");
92
95
  }
93
- const sourceCount = [target, options.profile, options.secretEnv]
94
- .filter((value) => value !== undefined).length;
95
- if (sourceCount > 1) {
96
- throw new StateQLError("INVALID_COMMAND", "Use exactly one connection target, profile, or secret environment variable.");
96
+ const sourceCount = [
97
+ target,
98
+ options.profile,
99
+ options.secretEnv,
100
+ options.credentialRef,
101
+ ].filter((value) => value !== undefined).length;
102
+ if (sourceCount !== 1) {
103
+ throw new StateQLError("INVALID_COMMAND", "Use exactly one connection target, profile, secret environment variable, or credential reference.");
97
104
  }
98
- const implicitProfile = !options.profile && !options.secretEnv && target
105
+ const implicitProfile = !options.profile && !options.secretEnv && !options.credentialRef && target
99
106
  ? this.store.getProfile(target)
100
107
  : undefined;
101
108
  const profile = options.profile
@@ -104,16 +111,28 @@ export class StateQL {
104
111
  if (options.profile && !profile) {
105
112
  throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${options.profile}" was not found.`, { suggestedAction: "Run stql profile list." });
106
113
  }
114
+ if (profile &&
115
+ [profile.target, profile.secret_env, profile.credential_ref]
116
+ .filter((value) => value !== null).length !== 1) {
117
+ throw new StateQLError("STATE_CORRUPTED", `Profile "${profile.name}" does not have exactly one connection source.`);
118
+ }
107
119
  const resolvedTarget = profile?.target ?? target;
108
120
  const secretEnv = options.secretEnv ?? profile?.secret_env ?? undefined;
109
- if (secretEnv && !isEnvironmentName(secretEnv)) {
121
+ const credentialRef = options.credentialRef ?? profile?.credential_ref ?? undefined;
122
+ if (secretEnv !== undefined && !isEnvironmentName(secretEnv)) {
110
123
  throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
111
124
  }
125
+ if (credentialRef !== undefined)
126
+ validateCredentialRef(credentialRef);
127
+ const credentialReference = secretEnv ?? credentialRef;
128
+ const credentialReferenceSource = secretEnv !== undefined
129
+ ? "secret_env"
130
+ : credentialRef !== undefined ? "credential_ref" : undefined;
112
131
  const readOnly = options.readOnly ??
113
132
  (profile ? Boolean(profile.read_only) : true);
114
133
  const context = this.executionContext(options);
115
- const secret = secretEnv
116
- ? await this.resolveCredential(secretEnv, session, "connect", readOnly ? "read" : "write", context, {
134
+ const secret = credentialReference && credentialReferenceSource
135
+ ? await this.resolveCredential(credentialReference, credentialReferenceSource, session, "connect", readOnly ? "read" : "write", context, {
117
136
  ...(profile ? { profile: { name: profile.name } } : {}),
118
137
  requestedReadOnly: readOnly,
119
138
  })
@@ -121,23 +140,23 @@ export class StateQL {
121
140
  if (!secret) {
122
141
  throw new StateQLError("INVALID_COMMAND", "Connection target is required.");
123
142
  }
124
- const resolvedSource = secretEnv
125
- ? credentialSource(secret)
143
+ const resolvedSource = credentialReferenceSource
144
+ ? credentialSource(secret, undefined, credentialReferenceSource)
126
145
  : { driver: detectDriver(secret), source: secret };
127
146
  const { driver } = resolvedSource;
128
147
  if (driver !== "sqlite" &&
129
- !secretEnv &&
148
+ !credentialReference &&
130
149
  databaseUrlHasSecret(secret)) {
131
- throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`, {
132
- suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
150
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`, {
151
+ suggestedAction: "Use an environment variable or trusted host credential reference.",
133
152
  });
134
153
  }
135
- const adapterSource = secretEnv
154
+ const adapterSource = credentialReferenceSource
136
155
  ? resolvedSource.source
137
156
  : driver === "sqlite" ? normalizeSqliteSource(secret) : secret;
138
157
  const source = driver === "sqlite"
139
158
  ? adapterSource
140
- : secretEnv
159
+ : credentialReferenceSource
141
160
  ? redact(secret)
142
161
  : adapterSource;
143
162
  const databaseName = driver === "sqlite"
@@ -153,6 +172,7 @@ export class StateQL {
153
172
  database_name: databaseName,
154
173
  source,
155
174
  secret_env: secretEnv ?? null,
175
+ credential_ref: credentialRef ?? null,
156
176
  read_only: readOnly ? 1 : 0,
157
177
  version: 0,
158
178
  created_at: this.now().toISOString(),
@@ -180,6 +200,7 @@ export class StateQL {
180
200
  databaseName,
181
201
  source,
182
202
  ...(secretEnv ? { secretEnv } : {}),
203
+ ...(credentialRef ? { credentialRef } : {}),
183
204
  readOnly,
184
205
  });
185
206
  if (!connection) {
@@ -206,20 +227,25 @@ export class StateQL {
206
227
  async addProfile(name, target, options = {}) {
207
228
  return this.run("profile.add", async () => {
208
229
  validateProfileName(name);
209
- if (Boolean(target) === Boolean(options.secretEnv)) {
210
- throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target or secret environment variable.");
230
+ const sourceCount = [target, options.secretEnv, options.credentialRef]
231
+ .filter((value) => value !== undefined).length;
232
+ if (sourceCount !== 1 || target === "") {
233
+ throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target, secret environment variable, or credential reference.");
211
234
  }
212
235
  if (this.store.getProfile(name)) {
213
236
  throw new StateQLError("INVALID_COMMAND", `Profile "${name}" already exists.`);
214
237
  }
215
- if (options.secretEnv && !isEnvironmentName(options.secretEnv)) {
238
+ if (options.secretEnv !== undefined && !isEnvironmentName(options.secretEnv)) {
216
239
  throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
217
240
  }
241
+ if (options.credentialRef !== undefined) {
242
+ validateCredentialRef(options.credentialRef);
243
+ }
218
244
  let storedTarget = target;
219
245
  if (target) {
220
246
  const driver = detectDriver(target);
221
247
  if (driver !== "sqlite" && databaseUrlHasSecret(target)) {
222
- throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`);
248
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`);
223
249
  }
224
250
  if (driver === "sqlite")
225
251
  storedTarget = normalizeSqliteSource(target);
@@ -228,6 +254,7 @@ export class StateQL {
228
254
  name,
229
255
  target: storedTarget,
230
256
  secretEnv: options.secretEnv,
257
+ credentialRef: options.credentialRef,
231
258
  readOnly: options.readOnly ?? true,
232
259
  });
233
260
  return {
@@ -1387,6 +1414,7 @@ export class StateQL {
1387
1414
  name: command.name,
1388
1415
  readOnly: command.read_only,
1389
1416
  secretEnv: command.secret_env,
1417
+ credentialRef: command.credential_ref,
1390
1418
  profile: command.profile,
1391
1419
  timeoutMs: command.timeout_ms,
1392
1420
  });
@@ -1398,6 +1426,7 @@ export class StateQL {
1398
1426
  return this.addProfile(batchString(command.name, "name"), command.target, {
1399
1427
  readOnly: command.read_only ?? true,
1400
1428
  secretEnv: command.secret_env,
1429
+ credentialRef: command.credential_ref,
1401
1430
  });
1402
1431
  case "profile.list":
1403
1432
  return this.listProfiles();
@@ -2022,9 +2051,13 @@ export class StateQL {
2022
2051
  throw new StateQLError("PERMISSION_DENIED", `Actor "${this.actorId}" is not attached to session "${session.name}".`);
2023
2052
  }
2024
2053
  async resolveConnectionSource(connection, session, operation, access, context) {
2025
- if (!connection.secret_env)
2054
+ const reference = connection.secret_env ?? connection.credential_ref;
2055
+ if (!reference)
2026
2056
  return connection.source;
2027
- const value = await this.resolveCredential(connection.secret_env, session, operation, access, context, {
2057
+ const source = connection.secret_env
2058
+ ? "secret_env"
2059
+ : "credential_ref";
2060
+ const value = await this.resolveCredential(reference, source, session, operation, access, context, {
2028
2061
  connection: {
2029
2062
  id: connection.id,
2030
2063
  name: connection.name,
@@ -2033,40 +2066,45 @@ export class StateQL {
2033
2066
  readOnly: Boolean(connection.read_only),
2034
2067
  },
2035
2068
  });
2036
- return credentialSource(value, connection.driver).source;
2069
+ return credentialSource(value, connection.driver, source).source;
2037
2070
  }
2038
- async resolveCredential(reference, session, operation, access, context, details = {}) {
2071
+ async resolveCredential(reference, source, session, operation, access, context, details = {}) {
2039
2072
  const resolver = this.credentialResolver;
2073
+ const credentialContext = createAdapterContext(this.credentialTimeoutMs, context.signal);
2074
+ let value;
2040
2075
  if (!resolver) {
2041
- if (context.signal?.aborted) {
2076
+ if (credentialContext.signal?.aborted) {
2042
2077
  throw credentialStateQLError(reference, new CredentialResolutionError("cancelled"));
2043
2078
  }
2044
- if (context.deadline <= Date.now()) {
2079
+ if (credentialContext.deadline <= Date.now()) {
2045
2080
  throw credentialStateQLError(reference, new CredentialResolutionError("timeout"));
2046
2081
  }
2047
- const value = env[reference];
2048
- if (value)
2049
- return value;
2050
- throw credentialStateQLError(reference, new CredentialResolutionError("unavailable"));
2082
+ if (source === "secret_env")
2083
+ value = env[reference];
2051
2084
  }
2052
- const request = {
2053
- reference,
2054
- actorId: this.actorId,
2055
- session: { id: session.id, name: session.name },
2056
- operation,
2057
- access,
2058
- ...(context.signal ? { signal: context.signal } : {}),
2059
- ...details,
2060
- };
2061
- try {
2062
- const value = await resolveCredentialBeforeDeadline(resolver, request, context);
2063
- if (!value)
2064
- throw new CredentialResolutionError("unavailable");
2065
- return value;
2085
+ else {
2086
+ const request = {
2087
+ reference,
2088
+ source,
2089
+ actorId: this.actorId,
2090
+ session: { id: session.id, name: session.name },
2091
+ operation,
2092
+ access,
2093
+ ...(context.signal ? { signal: context.signal } : {}),
2094
+ ...details,
2095
+ };
2096
+ try {
2097
+ value = await resolveCredentialBeforeDeadline(resolver, request, credentialContext);
2098
+ }
2099
+ catch (error) {
2100
+ throw credentialStateQLError(reference, error);
2101
+ }
2066
2102
  }
2067
- catch (error) {
2068
- throw credentialStateQLError(reference, error);
2103
+ if (!value) {
2104
+ throw credentialStateQLError(reference, new CredentialResolutionError("unavailable"));
2069
2105
  }
2106
+ context.deadline = Date.now() + (context.timeoutMs ?? this.timeoutMs);
2107
+ return value;
2070
2108
  }
2071
2109
  async openAdapter(connection, context, source) {
2072
2110
  try {
@@ -2402,19 +2440,19 @@ function credentialStateQLError(reference, error) {
2402
2440
  case "cancelled":
2403
2441
  return new StateQLError("OPERATION_CANCELLED", "Credential resolution was cancelled.", { retryable: true });
2404
2442
  case "timeout":
2405
- return new StateQLError("DEADLINE_EXCEEDED", "Credential resolution exceeded the operation deadline.", { retryable: true });
2443
+ return new StateQLError("DEADLINE_EXCEEDED", "Credential resolution exceeded the credential deadline.", { retryable: true });
2406
2444
  }
2407
2445
  }
2408
2446
  return new StateQLError("CREDENTIAL_RESOLUTION_FAILED", `Credential reference "${reference}" could not be resolved.`, { retryable: true });
2409
2447
  }
2410
2448
  function safeCredentialErrorMessage(error, source) {
2411
2449
  return redact(errorMessage(error).split(source).join("[credential redacted]"))
2412
- .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/giu, "$1***@");
2450
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s\/@]+(?::[^\s\/@]*)?@/giu, "$1***@");
2413
2451
  }
2414
- function executionTimeout(value) {
2415
- const timeout = positiveInteger(value, "timeoutMs");
2452
+ function executionTimeout(value, name = "timeoutMs") {
2453
+ const timeout = positiveInteger(value, name);
2416
2454
  if (timeout > 2_147_483_647) {
2417
- throw new StateQLError("INVALID_COMMAND", "timeoutMs cannot exceed 2147483647 milliseconds.");
2455
+ throw new StateQLError("INVALID_COMMAND", `${name} cannot exceed 2147483647 milliseconds.`);
2418
2456
  }
2419
2457
  return timeout;
2420
2458
  }
@@ -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);
@@ -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 interface CredentialRequest {
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 {
@@ -206,6 +213,8 @@ export interface StateQLOptions extends ExecutionOptions {
206
213
  maxResultBytes?: number;
207
214
  maxStateBytes?: number;
208
215
  credentialResolver?: CredentialResolver;
216
+ /** Maximum time allowed for one credential resolution; defaults to two minutes. */
217
+ credentialTimeoutMs?: number;
209
218
  now?: () => Date;
210
219
  }
211
220
  export type StateQLActorOptions = Omit<StateQLOptions, "session" | "actor"> & {
@@ -239,10 +248,12 @@ export interface ConnectOptions extends ExecutionOptions {
239
248
  readOnly?: boolean;
240
249
  secretEnv?: string;
241
250
  profile?: string;
251
+ credentialRef?: string;
242
252
  }
243
253
  export interface ProfileOptions {
244
254
  readOnly?: boolean;
245
255
  secretEnv?: string;
256
+ credentialRef?: string;
246
257
  }
247
258
  export interface RowsOptions {
248
259
  offset?: number;
@@ -273,6 +284,7 @@ export interface BatchCommand {
273
284
  cache?: "auto" | "bypass" | "require";
274
285
  read_only?: boolean;
275
286
  secret_env?: string;
287
+ credential_ref?: string;
276
288
  profile?: string;
277
289
  replay?: boolean;
278
290
  idempotency_key?: string;
@@ -311,6 +323,7 @@ export interface ProfileData {
311
323
  profile: string;
312
324
  target: string | null;
313
325
  secret_env: string | null;
326
+ credential_ref: string | null;
314
327
  read_only: boolean;
315
328
  }
316
329
  export interface ProfilesData {
@@ -534,3 +547,4 @@ export interface CloseSessionData {
534
547
  session_id: string;
535
548
  state: string;
536
549
  }
550
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",