turbine-orm 0.81.0 → 0.90.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 +26 -25
- package/dist/checkout.d.ts +27 -2
- package/dist/checkout.js +75 -4
- package/dist/cjs/checkout.d.ts +27 -2
- package/dist/cjs/checkout.js +76 -3
- package/dist/cjs/cli/code-first-tags.d.ts +62 -0
- package/dist/cjs/cli/code-first-tags.js +124 -0
- package/dist/cjs/cli/compile-query.js +9 -0
- package/dist/cjs/cli/config.d.ts +35 -6
- package/dist/cjs/cli/config.js +17 -1
- package/dist/cjs/cli/destructive.d.ts +7 -4
- package/dist/cjs/cli/destructive.js +224 -20
- package/dist/cjs/cli/error-catalog.js +36 -0
- package/dist/cjs/cli/error-text.d.ts +15 -0
- package/dist/cjs/cli/error-text.js +40 -0
- package/dist/cjs/cli/index.d.ts +62 -4
- package/dist/cjs/cli/index.js +568 -108
- package/dist/cjs/cli/local-server-guard.d.ts +53 -0
- package/dist/cjs/cli/local-server-guard.js +119 -0
- package/dist/cjs/cli/local-server-output.d.ts +41 -0
- package/dist/cjs/cli/local-server-output.js +103 -0
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +121 -32
- package/dist/cjs/cli/migrate.d.ts +113 -5
- package/dist/cjs/cli/migrate.js +270 -21
- package/dist/cjs/cli/observe.d.ts +6 -1
- package/dist/cjs/cli/observe.js +85 -55
- package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cjs/cli/pii-predicate-guard.js +98 -37
- package/dist/cjs/cli/prisma-resolve.js +17 -13
- package/dist/cjs/cli/prisma-schema.js +12 -4
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +14 -2
- package/dist/cjs/cli/studio.js +244 -69
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +558 -85
- package/dist/cjs/client.js +1270 -395
- package/dist/cjs/connection-guard.d.ts +13 -0
- package/dist/cjs/connection-guard.js +28 -0
- package/dist/cjs/connection-url.d.ts +51 -16
- package/dist/cjs/connection-url.js +67 -34
- package/dist/cjs/diagnostics.d.ts +81 -0
- package/dist/cjs/diagnostics.js +147 -0
- package/dist/cjs/dialect.d.ts +449 -20
- package/dist/cjs/dialect.js +117 -76
- package/dist/cjs/engine-config.d.ts +7 -6
- package/dist/cjs/errors.d.ts +126 -6
- package/dist/cjs/errors.js +256 -13
- package/dist/cjs/generate.d.ts +57 -7
- package/dist/cjs/generate.js +635 -279
- package/dist/cjs/global-filters-config.d.ts +59 -0
- package/dist/cjs/global-filters-config.js +367 -0
- package/dist/cjs/index.d.ts +9 -4
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.d.ts +31 -6
- package/dist/cjs/introspect.js +108 -50
- package/dist/cjs/log-codes.d.ts +12 -0
- package/dist/cjs/log-codes.js +77 -0
- package/dist/cjs/logger.d.ts +87 -0
- package/dist/cjs/logger.js +98 -0
- package/dist/cjs/mssql.d.ts +8 -5
- package/dist/cjs/mssql.js +352 -65
- package/dist/cjs/mysql.d.ts +3 -5
- package/dist/cjs/mysql.js +420 -70
- package/dist/cjs/nested-write.d.ts +3 -0
- package/dist/cjs/nested-write.js +179 -114
- package/dist/cjs/pipeline-submittable.d.ts +12 -0
- package/dist/cjs/pipeline-submittable.js +118 -44
- package/dist/cjs/pipeline.js +21 -26
- package/dist/cjs/powdb-introspect.d.ts +5 -2
- package/dist/cjs/powdb-introspect.js +20 -15
- package/dist/cjs/powdb-shared.d.ts +36 -5
- package/dist/cjs/powdb-shared.js +67 -8
- package/dist/cjs/powdb.d.ts +4 -0
- package/dist/cjs/powdb.js +183 -51
- package/dist/cjs/powql.d.ts +394 -51
- package/dist/cjs/powql.js +1829 -284
- package/dist/cjs/prisma-compat.d.ts +23 -15
- package/dist/cjs/prisma-compat.js +507 -372
- package/dist/cjs/query/aggregates.d.ts +17 -1
- package/dist/cjs/query/aggregates.js +261 -29
- package/dist/cjs/query/batched-loader.d.ts +25 -6
- package/dist/cjs/query/batched-loader.js +103 -20
- package/dist/cjs/query/builder.d.ts +325 -184
- package/dist/cjs/query/builder.js +1452 -490
- package/dist/cjs/query/column-ref.d.ts +61 -0
- package/dist/cjs/query/column-ref.js +73 -0
- package/dist/cjs/query/compound-unique.d.ts +21 -0
- package/dist/cjs/query/compound-unique.js +98 -18
- package/dist/cjs/query/deferred.d.ts +53 -16
- package/dist/cjs/query/expr.d.ts +217 -0
- package/dist/cjs/query/expr.js +859 -0
- package/dist/cjs/query/filters.d.ts +155 -53
- package/dist/cjs/query/filters.js +613 -76
- package/dist/cjs/query/index.d.ts +2 -0
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/json-value.d.ts +43 -0
- package/dist/cjs/query/json-value.js +59 -0
- package/dist/cjs/query/normalize-args.d.ts +87 -0
- package/dist/cjs/query/normalize-args.js +527 -0
- package/dist/cjs/query/option-surface.d.ts +16 -4
- package/dist/cjs/query/option-surface.js +57 -14
- package/dist/cjs/query/relation-names.d.ts +14 -0
- package/dist/cjs/query/relation-names.js +77 -18
- package/dist/cjs/query/relations.d.ts +30 -3
- package/dist/cjs/query/relations.js +219 -63
- package/dist/cjs/query/row-lock.d.ts +49 -0
- package/dist/cjs/query/row-lock.js +138 -0
- package/dist/cjs/query/sql-comment.d.ts +161 -0
- package/dist/cjs/query/sql-comment.js +381 -0
- package/dist/cjs/query/stream-idle.d.ts +132 -0
- package/dist/cjs/query/stream-idle.js +212 -0
- package/dist/cjs/query/table-client.d.ts +101 -0
- package/dist/cjs/query/table-client.js +74 -0
- package/dist/cjs/query/types.d.ts +677 -298
- package/dist/cjs/query/utils.d.ts +105 -6
- package/dist/cjs/query/utils.js +426 -19
- package/dist/cjs/query/warn-registry.d.ts +18 -0
- package/dist/cjs/query/warn-registry.js +18 -0
- package/dist/cjs/query/where-compile.d.ts +138 -14
- package/dist/cjs/query/where-compile.js +513 -29
- package/dist/cjs/query/where.d.ts +159 -59
- package/dist/cjs/query/where.js +716 -246
- package/dist/cjs/query/writes.d.ts +112 -50
- package/dist/cjs/query/writes.js +1498 -200
- package/dist/cjs/query-events.d.ts +4 -2
- package/dist/cjs/query-events.js +3 -1
- package/dist/cjs/realtime.d.ts +7 -3
- package/dist/cjs/realtime.js +13 -2
- package/dist/cjs/schema-builder.d.ts +55 -4
- package/dist/cjs/schema-builder.js +382 -18
- package/dist/cjs/schema-metadata.d.ts +21 -1
- package/dist/cjs/schema-metadata.js +29 -7
- package/dist/cjs/schema-sql.d.ts +75 -5
- package/dist/cjs/schema-sql.js +497 -45
- package/dist/cjs/schema.d.ts +26 -5
- package/dist/cjs/schema.js +110 -13
- package/dist/cjs/sql-placeholders.d.ts +56 -0
- package/dist/cjs/sql-placeholders.js +204 -0
- package/dist/cjs/sqlite.d.ts +75 -20
- package/dist/cjs/sqlite.js +593 -95
- package/dist/cjs/supplied-pool-types.d.ts +48 -0
- package/dist/cjs/supplied-pool-types.js +256 -0
- package/dist/cjs/transaction-control.d.ts +51 -0
- package/dist/cjs/transaction-control.js +316 -0
- package/dist/cjs/transaction-state.d.ts +342 -0
- package/dist/cjs/transaction-state.js +707 -0
- package/dist/cjs/typed-sql.d.ts +37 -3
- package/dist/cjs/typed-sql.js +102 -8
- package/dist/cli/code-first-tags.d.ts +62 -0
- package/dist/cli/code-first-tags.js +120 -0
- package/dist/cli/compile-query.js +9 -0
- package/dist/cli/config.d.ts +35 -6
- package/dist/cli/config.js +16 -1
- package/dist/cli/destructive.d.ts +7 -4
- package/dist/cli/destructive.js +224 -20
- package/dist/cli/error-catalog.js +36 -0
- package/dist/cli/error-text.d.ts +15 -0
- package/dist/cli/error-text.js +37 -0
- package/dist/cli/index.d.ts +62 -4
- package/dist/cli/index.js +569 -112
- package/dist/cli/local-server-guard.d.ts +53 -0
- package/dist/cli/local-server-guard.js +113 -0
- package/dist/cli/local-server-output.d.ts +41 -0
- package/dist/cli/local-server-output.js +98 -0
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +121 -34
- package/dist/cli/migrate.d.ts +113 -5
- package/dist/cli/migrate.js +261 -21
- package/dist/cli/observe.d.ts +6 -1
- package/dist/cli/observe.js +84 -54
- package/dist/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cli/pii-predicate-guard.js +100 -35
- package/dist/cli/prisma-resolve.js +15 -11
- package/dist/cli/prisma-schema.js +12 -4
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +14 -2
- package/dist/cli/studio.js +246 -71
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +558 -85
- package/dist/client.js +1275 -400
- package/dist/connection-guard.d.ts +13 -0
- package/dist/connection-guard.js +27 -0
- package/dist/connection-url.d.ts +51 -16
- package/dist/connection-url.js +63 -34
- package/dist/diagnostics.d.ts +81 -0
- package/dist/diagnostics.js +144 -0
- package/dist/dialect.d.ts +449 -20
- package/dist/dialect.js +115 -43
- package/dist/engine-config.d.ts +7 -6
- package/dist/errors.d.ts +126 -6
- package/dist/errors.js +252 -12
- package/dist/generate.d.ts +57 -7
- package/dist/generate.js +635 -281
- package/dist/global-filters-config.d.ts +59 -0
- package/dist/global-filters-config.js +361 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +4 -2
- package/dist/introspect.d.ts +31 -6
- package/dist/introspect.js +106 -50
- package/dist/log-codes.d.ts +12 -0
- package/dist/log-codes.js +74 -0
- package/dist/logger.d.ts +87 -0
- package/dist/logger.js +93 -0
- package/dist/mssql.d.ts +8 -5
- package/dist/mssql.js +354 -67
- package/dist/mysql.d.ts +3 -5
- package/dist/mysql.js +423 -73
- package/dist/nested-write.d.ts +3 -0
- package/dist/nested-write.js +177 -112
- package/dist/pipeline-submittable.d.ts +12 -0
- package/dist/pipeline-submittable.js +117 -44
- package/dist/pipeline.js +21 -26
- package/dist/powdb-introspect.d.ts +5 -2
- package/dist/powdb-introspect.js +21 -16
- package/dist/powdb-shared.d.ts +36 -5
- package/dist/powdb-shared.js +65 -8
- package/dist/powdb.d.ts +4 -0
- package/dist/powdb.js +183 -51
- package/dist/powql.d.ts +394 -51
- package/dist/powql.js +1836 -291
- package/dist/prisma-compat.d.ts +23 -15
- package/dist/prisma-compat.js +508 -373
- package/dist/query/aggregates.d.ts +17 -1
- package/dist/query/aggregates.js +263 -32
- package/dist/query/batched-loader.d.ts +25 -6
- package/dist/query/batched-loader.js +101 -21
- package/dist/query/builder.d.ts +325 -184
- package/dist/query/builder.js +1458 -495
- package/dist/query/column-ref.d.ts +61 -0
- package/dist/query/column-ref.js +66 -0
- package/dist/query/compound-unique.d.ts +21 -0
- package/dist/query/compound-unique.js +97 -19
- package/dist/query/deferred.d.ts +53 -16
- package/dist/query/expr.d.ts +217 -0
- package/dist/query/expr.js +848 -0
- package/dist/query/filters.d.ts +155 -53
- package/dist/query/filters.js +597 -72
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +4 -0
- package/dist/query/json-value.d.ts +43 -0
- package/dist/query/json-value.js +54 -0
- package/dist/query/normalize-args.d.ts +87 -0
- package/dist/query/normalize-args.js +517 -0
- package/dist/query/option-surface.d.ts +16 -4
- package/dist/query/option-surface.js +56 -13
- package/dist/query/relation-names.d.ts +14 -0
- package/dist/query/relation-names.js +77 -19
- package/dist/query/relations.d.ts +30 -3
- package/dist/query/relations.js +220 -67
- package/dist/query/row-lock.d.ts +49 -0
- package/dist/query/row-lock.js +131 -0
- package/dist/query/sql-comment.d.ts +161 -0
- package/dist/query/sql-comment.js +374 -0
- package/dist/query/stream-idle.d.ts +132 -0
- package/dist/query/stream-idle.js +203 -0
- package/dist/query/table-client.d.ts +101 -0
- package/dist/query/table-client.js +70 -0
- package/dist/query/types.d.ts +677 -298
- package/dist/query/utils.d.ts +105 -6
- package/dist/query/utils.js +414 -18
- package/dist/query/warn-registry.d.ts +18 -0
- package/dist/query/warn-registry.js +18 -0
- package/dist/query/where-compile.d.ts +138 -14
- package/dist/query/where-compile.js +508 -31
- package/dist/query/where.d.ts +159 -59
- package/dist/query/where.js +711 -248
- package/dist/query/writes.d.ts +112 -50
- package/dist/query/writes.js +1496 -202
- package/dist/query-events.d.ts +4 -2
- package/dist/query-events.js +3 -1
- package/dist/realtime.d.ts +7 -3
- package/dist/realtime.js +13 -2
- package/dist/schema-builder.d.ts +55 -4
- package/dist/schema-builder.js +381 -18
- package/dist/schema-metadata.d.ts +21 -1
- package/dist/schema-metadata.js +28 -7
- package/dist/schema-sql.d.ts +75 -5
- package/dist/schema-sql.js +494 -44
- package/dist/schema.d.ts +26 -5
- package/dist/schema.js +109 -13
- package/dist/sql-placeholders.d.ts +56 -0
- package/dist/sql-placeholders.js +200 -0
- package/dist/sqlite.d.ts +75 -20
- package/dist/sqlite.js +597 -99
- package/dist/supplied-pool-types.d.ts +48 -0
- package/dist/supplied-pool-types.js +247 -0
- package/dist/transaction-control.d.ts +51 -0
- package/dist/transaction-control.js +311 -0
- package/dist/transaction-state.d.ts +342 -0
- package/dist/transaction-state.js +696 -0
- package/dist/typed-sql.d.ts +37 -3
- package/dist/typed-sql.js +100 -8
- package/package.json +7 -4
- package/skills/turbine-orm/SKILL.md +56 -6
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm CLI: shared guards for the local servers
|
|
3
|
+
*
|
|
4
|
+
* A leaf module (Node's crypto and nothing else), the same role as
|
|
5
|
+
* `cli/rate-limit.ts`: Studio and Observe both bind a loopback port behind a
|
|
6
|
+
* random token, and the checks below are the parts of that perimeter that are
|
|
7
|
+
* identical for both, so neither server grows its own copy.
|
|
8
|
+
*
|
|
9
|
+
* - {@link isAllowedHostHeader}: a request whose `Host` names anything but this
|
|
10
|
+
* server is refused. This is what stops DNS rebinding: a page on an attacker's
|
|
11
|
+
* domain that re-resolves to 127.0.0.1 still sends `Host: attacker.example`.
|
|
12
|
+
* - {@link createTicketGate} / {@link redeemTicket}: the URL printed in the
|
|
13
|
+
* terminal carries a ONE-TIME login token, separate from the credential the
|
|
14
|
+
* session then uses, so a URL that lands in browser history, a process
|
|
15
|
+
* listing or a log is a spent ticket and not a standing key.
|
|
16
|
+
* - {@link constantTimeEqual}: the one token comparison.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Compare two secrets without leaking length or content through timing. Both
|
|
20
|
+
* sides are hashed to fixed-length digests first, so `timingSafeEqual` never
|
|
21
|
+
* throws on a length mismatch.
|
|
22
|
+
*/
|
|
23
|
+
export declare function constantTimeEqual(a: string, b: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Is this `Host` header one this server should answer to?
|
|
26
|
+
*
|
|
27
|
+
* Allowed: the loopback names (`127.0.0.1`, `localhost`, `[::1]`) and the host
|
|
28
|
+
* the server was bound to, each on the bound port. A server bound to a wildcard
|
|
29
|
+
* address (`0.0.0.0`, `::`) also accepts an IP literal, because it has no single
|
|
30
|
+
* name and a literal address cannot be rebound: rebinding needs a DNS name the
|
|
31
|
+
* attacker controls.
|
|
32
|
+
*
|
|
33
|
+
* An ABSENT header is allowed. HTTP/1.1 requires it and Node answers 400 itself
|
|
34
|
+
* when it is missing, so the only request that reaches this check without one is
|
|
35
|
+
* an HTTP/1.0 client, which is not a browser and can say anything anyway. The
|
|
36
|
+
* attack this refuses needs a browser, and a browser always sends one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isAllowedHostHeader(header: string | string[] | undefined, boundHost: string, port: number): boolean;
|
|
39
|
+
/** The one-time ticket carried by the URL a server prints. */
|
|
40
|
+
export interface TicketGate {
|
|
41
|
+
/** The value in the printed URL's `?token=`. Never a session credential. */
|
|
42
|
+
readonly token: string;
|
|
43
|
+
/** True once a GET carrying the token has been exchanged for a session cookie. */
|
|
44
|
+
used: boolean;
|
|
45
|
+
}
|
|
46
|
+
export type TicketOutcome = 'granted' | 'used' | 'invalid';
|
|
47
|
+
/** Mint a gate. `token` is only passed by a caller that has no separate one to give. */
|
|
48
|
+
export declare function createTicketGate(token?: string): TicketGate;
|
|
49
|
+
/**
|
|
50
|
+
* Try to exchange `candidate` for a session. `granted` happens at most once per
|
|
51
|
+
* gate; afterwards the same token reports `used`, which a server must refuse.
|
|
52
|
+
*/
|
|
53
|
+
export declare function redeemTicket(gate: TicketGate, candidate: string): TicketOutcome;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm CLI: shared guards for the local servers
|
|
3
|
+
*
|
|
4
|
+
* A leaf module (Node's crypto and nothing else), the same role as
|
|
5
|
+
* `cli/rate-limit.ts`: Studio and Observe both bind a loopback port behind a
|
|
6
|
+
* random token, and the checks below are the parts of that perimeter that are
|
|
7
|
+
* identical for both, so neither server grows its own copy.
|
|
8
|
+
*
|
|
9
|
+
* - {@link isAllowedHostHeader}: a request whose `Host` names anything but this
|
|
10
|
+
* server is refused. This is what stops DNS rebinding: a page on an attacker's
|
|
11
|
+
* domain that re-resolves to 127.0.0.1 still sends `Host: attacker.example`.
|
|
12
|
+
* - {@link createTicketGate} / {@link redeemTicket}: the URL printed in the
|
|
13
|
+
* terminal carries a ONE-TIME login token, separate from the credential the
|
|
14
|
+
* session then uses, so a URL that lands in browser history, a process
|
|
15
|
+
* listing or a log is a spent ticket and not a standing key.
|
|
16
|
+
* - {@link constantTimeEqual}: the one token comparison.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
19
|
+
/**
|
|
20
|
+
* Compare two secrets without leaking length or content through timing. Both
|
|
21
|
+
* sides are hashed to fixed-length digests first, so `timingSafeEqual` never
|
|
22
|
+
* throws on a length mismatch.
|
|
23
|
+
*/
|
|
24
|
+
export function constantTimeEqual(a, b) {
|
|
25
|
+
const ah = createHash('sha256').update(a).digest();
|
|
26
|
+
const bh = createHash('sha256').update(b).digest();
|
|
27
|
+
return timingSafeEqual(ah, bh);
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Host header allowlist
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '[::1]'];
|
|
33
|
+
const WILDCARD_HOSTNAMES = new Set(['0.0.0.0', '[::]', '::']);
|
|
34
|
+
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
35
|
+
/** `host`, `host:port`, `[v6]` or `[v6]:port`, or null when it is none of those. */
|
|
36
|
+
function parseHostHeader(value) {
|
|
37
|
+
const trimmed = value.trim().toLowerCase();
|
|
38
|
+
if (trimmed === '')
|
|
39
|
+
return null;
|
|
40
|
+
if (trimmed.startsWith('[')) {
|
|
41
|
+
const end = trimmed.indexOf(']');
|
|
42
|
+
if (end < 0)
|
|
43
|
+
return null;
|
|
44
|
+
const rest = trimmed.slice(end + 1);
|
|
45
|
+
if (rest === '')
|
|
46
|
+
return { hostname: trimmed.slice(0, end + 1), port: null };
|
|
47
|
+
if (!/^:\d+$/.test(rest))
|
|
48
|
+
return null;
|
|
49
|
+
return { hostname: trimmed.slice(0, end + 1), port: rest.slice(1) };
|
|
50
|
+
}
|
|
51
|
+
const colon = trimmed.indexOf(':');
|
|
52
|
+
if (colon < 0)
|
|
53
|
+
return { hostname: trimmed, port: null };
|
|
54
|
+
// A bare name never contains a second colon: that would be an unbracketed IPv6.
|
|
55
|
+
if (trimmed.indexOf(':', colon + 1) >= 0)
|
|
56
|
+
return null;
|
|
57
|
+
const port = trimmed.slice(colon + 1);
|
|
58
|
+
if (!/^\d+$/.test(port))
|
|
59
|
+
return null;
|
|
60
|
+
return { hostname: trimmed.slice(0, colon), port };
|
|
61
|
+
}
|
|
62
|
+
function bracketIfIpv6(host) {
|
|
63
|
+
const lower = host.toLowerCase();
|
|
64
|
+
return lower.includes(':') && !lower.startsWith('[') ? `[${lower}]` : lower;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Is this `Host` header one this server should answer to?
|
|
68
|
+
*
|
|
69
|
+
* Allowed: the loopback names (`127.0.0.1`, `localhost`, `[::1]`) and the host
|
|
70
|
+
* the server was bound to, each on the bound port. A server bound to a wildcard
|
|
71
|
+
* address (`0.0.0.0`, `::`) also accepts an IP literal, because it has no single
|
|
72
|
+
* name and a literal address cannot be rebound: rebinding needs a DNS name the
|
|
73
|
+
* attacker controls.
|
|
74
|
+
*
|
|
75
|
+
* An ABSENT header is allowed. HTTP/1.1 requires it and Node answers 400 itself
|
|
76
|
+
* when it is missing, so the only request that reaches this check without one is
|
|
77
|
+
* an HTTP/1.0 client, which is not a browser and can say anything anyway. The
|
|
78
|
+
* attack this refuses needs a browser, and a browser always sends one.
|
|
79
|
+
*/
|
|
80
|
+
export function isAllowedHostHeader(header, boundHost, port) {
|
|
81
|
+
if (header === undefined)
|
|
82
|
+
return true;
|
|
83
|
+
if (typeof header !== 'string')
|
|
84
|
+
return false;
|
|
85
|
+
const parsed = parseHostHeader(header);
|
|
86
|
+
if (!parsed)
|
|
87
|
+
return false;
|
|
88
|
+
if (parsed.port === null ? port !== 80 : parsed.port !== String(port))
|
|
89
|
+
return false;
|
|
90
|
+
const bound = bracketIfIpv6(boundHost);
|
|
91
|
+
if (parsed.hostname === bound || LOOPBACK_HOSTNAMES.includes(parsed.hostname))
|
|
92
|
+
return true;
|
|
93
|
+
if (WILDCARD_HOSTNAMES.has(boundHost.toLowerCase()) || WILDCARD_HOSTNAMES.has(bound)) {
|
|
94
|
+
return IPV4_LITERAL.test(parsed.hostname) || parsed.hostname.startsWith('[');
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
/** Mint a gate. `token` is only passed by a caller that has no separate one to give. */
|
|
99
|
+
export function createTicketGate(token = randomBytes(24).toString('hex')) {
|
|
100
|
+
return { token, used: false };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Try to exchange `candidate` for a session. `granted` happens at most once per
|
|
104
|
+
* gate; afterwards the same token reports `used`, which a server must refuse.
|
|
105
|
+
*/
|
|
106
|
+
export function redeemTicket(gate, candidate) {
|
|
107
|
+
if (!constantTimeEqual(candidate, gate.token))
|
|
108
|
+
return 'invalid';
|
|
109
|
+
if (gate.used)
|
|
110
|
+
return 'used';
|
|
111
|
+
gate.used = true;
|
|
112
|
+
return 'granted';
|
|
113
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the two local servers (Studio and Observe) send OUT of the process: the
|
|
3
|
+
* text of an error response, and the command that opens a browser. A leaf that
|
|
4
|
+
* imports only `node:child_process`, so both servers share one copy.
|
|
5
|
+
*
|
|
6
|
+
* Both exist because the input to them is not entirely ours. A caught value can
|
|
7
|
+
* be anything a dependency threw, and the host in the printed URL comes from a
|
|
8
|
+
* command-line flag.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
/**
|
|
12
|
+
* The text a Studio or Observe error response may carry for a caught value.
|
|
13
|
+
*
|
|
14
|
+
* An `Error` contributes its own message, and only that: never `stack`, and
|
|
15
|
+
* never the result of stringifying the object (a `toString` on a foreign error
|
|
16
|
+
* type is free to print one). When the message is empty, which is what Node's
|
|
17
|
+
* `AggregateError` has for a refused connection, the messages of the errors it
|
|
18
|
+
* aggregates or its `cause` are used, then its `code`, then its name. A thrown
|
|
19
|
+
* value that is not an `Error` has nothing safe to say, so it gets a fixed
|
|
20
|
+
* sentence rather than a rendering of whatever it was.
|
|
21
|
+
*/
|
|
22
|
+
export declare function responseErrorText(err: unknown): string;
|
|
23
|
+
/** @internal exported for tests. */
|
|
24
|
+
export declare function isLaunchableUrl(url: string): boolean;
|
|
25
|
+
/** @internal the seams the tests replace; production passes neither. */
|
|
26
|
+
export interface BrowserLaunch {
|
|
27
|
+
platform: string;
|
|
28
|
+
spawn: typeof spawn;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Open `url` in the user's browser. Best effort: false when the URL is not one
|
|
32
|
+
* of ours to launch or the launcher could not be started, and the caller has
|
|
33
|
+
* already printed the URL either way.
|
|
34
|
+
*
|
|
35
|
+
* The launcher is started directly with an argument list and no shell
|
|
36
|
+
* (`xdg-open <url>`, `open <url>`, and on Windows `cmd /c start "" <url>`).
|
|
37
|
+
* A launcher that is not installed is reported as an 'error' EVENT rather than
|
|
38
|
+
* a throw, and with no listener Node turns it into an uncaught exception that
|
|
39
|
+
* ends the server right after it printed its address.
|
|
40
|
+
*/
|
|
41
|
+
export declare function openInBrowser(url: string, launch?: BrowserLaunch): boolean;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the two local servers (Studio and Observe) send OUT of the process: the
|
|
3
|
+
* text of an error response, and the command that opens a browser. A leaf that
|
|
4
|
+
* imports only `node:child_process`, so both servers share one copy.
|
|
5
|
+
*
|
|
6
|
+
* Both exist because the input to them is not entirely ours. A caught value can
|
|
7
|
+
* be anything a dependency threw, and the host in the printed URL comes from a
|
|
8
|
+
* command-line flag.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Error text for an HTTP response
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const UNEXPECTED_ERROR = 'unexpected error';
|
|
15
|
+
/**
|
|
16
|
+
* The text a Studio or Observe error response may carry for a caught value.
|
|
17
|
+
*
|
|
18
|
+
* An `Error` contributes its own message, and only that: never `stack`, and
|
|
19
|
+
* never the result of stringifying the object (a `toString` on a foreign error
|
|
20
|
+
* type is free to print one). When the message is empty, which is what Node's
|
|
21
|
+
* `AggregateError` has for a refused connection, the messages of the errors it
|
|
22
|
+
* aggregates or its `cause` are used, then its `code`, then its name. A thrown
|
|
23
|
+
* value that is not an `Error` has nothing safe to say, so it gets a fixed
|
|
24
|
+
* sentence rather than a rendering of whatever it was.
|
|
25
|
+
*/
|
|
26
|
+
export function responseErrorText(err) {
|
|
27
|
+
return err instanceof Error ? errorMessage(err, 0) : UNEXPECTED_ERROR;
|
|
28
|
+
}
|
|
29
|
+
function errorMessage(err, depth) {
|
|
30
|
+
if (err.message)
|
|
31
|
+
return err.message;
|
|
32
|
+
if (depth < 4) {
|
|
33
|
+
const inner = err.errors;
|
|
34
|
+
if (Array.isArray(inner)) {
|
|
35
|
+
const parts = new Set();
|
|
36
|
+
for (const e of inner) {
|
|
37
|
+
const text = e instanceof Error ? errorMessage(e, depth + 1) : '';
|
|
38
|
+
if (text !== '')
|
|
39
|
+
parts.add(text);
|
|
40
|
+
}
|
|
41
|
+
if (parts.size > 0)
|
|
42
|
+
return [...parts].join('; ');
|
|
43
|
+
}
|
|
44
|
+
if (err.cause instanceof Error) {
|
|
45
|
+
const cause = errorMessage(err.cause, depth + 1);
|
|
46
|
+
if (cause !== '')
|
|
47
|
+
return cause;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const code = err.code;
|
|
51
|
+
if (typeof code === 'string' && code !== '')
|
|
52
|
+
return code;
|
|
53
|
+
return err.name || 'Error';
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Browser launch
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
/**
|
|
59
|
+
* The only URL shape a server launches: plain `http`, a host name or IP
|
|
60
|
+
* literal, a port, and optionally the one-time `?token=`. The host comes from
|
|
61
|
+
* `--host`, and on Windows the URL is an argument of `cmd /c start`, where `&`,
|
|
62
|
+
* `|`, `^`, `%` and `"` are command syntax rather than data, so anything outside
|
|
63
|
+
* this set is not handed to a launcher at all. The URL is still printed.
|
|
64
|
+
*/
|
|
65
|
+
const LAUNCHABLE_URL = /^http:\/\/(?:[A-Za-z0-9._-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}\/(?:\?token=[A-Za-z0-9_-]+)?$/;
|
|
66
|
+
/** @internal exported for tests. */
|
|
67
|
+
export function isLaunchableUrl(url) {
|
|
68
|
+
return LAUNCHABLE_URL.test(url);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Open `url` in the user's browser. Best effort: false when the URL is not one
|
|
72
|
+
* of ours to launch or the launcher could not be started, and the caller has
|
|
73
|
+
* already printed the URL either way.
|
|
74
|
+
*
|
|
75
|
+
* The launcher is started directly with an argument list and no shell
|
|
76
|
+
* (`xdg-open <url>`, `open <url>`, and on Windows `cmd /c start "" <url>`).
|
|
77
|
+
* A launcher that is not installed is reported as an 'error' EVENT rather than
|
|
78
|
+
* a throw, and with no listener Node turns it into an uncaught exception that
|
|
79
|
+
* ends the server right after it printed its address.
|
|
80
|
+
*/
|
|
81
|
+
export function openInBrowser(url, launch = { platform: process.platform, spawn }) {
|
|
82
|
+
if (!isLaunchableUrl(url))
|
|
83
|
+
return false;
|
|
84
|
+
const [command, args] = launch.platform === 'darwin'
|
|
85
|
+
? ['open', [url]]
|
|
86
|
+
: launch.platform === 'win32'
|
|
87
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
88
|
+
: ['xdg-open', [url]];
|
|
89
|
+
try {
|
|
90
|
+
const child = launch.spawn(command, args, { stdio: 'ignore', detached: true });
|
|
91
|
+
child.on('error', () => { });
|
|
92
|
+
child.unref();
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/cli/mcp.d.ts
CHANGED
|
@@ -43,6 +43,14 @@ export interface McpServerHandle {
|
|
|
43
43
|
*/
|
|
44
44
|
closed: Promise<McpCloseReason>;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Split an identifier into lowercase words on separators, case changes
|
|
48
|
+
* (`refreshToken`), acronym edges (`APIKey` -> `api`, `key`) and letter/digit
|
|
49
|
+
* edges (`token2`).
|
|
50
|
+
*/
|
|
51
|
+
export declare function secretNameWords(name: string): string[];
|
|
52
|
+
/** True when `name` looks like it holds a credential. See {@link SECRET_WORDS}. */
|
|
53
|
+
export declare function looksLikeSecretName(name: string): boolean;
|
|
46
54
|
/**
|
|
47
55
|
* The most bytes the stdio reader will hold WITHOUT seeing a newline.
|
|
48
56
|
*
|
package/dist/cli/mcp.js
CHANGED
|
@@ -5,14 +5,14 @@ import pg from 'pg';
|
|
|
5
5
|
import { absorbCheckedOutErrors } from '../connection-guard.js';
|
|
6
6
|
import { findMissingRelationIndexes } from '../index-advisor.js';
|
|
7
7
|
import { formatBytes } from '../index-stats.js';
|
|
8
|
-
import { deriveCatalogRelations, indexKeyColumn, parseIndexKeyEntries } from '../introspect.js';
|
|
8
|
+
import { deriveCatalogRelations, indexKeyColumn, parseIndexKeyEntries, printableCatalogName, } from '../introspect.js';
|
|
9
9
|
import { QueryInterface, quoteIdent } from '../query/index.js';
|
|
10
10
|
import { ownLookup, registerUtcTemporalParsers } from '../query/utils.js';
|
|
11
11
|
import { isDateType, pgArrayType, pgTypeToTs, snakeToCamel, } from '../schema.js';
|
|
12
12
|
import { COMPILE_OPERATIONS, carriesColumnNamingArg, collectAggregateColumnNames, compileQueryPlan, isAggregateShaped, } from './compile-query.js';
|
|
13
13
|
import { CATALOGUED_ERROR_CODES, explainErrorCode } from './error-catalog.js';
|
|
14
14
|
import { listMigrationFiles } from './migrate.js';
|
|
15
|
-
import { assertNoPiiPredicates as assertNoPiiPredicatesShared } from './pii-predicate-guard.js';
|
|
15
|
+
import { assertNoPiiPredicates as assertNoPiiPredicatesShared, findRowLockOption, rowLockRefusalMessage, } from './pii-predicate-guard.js';
|
|
16
16
|
import { applyPiiTags, loadPiiTags } from './pii-tags.js';
|
|
17
17
|
import { redactUrl } from './ui.js';
|
|
18
18
|
/**
|
|
@@ -51,6 +51,12 @@ function readOwnVersion() {
|
|
|
51
51
|
const PROTOCOL_VERSION = '2025-06-18';
|
|
52
52
|
const STATEMENT_TIMEOUT = '30s';
|
|
53
53
|
const TRACKING_TABLE = '_turbine_migrations';
|
|
54
|
+
/**
|
|
55
|
+
* A name made safe to quote in a message. Catalog names can carry control or
|
|
56
|
+
* bidi characters, and an error message or stderr line can reach a terminal or
|
|
57
|
+
* a log. Same helper `generate`, `status` and `doctor` print through.
|
|
58
|
+
*/
|
|
59
|
+
const shown = printableCatalogName;
|
|
54
60
|
/**
|
|
55
61
|
* Marker written in place of a hidden cell. Never `null` and never the value,
|
|
56
62
|
* so the agent can tell "hidden" from "empty" (same string Studio uses).
|
|
@@ -78,36 +84,93 @@ const REDACTED = '•• redacted ••';
|
|
|
78
84
|
/**
|
|
79
85
|
* Column names that look like they hold a credential.
|
|
80
86
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* matched a perfectly ordinary `token_count`, so the guard refused
|
|
84
|
-
* queries: a false refusal is not free here, it degrades the tool
|
|
85
|
-
* people to route around it.
|
|
87
|
+
* A name is split into WORDS (see {@link secretNameWords}) and tested by word
|
|
88
|
+
* membership, never by substring. Unanchored, `secret` matched `secretary_id`
|
|
89
|
+
* and `token` matched a perfectly ordinary `token_count`, so the guard refused
|
|
90
|
+
* legitimate queries: a false refusal is not free here, it degrades the tool
|
|
91
|
+
* and trains people to route around it. Word splitting reads snake_case,
|
|
92
|
+
* kebab-case, camelCase, PascalCase and SCREAMING_CASE the same way, which the
|
|
93
|
+
* earlier underscore-anchored pattern did not: on a camelCase database (the
|
|
94
|
+
* default naming of a Prisma-created one) it caught `apiKey` only by accident
|
|
95
|
+
* of the whole name being a listed word and returned `refreshToken`,
|
|
96
|
+
* `passwordHash` and `clientSecret` verbatim.
|
|
86
97
|
*
|
|
87
98
|
* Deliberately still conservative in the other direction. A column whose name
|
|
88
|
-
* genuinely carries a
|
|
99
|
+
* genuinely carries a word like `token` or `secret` is refused even when it
|
|
89
100
|
* holds nothing sensitive, because this gate decides whether raw bytes, or a
|
|
90
101
|
* row-count oracle over them, reach an LLM context. Renaming the column is a
|
|
91
102
|
* cheaper fix than the disclosure it prevents.
|
|
92
103
|
*/
|
|
93
|
-
const SECRET_WORDS = [
|
|
104
|
+
const SECRET_WORDS = new Set([
|
|
94
105
|
'passwd',
|
|
95
106
|
'password',
|
|
107
|
+
'passphrase',
|
|
108
|
+
'passcode',
|
|
96
109
|
'secret',
|
|
97
110
|
'token',
|
|
98
111
|
'apikey',
|
|
99
|
-
'api_key',
|
|
100
112
|
'privatekey',
|
|
101
|
-
'
|
|
113
|
+
'accesskey',
|
|
102
114
|
'credential',
|
|
103
|
-
'credentials',
|
|
104
115
|
'sessionid',
|
|
105
|
-
'session_id',
|
|
106
116
|
'otp',
|
|
107
117
|
'mfa',
|
|
108
118
|
'totp',
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
'jwt',
|
|
120
|
+
'salt',
|
|
121
|
+
]);
|
|
122
|
+
/** Word pairs that are secret only together (`api` + `key`), as one joined string. */
|
|
123
|
+
const SECRET_WORD_PAIRS = new Set(['apikey', 'privatekey', 'accesskey', 'sessionid']);
|
|
124
|
+
/**
|
|
125
|
+
* Suffixes and prefixes for a name that arrives as ONE lowercase word
|
|
126
|
+
* (`refreshtoken`, `passwordhash`), where there is no boundary left to split
|
|
127
|
+
* on. Suffix-only for the short, common words so `secretary` and `tokenizer`
|
|
128
|
+
* stay unflagged; the prefix list is limited to words nothing else starts with.
|
|
129
|
+
*/
|
|
130
|
+
const SECRET_SUFFIXES = ['token', 'secret', 'password', 'passwd', 'passphrase', 'apikey', 'privatekey', 'sessionid'];
|
|
131
|
+
const SECRET_PREFIXES = ['password', 'passwd', 'passphrase', 'apikey', 'privatekey', 'accesskey'];
|
|
132
|
+
/**
|
|
133
|
+
* Split an identifier into lowercase words on separators, case changes
|
|
134
|
+
* (`refreshToken`), acronym edges (`APIKey` -> `api`, `key`) and letter/digit
|
|
135
|
+
* edges (`token2`).
|
|
136
|
+
*/
|
|
137
|
+
export function secretNameWords(name) {
|
|
138
|
+
return name
|
|
139
|
+
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
140
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
141
|
+
.replace(/([A-Za-z])(\d)/g, '$1 $2')
|
|
142
|
+
.replace(/(\d)([A-Za-z])/g, '$1 $2')
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
.split(/[^a-z0-9]+/)
|
|
145
|
+
.filter((word) => word !== '');
|
|
146
|
+
}
|
|
147
|
+
function isSecretWord(word) {
|
|
148
|
+
if (SECRET_WORDS.has(word))
|
|
149
|
+
return true;
|
|
150
|
+
// Plural (`tokens`, `api_keys` via the pair join, `credentials`).
|
|
151
|
+
return word.length > 3 && word.endsWith('s') && SECRET_WORDS.has(word.slice(0, -1));
|
|
152
|
+
}
|
|
153
|
+
/** True when `name` looks like it holds a credential. See {@link SECRET_WORDS}. */
|
|
154
|
+
export function looksLikeSecretName(name) {
|
|
155
|
+
const words = secretNameWords(name);
|
|
156
|
+
for (let i = 0; i < words.length; i++) {
|
|
157
|
+
const word = words[i];
|
|
158
|
+
if (isSecretWord(word))
|
|
159
|
+
return true;
|
|
160
|
+
const next = words[i + 1];
|
|
161
|
+
if (next !== undefined && SECRET_WORD_PAIRS.has(`${word}${next.replace(/s$/, '')}`))
|
|
162
|
+
return true;
|
|
163
|
+
// One unsplittable lowercase word: only worth a second look when it is long
|
|
164
|
+
// enough to be a compound, and only against the safe suffix/prefix lists.
|
|
165
|
+
if (word.length > 5 && /^[a-z]+$/.test(word)) {
|
|
166
|
+
if (SECRET_SUFFIXES.some((suffix) => word.length > suffix.length && word.endsWith(suffix)))
|
|
167
|
+
return true;
|
|
168
|
+
if (SECRET_PREFIXES.some((prefix) => word.length > prefix.length && word.startsWith(prefix)))
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
111
174
|
/**
|
|
112
175
|
* The most bytes the stdio reader will hold WITHOUT seeing a newline.
|
|
113
176
|
*
|
|
@@ -149,7 +212,7 @@ const TOOLS = [
|
|
|
149
212
|
},
|
|
150
213
|
{
|
|
151
214
|
name: 'explain_query',
|
|
152
|
-
description: 'Run EXPLAIN (FORMAT JSON) for a schema-validated findMany query. Pass table + optional where/orderBy/limit/select, free-form SQL is rejected. A where or orderBy on a PII-tagged or secret-named column is refused: the planner row estimate would leak the value. Naming
|
|
215
|
+
description: 'Run EXPLAIN (FORMAT JSON) for a schema-validated findMany query. Pass table + optional where/orderBy/limit/select, free-form SQL is rejected. A where or orderBy on a PII-tagged or secret-named column is refused: the planner row estimate would leak the value. Naming a PII-tagged column in select is refused, as it is in the library without includePii: UNSAFE, which this server cannot pass. A `lock` option is refused: this server is read-only and a locking read needs a write-capable transaction in your application.',
|
|
153
216
|
inputSchema: {
|
|
154
217
|
type: 'object',
|
|
155
218
|
properties: {
|
|
@@ -174,7 +237,7 @@ const TOOLS = [
|
|
|
174
237
|
},
|
|
175
238
|
{
|
|
176
239
|
name: 'compile_query',
|
|
177
|
-
description: 'Compile a Turbine read query to the EXACT SQL it would send, WITHOUT running it: no statement is executed and no row is read, so this is safe to call on production and safe to call in a loop. Use it before you write the query into code. Pass `table`, an `operation` (findMany / findUnique / findFirst / count / aggregate / groupBy, default findMany) and `args`, the same object you would pass to that method. Returns the SQL, the bound parameters, how many STATEMENTS the query costs at execution (a `with` clause can be one join or one follow-up per relation), which relation-load strategy it takes, whether it is bounded by a LIMIT or reads the whole table, the relation depth, and warnings such as a relation whose correlation column has no index. A query that FAILS to compile is a successful answer, not an error: an unknown column (TURBINE_E003), an unknown relation (TURBINE_E005) or the empty-where guard comes back as `ok: false` with the code, the message and how to fix it. READ OPERATIONS ONLY, deliberately: this server has no write surface and compiling one would be the first. A where or orderBy on a PII-tagged or secret-named column is refused, exactly as in explain_query.',
|
|
240
|
+
description: 'Compile a Turbine read query to the EXACT SQL it would send, WITHOUT running it: no statement is executed and no row is read, so this is safe to call on production and safe to call in a loop. Use it before you write the query into code. Pass `table`, an `operation` (findMany / findUnique / findFirst / count / aggregate / groupBy, default findMany) and `args`, the same object you would pass to that method. Returns the SQL, the bound parameters, how many STATEMENTS the query costs at execution (a `with` clause can be one join or one follow-up per relation), which relation-load strategy it takes, whether it is bounded by a LIMIT or reads the whole table, the relation depth, and warnings such as a relation whose correlation column has no index. A query that FAILS to compile is a successful answer, not an error: an unknown column (TURBINE_E003), an unknown relation (TURBINE_E005) or the empty-where guard comes back as `ok: false` with the code, the message and how to fix it. READ OPERATIONS ONLY, deliberately: this server has no write surface and compiling one would be the first. A where or orderBy on a PII-tagged or secret-named column is refused, exactly as in explain_query, and so is a `lock` option (on the query or on any `with` entry): compile the query without it.',
|
|
178
241
|
inputSchema: {
|
|
179
242
|
type: 'object',
|
|
180
243
|
properties: {
|
|
@@ -753,6 +816,9 @@ async function explainQuery(ctx, args) {
|
|
|
753
816
|
throw jsonRpcError(-32602, 'explain_query no longer accepts free-form SQL; pass table + findMany-style args (where/orderBy/limit/select)');
|
|
754
817
|
}
|
|
755
818
|
const tableName = requiredString(args, 'table');
|
|
819
|
+
// Before the allowlist below, which would otherwise drop `lock` unseen and
|
|
820
|
+
// plan an UNLOCKED query for a caller who asked for a locking one.
|
|
821
|
+
assertNoLockingRead(args, 'explain_query');
|
|
756
822
|
const findManyArgs = parseExplainFindManyArgs(args);
|
|
757
823
|
return withReadOnly(ctx, async (client) => {
|
|
758
824
|
const { metadata, piiTags } = await loadSchemaMetadata(client, ctx.options);
|
|
@@ -789,7 +855,7 @@ async function explainQuery(ctx, args) {
|
|
|
789
855
|
}
|
|
790
856
|
/**
|
|
791
857
|
* Refuse an `explain_query` that filters or sorts on a hidden column: one that
|
|
792
|
-
* is PII-tagged, or one whose NAME matches {@link
|
|
858
|
+
* is PII-tagged, or one whose NAME matches {@link looksLikeSecretName}.
|
|
793
859
|
*
|
|
794
860
|
* The name half is not a second-best approximation of the tag half, it is the
|
|
795
861
|
* same rule `sample_rows` applies, applied to the other value-bearing path.
|
|
@@ -815,8 +881,10 @@ async function explainQuery(ctx, args) {
|
|
|
815
881
|
* What stays here is the part that is genuinely the MCP server's: the two
|
|
816
882
|
* reasons a column is hidden, and a JSON-RPC refusal.
|
|
817
883
|
*
|
|
818
|
-
* `select` is
|
|
819
|
-
* nothing about its contents.
|
|
884
|
+
* `select` is not judged HERE: explain returns no rows, and naming a column reveals
|
|
885
|
+
* nothing about its contents. The library itself refuses a PII-tagged column in
|
|
886
|
+
* `select` without `includePii: UNSAFE`, which no JSON-RPC caller can supply, so
|
|
887
|
+
* the builder's own E003 reaches the caller as an invalid-params error.
|
|
820
888
|
*
|
|
821
889
|
* FAILS CLOSED when the tag scan failed: with no trustworthy tag list, no
|
|
822
890
|
* column can be shown to be safe, so every where/orderBy is refused rather than
|
|
@@ -839,7 +907,7 @@ async function explainQuery(ctx, args) {
|
|
|
839
907
|
function hiddenColumnReason(owner, column) {
|
|
840
908
|
if (owner.columns.some((col) => col.name === column && col.pii === true))
|
|
841
909
|
return 'is PII-tagged';
|
|
842
|
-
if (
|
|
910
|
+
if (looksLikeSecretName(column))
|
|
843
911
|
return 'has a secret-looking name';
|
|
844
912
|
return null;
|
|
845
913
|
}
|
|
@@ -854,7 +922,7 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
|
|
|
854
922
|
metadata,
|
|
855
923
|
hiddenReason: hiddenColumnReason,
|
|
856
924
|
refuseColumn: (owner, column, why) => {
|
|
857
|
-
throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so it cannot be used in a where or orderBy here: ` +
|
|
925
|
+
throw jsonRpcError(-32602, `Column "${shown(column)}" on "${shown(owner.name)}" ${why}, so it cannot be used in a where or orderBy here: ` +
|
|
858
926
|
`EXPLAIN reports the planner's row estimate, and the estimate for a predicate on a hidden value ` +
|
|
859
927
|
`reveals that value one character at a time. Filter on a visible column instead.`);
|
|
860
928
|
},
|
|
@@ -863,7 +931,7 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
|
|
|
863
931
|
`prove it does not filter or sort on a tagged column, so it is refused. Flatten the query.`);
|
|
864
932
|
},
|
|
865
933
|
refuseShape: (owner, key) => {
|
|
866
|
-
throw jsonRpcError(-32602, `The PII guard does not recognize "${key}" in a query on "${owner.name}", so it cannot prove the ` +
|
|
934
|
+
throw jsonRpcError(-32602, `The PII guard does not recognize "${shown(key)}" in a query on "${shown(owner.name)}", so it cannot prove the ` +
|
|
867
935
|
`query does not filter or sort on a hidden column, and refuses it rather than guessing. ` +
|
|
868
936
|
`Remove it and explain the query without it.`);
|
|
869
937
|
},
|
|
@@ -909,6 +977,10 @@ async function compileQuery(ctx, args) {
|
|
|
909
977
|
const tableName = requiredString(args, 'table');
|
|
910
978
|
const operation = parseCompileOperation(args.operation);
|
|
911
979
|
const queryArgs = parseCompileArgs(args.args);
|
|
980
|
+
// Whatever the operation: count / aggregate / groupBy take no `lock`, and
|
|
981
|
+
// compiling them would drop it unseen, so the SQL shown would not be the
|
|
982
|
+
// query the caller wrote. Refused before the catalog read.
|
|
983
|
+
assertNoLockingRead(queryArgs, 'compile_query');
|
|
912
984
|
const { metadata, piiTags } = await withReadOnly(ctx, (client) => loadSchemaMetadata(client, ctx.options));
|
|
913
985
|
const table = requireTable(metadata, tableName);
|
|
914
986
|
assertCompileHidesNothing(queryArgs, operation, table, metadata, piiTags);
|
|
@@ -986,7 +1058,7 @@ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
|
|
|
986
1058
|
metadata,
|
|
987
1059
|
hiddenReason: hiddenColumnReason,
|
|
988
1060
|
refuseColumn: (owner, column, why) => {
|
|
989
|
-
throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so compile_query will not build a statement that filters, ` +
|
|
1061
|
+
throw jsonRpcError(-32602, `Column "${shown(column)}" on "${shown(owner.name)}" ${why}, so compile_query will not build a statement that filters, ` +
|
|
990
1062
|
`sorts, pages or groups on it. This server does not author queries that interrogate a hidden value, ` +
|
|
991
1063
|
`wherever that statement would eventually run. Use a visible column.`);
|
|
992
1064
|
},
|
|
@@ -995,7 +1067,7 @@ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
|
|
|
995
1067
|
`not name a hidden column, so it is refused. Flatten the query.`);
|
|
996
1068
|
},
|
|
997
1069
|
refuseShape: (owner, key) => {
|
|
998
|
-
throw jsonRpcError(-32602, `The PII guard does not recognize "${key}" in a query on "${owner.name}", so it cannot prove the query ` +
|
|
1070
|
+
throw jsonRpcError(-32602, `The PII guard does not recognize "${shown(key)}" in a query on "${shown(owner.name)}", so it cannot prove the query ` +
|
|
999
1071
|
`does not name a hidden column, and refuses it rather than guessing. Remove it and compile without it.`);
|
|
1000
1072
|
},
|
|
1001
1073
|
});
|
|
@@ -1015,11 +1087,26 @@ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
|
|
|
1015
1087
|
const why = hiddenColumnReason(table, column);
|
|
1016
1088
|
if (!why)
|
|
1017
1089
|
continue;
|
|
1018
|
-
throw jsonRpcError(-32602, `Column "${column}" on "${table.name}" ${why}, so it cannot be used as a group key, an aggregate target, a ` +
|
|
1090
|
+
throw jsonRpcError(-32602, `Column "${shown(column)}" on "${shown(table.name)}" ${why}, so it cannot be used as a group key, an aggregate target, a ` +
|
|
1019
1091
|
`HAVING term or an ordering in compile_query. _count over the table is unaffected; group and aggregate on ` +
|
|
1020
1092
|
`a visible column instead.`);
|
|
1021
1093
|
}
|
|
1022
1094
|
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Refuse a `lock` option, at the top level or on any `with` entry, by name.
|
|
1097
|
+
*
|
|
1098
|
+
* Both tools that take query args are read-only, and a locking read takes row
|
|
1099
|
+
* locks, which needs a write-capable transaction in the application. The search
|
|
1100
|
+
* and the wording are the shared ones in `cli/pii-predicate-guard.ts`, the same
|
|
1101
|
+
* pair Studio's Query tab uses, so the rule is stated once. It runs on the RAW
|
|
1102
|
+
* args, ahead of every parser and guard that might drop or reshape the key.
|
|
1103
|
+
*/
|
|
1104
|
+
function assertNoLockingRead(args, tool) {
|
|
1105
|
+
const locked = findRowLockOption(args);
|
|
1106
|
+
if (locked === null)
|
|
1107
|
+
return;
|
|
1108
|
+
throw jsonRpcError(-32602, rowLockRefusalMessage(shown(locked), tool));
|
|
1109
|
+
}
|
|
1023
1110
|
/**
|
|
1024
1111
|
* Extract the allowed findMany subset for explain_query (no `with` / raw SQL).
|
|
1025
1112
|
* Returns a plain object cast at the buildFindMany call site, same pattern as Studio.
|
|
@@ -1074,7 +1161,7 @@ function classifyHiddenColumns(table, piiTags) {
|
|
|
1074
1161
|
? 'tags-unreadable'
|
|
1075
1162
|
: col.pii === true
|
|
1076
1163
|
? 'pii'
|
|
1077
|
-
:
|
|
1164
|
+
: looksLikeSecretName(col.name)
|
|
1078
1165
|
? 'secret-name'
|
|
1079
1166
|
: null;
|
|
1080
1167
|
if (reason) {
|
|
@@ -1469,7 +1556,7 @@ async function findJoinPath(ctx, args) {
|
|
|
1469
1556
|
paths,
|
|
1470
1557
|
pathsTruncated: false,
|
|
1471
1558
|
notes: [
|
|
1472
|
-
`"${fromTable.name}" is both ends of this query, so no join is needed to read its own columns.`,
|
|
1559
|
+
`"${shown(fromTable.name)}" is both ends of this query, so no join is needed to read its own columns.`,
|
|
1473
1560
|
paths.length > 0
|
|
1474
1561
|
? 'The paths below are its SELF-relations: a relation whose target is the same table, which is the only way to join it to itself.'
|
|
1475
1562
|
: 'It declares no self-relation, so there is nothing to join it to itself through.',
|
|
@@ -1491,12 +1578,12 @@ async function findJoinPath(ctx, args) {
|
|
|
1491
1578
|
pathsTruncated: false,
|
|
1492
1579
|
searchExhausted: exhausted,
|
|
1493
1580
|
reason: exhausted
|
|
1494
|
-
? `The search for a chain from "${fromTable.name}" to "${toTable.name}" hit this tool's node budget before ` +
|
|
1581
|
+
? `The search for a chain from "${shown(fromTable.name)}" to "${shown(toTable.name)}" hit this tool's node budget before ` +
|
|
1495
1582
|
`it finished, so this is NOT an answer that they are unconnected. Lower maxDepth (it is ${maxDepth}) to ` +
|
|
1496
|
-
`bound the search, or call relation_graph on "${fromTable.name}" and walk it a hop at a time.`
|
|
1497
|
-
: `No relation chain connects "${fromTable.name}" to "${toTable.name}" within ${maxDepth} hop(s). Either the ` +
|
|
1583
|
+
`bound the search, or call relation_graph on "${shown(fromTable.name)}" and walk it a hop at a time.`
|
|
1584
|
+
: `No relation chain connects "${shown(fromTable.name)}" to "${shown(toTable.name)}" within ${maxDepth} hop(s). Either the ` +
|
|
1498
1585
|
`schema declares no foreign key path between them, or the path is longer than the search depth. Raise ` +
|
|
1499
|
-
`maxDepth, or call relation_graph on "${fromTable.name}" to see what it does reach.`,
|
|
1586
|
+
`maxDepth, or call relation_graph on "${shown(fromTable.name)}" to see what it does reach.`,
|
|
1500
1587
|
notes: exhausted
|
|
1501
1588
|
? ['The search did not complete. Do not report these tables as unconnected on the strength of this reply.']
|
|
1502
1589
|
: [
|
|
@@ -2016,8 +2103,8 @@ function estimatedRowCount(stats) {
|
|
|
2016
2103
|
function requireTable(metadata, tableName) {
|
|
2017
2104
|
const table = ownLookup(metadata.tables, tableName);
|
|
2018
2105
|
if (!table) {
|
|
2019
|
-
const available = Object.keys(metadata.tables).join(', ') || '(none)';
|
|
2020
|
-
throw jsonRpcError(-32602, `Unknown table "${tableName}". Available: ${available}`);
|
|
2106
|
+
const available = Object.keys(metadata.tables).map(shown).join(', ') || '(none)';
|
|
2107
|
+
throw jsonRpcError(-32602, `Unknown table "${shown(tableName)}". Available: ${available}`);
|
|
2021
2108
|
}
|
|
2022
2109
|
return table;
|
|
2023
2110
|
}
|