turbine-orm 0.60.1 → 0.62.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 +71 -27
- package/dist/cjs/cli/config.d.ts +40 -0
- package/dist/cjs/cli/config.js +74 -2
- package/dist/cjs/cli/index.d.ts +85 -1
- package/dist/cjs/cli/index.js +374 -24
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +448 -29
- package/dist/cjs/cli/pii-tags.d.ts +64 -9
- package/dist/cjs/cli/pii-tags.js +218 -39
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +23 -0
- package/dist/cjs/cli/studio.js +126 -53
- package/dist/cjs/cli/ui.d.ts +15 -1
- package/dist/cjs/cli/ui.js +19 -5
- package/dist/cjs/client.js +248 -11
- package/dist/cjs/errors.d.ts +38 -1
- package/dist/cjs/errors.js +235 -24
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/pipeline-submittable.js +26 -3
- package/dist/cjs/pipeline.js +15 -2
- package/dist/cjs/powql.d.ts +12 -0
- package/dist/cjs/powql.js +46 -21
- package/dist/cjs/prisma-compat.d.ts +15 -5
- package/dist/cjs/prisma-compat.js +273 -78
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +24 -10
- package/dist/cjs/query/batched-loader.d.ts +9 -4
- package/dist/cjs/query/batched-loader.js +4 -1
- package/dist/cjs/query/builder.d.ts +47 -0
- package/dist/cjs/query/builder.js +149 -21
- package/dist/cjs/query/index.d.ts +3 -1
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/option-surface.d.ts +11 -0
- package/dist/cjs/query/option-surface.js +13 -0
- package/dist/cjs/query/relations.d.ts +8 -0
- package/dist/cjs/query/relations.js +21 -1
- package/dist/cjs/query/types.d.ts +152 -18
- package/dist/cjs/query/types.js +212 -1
- package/dist/cjs/query/where.d.ts +3 -3
- package/dist/cjs/query/where.js +8 -2
- package/dist/cjs/query/writes.js +10 -9
- package/dist/cli/config.d.ts +40 -0
- package/dist/cli/config.js +73 -2
- package/dist/cli/index.d.ts +85 -1
- package/dist/cli/index.js +373 -27
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +448 -29
- package/dist/cli/pii-tags.d.ts +64 -9
- package/dist/cli/pii-tags.js +217 -39
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +23 -0
- package/dist/cli/studio.js +125 -53
- package/dist/cli/ui.d.ts +15 -1
- package/dist/cli/ui.js +18 -4
- package/dist/client.js +250 -13
- package/dist/errors.d.ts +38 -1
- package/dist/errors.js +234 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -2
- package/dist/pipeline-submittable.js +26 -3
- package/dist/pipeline.js +15 -2
- package/dist/powql.d.ts +12 -0
- package/dist/powql.js +46 -21
- package/dist/prisma-compat.d.ts +15 -5
- package/dist/prisma-compat.js +274 -79
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +24 -10
- package/dist/query/batched-loader.d.ts +9 -4
- package/dist/query/batched-loader.js +4 -1
- package/dist/query/builder.d.ts +47 -0
- package/dist/query/builder.js +148 -21
- package/dist/query/index.d.ts +3 -1
- package/dist/query/index.js +2 -0
- package/dist/query/option-surface.d.ts +11 -0
- package/dist/query/option-surface.js +13 -0
- package/dist/query/relations.d.ts +8 -0
- package/dist/query/relations.js +21 -1
- package/dist/query/types.d.ts +152 -18
- package/dist/query/types.js +207 -2
- package/dist/query/where.d.ts +3 -3
- package/dist/query/where.js +8 -2
- package/dist/query/writes.js +10 -9
- package/package.json +13 -3
|
@@ -1,8 +1,122 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* turbine-orm, Query builder types
|
|
3
3
|
*
|
|
4
|
-
* All exported type and interface definitions for the query builder module
|
|
4
|
+
* All exported type and interface definitions for the query builder module,
|
|
5
|
+
* plus the small runtime guards that police the values those types describe
|
|
6
|
+
* (the {@link UNSAFE} sentinel and the orderBy-direction check). Those guards
|
|
7
|
+
* live here, next to the declarations they enforce, so a new privilege option
|
|
8
|
+
* or direction-bearing shape has one obvious place to be wired in.
|
|
5
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* The value that unlocks a PRIVILEGE option: `skipGlobalFilters`,
|
|
12
|
+
* `includePii`, `allowFullTableScan`.
|
|
13
|
+
*
|
|
14
|
+
* ## The bug this closes
|
|
15
|
+
*
|
|
16
|
+
* All three of those options are ordinary siblings of `where` on the query-args
|
|
17
|
+
* object, so a handler written the idiomatic way,
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* app.get('/users', (req, res) => db.users.findMany({ ...req.body }));
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* used to let the REQUEST BODY turn them on. `{"where":{"name":"x"},
|
|
24
|
+
* "skipGlobalFilters":true}` compiled to the same statement minus the tenant
|
|
25
|
+
* predicate: the documented multi-tenancy mechanism, removed by an attacker
|
|
26
|
+
* over the wire. `includePii: true` unlocked the PII projection the same way,
|
|
27
|
+
* and `allowFullTableScan: true` disarmed the empty-`where` guard that stops a
|
|
28
|
+
* mass update or delete.
|
|
29
|
+
*
|
|
30
|
+
* Typing the options `boolean` and writing "be careful" in the docs is not a
|
|
31
|
+
* fix: the escalation is a mass-assignment shape, and mass assignment happens
|
|
32
|
+
* because nobody enumerated the keys.
|
|
33
|
+
*
|
|
34
|
+
* ## Why a symbol
|
|
35
|
+
*
|
|
36
|
+
* `JSON.parse` cannot produce a symbol. There is no JSON document, no query
|
|
37
|
+
* string, no form body and no `structuredClone` of untrusted input that yields
|
|
38
|
+
* this value, so an attacker cannot put it on an args object at all. The
|
|
39
|
+
* escalation stops being "discouraged" and becomes STRUCTURALLY impossible,
|
|
40
|
+
* which is the only property that survives a refactor.
|
|
41
|
+
*
|
|
42
|
+
* Registered via `Symbol.for` rather than `Symbol()` on purpose: this package
|
|
43
|
+
* ships dual ESM + CJS builds, and a consumer can easily import `UNSAFE` from
|
|
44
|
+
* one copy while the query interface it calls came from the other. A plain
|
|
45
|
+
* `Symbol()` would be a different value in each copy and every privileged call
|
|
46
|
+
* would throw. The global registry makes the two copies agree. (Reachability of
|
|
47
|
+
* `Symbol.for` from application code is irrelevant to the threat model here:
|
|
48
|
+
* the attacker's channel is parsed data, which cannot carry a symbol either
|
|
49
|
+
* way.)
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { UNSAFE } from 'turbine-orm';
|
|
54
|
+
*
|
|
55
|
+
* // Deliberate, in a background job that legitimately crosses tenants:
|
|
56
|
+
* await db.users.findMany({ where: { active: true }, skipGlobalFilters: UNSAFE });
|
|
57
|
+
*
|
|
58
|
+
* // Skip the global filter on named tables only (the head element is the opt-in):
|
|
59
|
+
* await db.users.findMany({ with: { posts: true }, skipGlobalFilters: [UNSAFE, 'posts'] });
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export declare const UNSAFE: unique symbol;
|
|
63
|
+
/** The type of the {@link UNSAFE} sentinel. */
|
|
64
|
+
export type Unsafe = typeof UNSAFE;
|
|
65
|
+
/** Every privilege option, for the diagnostic below and for tests to enumerate. */
|
|
66
|
+
export type PrivilegeOption = 'skipGlobalFilters' | 'includePii' | 'allowFullTableScan';
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a boolean-shaped privilege option (`includePii`,
|
|
69
|
+
* `allowFullTableScan`) into the boolean the builders use.
|
|
70
|
+
*
|
|
71
|
+
* - absent / `undefined` / `null` / `false` → `false`. These are unambiguously
|
|
72
|
+
* NOT a request for the privilege, and throwing on them would break the
|
|
73
|
+
* ordinary `allowFullTableScan: someFlag` call whose flag is off (prisma-compat
|
|
74
|
+
* relies on `allowFullTableScan: false` staying a no-op, see its updateMany).
|
|
75
|
+
* - {@link UNSAFE} → `true`.
|
|
76
|
+
* - ANYTHING else, `true` included → throws {@link ValidationError} (E003).
|
|
77
|
+
*
|
|
78
|
+
* The literal `true` throws rather than being ignored ON PURPOSE. Ignoring it
|
|
79
|
+
* would swap an escalation bug for a silent-failure bug: a legitimate caller
|
|
80
|
+
* who has not migrated would keep reading rows with the PII columns quietly
|
|
81
|
+
* missing, or keep expecting a cross-tenant read that no longer happens, with
|
|
82
|
+
* no signal anywhere. Throwing turns the attacker's spread into a 500 and the
|
|
83
|
+
* legitimate caller's stale code into an immediate, self-describing error.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveUnsafeFlag(value: unknown, option: PrivilegeOption): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* The internal form of {@link SkipGlobalFilters}: `true` (skip every table) or
|
|
88
|
+
* the list of table accessors to skip. This is what the global-filter resolver
|
|
89
|
+
* consumes, and it is produced ONLY by {@link resolveSkipGlobalFilters}.
|
|
90
|
+
*/
|
|
91
|
+
export type ResolvedSkipGlobalFilters = true | readonly string[];
|
|
92
|
+
/**
|
|
93
|
+
* Validate and normalize {@link SkipGlobalFilters}.
|
|
94
|
+
*
|
|
95
|
+
* The array form is policed exactly as hard as the bare form: an attacker who
|
|
96
|
+
* can post `{"skipGlobalFilters":["users"]}` drops the tenant predicate on the
|
|
97
|
+
* table they care about, which is the same breach with an extra step. So the
|
|
98
|
+
* array must LEAD with the sentinel (`[UNSAFE, 'posts']`) and the rest must be
|
|
99
|
+
* table-name strings.
|
|
100
|
+
*/
|
|
101
|
+
export declare function resolveSkipGlobalFilters(value: unknown): ResolvedSkipGlobalFilters | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* Refuse an orderBy direction that is neither `asc` nor `desc`.
|
|
104
|
+
*
|
|
105
|
+
* Every direction consumer used to be spelled `String(v).toLowerCase() ===
|
|
106
|
+
* 'desc' ? 'DESC' : 'ASC'`, so `'descending'`, `''`, `null`, `1` and every
|
|
107
|
+
* other typo all emitted `ASC`. TypeScript rejects them, but the value that
|
|
108
|
+
* reaches this code in practice is `orderBy: { [field]: req.query.dir }`, which
|
|
109
|
+
* is `any` at the boundary, and the failure is SILENT: the caller gets a
|
|
110
|
+
* correct-looking page sorted the exact opposite way, which is worse than an
|
|
111
|
+
* error and identical to a successful response.
|
|
112
|
+
*
|
|
113
|
+
* Accepts any casing (`'DESC'` already worked) and leaves `undefined` alone
|
|
114
|
+
* (every consumer treats an undefined entry as absent). Recursive/compound
|
|
115
|
+
* orderBy shapes are validated one node at a time by their own consumers.
|
|
116
|
+
*/
|
|
117
|
+
export declare function assertOrderDirection(value: unknown, context: string): void;
|
|
118
|
+
/** One direction token. `undefined` means "not specified", which defaults to asc. */
|
|
119
|
+
export declare function assertDirectionToken(value: unknown, context: string): void;
|
|
6
120
|
export type OrderDirection = 'asc' | 'desc';
|
|
7
121
|
/**
|
|
8
122
|
* How a query resolves its `with` relations.
|
|
@@ -186,13 +300,18 @@ export type GlobalFilters = {
|
|
|
186
300
|
[tableAccessor: string]: WhereClause<any> | (() => WhereClause<any>);
|
|
187
301
|
};
|
|
188
302
|
/**
|
|
189
|
-
* Per-query opt-out of the configured {@link GlobalFilters}.
|
|
190
|
-
* global filter on the query's own table AND on every relation target
|
|
191
|
-
* touches;
|
|
192
|
-
* relation targets). Global filters never satisfy the empty-`where`
|
|
193
|
-
* `update`/`delete`, that guard always checks the user-supplied
|
|
303
|
+
* Per-query opt-out of the configured {@link GlobalFilters}. {@link UNSAFE}
|
|
304
|
+
* skips the global filter on the query's own table AND on every relation target
|
|
305
|
+
* it touches; `[UNSAFE, ...names]` skips only the named table accessors (own
|
|
306
|
+
* table and/or relation targets). Global filters never satisfy the empty-`where`
|
|
307
|
+
* guard for `update`/`delete`, that guard always checks the user-supplied
|
|
308
|
+
* `where`.
|
|
309
|
+
*
|
|
310
|
+
* PRIVILEGE OPTION: it removes the documented multi-tenancy boundary, so it is
|
|
311
|
+
* unlocked by the {@link UNSAFE} sentinel only. `true` / `['users']` throw
|
|
312
|
+
* (E003). See {@link UNSAFE} for why.
|
|
194
313
|
*/
|
|
195
|
-
export type SkipGlobalFilters =
|
|
314
|
+
export type SkipGlobalFilters = Unsafe | readonly [Unsafe, ...string[]];
|
|
196
315
|
/**
|
|
197
316
|
* Reserved key in a `with` clause that requests correlated relation counts.
|
|
198
317
|
* `_count: true` counts every to-many relation of the table; a record form
|
|
@@ -463,8 +582,8 @@ export interface FindUniqueArgs<T, R extends object = {}, W extends TypedWithCla
|
|
|
463
582
|
stableRelationOrder?: boolean;
|
|
464
583
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
|
465
584
|
skipGlobalFilters?: SkipGlobalFilters;
|
|
466
|
-
/** Include PII-tagged columns
|
|
467
|
-
includePii?:
|
|
585
|
+
/** Include PII-tagged columns (`includePii: UNSAFE`). See {@link FindManyArgs.includePii}. */
|
|
586
|
+
includePii?: Unsafe;
|
|
468
587
|
/** Plan this query with its real parameter values. See {@link FindManyArgs.forceCustomPlan}. */
|
|
469
588
|
forceCustomPlan?: boolean;
|
|
470
589
|
}
|
|
@@ -518,8 +637,12 @@ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClaus
|
|
|
518
637
|
*
|
|
519
638
|
* Referencing a PII column in `where` / `orderBy` / `groupBy` / aggregates is
|
|
520
639
|
* always allowed regardless of this flag (the reference is explicit).
|
|
640
|
+
*
|
|
641
|
+
* PRIVILEGE OPTION: unlocked by the {@link UNSAFE} sentinel only
|
|
642
|
+
* (`includePii: UNSAFE`). A literal `true` throws (E003), because a request
|
|
643
|
+
* body spread into these args must not be able to unlock the PII projection.
|
|
521
644
|
*/
|
|
522
|
-
includePii?:
|
|
645
|
+
includePii?: Unsafe;
|
|
523
646
|
/**
|
|
524
647
|
* Plan THIS query with its actual parameter values, every execution.
|
|
525
648
|
* PostgreSQL only (see the refusal below).
|
|
@@ -728,10 +851,15 @@ export interface UpdateArgs<T, R extends object = {}> {
|
|
|
728
851
|
* Opt in to running this mutation when `where` resolves to an empty
|
|
729
852
|
* predicate (e.g. `{}` or `{ id: undefined }`). Default `false`, an
|
|
730
853
|
* empty predicate throws `ValidationError` to catch the common case of
|
|
731
|
-
* a filter value accidentally being `undefined`.
|
|
732
|
-
*
|
|
854
|
+
* a filter value accidentally being `undefined`.
|
|
855
|
+
*
|
|
856
|
+
* PRIVILEGE OPTION: unlocked by the {@link UNSAFE} sentinel only
|
|
857
|
+
* (`allowFullTableScan: UNSAFE`), and only when an unconditional mutation is
|
|
858
|
+
* genuinely the intent. A literal `true` throws (E003): this guard is the
|
|
859
|
+
* last thing standing between a spread request body and an unconditional
|
|
860
|
+
* `UPDATE` / `DELETE` over the whole table.
|
|
733
861
|
*/
|
|
734
|
-
allowFullTableScan?:
|
|
862
|
+
allowFullTableScan?: Unsafe;
|
|
735
863
|
/**
|
|
736
864
|
* Optimistic locking, prevents lost updates in concurrent scenarios.
|
|
737
865
|
* Specify the version field and its expected value. The update adds a
|
|
@@ -762,7 +890,7 @@ export interface UpdateManyArgs<T, R extends object = {}> {
|
|
|
762
890
|
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
763
891
|
timeout?: number;
|
|
764
892
|
/** See {@link UpdateArgs.allowFullTableScan}. */
|
|
765
|
-
allowFullTableScan?:
|
|
893
|
+
allowFullTableScan?: Unsafe;
|
|
766
894
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
|
767
895
|
skipGlobalFilters?: SkipGlobalFilters;
|
|
768
896
|
}
|
|
@@ -771,7 +899,7 @@ export interface DeleteArgs<T, R extends object = {}> {
|
|
|
771
899
|
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
772
900
|
timeout?: number;
|
|
773
901
|
/** See {@link UpdateArgs.allowFullTableScan}. */
|
|
774
|
-
allowFullTableScan?:
|
|
902
|
+
allowFullTableScan?: Unsafe;
|
|
775
903
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
|
776
904
|
skipGlobalFilters?: SkipGlobalFilters;
|
|
777
905
|
}
|
|
@@ -780,7 +908,7 @@ export interface DeleteManyArgs<T, R extends object = {}> {
|
|
|
780
908
|
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
781
909
|
timeout?: number;
|
|
782
910
|
/** See {@link UpdateArgs.allowFullTableScan}. */
|
|
783
|
-
allowFullTableScan?:
|
|
911
|
+
allowFullTableScan?: Unsafe;
|
|
784
912
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
|
785
913
|
skipGlobalFilters?: SkipGlobalFilters;
|
|
786
914
|
}
|
|
@@ -1085,8 +1213,11 @@ export interface GroupByArgs<T, R extends object = {}> {
|
|
|
1085
1213
|
* STORED VALUES, so without this they throw `ValidationError` (E003).
|
|
1086
1214
|
* `_count` (a count, not a value), `_sum` / `_avg`, and `where` / `orderBy` /
|
|
1087
1215
|
* `having` on PII columns stay allowed.
|
|
1216
|
+
*
|
|
1217
|
+
* PRIVILEGE OPTION: unlocked by the {@link UNSAFE} sentinel only. See
|
|
1218
|
+
* {@link FindManyArgs.includePii}.
|
|
1088
1219
|
*/
|
|
1089
|
-
includePii?:
|
|
1220
|
+
includePii?: Unsafe;
|
|
1090
1221
|
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
1091
1222
|
timeout?: number;
|
|
1092
1223
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
|
@@ -1187,8 +1318,11 @@ export interface AggregateArgs<T, R extends object = {}> {
|
|
|
1187
1318
|
* `ValidationError` (E003) on a PII column. `_count` (a count, not a value)
|
|
1188
1319
|
* and `_sum` / `_avg` (a computed total over many rows) stay allowed, as do
|
|
1189
1320
|
* `where` filters on PII columns.
|
|
1321
|
+
*
|
|
1322
|
+
* PRIVILEGE OPTION: unlocked by the {@link UNSAFE} sentinel only. See
|
|
1323
|
+
* {@link FindManyArgs.includePii}.
|
|
1190
1324
|
*/
|
|
1191
|
-
includePii?:
|
|
1325
|
+
includePii?: Unsafe;
|
|
1192
1326
|
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
1193
1327
|
timeout?: number;
|
|
1194
1328
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
package/dist/cjs/query/types.js
CHANGED
|
@@ -2,6 +2,217 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* turbine-orm, Query builder types
|
|
4
4
|
*
|
|
5
|
-
* All exported type and interface definitions for the query builder module
|
|
5
|
+
* All exported type and interface definitions for the query builder module,
|
|
6
|
+
* plus the small runtime guards that police the values those types describe
|
|
7
|
+
* (the {@link UNSAFE} sentinel and the orderBy-direction check). Those guards
|
|
8
|
+
* live here, next to the declarations they enforce, so a new privilege option
|
|
9
|
+
* or direction-bearing shape has one obvious place to be wired in.
|
|
6
10
|
*/
|
|
7
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.UNSAFE = void 0;
|
|
13
|
+
exports.resolveUnsafeFlag = resolveUnsafeFlag;
|
|
14
|
+
exports.resolveSkipGlobalFilters = resolveSkipGlobalFilters;
|
|
15
|
+
exports.assertOrderDirection = assertOrderDirection;
|
|
16
|
+
exports.assertDirectionToken = assertDirectionToken;
|
|
17
|
+
const errors_js_1 = require("../errors.js");
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// The privilege sentinel
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/**
|
|
22
|
+
* The value that unlocks a PRIVILEGE option: `skipGlobalFilters`,
|
|
23
|
+
* `includePii`, `allowFullTableScan`.
|
|
24
|
+
*
|
|
25
|
+
* ## The bug this closes
|
|
26
|
+
*
|
|
27
|
+
* All three of those options are ordinary siblings of `where` on the query-args
|
|
28
|
+
* object, so a handler written the idiomatic way,
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* app.get('/users', (req, res) => db.users.findMany({ ...req.body }));
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* used to let the REQUEST BODY turn them on. `{"where":{"name":"x"},
|
|
35
|
+
* "skipGlobalFilters":true}` compiled to the same statement minus the tenant
|
|
36
|
+
* predicate: the documented multi-tenancy mechanism, removed by an attacker
|
|
37
|
+
* over the wire. `includePii: true` unlocked the PII projection the same way,
|
|
38
|
+
* and `allowFullTableScan: true` disarmed the empty-`where` guard that stops a
|
|
39
|
+
* mass update or delete.
|
|
40
|
+
*
|
|
41
|
+
* Typing the options `boolean` and writing "be careful" in the docs is not a
|
|
42
|
+
* fix: the escalation is a mass-assignment shape, and mass assignment happens
|
|
43
|
+
* because nobody enumerated the keys.
|
|
44
|
+
*
|
|
45
|
+
* ## Why a symbol
|
|
46
|
+
*
|
|
47
|
+
* `JSON.parse` cannot produce a symbol. There is no JSON document, no query
|
|
48
|
+
* string, no form body and no `structuredClone` of untrusted input that yields
|
|
49
|
+
* this value, so an attacker cannot put it on an args object at all. The
|
|
50
|
+
* escalation stops being "discouraged" and becomes STRUCTURALLY impossible,
|
|
51
|
+
* which is the only property that survives a refactor.
|
|
52
|
+
*
|
|
53
|
+
* Registered via `Symbol.for` rather than `Symbol()` on purpose: this package
|
|
54
|
+
* ships dual ESM + CJS builds, and a consumer can easily import `UNSAFE` from
|
|
55
|
+
* one copy while the query interface it calls came from the other. A plain
|
|
56
|
+
* `Symbol()` would be a different value in each copy and every privileged call
|
|
57
|
+
* would throw. The global registry makes the two copies agree. (Reachability of
|
|
58
|
+
* `Symbol.for` from application code is irrelevant to the threat model here:
|
|
59
|
+
* the attacker's channel is parsed data, which cannot carry a symbol either
|
|
60
|
+
* way.)
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* import { UNSAFE } from 'turbine-orm';
|
|
65
|
+
*
|
|
66
|
+
* // Deliberate, in a background job that legitimately crosses tenants:
|
|
67
|
+
* await db.users.findMany({ where: { active: true }, skipGlobalFilters: UNSAFE });
|
|
68
|
+
*
|
|
69
|
+
* // Skip the global filter on named tables only (the head element is the opt-in):
|
|
70
|
+
* await db.users.findMany({ with: { posts: true }, skipGlobalFilters: [UNSAFE, 'posts'] });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
exports.UNSAFE = Symbol.for('turbine-orm.UNSAFE');
|
|
74
|
+
/** The one message shape every privilege refusal uses. */
|
|
75
|
+
function privilegeRefusal(option, received, extra = '') {
|
|
76
|
+
return new errors_js_1.ValidationError(`[turbine] \`${option}\` must be the \`UNSAFE\` symbol, received ${describeValue(received)}. ` +
|
|
77
|
+
`${option} is a privilege option: it removes a safety boundary (a tenant filter, the PII ` +
|
|
78
|
+
'projection, or the empty-`where` guard), so it cannot be enabled by a plain value that ' +
|
|
79
|
+
'JSON.parse can produce. Import the sentinel and pass it explicitly: ' +
|
|
80
|
+
`import { UNSAFE } from 'turbine-orm'; → { ${option}: UNSAFE }.${extra ? ` ${extra}` : ''} ` +
|
|
81
|
+
'If you did not write this option, an untrusted object was spread into these query args.');
|
|
82
|
+
}
|
|
83
|
+
/** Short, non-leaking rendering of a rejected value for the diagnostic. */
|
|
84
|
+
function describeValue(value) {
|
|
85
|
+
if (typeof value === 'symbol')
|
|
86
|
+
return 'a different symbol';
|
|
87
|
+
if (Array.isArray(value))
|
|
88
|
+
return 'an array';
|
|
89
|
+
if (value === null)
|
|
90
|
+
return 'null';
|
|
91
|
+
if (typeof value === 'object')
|
|
92
|
+
return 'an object';
|
|
93
|
+
if (typeof value === 'string')
|
|
94
|
+
return `the string ${JSON.stringify(value)}`;
|
|
95
|
+
return String(value);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve a boolean-shaped privilege option (`includePii`,
|
|
99
|
+
* `allowFullTableScan`) into the boolean the builders use.
|
|
100
|
+
*
|
|
101
|
+
* - absent / `undefined` / `null` / `false` → `false`. These are unambiguously
|
|
102
|
+
* NOT a request for the privilege, and throwing on them would break the
|
|
103
|
+
* ordinary `allowFullTableScan: someFlag` call whose flag is off (prisma-compat
|
|
104
|
+
* relies on `allowFullTableScan: false` staying a no-op, see its updateMany).
|
|
105
|
+
* - {@link UNSAFE} → `true`.
|
|
106
|
+
* - ANYTHING else, `true` included → throws {@link ValidationError} (E003).
|
|
107
|
+
*
|
|
108
|
+
* The literal `true` throws rather than being ignored ON PURPOSE. Ignoring it
|
|
109
|
+
* would swap an escalation bug for a silent-failure bug: a legitimate caller
|
|
110
|
+
* who has not migrated would keep reading rows with the PII columns quietly
|
|
111
|
+
* missing, or keep expecting a cross-tenant read that no longer happens, with
|
|
112
|
+
* no signal anywhere. Throwing turns the attacker's spread into a 500 and the
|
|
113
|
+
* legitimate caller's stale code into an immediate, self-describing error.
|
|
114
|
+
*/
|
|
115
|
+
function resolveUnsafeFlag(value, option) {
|
|
116
|
+
if (value === undefined || value === null || value === false)
|
|
117
|
+
return false;
|
|
118
|
+
if (value === exports.UNSAFE)
|
|
119
|
+
return true;
|
|
120
|
+
throw privilegeRefusal(option, value);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Validate and normalize {@link SkipGlobalFilters}.
|
|
124
|
+
*
|
|
125
|
+
* The array form is policed exactly as hard as the bare form: an attacker who
|
|
126
|
+
* can post `{"skipGlobalFilters":["users"]}` drops the tenant predicate on the
|
|
127
|
+
* table they care about, which is the same breach with an extra step. So the
|
|
128
|
+
* array must LEAD with the sentinel (`[UNSAFE, 'posts']`) and the rest must be
|
|
129
|
+
* table-name strings.
|
|
130
|
+
*/
|
|
131
|
+
function resolveSkipGlobalFilters(value) {
|
|
132
|
+
if (value === undefined || value === null || value === false)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (value === exports.UNSAFE)
|
|
135
|
+
return true;
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
if (value[0] !== exports.UNSAFE) {
|
|
138
|
+
throw privilegeRefusal('skipGlobalFilters', value, 'The array form skips the named tables and must lead with the sentinel: [UNSAFE, "posts"].');
|
|
139
|
+
}
|
|
140
|
+
const tables = value.slice(1);
|
|
141
|
+
// `[UNSAFE]` names no table. It used to resolve to `[]`, which every
|
|
142
|
+
// consumer reads as "skip nothing", so the one shape that is an explicit,
|
|
143
|
+
// correctly-imported privilege request was ALSO the one shape that was
|
|
144
|
+
// silently a no-op. It is not read as "skip all" on purpose: that would let
|
|
145
|
+
// `[UNSAFE, ...tables]` ESCALATE to a global skip whenever `tables` happens
|
|
146
|
+
// to come back empty, which is the same accident with a much worse outcome.
|
|
147
|
+
if (tables.length === 0) {
|
|
148
|
+
throw new errors_js_1.ValidationError('[turbine] `skipGlobalFilters: [UNSAFE]` names no table, so it would skip nothing. ' +
|
|
149
|
+
'Pass the tables to skip (`[UNSAFE, "posts"]`), or the bare sentinel (`UNSAFE`) to skip every table.');
|
|
150
|
+
}
|
|
151
|
+
for (const t of tables) {
|
|
152
|
+
if (typeof t !== 'string') {
|
|
153
|
+
throw new errors_js_1.ValidationError('[turbine] `skipGlobalFilters: [UNSAFE, ...]` takes table accessor NAMES after the sentinel, ' +
|
|
154
|
+
`received ${describeValue(t)}.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return tables;
|
|
158
|
+
}
|
|
159
|
+
throw privilegeRefusal('skipGlobalFilters', value);
|
|
160
|
+
}
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// orderBy direction validation
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
/**
|
|
165
|
+
* Refuse an orderBy direction that is neither `asc` nor `desc`.
|
|
166
|
+
*
|
|
167
|
+
* Every direction consumer used to be spelled `String(v).toLowerCase() ===
|
|
168
|
+
* 'desc' ? 'DESC' : 'ASC'`, so `'descending'`, `''`, `null`, `1` and every
|
|
169
|
+
* other typo all emitted `ASC`. TypeScript rejects them, but the value that
|
|
170
|
+
* reaches this code in practice is `orderBy: { [field]: req.query.dir }`, which
|
|
171
|
+
* is `any` at the boundary, and the failure is SILENT: the caller gets a
|
|
172
|
+
* correct-looking page sorted the exact opposite way, which is worse than an
|
|
173
|
+
* error and identical to a successful response.
|
|
174
|
+
*
|
|
175
|
+
* Accepts any casing (`'DESC'` already worked) and leaves `undefined` alone
|
|
176
|
+
* (every consumer treats an undefined entry as absent). Recursive/compound
|
|
177
|
+
* orderBy shapes are validated one node at a time by their own consumers.
|
|
178
|
+
*/
|
|
179
|
+
function assertOrderDirection(value, context) {
|
|
180
|
+
// `undefined` alone means "this entry carries no ordering" (every consumer
|
|
181
|
+
// skips it). `null` is a VALUE, and a measured silent-ASC case, so it is
|
|
182
|
+
// refused like any other bad token.
|
|
183
|
+
if (value === undefined)
|
|
184
|
+
return;
|
|
185
|
+
if (typeof value === 'string') {
|
|
186
|
+
assertDirectionToken(value, context);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (typeof value === 'object' && value !== null) {
|
|
190
|
+
const v = value;
|
|
191
|
+
// `{ sort, nulls }` (OrderBySpec), `{ direction }` (JSON-path / pick-row),
|
|
192
|
+
// `{ distance: { metric, direction } }` (vector KNN).
|
|
193
|
+
if ('sort' in v)
|
|
194
|
+
assertDirectionToken(v.sort, context);
|
|
195
|
+
if ('direction' in v)
|
|
196
|
+
assertDirectionToken(v.direction, context);
|
|
197
|
+
const distance = v.distance;
|
|
198
|
+
if (distance !== null && typeof distance === 'object' && 'direction' in distance) {
|
|
199
|
+
assertDirectionToken(distance.direction, context);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// A number or boolean is never a direction.
|
|
204
|
+
assertDirectionToken(value, context);
|
|
205
|
+
}
|
|
206
|
+
/** One direction token. `undefined` means "not specified", which defaults to asc. */
|
|
207
|
+
function assertDirectionToken(value, context) {
|
|
208
|
+
if (value === undefined)
|
|
209
|
+
return;
|
|
210
|
+
if (typeof value === 'string') {
|
|
211
|
+
const lowered = value.toLowerCase();
|
|
212
|
+
if (lowered === 'asc' || lowered === 'desc')
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid orderBy direction ${describeValue(value)} for ${context}. ` +
|
|
216
|
+
"Use 'asc' or 'desc'. An unrecognized direction used to sort ASCENDING silently, " +
|
|
217
|
+
'which returns a correct-looking page in the wrong order.');
|
|
218
|
+
}
|
|
@@ -15,7 +15,7 @@ import type { Dialect } from '../dialect.js';
|
|
|
15
15
|
import { ValidationError } from '../errors.js';
|
|
16
16
|
import type { RelationDef, SchemaMetadata, TableMetadata } from '../schema.js';
|
|
17
17
|
import type { TemporalInfinityReading } from './deferred.js';
|
|
18
|
-
import type { ArrayFilter, ColumnRef, GlobalFilters, JsonFilter, JsonPathOrderBy,
|
|
18
|
+
import type { ArrayFilter, ColumnRef, GlobalFilters, JsonFilter, JsonPathOrderBy, ResolvedSkipGlobalFilters, TextSearchFilter, VectorFilter, WhereClause, WhereOperator } from './types.js';
|
|
19
19
|
import { type SqlCacheEntry } from './utils.js';
|
|
20
20
|
import { type WhereHost, type WhereRecord } from './where-compile.js';
|
|
21
21
|
/**
|
|
@@ -58,7 +58,7 @@ export interface BuilderCtx {
|
|
|
58
58
|
* synchronous SQL-build + param-collect tree reads it deep inside
|
|
59
59
|
* `resolveGlobalFilter`.
|
|
60
60
|
*/
|
|
61
|
-
currentSkip:
|
|
61
|
+
currentSkip: ResolvedSkipGlobalFilters | undefined;
|
|
62
62
|
q(name: string): string;
|
|
63
63
|
p(index: number): string;
|
|
64
64
|
inParam(values: unknown): unknown;
|
|
@@ -216,7 +216,7 @@ export declare function buildWhere<T extends object>(qi: BuilderCtx, where: Wher
|
|
|
216
216
|
* filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
|
|
217
217
|
* no filter applies, the query opted out, or the filter is empty.
|
|
218
218
|
*/
|
|
219
|
-
export declare function resolveGlobalFilter(qi: BuilderCtx, table: string, skip?:
|
|
219
|
+
export declare function resolveGlobalFilter(qi: BuilderCtx, table: string, skip?: ResolvedSkipGlobalFilters | undefined): Record<string, unknown> | null;
|
|
220
220
|
/**
|
|
221
221
|
* AND-merge this table's resolved global filter into a user `where`. Either
|
|
222
222
|
* side may be absent. When no filter applies the user where is returned by
|
package/dist/cjs/query/where.js
CHANGED
|
@@ -529,13 +529,19 @@ function userPredicateIsEmpty(qi, userWhere) {
|
|
|
529
529
|
const throwaway = [];
|
|
530
530
|
return buildWhereClause(qi, userWhere, throwaway) === null;
|
|
531
531
|
}
|
|
532
|
-
function assertMutationHasPredicate(qi, operation, whereSql,
|
|
532
|
+
function assertMutationHasPredicate(qi, operation, whereSql,
|
|
533
|
+
// Already RESOLVED by the caller (writes.ts) through `resolveUnsafeFlag`, so
|
|
534
|
+
// the sentinel check happens on every mutation, not only the guarded ones: a
|
|
535
|
+
// literal `allowFullTableScan: true` must throw even when the `where` is
|
|
536
|
+
// non-empty, or the escalation attempt goes unreported on most calls.
|
|
537
|
+
allowFullTableScan) {
|
|
533
538
|
if (whereSql.length > 0)
|
|
534
539
|
return;
|
|
535
540
|
if (allowFullTableScan === true)
|
|
536
541
|
return;
|
|
537
542
|
throw new errors_js_1.ValidationError(`[turbine] ${operation} on "${qi.table}" refused: the \`where\` clause is empty. ` +
|
|
538
|
-
|
|
543
|
+
"Pass `allowFullTableScan: UNSAFE` to opt in (import { UNSAFE } from 'turbine-orm'), " +
|
|
544
|
+
'or check that your filter values are defined.');
|
|
539
545
|
}
|
|
540
546
|
/**
|
|
541
547
|
* Build the inner WHERE expression (without the WHERE keyword).
|
package/dist/cjs/query/writes.js
CHANGED
|
@@ -71,6 +71,7 @@ const errors_js_1 = require("../errors.js");
|
|
|
71
71
|
const schema_js_1 = require("../schema.js");
|
|
72
72
|
const compound_unique_js_1 = require("./compound-unique.js");
|
|
73
73
|
const filters_js_1 = require("./filters.js");
|
|
74
|
+
const types_js_1 = require("./types.js");
|
|
74
75
|
const utils_js_1 = require("./utils.js");
|
|
75
76
|
const whereMod = __importStar(require("./where.js"));
|
|
76
77
|
/**
|
|
@@ -365,7 +366,7 @@ function buildCreateMany(qi, args) {
|
|
|
365
366
|
}
|
|
366
367
|
function buildUpdate(qi, args) {
|
|
367
368
|
assertWritable(qi, 'update');
|
|
368
|
-
qi.currentSkip = args.skipGlobalFilters;
|
|
369
|
+
qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
|
|
369
370
|
// `updatedAt`-tagged columns are filled in before anything reads `data`, so
|
|
370
371
|
// the SET list, the fingerprint and the param collector all see one object.
|
|
371
372
|
const dataObj = applyUpdatedAtColumns(qi, args.data);
|
|
@@ -378,7 +379,7 @@ function buildUpdate(qi, args) {
|
|
|
378
379
|
// The empty-`where` guard checks the USER predicate only, a global filter
|
|
379
380
|
// must never turn an unguarded mass update into an allowed one.
|
|
380
381
|
const userHasPredicate = !whereMod.userPredicateIsEmpty(qi, userWhere) || !!lock;
|
|
381
|
-
whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
|
|
382
|
+
whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'));
|
|
382
383
|
// The SQL is built from the global-filter-merged where (soft-delete keeps an
|
|
383
384
|
// update from touching already-deleted rows).
|
|
384
385
|
const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
|
|
@@ -507,11 +508,11 @@ function buildUpdate(qi, args) {
|
|
|
507
508
|
}
|
|
508
509
|
function buildDelete(qi, args) {
|
|
509
510
|
assertWritable(qi, 'delete');
|
|
510
|
-
qi.currentSkip = args.skipGlobalFilters;
|
|
511
|
+
qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
|
|
511
512
|
// Prisma compound-unique selector → the column conjunction (before the guard).
|
|
512
513
|
const userWhere = (0, compound_unique_js_1.expandCompoundUniqueWhere)(qi.tableMeta, args.where);
|
|
513
514
|
// Guard the USER predicate (a global filter must not satisfy the guard).
|
|
514
|
-
whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, userWhere) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
515
|
+
whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, userWhere) ? '' : ' WHERE x', (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'));
|
|
515
516
|
const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
|
|
516
517
|
const whereFp = whereMod.fingerprintWhere(qi, whereObj);
|
|
517
518
|
const ck = `d:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
|
|
@@ -562,7 +563,7 @@ function buildUpsert(qi, args) {
|
|
|
562
563
|
assertWritable(qi, 'upsert');
|
|
563
564
|
assertNoGeneratedColumns(qi, args.create, 'upsert');
|
|
564
565
|
assertNoGeneratedColumns(qi, args.update, 'upsert');
|
|
565
|
-
qi.currentSkip = args.skipGlobalFilters;
|
|
566
|
+
qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
|
|
566
567
|
// Prisma compound-unique selector on the conflict target → its member columns.
|
|
567
568
|
const upsertWhere = (0, compound_unique_js_1.expandCompoundUniqueWhere)(qi.tableMeta, args.where);
|
|
568
569
|
// Build the INSERT part from create data
|
|
@@ -631,10 +632,10 @@ function buildUpsert(qi, args) {
|
|
|
631
632
|
}
|
|
632
633
|
function buildUpdateMany(qi, args) {
|
|
633
634
|
assertWritable(qi, 'updateMany');
|
|
634
|
-
qi.currentSkip = args.skipGlobalFilters;
|
|
635
|
+
qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
|
|
635
636
|
const dataObj = applyUpdatedAtColumns(qi, args.data);
|
|
636
637
|
assertNoGeneratedColumns(qi, dataObj, 'updateMany');
|
|
637
|
-
whereMod.assertMutationHasPredicate(qi, 'updateMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
638
|
+
whereMod.assertMutationHasPredicate(qi, 'updateMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'));
|
|
638
639
|
const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
|
|
639
640
|
// Nothing to SET: a no-op, for the same reason as `update` above (that path
|
|
640
641
|
// has the full rationale). Reports `count: 0` because zero rows were
|
|
@@ -673,8 +674,8 @@ function buildUpdateMany(qi, args) {
|
|
|
673
674
|
}
|
|
674
675
|
function buildDeleteMany(qi, args) {
|
|
675
676
|
assertWritable(qi, 'deleteMany');
|
|
676
|
-
qi.currentSkip = args.skipGlobalFilters;
|
|
677
|
-
whereMod.assertMutationHasPredicate(qi, 'deleteMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
|
|
677
|
+
qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
|
|
678
|
+
whereMod.assertMutationHasPredicate(qi, 'deleteMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'));
|
|
678
679
|
const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
|
|
679
680
|
const whereFp = whereMod.fingerprintWhere(qi, whereObj);
|
|
680
681
|
const ck = `dm:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -189,4 +189,44 @@ export declare function resolveConfig(fileConfig: TurbineCliConfig, overrides: C
|
|
|
189
189
|
* order (root-level first, then the `turbine/` location `init` scaffolds).
|
|
190
190
|
*/
|
|
191
191
|
export declare function resolveSeedFile(config: Pick<TurbineCliConfig, 'seed' | 'seedFile'>, cwd?: string): string | null;
|
|
192
|
+
/**
|
|
193
|
+
* Does this connection string carry a password?
|
|
194
|
+
*
|
|
195
|
+
* `turbine init --url` used to inline whatever it was given straight into
|
|
196
|
+
* `turbine.config.ts`, a file projects commit, so the documented one-liner
|
|
197
|
+
* (`turbine init --url postgres://user:PASSWORD@host/db`) committed a live
|
|
198
|
+
* database password. Everything that decides between "inline the string" and
|
|
199
|
+
* "read `process.env.DATABASE_URL`" asks this function, so there is exactly one
|
|
200
|
+
* definition of "this value is a secret".
|
|
201
|
+
*
|
|
202
|
+
* Three spellings carry a password, and libpq (and `pg-connection-string`,
|
|
203
|
+
* which is what `pg` actually parses with) accepts all three:
|
|
204
|
+
*
|
|
205
|
+
* 1. URL userinfo: `postgres://user:pass@host/db`.
|
|
206
|
+
* 2. URL query parameter: `postgres://user@host/db?password=pass`, and its
|
|
207
|
+
* siblings such as `?sslpassword=`. This one was missed, on the reasoning
|
|
208
|
+
* that a successful `new URL` ruled out the keyword form below, which is
|
|
209
|
+
* true and irrelevant: the secret was in the query string.
|
|
210
|
+
* 3. libpq keyword form: `host=... password=...` (also `sslpassword=`), which
|
|
211
|
+
* is not a URL at all.
|
|
212
|
+
*
|
|
213
|
+
* The query-parameter test reuses `PASSWORD_QUERY_PARAM_PATTERN`, the very
|
|
214
|
+
* regex `redactUrl` redacts with, so a spelling the terminal output hides can
|
|
215
|
+
* never be a spelling this function calls safe to commit. `pg-connection-string`
|
|
216
|
+
* itself would be the ideal oracle, but it is only a TRANSITIVE dependency (of
|
|
217
|
+
* `pg`), and importing an undeclared package would break on any strict,
|
|
218
|
+
* non-hoisting installer. Detection stays self-contained.
|
|
219
|
+
*
|
|
220
|
+
* @internal exported for tests.
|
|
221
|
+
*/
|
|
222
|
+
export declare function connectionStringHasPassword(connectionString: string): boolean;
|
|
223
|
+
/**
|
|
224
|
+
* The `turbine.config.ts` scaffold.
|
|
225
|
+
*
|
|
226
|
+
* A password-bearing `connectionString` is NEVER inlined: the emitted config
|
|
227
|
+
* reads `process.env.DATABASE_URL` instead, and `turbine init` scaffolds the
|
|
228
|
+
* `.env` that holds the real value. The refusal lives here rather than at the
|
|
229
|
+
* call site so no future caller can reintroduce the leak by passing the raw
|
|
230
|
+
* `--url` through.
|
|
231
|
+
*/
|
|
192
232
|
export declare function configTemplate(connectionString?: string): string;
|