@fadhilp/stateql 0.10.0 → 0.11.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 +111 -49
- package/dist/src/connection.d.ts +7 -1
- package/dist/src/connection.js +117 -12
- package/dist/src/migrations.js +131 -0
- package/dist/src/response-data.js +1 -0
- package/dist/src/stateql.d.ts +2 -0
- package/dist/src/stateql.js +158 -56
- package/dist/src/store.d.ts +10 -1
- package/dist/src/store.js +118 -66
- package/dist/src/types.d.ts +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ reusable and operations traceable across commands.
|
|
|
7
7
|
|
|
8
8
|
StateQL is built around durable handles:
|
|
9
9
|
|
|
10
|
-
1. Run a query and receive a result handle such as `
|
|
10
|
+
1. Run a query and receive a result handle such as `q_k7m2v5x9c3d6f8h4j2n7p5r9tw`.
|
|
11
11
|
2. Reuse, filter, page, count, alias, or export that stored result without
|
|
12
12
|
rerunning the original SQL.
|
|
13
13
|
3. Use operation, plan, and transaction handles to inspect and control writes.
|
|
@@ -40,25 +40,25 @@ Parameters keep values separate from SQL. `ORDER BY` makes paging stable, and
|
|
|
40
40
|
one-line JSON:
|
|
41
41
|
|
|
42
42
|
```json
|
|
43
|
-
{"ok":true,"handle":"
|
|
43
|
+
{"ok":true,"handle":"q_k7m2v5x9c3d6f8h4j2n7p5r9tw","rows":[{"id":7,"name":"Ada","email":"ada@example.com"},{"id":12,"name":"Grace","email":"grace@example.com"},{"id":18,"name":"Linus","email":"linus@kernel.org"}],"truncated":false,"cached":false,"total":3,"next_offset":null}
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
`
|
|
46
|
+
`q_k7m2v5x9c3d6f8h4j2n7p5r9tw` is a durable snapshot. Filter it locally without accessing the original
|
|
47
47
|
database:
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
stql filter
|
|
50
|
+
stql filter q_k7m2v5x9c3d6f8h4j2n7p5r9tw "email LIKE ?" --param "%@example.com"
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
```json
|
|
54
|
-
{"ok":true,"handle":"
|
|
54
|
+
{"ok":true,"handle":"q_z4n8c2v6b3m7k5j9h2g4f6d8sa","rows":[{"id":7,"name":"Ada","email":"ada@example.com"},{"id":12,"name":"Grace","email":"grace@example.com"}],"truncated":false,"cached":false,"total":2,"next_offset":null}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
The filtered snapshot receives its own handle. Give it a readable alias, page
|
|
58
58
|
through it, inspect its count, or export it without rerunning SQL:
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
|
-
stql alias set example-users
|
|
61
|
+
stql alias set example-users q_z4n8c2v6b3m7k5j9h2g4f6d8sa
|
|
62
62
|
stql rows example-users --offset 0 --limit 1
|
|
63
63
|
stql rows example-users --offset 1 --limit 1
|
|
64
64
|
stql count example-users
|
|
@@ -68,16 +68,18 @@ stql export example-users --output example-users.csv --format csv
|
|
|
68
68
|
Example first page:
|
|
69
69
|
|
|
70
70
|
```json
|
|
71
|
-
{"ok":true,"handle":"
|
|
71
|
+
{"ok":true,"handle":"q_z4n8c2v6b3m7k5j9h2g4f6d8sa","rows":[{"id":7,"name":"Ada","email":"ada@example.com"}],"total":2,"truncated":true,"next_offset":1}
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
Running the same normalized query with the same parameters reuses `
|
|
74
|
+
Running the same normalized query with the same parameters reuses `q_k7m2v5x9c3d6f8h4j2n7p5r9tw` while
|
|
75
75
|
its cache entry is valid. Use `--cache bypass` when a fresh read is required.
|
|
76
76
|
|
|
77
77
|
## Connections and profiles
|
|
78
78
|
|
|
79
79
|
A connection accepts exactly one source: a direct target, `--env`,
|
|
80
|
-
`--credential-ref`, or `--profile`.
|
|
80
|
+
`--credential-ref`, or `--profile`. Library and batch callers may additionally
|
|
81
|
+
attach `passwordRef`/`password_ref` to a literal password-free remote target;
|
|
82
|
+
it is not a fourth source.
|
|
81
83
|
|
|
82
84
|
```bash
|
|
83
85
|
stql connect <sqlite-path|postgres-url|mysql-url|mongodb-url> [--name NAME] [--read-write]
|
|
@@ -108,16 +110,18 @@ export SQLITE_DATABASE='sqlite:./app.sqlite'
|
|
|
108
110
|
stql connect --env SQLITE_DATABASE --name local --read-only
|
|
109
111
|
```
|
|
110
112
|
|
|
111
|
-
StateQL stores no PostgreSQL, MySQL, or
|
|
112
|
-
URLs must be supplied through `--env
|
|
113
|
-
connection metadata.
|
|
113
|
+
StateQL stores no PostgreSQL, MySQL, MongoDB, or Redis password.
|
|
114
|
+
Credential-bearing URLs must be supplied through `--env` or an opaque full-URL
|
|
115
|
+
credential reference. SQLite paths remain persisted as connection metadata.
|
|
114
116
|
|
|
115
117
|
### Local profiles
|
|
116
118
|
|
|
117
119
|
Profiles store exactly one connection target, environment-variable name, or
|
|
118
|
-
opaque credential reference together with read-only policy.
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
opaque credential reference together with read-only policy. A remote literal
|
|
121
|
+
target may additionally store a `password_ref`; SQLite, environment-backed, and
|
|
122
|
+
full-URL `credential_ref` profiles cannot. Credential values are never stored.
|
|
123
|
+
Profiles persist under `STQL_HOME` with other StateQL metadata, and list/show
|
|
124
|
+
responses include nullable `credential_ref` and `password_ref` fields.
|
|
121
125
|
|
|
122
126
|
```bash
|
|
123
127
|
stql profile add local ./app.sqlite --read-write
|
|
@@ -134,6 +138,21 @@ apply environment-variable syntax or normalization to them. They can only be
|
|
|
134
138
|
resolved by a trusted host `CredentialResolver`, so the standalone CLI may
|
|
135
139
|
store them in profiles but cannot connect with them.
|
|
136
140
|
|
|
141
|
+
Library callers can keep nonsecret endpoint, username, database, TLS, and CA
|
|
142
|
+
options in the literal URL while resolving only its password:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
await stateql.connect(
|
|
146
|
+
"postgres://app@db.example/app?sslmode=verify-full&sslrootcert=/etc/app-ca.pem",
|
|
147
|
+
{ passwordRef: "vault://database/app/password", readOnly: true },
|
|
148
|
+
);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The same field is accepted by `addProfile`, `updateProfile`, and batch
|
|
152
|
+
`connect`/`profile.add`/`profile.update` commands (snake case in batch input).
|
|
153
|
+
Targets with an embedded password or query parameters that override endpoint or
|
|
154
|
+
credential fields are rejected before credential resolution or driver access.
|
|
155
|
+
|
|
137
156
|
A bare connection target matching a profile name resolves to that profile;
|
|
138
157
|
otherwise it remains a path or database URL.
|
|
139
158
|
|
|
@@ -473,9 +492,21 @@ for user confirmation before changing membership or the shared connection.
|
|
|
473
492
|
|
|
474
493
|
### Harness credential resolution
|
|
475
494
|
|
|
476
|
-
Library integrations can resolve environment-variable names
|
|
477
|
-
credential references through a trusted approval
|
|
478
|
-
instead of mutating `process.env`:
|
|
495
|
+
Library integrations can resolve environment-variable names, opaque full-URL
|
|
496
|
+
credential references, or password-only references through a trusted approval
|
|
497
|
+
or secret-storage layer instead of mutating `process.env`:
|
|
498
|
+
|
|
499
|
+
Integrations pinned to an older published package should gate setup before
|
|
500
|
+
sending `password_ref`:
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
if ((StateQL.passwordReferenceVersion ?? 0) < 1) {
|
|
504
|
+
throw new Error("Installed StateQL does not support password references.");
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
`passwordReferenceVersion = 1` guarantees the password-only resolver request,
|
|
509
|
+
validation, persistence, reconnect, and redaction contract documented below.
|
|
479
510
|
|
|
480
511
|
```ts
|
|
481
512
|
import {
|
|
@@ -512,27 +543,31 @@ Credential resolution has its own two-minute default deadline
|
|
|
512
543
|
The database-operation timeout begins after a credential is resolved.
|
|
513
544
|
|
|
514
545
|
When no custom resolver is configured, StateQL reads only `secret_env`
|
|
515
|
-
references from `process.env`; `credential_ref`
|
|
516
|
-
environment. A configured resolver is authoritative for
|
|
517
|
-
`undefined` produces `CREDENTIAL_UNAVAILABLE` and never falls
|
|
518
|
-
process environment. Resolver requests
|
|
519
|
-
`
|
|
520
|
-
legacy secret-environment request objects.
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
546
|
+
references from `process.env`; `credential_ref` and `password_ref` never fall
|
|
547
|
+
back to the environment. A configured resolver is authoritative for all
|
|
548
|
+
sources: returning `undefined` produces `CREDENTIAL_UNAVAILABLE` and never falls
|
|
549
|
+
back to the process environment. Resolver requests retain `reference` and
|
|
550
|
+
include `source` (`secret_env`, `credential_ref`, or `password_ref`); source may
|
|
551
|
+
be omitted only on legacy secret-environment request objects. A `password_ref`
|
|
552
|
+
request additionally includes the exact password-free effective `target`.
|
|
553
|
+
Resolvers may throw `CredentialResolutionError` with `denied`, `cancelled`,
|
|
554
|
+
`timeout`, or `unavailable` to produce controlled, secret-free failures. Unknown
|
|
555
|
+
resolver errors are replaced with a generic `CREDENTIAL_RESOLUTION_FAILED`
|
|
556
|
+
response.
|
|
524
557
|
|
|
525
558
|
StateQL calls the resolver only immediately before database access, after SQL
|
|
526
559
|
safety and duplicate checks. Requests contain actor and session identity, the
|
|
527
560
|
operation's effective read/write access, an abort signal, and sanitized
|
|
528
561
|
connection metadata.
|
|
529
562
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
563
|
+
For `secret_env` and `credential_ref`, returned values must be complete
|
|
564
|
+
PostgreSQL, MySQL, MongoDB, or Redis URLs, or explicit `sqlite:` sources. For
|
|
565
|
+
`password_ref`, the resolver returns only the password; an explicit empty string
|
|
566
|
+
is a resolved password, while `undefined` fails closed. StateQL percent-encodes
|
|
567
|
+
and injects only that password into the original target for adapter use, leaving
|
|
568
|
+
all nonsecret URL/TLS/CA bytes unchanged. It persists only the original target
|
|
569
|
+
and reference. Resolved credentials never enter connection metadata, history,
|
|
570
|
+
snapshots, cache keys, responses, or stored errors.
|
|
536
571
|
|
|
537
572
|
Harnesses remain responsible for approval policy, binding lifetime, revocation,
|
|
538
573
|
and keeping values out of their own logs and model-visible data.
|
|
@@ -547,13 +582,26 @@ retry.
|
|
|
547
582
|
### Result identities and aliases
|
|
548
583
|
|
|
549
584
|
Every materialized SQL, MongoDB, Redis, table, or derived result keeps its
|
|
550
|
-
immutable `q_*` `result_id
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
585
|
+
immutable canonical `q_*` `result_id`. New canonical resource IDs use a
|
|
586
|
+
cryptographically random 26-character lowercase base32 suffix; existing
|
|
587
|
+
incremental IDs such as `q_121` remain valid and are not rewritten. Results also
|
|
588
|
+
receive a random 10-character lowercase base32 `display_alias`.
|
|
589
|
+
`ResultData.alias` normally equals that alias. When a batch command supplies
|
|
590
|
+
`as`, `alias` remains the caller alias for backward compatibility while
|
|
591
|
+
`display_alias` remains canonical. Generated aliases are session-scoped,
|
|
592
|
+
allocated atomically with the result, stable on cache reuse, and cannot be
|
|
593
|
+
reassigned by `setAlias`; explicit aliases and all old handles continue to
|
|
594
|
+
resolve.
|
|
595
|
+
|
|
596
|
+
Connections likewise retain canonical `conn_*` IDs with random 26-character
|
|
597
|
+
suffixes and receive persistent random 10-character lowercase base32 aliases,
|
|
598
|
+
exposed as `alias` and `display_alias` by `connect()` and
|
|
599
|
+
`snapshot().connection` (optional in snapshot types for older producers).
|
|
600
|
+
Connection aliases are unique within the state store, allocated atomically with
|
|
601
|
+
the connection, and backfilled for existing records on startup. They survive
|
|
602
|
+
reopening; reconnecting creates a new ID and alias. They are display identities
|
|
603
|
+
only, separate from result aliases; internal references and lookups continue to
|
|
604
|
+
use canonical connection IDs.
|
|
557
605
|
|
|
558
606
|
### Safe profile updates
|
|
559
607
|
|
|
@@ -562,19 +610,33 @@ updateProfile(name, {
|
|
|
562
610
|
target?: string | null,
|
|
563
611
|
secretEnv?: string | null,
|
|
564
612
|
credentialRef?: string | null,
|
|
613
|
+
passwordRef?: string | null,
|
|
565
614
|
readOnly?: boolean,
|
|
566
615
|
})
|
|
567
616
|
```
|
|
568
617
|
|
|
569
|
-
Omitting all source fields keeps the existing source
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
a
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
618
|
+
Omitting all source and password-reference fields keeps the existing source and
|
|
619
|
+
adjunct reference. Supplying any source field replaces the source atomically:
|
|
620
|
+
exactly one non-null source is required and the other source columns are
|
|
621
|
+
cleared. An omitted `passwordRef` is preserved when the existing literal target
|
|
622
|
+
is unchanged; changing the target clears it unless the update explicitly supplies
|
|
623
|
+
a replacement. Replacing the source with `secretEnv` or `credentialRef` clears
|
|
624
|
+
it. An explicit non-null password reference combined with either
|
|
625
|
+
reference-backed source is rejected. Direct URLs
|
|
626
|
+
with embedded passwords or secret-like query parameters are rejected.
|
|
627
|
+
`profile.list/show/update` return only
|
|
628
|
+
`{profile,target,secret_env,credential_ref,password_ref,read_only}`. Profile
|
|
629
|
+
changes affect subsequent `connect` calls and do not silently mutate an
|
|
630
|
+
already-open connection.
|
|
631
|
+
|
|
632
|
+
The `password_refs_v1` migration adds nullable `password_ref` columns to both
|
|
633
|
+
profiles and connections and enforces that they accompany only literal target
|
|
634
|
+
configuration. It rejects incompatible profile schemas/rows instead of dropping
|
|
635
|
+
references. Downgrading a state home containing password references is
|
|
636
|
+
unsupported: older binaries do not resolve this source and may attempt the
|
|
637
|
+
password-free target using ambient/trust authentication; constraint-protected
|
|
638
|
+
source replacements may also fail. Use the same or newer StateQL binary, or
|
|
639
|
+
explicitly clear all password references before downgrade.
|
|
578
640
|
|
|
579
641
|
### Bounded catalog
|
|
580
642
|
|
package/dist/src/connection.d.ts
CHANGED
|
@@ -4,12 +4,18 @@ 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
6
|
export declare function redisDatabaseName(target: string): string;
|
|
7
|
-
export declare function credentialSource(value: string, expectedDriver?: Driver, referenceSource?: CredentialSource): {
|
|
7
|
+
export declare function credentialSource(value: string, expectedDriver?: Driver, referenceSource?: Exclude<CredentialSource, "password_ref">): {
|
|
8
8
|
driver: Driver;
|
|
9
9
|
source: string;
|
|
10
10
|
};
|
|
11
11
|
export declare function normalizeSqliteSource(target: string): string;
|
|
12
12
|
export declare function databaseUrlHasSecret(target: string): boolean;
|
|
13
|
+
export declare function validatePasswordReferenceTarget(target: string): Driver;
|
|
14
|
+
/** Injects only password userinfo while retaining every nonsecret target byte. */
|
|
15
|
+
export declare function injectPassword(target: string, password: string): {
|
|
16
|
+
driver: Driver;
|
|
17
|
+
source: string;
|
|
18
|
+
};
|
|
13
19
|
export declare function version(connection: ConnectionRecord): string;
|
|
14
20
|
export declare function confidence(connection: ConnectionRecord): StateConfidence;
|
|
15
21
|
export declare function validateProfileName(name: string): void;
|
package/dist/src/connection.js
CHANGED
|
@@ -7,6 +7,7 @@ export function databaseIdentity(connection) {
|
|
|
7
7
|
source: connection.source,
|
|
8
8
|
secretEnvironment: connection.secret_env,
|
|
9
9
|
credentialReference: connection.credential_ref,
|
|
10
|
+
passwordReference: connection.password_ref,
|
|
10
11
|
};
|
|
11
12
|
}
|
|
12
13
|
export function detectDriver(target) {
|
|
@@ -24,22 +25,26 @@ export function detectDriver(target) {
|
|
|
24
25
|
return "sqlite";
|
|
25
26
|
}
|
|
26
27
|
export function mongoDatabaseName(target) {
|
|
27
|
-
let
|
|
28
|
+
let pathname;
|
|
28
29
|
try {
|
|
29
|
-
url = new URL(target);
|
|
30
|
+
const url = new URL(target);
|
|
31
|
+
if (!["mongodb:", "mongodb+srv:"].includes(url.protocol.toLowerCase()) || !url.hostname)
|
|
32
|
+
throw new Error();
|
|
33
|
+
pathname = url.pathname;
|
|
30
34
|
}
|
|
31
35
|
catch {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
const match = /^mongodb(?:\+srv)?:\/\/([^/?#]+)(\/[^?#]*)?(?:[?#]|$)/i.exec(target);
|
|
37
|
+
if (!match)
|
|
38
|
+
throw new StateQLError("INVALID_COMMAND", "Invalid MongoDB URL.");
|
|
39
|
+
const authority = match[1];
|
|
40
|
+
const at = authority.lastIndexOf("@");
|
|
41
|
+
validateMongoHosts(authority.slice(at + 1), /^mongodb\+srv:/i.test(target));
|
|
42
|
+
pathname = match[2] ?? "";
|
|
37
43
|
}
|
|
38
44
|
try {
|
|
39
|
-
const database = decodeURIComponent(
|
|
40
|
-
if (database && !database.includes("/") && !database.includes("\0"))
|
|
45
|
+
const database = decodeURIComponent(pathname.replace(/^\//, ""));
|
|
46
|
+
if (database && !database.includes("/") && !database.includes("\0"))
|
|
41
47
|
return database;
|
|
42
|
-
}
|
|
43
48
|
}
|
|
44
49
|
catch {
|
|
45
50
|
// Report malformed escaping as an invalid explicit database name.
|
|
@@ -98,8 +103,15 @@ export function normalizeSqliteSource(target) {
|
|
|
98
103
|
}
|
|
99
104
|
export function databaseUrlHasSecret(target) {
|
|
100
105
|
try {
|
|
101
|
-
const
|
|
102
|
-
|
|
106
|
+
const match = /^([a-z][a-z\d+.-]*:\/\/)([^/?#]*)([\s\S]*)$/i.exec(target);
|
|
107
|
+
if (!match)
|
|
108
|
+
throw new Error();
|
|
109
|
+
const authority = match[2];
|
|
110
|
+
const at = authority.lastIndexOf("@");
|
|
111
|
+
const url = /^mongodb(?:\+srv)?:\/\//i.test(target)
|
|
112
|
+
? new URL(`http://placeholder${match[3]}`)
|
|
113
|
+
: new URL(target);
|
|
114
|
+
return ((at >= 0 && authority.slice(0, at).includes(":")) ||
|
|
103
115
|
Boolean(url.password) ||
|
|
104
116
|
[...url.searchParams.keys()].some((key) => /pass|token|secret|private[_-]?key|api[_-]?key/i.test(key)));
|
|
105
117
|
}
|
|
@@ -107,6 +119,99 @@ export function databaseUrlHasSecret(target) {
|
|
|
107
119
|
throw new StateQLError("INVALID_COMMAND", "Invalid database URL.");
|
|
108
120
|
}
|
|
109
121
|
}
|
|
122
|
+
const AMBIGUOUS_PASSWORD_TARGET_PARAMETERS = new Set([
|
|
123
|
+
"host",
|
|
124
|
+
"hostaddr",
|
|
125
|
+
"hostname",
|
|
126
|
+
"port",
|
|
127
|
+
"socket",
|
|
128
|
+
"socketpath",
|
|
129
|
+
"user",
|
|
130
|
+
"username",
|
|
131
|
+
]);
|
|
132
|
+
export function validatePasswordReferenceTarget(target) {
|
|
133
|
+
if (/\s/.test(target)) {
|
|
134
|
+
throw new StateQLError("INVALID_COMMAND", "Password-reference target must not contain whitespace.");
|
|
135
|
+
}
|
|
136
|
+
const driver = detectDriver(target);
|
|
137
|
+
if (driver === "sqlite") {
|
|
138
|
+
throw new StateQLError("INVALID_COMMAND", "Password references require a remote PostgreSQL, MySQL, MongoDB, or Redis target.");
|
|
139
|
+
}
|
|
140
|
+
const authorityMatch = /^([a-z][a-z\d+.-]*:\/\/)([^/?#]*)([\s\S]*)$/i.exec(target);
|
|
141
|
+
if (!authorityMatch) {
|
|
142
|
+
throw new StateQLError("INVALID_COMMAND", "Password-reference target must be a valid remote database URL.");
|
|
143
|
+
}
|
|
144
|
+
const authority = authorityMatch[2];
|
|
145
|
+
const at = authority.lastIndexOf("@");
|
|
146
|
+
const userinfo = at >= 0 ? authority.slice(0, at) : "";
|
|
147
|
+
if (userinfo.includes(":") || userinfo.includes("@")) {
|
|
148
|
+
throw new StateQLError("PERMISSION_DENIED", "Password-reference target must not contain an embedded password.");
|
|
149
|
+
}
|
|
150
|
+
if (/%(?![0-9a-f]{2})/i.test(userinfo)) {
|
|
151
|
+
throw new StateQLError("INVALID_COMMAND", "Password-reference target contains malformed username escaping.");
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
if (userinfo)
|
|
155
|
+
decodeURIComponent(userinfo);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
throw new StateQLError("INVALID_COMMAND", "Password-reference target contains malformed username escaping.");
|
|
159
|
+
}
|
|
160
|
+
let url;
|
|
161
|
+
try {
|
|
162
|
+
if (driver === "mongodb") {
|
|
163
|
+
validateMongoHosts(authority.slice(at + 1), /^mongodb\+srv:/i.test(target));
|
|
164
|
+
url = new URL(`http://placeholder${authorityMatch[3]}`);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
url = new URL(target);
|
|
168
|
+
if (!url.hostname || url.password)
|
|
169
|
+
throw new Error();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new StateQLError("INVALID_COMMAND", "Password-reference target must be a valid remote database URL.");
|
|
174
|
+
}
|
|
175
|
+
for (const key of url.searchParams.keys()) {
|
|
176
|
+
const normalized = key.toLowerCase().replaceAll("_", "").replaceAll("-", "");
|
|
177
|
+
if (AMBIGUOUS_PASSWORD_TARGET_PARAMETERS.has(normalized) ||
|
|
178
|
+
/pass|token|secret|privatekey|apikey/.test(normalized)) {
|
|
179
|
+
throw new StateQLError("PERMISSION_DENIED", "Password-reference target must not contain endpoint or credential query overrides.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (driver === "mongodb" && !userinfo) {
|
|
183
|
+
throw new StateQLError("INVALID_COMMAND", "MongoDB password-reference targets require a username.");
|
|
184
|
+
}
|
|
185
|
+
return driver;
|
|
186
|
+
}
|
|
187
|
+
function validateMongoHosts(hosts, srv) {
|
|
188
|
+
const entries = hosts.split(",");
|
|
189
|
+
if (!hosts || (srv && entries.length !== 1) || entries.some((host) => {
|
|
190
|
+
if (srv)
|
|
191
|
+
return !/^[^:[\],%]+$/u.test(host);
|
|
192
|
+
return !(/^[^:[\],%]+(?::\d+)?$/u.test(host) || /^\[[0-9a-f:.]+\](?::\d+)?$/iu.test(host));
|
|
193
|
+
})) {
|
|
194
|
+
throw new StateQLError("INVALID_COMMAND", "Password-reference target must be a valid remote database URL.");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Injects only password userinfo while retaining every nonsecret target byte. */
|
|
198
|
+
export function injectPassword(target, password) {
|
|
199
|
+
const driver = validatePasswordReferenceTarget(target);
|
|
200
|
+
let encoded;
|
|
201
|
+
try {
|
|
202
|
+
encoded = encodeURIComponent(password);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
throw new StateQLError("CREDENTIAL_RESOLUTION_FAILED", "Resolved password could not be encoded.");
|
|
206
|
+
}
|
|
207
|
+
const match = /^([a-z][a-z\d+.-]*:\/\/)([^/?#]*)([\s\S]*)$/i.exec(target);
|
|
208
|
+
const authority = match[2];
|
|
209
|
+
const at = authority.lastIndexOf("@");
|
|
210
|
+
const injectedAuthority = at >= 0
|
|
211
|
+
? `${authority.slice(0, at)}:${encoded}${authority.slice(at)}`
|
|
212
|
+
: `:${encoded}@${authority}`;
|
|
213
|
+
return { driver, source: `${match[1]}${injectedAuthority}${match[3]}` };
|
|
214
|
+
}
|
|
110
215
|
export function version(connection) {
|
|
111
216
|
return `sv_${connection.version}`;
|
|
112
217
|
}
|
package/dist/src/migrations.js
CHANGED
|
@@ -115,6 +115,22 @@ const MIGRATIONS = [
|
|
|
115
115
|
requireIndexes(db, ["history_session_category"]);
|
|
116
116
|
},
|
|
117
117
|
},
|
|
118
|
+
{
|
|
119
|
+
name: "connection_aliases_v1",
|
|
120
|
+
apply(db) {
|
|
121
|
+
addColumn(db, "connections", "alias", "TEXT");
|
|
122
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS connections_alias ON connections(alias)");
|
|
123
|
+
},
|
|
124
|
+
validate(db) {
|
|
125
|
+
requireColumns(db, "connections", ["alias"]);
|
|
126
|
+
requireIndexes(db, ["connections_alias"]);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "password_refs_v1",
|
|
131
|
+
apply: migratePasswordRefs,
|
|
132
|
+
validate: validatePasswordRefs,
|
|
133
|
+
},
|
|
118
134
|
];
|
|
119
135
|
export function runMigrations(db, now) {
|
|
120
136
|
db.exec(`
|
|
@@ -353,7 +369,11 @@ function validateSharedSessionActors(db) {
|
|
|
353
369
|
function migrateCredentialRefs(db) {
|
|
354
370
|
const profileColumns = new Set(db.prepare("PRAGMA table_info(profiles)").all().map((column) => column.name));
|
|
355
371
|
const hasCredentialRef = profileColumns.has("credential_ref");
|
|
372
|
+
const hasPasswordRef = profileColumns.has("password_ref");
|
|
356
373
|
if (!profileCredentialRefSchemaCurrent(db)) {
|
|
374
|
+
if (hasPasswordRef) {
|
|
375
|
+
throw new Error("State migration cannot safely rebuild a profile schema that already contains password references.");
|
|
376
|
+
}
|
|
357
377
|
const missingSources = db.prepare(`SELECT COUNT(*) AS count FROM profiles
|
|
358
378
|
WHERE target IS NULL AND secret_env IS NULL${hasCredentialRef ? " AND credential_ref IS NULL" : ""}`).get();
|
|
359
379
|
if (missingSources.count) {
|
|
@@ -413,6 +433,117 @@ function profileCredentialRefSchemaCurrent(db) {
|
|
|
413
433
|
const row = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'profiles'").get();
|
|
414
434
|
return Boolean(row?.sql && /CHECK\s*\(\s*\(target IS NOT NULL\)\s*\+\s*\(secret_env IS NOT NULL\)\s*\+\s*\(credential_ref IS NOT NULL\)\s*=\s*1\s*\)/i.test(row.sql));
|
|
415
435
|
}
|
|
436
|
+
function migratePasswordRefs(db) {
|
|
437
|
+
const profileColumns = tableColumns(db, "profiles");
|
|
438
|
+
const profilePasswordRef = profileColumns.has("password_ref") ? "password_ref" : "NULL";
|
|
439
|
+
if (!profilePasswordRefSchemaCurrent(db)) {
|
|
440
|
+
const invalid = db.prepare(`SELECT COUNT(*) AS count FROM profiles
|
|
441
|
+
WHERE ${profilePasswordRef} IS NOT NULL AND target IS NULL`).get();
|
|
442
|
+
if (invalid.count) {
|
|
443
|
+
throw new Error("State migration found a password reference without a target profile source.");
|
|
444
|
+
}
|
|
445
|
+
db.exec(`
|
|
446
|
+
ALTER TABLE profiles RENAME TO profiles_legacy_password_refs_v1;
|
|
447
|
+
CREATE TABLE profiles (
|
|
448
|
+
name TEXT PRIMARY KEY,
|
|
449
|
+
target TEXT,
|
|
450
|
+
secret_env TEXT,
|
|
451
|
+
credential_ref TEXT,
|
|
452
|
+
password_ref TEXT,
|
|
453
|
+
read_only INTEGER NOT NULL,
|
|
454
|
+
created_at TEXT NOT NULL,
|
|
455
|
+
updated_at TEXT NOT NULL,
|
|
456
|
+
CHECK (
|
|
457
|
+
(target IS NOT NULL) +
|
|
458
|
+
(secret_env IS NOT NULL) +
|
|
459
|
+
(credential_ref IS NOT NULL) = 1
|
|
460
|
+
),
|
|
461
|
+
CHECK (password_ref IS NULL OR target IS NOT NULL)
|
|
462
|
+
);
|
|
463
|
+
INSERT INTO profiles
|
|
464
|
+
(name, target, secret_env, credential_ref, password_ref, read_only, created_at, updated_at)
|
|
465
|
+
SELECT name, target, secret_env, credential_ref, ${profilePasswordRef}, read_only, created_at, updated_at
|
|
466
|
+
FROM profiles_legacy_password_refs_v1;
|
|
467
|
+
DROP TABLE profiles_legacy_password_refs_v1;
|
|
468
|
+
`);
|
|
469
|
+
}
|
|
470
|
+
const connectionColumns = tableColumns(db, "connections");
|
|
471
|
+
const connectionPasswordRef = connectionColumns.has("password_ref") ? "password_ref" : "NULL";
|
|
472
|
+
if (!connectionPasswordRefSchemaCurrent(db)) {
|
|
473
|
+
const invalid = db.prepare(`SELECT COUNT(*) AS count FROM connections
|
|
474
|
+
WHERE ${connectionPasswordRef} IS NOT NULL
|
|
475
|
+
AND (secret_env IS NOT NULL OR credential_ref IS NOT NULL OR
|
|
476
|
+
driver NOT IN ('postgres', 'mysql', 'mongodb', 'redis'))`).get();
|
|
477
|
+
if (invalid.count) {
|
|
478
|
+
throw new Error("State migration found a password reference on a non-target connection source.");
|
|
479
|
+
}
|
|
480
|
+
db.exec(`
|
|
481
|
+
ALTER TABLE connections RENAME TO connections_legacy_password_refs_v1;
|
|
482
|
+
CREATE TABLE connections (
|
|
483
|
+
id TEXT PRIMARY KEY,
|
|
484
|
+
session_id TEXT NOT NULL,
|
|
485
|
+
name TEXT NOT NULL,
|
|
486
|
+
driver TEXT NOT NULL,
|
|
487
|
+
database_name TEXT NOT NULL,
|
|
488
|
+
source TEXT NOT NULL,
|
|
489
|
+
secret_env TEXT,
|
|
490
|
+
credential_ref TEXT,
|
|
491
|
+
password_ref TEXT,
|
|
492
|
+
read_only INTEGER NOT NULL,
|
|
493
|
+
version INTEGER NOT NULL,
|
|
494
|
+
created_at TEXT NOT NULL,
|
|
495
|
+
alias TEXT,
|
|
496
|
+
CHECK (
|
|
497
|
+
password_ref IS NULL OR
|
|
498
|
+
(secret_env IS NULL AND credential_ref IS NULL AND
|
|
499
|
+
driver IN ('postgres', 'mysql', 'mongodb', 'redis'))
|
|
500
|
+
),
|
|
501
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
502
|
+
);
|
|
503
|
+
INSERT INTO connections
|
|
504
|
+
(id, session_id, name, driver, database_name, source, secret_env,
|
|
505
|
+
credential_ref, password_ref, read_only, version, created_at, alias)
|
|
506
|
+
SELECT id, session_id, name, driver, database_name, source, secret_env,
|
|
507
|
+
credential_ref, ${connectionPasswordRef}, read_only, version, created_at, alias
|
|
508
|
+
FROM connections_legacy_password_refs_v1;
|
|
509
|
+
DROP TABLE connections_legacy_password_refs_v1;
|
|
510
|
+
CREATE UNIQUE INDEX connections_alias ON connections(alias);
|
|
511
|
+
`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function validatePasswordRefs(db) {
|
|
515
|
+
requireColumns(db, "profiles", ["password_ref"]);
|
|
516
|
+
requireColumns(db, "connections", ["password_ref"]);
|
|
517
|
+
if (!profilePasswordRefSchemaCurrent(db) || !connectionPasswordRefSchemaCurrent(db)) {
|
|
518
|
+
throw new Error("State migration did not enforce password-reference source constraints.");
|
|
519
|
+
}
|
|
520
|
+
const invalidProfiles = db.prepare(`SELECT COUNT(*) AS count FROM profiles
|
|
521
|
+
WHERE password_ref IS NOT NULL AND target IS NULL`).get();
|
|
522
|
+
const invalidConnections = db.prepare(`SELECT COUNT(*) AS count FROM connections
|
|
523
|
+
WHERE password_ref IS NOT NULL
|
|
524
|
+
AND (secret_env IS NOT NULL OR credential_ref IS NOT NULL OR
|
|
525
|
+
driver NOT IN ('postgres', 'mysql', 'mongodb', 'redis'))`).get();
|
|
526
|
+
if (invalidProfiles.count || invalidConnections.count) {
|
|
527
|
+
throw new Error("State migration left an invalid password-reference source.");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function profilePasswordRefSchemaCurrent(db) {
|
|
531
|
+
const sql = tableSql(db, "profiles");
|
|
532
|
+
return profileCredentialRefSchemaCurrent(db) &&
|
|
533
|
+
/CHECK\s*\(\s*password_ref IS NULL OR target IS NOT NULL\s*\)/i.test(sql);
|
|
534
|
+
}
|
|
535
|
+
function connectionPasswordRefSchemaCurrent(db) {
|
|
536
|
+
return /CHECK\s*\(\s*password_ref IS NULL OR\s*\(secret_env IS NULL AND credential_ref IS NULL AND\s*driver IN \('postgres', 'mysql', 'mongodb', 'redis'\)\)\s*\)/i
|
|
537
|
+
.test(tableSql(db, "connections"));
|
|
538
|
+
}
|
|
539
|
+
function tableSql(db, table) {
|
|
540
|
+
const row = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table);
|
|
541
|
+
return row?.sql ?? "";
|
|
542
|
+
}
|
|
543
|
+
function tableColumns(db, table) {
|
|
544
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all()
|
|
545
|
+
.map((column) => column.name));
|
|
546
|
+
}
|
|
416
547
|
function addColumn(db, table, column, definition) {
|
|
417
548
|
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
418
549
|
if (!columns.some((candidate) => candidate.name === column)) {
|
package/dist/src/stateql.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type TableChange } from "./table-editor.js";
|
|
2
2
|
import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, CommandOrigin, BatchOptions, CapabilitiesData, CatalogObject, DescribeObjectData, ListObjectsData, ListObjectsFilter, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, HistoryOptions, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, ProfileUpdateOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, Column, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StateQLSnapshotOptions, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
|
|
3
3
|
export declare class StateQL {
|
|
4
|
+
/** Runtime contract marker for passwordRef/password_ref support. */
|
|
5
|
+
static readonly passwordReferenceVersion: 1;
|
|
4
6
|
static forActor(options: StateQLActorOptions): StateQL;
|
|
5
7
|
private readonly store;
|
|
6
8
|
private readonly sessionName;
|