@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 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
 
@@ -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 a profile's credential reference through a
468
- trusted approval or secret-storage layer instead of mutating `process.env`:
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 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.
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, 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
  }
@@ -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 = [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.");
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
- if (secretEnv && !isEnvironmentName(secretEnv)) {
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 = secretEnv
116
- ? await this.resolveCredential(secretEnv, session, "connect", readOnly ? "read" : "write", context, {
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 = secretEnv
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
- !secretEnv &&
145
+ !credentialReference &&
130
146
  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.",
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 = secretEnv
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
- : secretEnv
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
- if (Boolean(target) === Boolean(options.secretEnv)) {
210
- throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target or secret environment variable.");
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
- if (!connection.secret_env)
2051
+ const reference = connection.secret_env ?? connection.credential_ref;
2052
+ if (!reference)
2026
2053
  return connection.source;
2027
- const value = await this.resolveCredential(connection.secret_env, session, operation, access, context, {
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
- const value = env[reference];
2048
- if (value)
2049
- return value;
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,
@@ -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 {
@@ -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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",