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,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm CLI: shared guards for the local servers
|
|
4
|
+
*
|
|
5
|
+
* A leaf module (Node's crypto and nothing else), the same role as
|
|
6
|
+
* `cli/rate-limit.ts`: Studio and Observe both bind a loopback port behind a
|
|
7
|
+
* random token, and the checks below are the parts of that perimeter that are
|
|
8
|
+
* identical for both, so neither server grows its own copy.
|
|
9
|
+
*
|
|
10
|
+
* - {@link isAllowedHostHeader}: a request whose `Host` names anything but this
|
|
11
|
+
* server is refused. This is what stops DNS rebinding: a page on an attacker's
|
|
12
|
+
* domain that re-resolves to 127.0.0.1 still sends `Host: attacker.example`.
|
|
13
|
+
* - {@link createTicketGate} / {@link redeemTicket}: the URL printed in the
|
|
14
|
+
* terminal carries a ONE-TIME login token, separate from the credential the
|
|
15
|
+
* session then uses, so a URL that lands in browser history, a process
|
|
16
|
+
* listing or a log is a spent ticket and not a standing key.
|
|
17
|
+
* - {@link constantTimeEqual}: the one token comparison.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.constantTimeEqual = constantTimeEqual;
|
|
21
|
+
exports.isAllowedHostHeader = isAllowedHostHeader;
|
|
22
|
+
exports.createTicketGate = createTicketGate;
|
|
23
|
+
exports.redeemTicket = redeemTicket;
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
/**
|
|
26
|
+
* Compare two secrets without leaking length or content through timing. Both
|
|
27
|
+
* sides are hashed to fixed-length digests first, so `timingSafeEqual` never
|
|
28
|
+
* throws on a length mismatch.
|
|
29
|
+
*/
|
|
30
|
+
function constantTimeEqual(a, b) {
|
|
31
|
+
const ah = (0, node_crypto_1.createHash)('sha256').update(a).digest();
|
|
32
|
+
const bh = (0, node_crypto_1.createHash)('sha256').update(b).digest();
|
|
33
|
+
return (0, node_crypto_1.timingSafeEqual)(ah, bh);
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Host header allowlist
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '[::1]'];
|
|
39
|
+
const WILDCARD_HOSTNAMES = new Set(['0.0.0.0', '[::]', '::']);
|
|
40
|
+
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
41
|
+
/** `host`, `host:port`, `[v6]` or `[v6]:port`, or null when it is none of those. */
|
|
42
|
+
function parseHostHeader(value) {
|
|
43
|
+
const trimmed = value.trim().toLowerCase();
|
|
44
|
+
if (trimmed === '')
|
|
45
|
+
return null;
|
|
46
|
+
if (trimmed.startsWith('[')) {
|
|
47
|
+
const end = trimmed.indexOf(']');
|
|
48
|
+
if (end < 0)
|
|
49
|
+
return null;
|
|
50
|
+
const rest = trimmed.slice(end + 1);
|
|
51
|
+
if (rest === '')
|
|
52
|
+
return { hostname: trimmed.slice(0, end + 1), port: null };
|
|
53
|
+
if (!/^:\d+$/.test(rest))
|
|
54
|
+
return null;
|
|
55
|
+
return { hostname: trimmed.slice(0, end + 1), port: rest.slice(1) };
|
|
56
|
+
}
|
|
57
|
+
const colon = trimmed.indexOf(':');
|
|
58
|
+
if (colon < 0)
|
|
59
|
+
return { hostname: trimmed, port: null };
|
|
60
|
+
// A bare name never contains a second colon: that would be an unbracketed IPv6.
|
|
61
|
+
if (trimmed.indexOf(':', colon + 1) >= 0)
|
|
62
|
+
return null;
|
|
63
|
+
const port = trimmed.slice(colon + 1);
|
|
64
|
+
if (!/^\d+$/.test(port))
|
|
65
|
+
return null;
|
|
66
|
+
return { hostname: trimmed.slice(0, colon), port };
|
|
67
|
+
}
|
|
68
|
+
function bracketIfIpv6(host) {
|
|
69
|
+
const lower = host.toLowerCase();
|
|
70
|
+
return lower.includes(':') && !lower.startsWith('[') ? `[${lower}]` : lower;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Is this `Host` header one this server should answer to?
|
|
74
|
+
*
|
|
75
|
+
* Allowed: the loopback names (`127.0.0.1`, `localhost`, `[::1]`) and the host
|
|
76
|
+
* the server was bound to, each on the bound port. A server bound to a wildcard
|
|
77
|
+
* address (`0.0.0.0`, `::`) also accepts an IP literal, because it has no single
|
|
78
|
+
* name and a literal address cannot be rebound: rebinding needs a DNS name the
|
|
79
|
+
* attacker controls.
|
|
80
|
+
*
|
|
81
|
+
* An ABSENT header is allowed. HTTP/1.1 requires it and Node answers 400 itself
|
|
82
|
+
* when it is missing, so the only request that reaches this check without one is
|
|
83
|
+
* an HTTP/1.0 client, which is not a browser and can say anything anyway. The
|
|
84
|
+
* attack this refuses needs a browser, and a browser always sends one.
|
|
85
|
+
*/
|
|
86
|
+
function isAllowedHostHeader(header, boundHost, port) {
|
|
87
|
+
if (header === undefined)
|
|
88
|
+
return true;
|
|
89
|
+
if (typeof header !== 'string')
|
|
90
|
+
return false;
|
|
91
|
+
const parsed = parseHostHeader(header);
|
|
92
|
+
if (!parsed)
|
|
93
|
+
return false;
|
|
94
|
+
if (parsed.port === null ? port !== 80 : parsed.port !== String(port))
|
|
95
|
+
return false;
|
|
96
|
+
const bound = bracketIfIpv6(boundHost);
|
|
97
|
+
if (parsed.hostname === bound || LOOPBACK_HOSTNAMES.includes(parsed.hostname))
|
|
98
|
+
return true;
|
|
99
|
+
if (WILDCARD_HOSTNAMES.has(boundHost.toLowerCase()) || WILDCARD_HOSTNAMES.has(bound)) {
|
|
100
|
+
return IPV4_LITERAL.test(parsed.hostname) || parsed.hostname.startsWith('[');
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
/** Mint a gate. `token` is only passed by a caller that has no separate one to give. */
|
|
105
|
+
function createTicketGate(token = (0, node_crypto_1.randomBytes)(24).toString('hex')) {
|
|
106
|
+
return { token, used: false };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Try to exchange `candidate` for a session. `granted` happens at most once per
|
|
110
|
+
* gate; afterwards the same token reports `used`, which a server must refuse.
|
|
111
|
+
*/
|
|
112
|
+
function redeemTicket(gate, candidate) {
|
|
113
|
+
if (!constantTimeEqual(candidate, gate.token))
|
|
114
|
+
return 'invalid';
|
|
115
|
+
if (gate.used)
|
|
116
|
+
return 'used';
|
|
117
|
+
gate.used = true;
|
|
118
|
+
return 'granted';
|
|
119
|
+
}
|
|
@@ -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,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What the two local servers (Studio and Observe) send OUT of the process: the
|
|
4
|
+
* text of an error response, and the command that opens a browser. A leaf that
|
|
5
|
+
* imports only `node:child_process`, so both servers share one copy.
|
|
6
|
+
*
|
|
7
|
+
* Both exist because the input to them is not entirely ours. A caught value can
|
|
8
|
+
* be anything a dependency threw, and the host in the printed URL comes from a
|
|
9
|
+
* command-line flag.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.responseErrorText = responseErrorText;
|
|
13
|
+
exports.isLaunchableUrl = isLaunchableUrl;
|
|
14
|
+
exports.openInBrowser = openInBrowser;
|
|
15
|
+
const node_child_process_1 = require("node:child_process");
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Error text for an HTTP response
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const UNEXPECTED_ERROR = 'unexpected error';
|
|
20
|
+
/**
|
|
21
|
+
* The text a Studio or Observe error response may carry for a caught value.
|
|
22
|
+
*
|
|
23
|
+
* An `Error` contributes its own message, and only that: never `stack`, and
|
|
24
|
+
* never the result of stringifying the object (a `toString` on a foreign error
|
|
25
|
+
* type is free to print one). When the message is empty, which is what Node's
|
|
26
|
+
* `AggregateError` has for a refused connection, the messages of the errors it
|
|
27
|
+
* aggregates or its `cause` are used, then its `code`, then its name. A thrown
|
|
28
|
+
* value that is not an `Error` has nothing safe to say, so it gets a fixed
|
|
29
|
+
* sentence rather than a rendering of whatever it was.
|
|
30
|
+
*/
|
|
31
|
+
function responseErrorText(err) {
|
|
32
|
+
return err instanceof Error ? errorMessage(err, 0) : UNEXPECTED_ERROR;
|
|
33
|
+
}
|
|
34
|
+
function errorMessage(err, depth) {
|
|
35
|
+
if (err.message)
|
|
36
|
+
return err.message;
|
|
37
|
+
if (depth < 4) {
|
|
38
|
+
const inner = err.errors;
|
|
39
|
+
if (Array.isArray(inner)) {
|
|
40
|
+
const parts = new Set();
|
|
41
|
+
for (const e of inner) {
|
|
42
|
+
const text = e instanceof Error ? errorMessage(e, depth + 1) : '';
|
|
43
|
+
if (text !== '')
|
|
44
|
+
parts.add(text);
|
|
45
|
+
}
|
|
46
|
+
if (parts.size > 0)
|
|
47
|
+
return [...parts].join('; ');
|
|
48
|
+
}
|
|
49
|
+
if (err.cause instanceof Error) {
|
|
50
|
+
const cause = errorMessage(err.cause, depth + 1);
|
|
51
|
+
if (cause !== '')
|
|
52
|
+
return cause;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const code = err.code;
|
|
56
|
+
if (typeof code === 'string' && code !== '')
|
|
57
|
+
return code;
|
|
58
|
+
return err.name || 'Error';
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Browser launch
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* The only URL shape a server launches: plain `http`, a host name or IP
|
|
65
|
+
* literal, a port, and optionally the one-time `?token=`. The host comes from
|
|
66
|
+
* `--host`, and on Windows the URL is an argument of `cmd /c start`, where `&`,
|
|
67
|
+
* `|`, `^`, `%` and `"` are command syntax rather than data, so anything outside
|
|
68
|
+
* this set is not handed to a launcher at all. The URL is still printed.
|
|
69
|
+
*/
|
|
70
|
+
const LAUNCHABLE_URL = /^http:\/\/(?:[A-Za-z0-9._-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}\/(?:\?token=[A-Za-z0-9_-]+)?$/;
|
|
71
|
+
/** @internal exported for tests. */
|
|
72
|
+
function isLaunchableUrl(url) {
|
|
73
|
+
return LAUNCHABLE_URL.test(url);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Open `url` in the user's browser. Best effort: false when the URL is not one
|
|
77
|
+
* of ours to launch or the launcher could not be started, and the caller has
|
|
78
|
+
* already printed the URL either way.
|
|
79
|
+
*
|
|
80
|
+
* The launcher is started directly with an argument list and no shell
|
|
81
|
+
* (`xdg-open <url>`, `open <url>`, and on Windows `cmd /c start "" <url>`).
|
|
82
|
+
* A launcher that is not installed is reported as an 'error' EVENT rather than
|
|
83
|
+
* a throw, and with no listener Node turns it into an uncaught exception that
|
|
84
|
+
* ends the server right after it printed its address.
|
|
85
|
+
*/
|
|
86
|
+
function openInBrowser(url, launch = { platform: process.platform, spawn: node_child_process_1.spawn }) {
|
|
87
|
+
if (!isLaunchableUrl(url))
|
|
88
|
+
return false;
|
|
89
|
+
const [command, args] = launch.platform === 'darwin'
|
|
90
|
+
? ['open', [url]]
|
|
91
|
+
: launch.platform === 'win32'
|
|
92
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
93
|
+
: ['xdg-open', [url]];
|
|
94
|
+
try {
|
|
95
|
+
const child = launch.spawn(command, args, { stdio: 'ignore', detached: true });
|
|
96
|
+
child.on('error', () => { });
|
|
97
|
+
child.unref();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/cjs/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/cjs/cli/mcp.js
CHANGED
|
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.MAX_STDIO_BUFFER_BYTES = void 0;
|
|
7
|
+
exports.secretNameWords = secretNameWords;
|
|
8
|
+
exports.looksLikeSecretName = looksLikeSecretName;
|
|
7
9
|
exports.startMcpServer = startMcpServer;
|
|
8
10
|
exports.shortestJoinPaths = shortestJoinPaths;
|
|
9
11
|
exports.buildRelations = buildRelations;
|
|
@@ -61,6 +63,12 @@ function readOwnVersion() {
|
|
|
61
63
|
const PROTOCOL_VERSION = '2025-06-18';
|
|
62
64
|
const STATEMENT_TIMEOUT = '30s';
|
|
63
65
|
const TRACKING_TABLE = '_turbine_migrations';
|
|
66
|
+
/**
|
|
67
|
+
* A name made safe to quote in a message. Catalog names can carry control or
|
|
68
|
+
* bidi characters, and an error message or stderr line can reach a terminal or
|
|
69
|
+
* a log. Same helper `generate`, `status` and `doctor` print through.
|
|
70
|
+
*/
|
|
71
|
+
const shown = introspect_js_1.printableCatalogName;
|
|
64
72
|
/**
|
|
65
73
|
* Marker written in place of a hidden cell. Never `null` and never the value,
|
|
66
74
|
* so the agent can tell "hidden" from "empty" (same string Studio uses).
|
|
@@ -88,36 +96,93 @@ const REDACTED = '•• redacted ••';
|
|
|
88
96
|
/**
|
|
89
97
|
* Column names that look like they hold a credential.
|
|
90
98
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* matched a perfectly ordinary `token_count`, so the guard refused
|
|
94
|
-
* queries: a false refusal is not free here, it degrades the tool
|
|
95
|
-
* people to route around it.
|
|
99
|
+
* A name is split into WORDS (see {@link secretNameWords}) and tested by word
|
|
100
|
+
* membership, never by substring. Unanchored, `secret` matched `secretary_id`
|
|
101
|
+
* and `token` matched a perfectly ordinary `token_count`, so the guard refused
|
|
102
|
+
* legitimate queries: a false refusal is not free here, it degrades the tool
|
|
103
|
+
* and trains people to route around it. Word splitting reads snake_case,
|
|
104
|
+
* kebab-case, camelCase, PascalCase and SCREAMING_CASE the same way, which the
|
|
105
|
+
* earlier underscore-anchored pattern did not: on a camelCase database (the
|
|
106
|
+
* default naming of a Prisma-created one) it caught `apiKey` only by accident
|
|
107
|
+
* of the whole name being a listed word and returned `refreshToken`,
|
|
108
|
+
* `passwordHash` and `clientSecret` verbatim.
|
|
96
109
|
*
|
|
97
110
|
* Deliberately still conservative in the other direction. A column whose name
|
|
98
|
-
* genuinely carries a
|
|
111
|
+
* genuinely carries a word like `token` or `secret` is refused even when it
|
|
99
112
|
* holds nothing sensitive, because this gate decides whether raw bytes, or a
|
|
100
113
|
* row-count oracle over them, reach an LLM context. Renaming the column is a
|
|
101
114
|
* cheaper fix than the disclosure it prevents.
|
|
102
115
|
*/
|
|
103
|
-
const SECRET_WORDS = [
|
|
116
|
+
const SECRET_WORDS = new Set([
|
|
104
117
|
'passwd',
|
|
105
118
|
'password',
|
|
119
|
+
'passphrase',
|
|
120
|
+
'passcode',
|
|
106
121
|
'secret',
|
|
107
122
|
'token',
|
|
108
123
|
'apikey',
|
|
109
|
-
'api_key',
|
|
110
124
|
'privatekey',
|
|
111
|
-
'
|
|
125
|
+
'accesskey',
|
|
112
126
|
'credential',
|
|
113
|
-
'credentials',
|
|
114
127
|
'sessionid',
|
|
115
|
-
'session_id',
|
|
116
128
|
'otp',
|
|
117
129
|
'mfa',
|
|
118
130
|
'totp',
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
'jwt',
|
|
132
|
+
'salt',
|
|
133
|
+
]);
|
|
134
|
+
/** Word pairs that are secret only together (`api` + `key`), as one joined string. */
|
|
135
|
+
const SECRET_WORD_PAIRS = new Set(['apikey', 'privatekey', 'accesskey', 'sessionid']);
|
|
136
|
+
/**
|
|
137
|
+
* Suffixes and prefixes for a name that arrives as ONE lowercase word
|
|
138
|
+
* (`refreshtoken`, `passwordhash`), where there is no boundary left to split
|
|
139
|
+
* on. Suffix-only for the short, common words so `secretary` and `tokenizer`
|
|
140
|
+
* stay unflagged; the prefix list is limited to words nothing else starts with.
|
|
141
|
+
*/
|
|
142
|
+
const SECRET_SUFFIXES = ['token', 'secret', 'password', 'passwd', 'passphrase', 'apikey', 'privatekey', 'sessionid'];
|
|
143
|
+
const SECRET_PREFIXES = ['password', 'passwd', 'passphrase', 'apikey', 'privatekey', 'accesskey'];
|
|
144
|
+
/**
|
|
145
|
+
* Split an identifier into lowercase words on separators, case changes
|
|
146
|
+
* (`refreshToken`), acronym edges (`APIKey` -> `api`, `key`) and letter/digit
|
|
147
|
+
* edges (`token2`).
|
|
148
|
+
*/
|
|
149
|
+
function secretNameWords(name) {
|
|
150
|
+
return name
|
|
151
|
+
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
152
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
153
|
+
.replace(/([A-Za-z])(\d)/g, '$1 $2')
|
|
154
|
+
.replace(/(\d)([A-Za-z])/g, '$1 $2')
|
|
155
|
+
.toLowerCase()
|
|
156
|
+
.split(/[^a-z0-9]+/)
|
|
157
|
+
.filter((word) => word !== '');
|
|
158
|
+
}
|
|
159
|
+
function isSecretWord(word) {
|
|
160
|
+
if (SECRET_WORDS.has(word))
|
|
161
|
+
return true;
|
|
162
|
+
// Plural (`tokens`, `api_keys` via the pair join, `credentials`).
|
|
163
|
+
return word.length > 3 && word.endsWith('s') && SECRET_WORDS.has(word.slice(0, -1));
|
|
164
|
+
}
|
|
165
|
+
/** True when `name` looks like it holds a credential. See {@link SECRET_WORDS}. */
|
|
166
|
+
function looksLikeSecretName(name) {
|
|
167
|
+
const words = secretNameWords(name);
|
|
168
|
+
for (let i = 0; i < words.length; i++) {
|
|
169
|
+
const word = words[i];
|
|
170
|
+
if (isSecretWord(word))
|
|
171
|
+
return true;
|
|
172
|
+
const next = words[i + 1];
|
|
173
|
+
if (next !== undefined && SECRET_WORD_PAIRS.has(`${word}${next.replace(/s$/, '')}`))
|
|
174
|
+
return true;
|
|
175
|
+
// One unsplittable lowercase word: only worth a second look when it is long
|
|
176
|
+
// enough to be a compound, and only against the safe suffix/prefix lists.
|
|
177
|
+
if (word.length > 5 && /^[a-z]+$/.test(word)) {
|
|
178
|
+
if (SECRET_SUFFIXES.some((suffix) => word.length > suffix.length && word.endsWith(suffix)))
|
|
179
|
+
return true;
|
|
180
|
+
if (SECRET_PREFIXES.some((prefix) => word.length > prefix.length && word.startsWith(prefix)))
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
121
186
|
/**
|
|
122
187
|
* The most bytes the stdio reader will hold WITHOUT seeing a newline.
|
|
123
188
|
*
|
|
@@ -159,7 +224,7 @@ const TOOLS = [
|
|
|
159
224
|
},
|
|
160
225
|
{
|
|
161
226
|
name: 'explain_query',
|
|
162
|
-
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
|
|
227
|
+
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.',
|
|
163
228
|
inputSchema: {
|
|
164
229
|
type: 'object',
|
|
165
230
|
properties: {
|
|
@@ -184,7 +249,7 @@ const TOOLS = [
|
|
|
184
249
|
},
|
|
185
250
|
{
|
|
186
251
|
name: 'compile_query',
|
|
187
|
-
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.',
|
|
252
|
+
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.',
|
|
188
253
|
inputSchema: {
|
|
189
254
|
type: 'object',
|
|
190
255
|
properties: {
|
|
@@ -763,6 +828,9 @@ async function explainQuery(ctx, args) {
|
|
|
763
828
|
throw jsonRpcError(-32602, 'explain_query no longer accepts free-form SQL; pass table + findMany-style args (where/orderBy/limit/select)');
|
|
764
829
|
}
|
|
765
830
|
const tableName = requiredString(args, 'table');
|
|
831
|
+
// Before the allowlist below, which would otherwise drop `lock` unseen and
|
|
832
|
+
// plan an UNLOCKED query for a caller who asked for a locking one.
|
|
833
|
+
assertNoLockingRead(args, 'explain_query');
|
|
766
834
|
const findManyArgs = parseExplainFindManyArgs(args);
|
|
767
835
|
return withReadOnly(ctx, async (client) => {
|
|
768
836
|
const { metadata, piiTags } = await loadSchemaMetadata(client, ctx.options);
|
|
@@ -799,7 +867,7 @@ async function explainQuery(ctx, args) {
|
|
|
799
867
|
}
|
|
800
868
|
/**
|
|
801
869
|
* Refuse an `explain_query` that filters or sorts on a hidden column: one that
|
|
802
|
-
* is PII-tagged, or one whose NAME matches {@link
|
|
870
|
+
* is PII-tagged, or one whose NAME matches {@link looksLikeSecretName}.
|
|
803
871
|
*
|
|
804
872
|
* The name half is not a second-best approximation of the tag half, it is the
|
|
805
873
|
* same rule `sample_rows` applies, applied to the other value-bearing path.
|
|
@@ -825,8 +893,10 @@ async function explainQuery(ctx, args) {
|
|
|
825
893
|
* What stays here is the part that is genuinely the MCP server's: the two
|
|
826
894
|
* reasons a column is hidden, and a JSON-RPC refusal.
|
|
827
895
|
*
|
|
828
|
-
* `select` is
|
|
829
|
-
* nothing about its contents.
|
|
896
|
+
* `select` is not judged HERE: explain returns no rows, and naming a column reveals
|
|
897
|
+
* nothing about its contents. The library itself refuses a PII-tagged column in
|
|
898
|
+
* `select` without `includePii: UNSAFE`, which no JSON-RPC caller can supply, so
|
|
899
|
+
* the builder's own E003 reaches the caller as an invalid-params error.
|
|
830
900
|
*
|
|
831
901
|
* FAILS CLOSED when the tag scan failed: with no trustworthy tag list, no
|
|
832
902
|
* column can be shown to be safe, so every where/orderBy is refused rather than
|
|
@@ -849,7 +919,7 @@ async function explainQuery(ctx, args) {
|
|
|
849
919
|
function hiddenColumnReason(owner, column) {
|
|
850
920
|
if (owner.columns.some((col) => col.name === column && col.pii === true))
|
|
851
921
|
return 'is PII-tagged';
|
|
852
|
-
if (
|
|
922
|
+
if (looksLikeSecretName(column))
|
|
853
923
|
return 'has a secret-looking name';
|
|
854
924
|
return null;
|
|
855
925
|
}
|
|
@@ -864,7 +934,7 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
|
|
|
864
934
|
metadata,
|
|
865
935
|
hiddenReason: hiddenColumnReason,
|
|
866
936
|
refuseColumn: (owner, column, why) => {
|
|
867
|
-
throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so it cannot be used in a where or orderBy here: ` +
|
|
937
|
+
throw jsonRpcError(-32602, `Column "${shown(column)}" on "${shown(owner.name)}" ${why}, so it cannot be used in a where or orderBy here: ` +
|
|
868
938
|
`EXPLAIN reports the planner's row estimate, and the estimate for a predicate on a hidden value ` +
|
|
869
939
|
`reveals that value one character at a time. Filter on a visible column instead.`);
|
|
870
940
|
},
|
|
@@ -873,7 +943,7 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
|
|
|
873
943
|
`prove it does not filter or sort on a tagged column, so it is refused. Flatten the query.`);
|
|
874
944
|
},
|
|
875
945
|
refuseShape: (owner, key) => {
|
|
876
|
-
throw jsonRpcError(-32602, `The PII guard does not recognize "${key}" in a query on "${owner.name}", so it cannot prove the ` +
|
|
946
|
+
throw jsonRpcError(-32602, `The PII guard does not recognize "${shown(key)}" in a query on "${shown(owner.name)}", so it cannot prove the ` +
|
|
877
947
|
`query does not filter or sort on a hidden column, and refuses it rather than guessing. ` +
|
|
878
948
|
`Remove it and explain the query without it.`);
|
|
879
949
|
},
|
|
@@ -919,6 +989,10 @@ async function compileQuery(ctx, args) {
|
|
|
919
989
|
const tableName = requiredString(args, 'table');
|
|
920
990
|
const operation = parseCompileOperation(args.operation);
|
|
921
991
|
const queryArgs = parseCompileArgs(args.args);
|
|
992
|
+
// Whatever the operation: count / aggregate / groupBy take no `lock`, and
|
|
993
|
+
// compiling them would drop it unseen, so the SQL shown would not be the
|
|
994
|
+
// query the caller wrote. Refused before the catalog read.
|
|
995
|
+
assertNoLockingRead(queryArgs, 'compile_query');
|
|
922
996
|
const { metadata, piiTags } = await withReadOnly(ctx, (client) => loadSchemaMetadata(client, ctx.options));
|
|
923
997
|
const table = requireTable(metadata, tableName);
|
|
924
998
|
assertCompileHidesNothing(queryArgs, operation, table, metadata, piiTags);
|
|
@@ -996,7 +1070,7 @@ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
|
|
|
996
1070
|
metadata,
|
|
997
1071
|
hiddenReason: hiddenColumnReason,
|
|
998
1072
|
refuseColumn: (owner, column, why) => {
|
|
999
|
-
throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so compile_query will not build a statement that filters, ` +
|
|
1073
|
+
throw jsonRpcError(-32602, `Column "${shown(column)}" on "${shown(owner.name)}" ${why}, so compile_query will not build a statement that filters, ` +
|
|
1000
1074
|
`sorts, pages or groups on it. This server does not author queries that interrogate a hidden value, ` +
|
|
1001
1075
|
`wherever that statement would eventually run. Use a visible column.`);
|
|
1002
1076
|
},
|
|
@@ -1005,7 +1079,7 @@ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
|
|
|
1005
1079
|
`not name a hidden column, so it is refused. Flatten the query.`);
|
|
1006
1080
|
},
|
|
1007
1081
|
refuseShape: (owner, key) => {
|
|
1008
|
-
throw jsonRpcError(-32602, `The PII guard does not recognize "${key}" in a query on "${owner.name}", so it cannot prove the query ` +
|
|
1082
|
+
throw jsonRpcError(-32602, `The PII guard does not recognize "${shown(key)}" in a query on "${shown(owner.name)}", so it cannot prove the query ` +
|
|
1009
1083
|
`does not name a hidden column, and refuses it rather than guessing. Remove it and compile without it.`);
|
|
1010
1084
|
},
|
|
1011
1085
|
});
|
|
@@ -1025,11 +1099,26 @@ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
|
|
|
1025
1099
|
const why = hiddenColumnReason(table, column);
|
|
1026
1100
|
if (!why)
|
|
1027
1101
|
continue;
|
|
1028
|
-
throw jsonRpcError(-32602, `Column "${column}" on "${table.name}" ${why}, so it cannot be used as a group key, an aggregate target, a ` +
|
|
1102
|
+
throw jsonRpcError(-32602, `Column "${shown(column)}" on "${shown(table.name)}" ${why}, so it cannot be used as a group key, an aggregate target, a ` +
|
|
1029
1103
|
`HAVING term or an ordering in compile_query. _count over the table is unaffected; group and aggregate on ` +
|
|
1030
1104
|
`a visible column instead.`);
|
|
1031
1105
|
}
|
|
1032
1106
|
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Refuse a `lock` option, at the top level or on any `with` entry, by name.
|
|
1109
|
+
*
|
|
1110
|
+
* Both tools that take query args are read-only, and a locking read takes row
|
|
1111
|
+
* locks, which needs a write-capable transaction in the application. The search
|
|
1112
|
+
* and the wording are the shared ones in `cli/pii-predicate-guard.ts`, the same
|
|
1113
|
+
* pair Studio's Query tab uses, so the rule is stated once. It runs on the RAW
|
|
1114
|
+
* args, ahead of every parser and guard that might drop or reshape the key.
|
|
1115
|
+
*/
|
|
1116
|
+
function assertNoLockingRead(args, tool) {
|
|
1117
|
+
const locked = (0, pii_predicate_guard_js_1.findRowLockOption)(args);
|
|
1118
|
+
if (locked === null)
|
|
1119
|
+
return;
|
|
1120
|
+
throw jsonRpcError(-32602, (0, pii_predicate_guard_js_1.rowLockRefusalMessage)(shown(locked), tool));
|
|
1121
|
+
}
|
|
1033
1122
|
/**
|
|
1034
1123
|
* Extract the allowed findMany subset for explain_query (no `with` / raw SQL).
|
|
1035
1124
|
* Returns a plain object cast at the buildFindMany call site, same pattern as Studio.
|
|
@@ -1084,7 +1173,7 @@ function classifyHiddenColumns(table, piiTags) {
|
|
|
1084
1173
|
? 'tags-unreadable'
|
|
1085
1174
|
: col.pii === true
|
|
1086
1175
|
? 'pii'
|
|
1087
|
-
:
|
|
1176
|
+
: looksLikeSecretName(col.name)
|
|
1088
1177
|
? 'secret-name'
|
|
1089
1178
|
: null;
|
|
1090
1179
|
if (reason) {
|
|
@@ -1479,7 +1568,7 @@ async function findJoinPath(ctx, args) {
|
|
|
1479
1568
|
paths,
|
|
1480
1569
|
pathsTruncated: false,
|
|
1481
1570
|
notes: [
|
|
1482
|
-
`"${fromTable.name}" is both ends of this query, so no join is needed to read its own columns.`,
|
|
1571
|
+
`"${shown(fromTable.name)}" is both ends of this query, so no join is needed to read its own columns.`,
|
|
1483
1572
|
paths.length > 0
|
|
1484
1573
|
? '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.'
|
|
1485
1574
|
: 'It declares no self-relation, so there is nothing to join it to itself through.',
|
|
@@ -1501,12 +1590,12 @@ async function findJoinPath(ctx, args) {
|
|
|
1501
1590
|
pathsTruncated: false,
|
|
1502
1591
|
searchExhausted: exhausted,
|
|
1503
1592
|
reason: exhausted
|
|
1504
|
-
? `The search for a chain from "${fromTable.name}" to "${toTable.name}" hit this tool's node budget before ` +
|
|
1593
|
+
? `The search for a chain from "${shown(fromTable.name)}" to "${shown(toTable.name)}" hit this tool's node budget before ` +
|
|
1505
1594
|
`it finished, so this is NOT an answer that they are unconnected. Lower maxDepth (it is ${maxDepth}) to ` +
|
|
1506
|
-
`bound the search, or call relation_graph on "${fromTable.name}" and walk it a hop at a time.`
|
|
1507
|
-
: `No relation chain connects "${fromTable.name}" to "${toTable.name}" within ${maxDepth} hop(s). Either the ` +
|
|
1595
|
+
`bound the search, or call relation_graph on "${shown(fromTable.name)}" and walk it a hop at a time.`
|
|
1596
|
+
: `No relation chain connects "${shown(fromTable.name)}" to "${shown(toTable.name)}" within ${maxDepth} hop(s). Either the ` +
|
|
1508
1597
|
`schema declares no foreign key path between them, or the path is longer than the search depth. Raise ` +
|
|
1509
|
-
`maxDepth, or call relation_graph on "${fromTable.name}" to see what it does reach.`,
|
|
1598
|
+
`maxDepth, or call relation_graph on "${shown(fromTable.name)}" to see what it does reach.`,
|
|
1510
1599
|
notes: exhausted
|
|
1511
1600
|
? ['The search did not complete. Do not report these tables as unconnected on the strength of this reply.']
|
|
1512
1601
|
: [
|
|
@@ -2026,8 +2115,8 @@ function estimatedRowCount(stats) {
|
|
|
2026
2115
|
function requireTable(metadata, tableName) {
|
|
2027
2116
|
const table = (0, utils_js_1.ownLookup)(metadata.tables, tableName);
|
|
2028
2117
|
if (!table) {
|
|
2029
|
-
const available = Object.keys(metadata.tables).join(', ') || '(none)';
|
|
2030
|
-
throw jsonRpcError(-32602, `Unknown table "${tableName}". Available: ${available}`);
|
|
2118
|
+
const available = Object.keys(metadata.tables).map(shown).join(', ') || '(none)';
|
|
2119
|
+
throw jsonRpcError(-32602, `Unknown table "${shown(tableName)}". Available: ${available}`);
|
|
2031
2120
|
}
|
|
2032
2121
|
return table;
|
|
2033
2122
|
}
|