turbine-orm 0.34.0 → 0.36.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 +18 -16
- package/dist/cjs/cli/index.js +109 -16
- package/dist/cjs/cli/migrate.js +78 -3
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +333 -22
- package/dist/cjs/cli/ui.js +7 -1
- package/dist/cjs/client.js +26 -4
- package/dist/cjs/dialect.js +2 -1
- package/dist/cjs/errors.js +41 -1
- package/dist/cjs/generate.js +23 -2
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/mssql.js +27 -5
- package/dist/cjs/mysql.js +4 -0
- package/dist/cjs/powdb.js +197 -25
- package/dist/cjs/powql.js +515 -51
- package/dist/cjs/query/aggregates.js +683 -0
- package/dist/cjs/query/batched-loader.js +2 -0
- package/dist/cjs/query/builder.js +361 -4508
- package/dist/cjs/query/filters.js +12 -0
- package/dist/cjs/query/relations.js +1698 -0
- package/dist/cjs/query/where-compile.js +180 -0
- package/dist/cjs/query/where.js +1491 -0
- package/dist/cjs/query/writes.js +680 -0
- package/dist/cjs/schema-builder.js +6 -0
- package/dist/cjs/schema-metadata.js +4 -0
- package/dist/cjs/schema-sql.js +265 -3
- package/dist/cjs/sqlite.js +4 -1
- package/dist/cli/index.d.ts +8 -2
- package/dist/cli/index.js +111 -18
- package/dist/cli/migrate.d.ts +24 -1
- package/dist/cli/migrate.js +77 -3
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +46 -13
- package/dist/cli/studio.js +331 -23
- package/dist/cli/ui.js +7 -1
- package/dist/client.d.ts +32 -5
- package/dist/client.js +26 -4
- package/dist/dialect.d.ts +28 -6
- package/dist/dialect.js +2 -1
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +39 -0
- package/dist/generate.js +23 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mssql.js +27 -5
- package/dist/mysql.js +4 -0
- package/dist/powdb.d.ts +135 -9
- package/dist/powdb.js +197 -25
- package/dist/powql.d.ts +166 -4
- package/dist/powql.js +516 -52
- package/dist/query/aggregates.d.ts +74 -0
- package/dist/query/aggregates.js +641 -0
- package/dist/query/batched-loader.d.ts +6 -0
- package/dist/query/batched-loader.js +2 -0
- package/dist/query/builder.d.ts +98 -830
- package/dist/query/builder.js +366 -4513
- package/dist/query/deferred.d.ts +13 -2
- package/dist/query/filters.d.ts +7 -0
- package/dist/query/filters.js +11 -0
- package/dist/query/relations.d.ts +441 -0
- package/dist/query/relations.js +1627 -0
- package/dist/query/types.d.ts +25 -6
- package/dist/query/where-compile.d.ts +139 -0
- package/dist/query/where-compile.js +175 -0
- package/dist/query/where.d.ts +494 -0
- package/dist/query/where.js +1431 -0
- package/dist/query/writes.d.ts +131 -0
- package/dist/query/writes.js +626 -0
- package/dist/schema-builder.d.ts +18 -3
- package/dist/schema-builder.js +6 -0
- package/dist/schema-metadata.js +4 -0
- package/dist/schema-sql.d.ts +60 -3
- package/dist/schema-sql.js +261 -4
- package/dist/schema.d.ts +10 -0
- package/dist/sqlite.js +4 -1
- package/package.json +4 -4
package/dist/cjs/cli/studio.js
CHANGED
|
@@ -2,36 +2,52 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* turbine-orm CLI — Studio
|
|
4
4
|
*
|
|
5
|
-
* A local
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* A local web UI for browsing databases, exploring relations, and composing
|
|
6
|
+
* queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
|
|
7
|
+
* The Query tab builds `findMany` args that are validated against introspected
|
|
8
|
+
* metadata and compiled by QueryInterface (`/api/builder`). Pure Node (built-in
|
|
9
|
+
* `http` module), no runtime dependencies beyond `pg`. CLI defaults to 127.0.0.1
|
|
10
|
+
* and refuses non-loopback hosts unless `npx turbine studio --allow-remote`.
|
|
11
|
+
*
|
|
12
|
+
* Read-only by default. `turbine studio --write` opts in to single-row writes
|
|
13
|
+
* (see the write model below); without the flag the write API routes do not
|
|
14
|
+
* exist (they 404) and the UI renders no write affordances.
|
|
12
15
|
*
|
|
13
16
|
* Security model:
|
|
14
17
|
* • Loopback by default; CLI refuses non-loopback without --allow-remote
|
|
15
18
|
* • Random auth token generated per process, required in Cookie header
|
|
16
|
-
* • No SQL input surface at all
|
|
17
|
-
* validated against the introspected schema; all values are
|
|
18
|
-
*
|
|
19
|
+
* • No SQL input surface at all: every identifier in a builder or write
|
|
20
|
+
* request is validated against the introspected schema; all values are
|
|
21
|
+
* $N params compiled through the query builders
|
|
22
|
+
* • Read routes run in a READ ONLY transaction (belt-and-suspenders)
|
|
23
|
+
* • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
|
|
24
|
+
* transaction, require a matching Origin header (CSRF), and target exactly
|
|
25
|
+
* one row by its full primary key
|
|
19
26
|
* • 30s statement timeout via parameterized set_config()
|
|
20
|
-
* • Per-session rate limiting,
|
|
27
|
+
* • Per-session rate limiting, cross-origin refusal, security headers, and a
|
|
28
|
+
* per-request CSP nonce for the inline script (no `unsafe-inline`)
|
|
29
|
+
*
|
|
30
|
+
* PII: columns tagged `pii` in code-first metadata are redacted server-side in
|
|
31
|
+
* every row-bearing response (the literal `•• redacted ••`) unless the server
|
|
32
|
+
* was started with `--show-pii`.
|
|
21
33
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
34
|
+
* Write model (opt-in): update/insert/delete a single row. DDL and multi-row or
|
|
35
|
+
* unconditional writes are deliberately unsupported. Use the CLI or migrate for
|
|
36
|
+
* schema changes and bulk operations.
|
|
24
37
|
*/
|
|
25
38
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
39
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
40
|
};
|
|
28
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.PII_REDACTED = void 0;
|
|
29
43
|
exports.startStudio = startStudio;
|
|
44
|
+
exports.handleRequest = handleRequest;
|
|
30
45
|
exports.apiTableRows = apiTableRows;
|
|
31
46
|
exports.resolveColumnName = resolveColumnName;
|
|
32
47
|
exports.isTextishType = isTextishType;
|
|
33
48
|
exports.escapeLikePattern = escapeLikePattern;
|
|
34
49
|
exports.apiBuilder = apiBuilder;
|
|
50
|
+
exports.apiRowWrite = apiRowWrite;
|
|
35
51
|
exports.apiListSavedQueries = apiListSavedQueries;
|
|
36
52
|
exports.apiCreateSavedQuery = apiCreateSavedQuery;
|
|
37
53
|
exports.apiDeleteSavedQuery = apiDeleteSavedQuery;
|
|
@@ -88,7 +104,17 @@ async function startStudio(options) {
|
|
|
88
104
|
params: ['30s'],
|
|
89
105
|
};
|
|
90
106
|
const rateLimiter = new Map();
|
|
91
|
-
const ctx = {
|
|
107
|
+
const ctx = {
|
|
108
|
+
pool,
|
|
109
|
+
metadata,
|
|
110
|
+
options,
|
|
111
|
+
authToken,
|
|
112
|
+
stateDir,
|
|
113
|
+
statementTimeout,
|
|
114
|
+
rateLimiter,
|
|
115
|
+
writable: options.write === true,
|
|
116
|
+
showPii: options.showPii === true,
|
|
117
|
+
};
|
|
92
118
|
const server = (0, node_http_1.createServer)((req, res) => {
|
|
93
119
|
handleRequest(req, res, ctx).catch((err) => {
|
|
94
120
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -157,7 +183,7 @@ async function handleRequest(req, res, ctx) {
|
|
|
157
183
|
res.end();
|
|
158
184
|
return;
|
|
159
185
|
}
|
|
160
|
-
sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML);
|
|
186
|
+
sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML, cspNonce());
|
|
161
187
|
return;
|
|
162
188
|
}
|
|
163
189
|
// Favicon — answered before the auth gate so the browser's automatic request
|
|
@@ -200,6 +226,26 @@ async function handleRequest(req, res, ctx) {
|
|
|
200
226
|
const id = decodeURIComponent(pathname.slice('/api/saved-queries/'.length));
|
|
201
227
|
return apiDeleteSavedQuery(res, ctx, id);
|
|
202
228
|
}
|
|
229
|
+
// Write routes: ONLY exist in write mode. In read-only mode they fall through
|
|
230
|
+
// to the 404 below (deliberately not 403: a read-only Studio has no such API).
|
|
231
|
+
if (ctx.writable && pathname.startsWith('/api/row/') && req.method === 'POST') {
|
|
232
|
+
// CSRF: a state-changing request MUST carry a same-origin Origin header. The
|
|
233
|
+
// top-of-handler check already rejects a MISMATCHED origin (403); this also
|
|
234
|
+
// rejects an ABSENT one, which read (GET) routes tolerate for curl ergonomics
|
|
235
|
+
// but a browser always sends on a cross-scheme/site POST. `fetch` from the
|
|
236
|
+
// Studio page always sets it for same-origin, so the real UI is unaffected.
|
|
237
|
+
if (origin !== expectedOrigin) {
|
|
238
|
+
sendJson(res, 403, { error: 'a matching Origin header is required for write requests' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const op = pathname.slice('/api/row/'.length);
|
|
242
|
+
if (op === 'update')
|
|
243
|
+
return apiRowWrite(req, res, ctx, 'update');
|
|
244
|
+
if (op === 'insert')
|
|
245
|
+
return apiRowWrite(req, res, ctx, 'insert');
|
|
246
|
+
if (op === 'delete')
|
|
247
|
+
return apiRowWrite(req, res, ctx, 'delete');
|
|
248
|
+
}
|
|
203
249
|
sendJson(res, 404, { error: 'not found' });
|
|
204
250
|
}
|
|
205
251
|
// ---------------------------------------------------------------------------
|
|
@@ -269,6 +315,7 @@ async function apiSchema(res, ctx) {
|
|
|
269
315
|
nullable: col.nullable,
|
|
270
316
|
hasDefault: col.hasDefault,
|
|
271
317
|
isPrimaryKey: tbl.primaryKey.includes(col.name),
|
|
318
|
+
pii: col.pii === true,
|
|
272
319
|
})),
|
|
273
320
|
relations: Object.entries(tbl.relations).map(([name, rel]) => ({
|
|
274
321
|
name,
|
|
@@ -294,6 +341,10 @@ async function apiSchema(res, ctx) {
|
|
|
294
341
|
schema: ctx.options.schema,
|
|
295
342
|
tables: tables.map((t) => ({ ...t, estimatedRows: counts.get(t.name) ?? 0 })),
|
|
296
343
|
enums: ctx.metadata.enums,
|
|
344
|
+
// Client-config flags the UI reads to gate write affordances / PII masking.
|
|
345
|
+
// Read-only Studio reports `writable: false` so the UI renders no write UI.
|
|
346
|
+
writable: ctx.writable === true,
|
|
347
|
+
showPii: ctx.showPii === true,
|
|
297
348
|
});
|
|
298
349
|
}
|
|
299
350
|
// ---------------------------------------------------------------------------
|
|
@@ -311,10 +362,14 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
311
362
|
const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
312
363
|
// orderBy — accept either the Postgres column name (snake) or the TS field
|
|
313
364
|
// name (camel). Always emit the Postgres column in the SQL.
|
|
365
|
+
// When redaction is on, PII columns are excluded from orderBy (and from the
|
|
366
|
+
// search OR-set below): a redacted value must not be inferable through sort
|
|
367
|
+
// position or substring probing.
|
|
368
|
+
const redactedPii = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
|
|
314
369
|
let orderByClause = '';
|
|
315
370
|
if (orderByRaw) {
|
|
316
371
|
const col = resolveColumnName(table, orderByRaw);
|
|
317
|
-
if (col)
|
|
372
|
+
if (col && !redactedPii.has(col))
|
|
318
373
|
orderByClause = `ORDER BY ${(0, index_js_1.quoteIdent)(col)} ${dir}`;
|
|
319
374
|
}
|
|
320
375
|
if (!orderByClause && table.primaryKey.length > 0 && table.primaryKey[0]) {
|
|
@@ -324,7 +379,9 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
324
379
|
// parameterized so injection is impossible. Each query gets its own
|
|
325
380
|
// WHERE clause with parameter indices matching that query's param array.
|
|
326
381
|
const search = params.get('search')?.trim() ?? '';
|
|
327
|
-
const textColumns = table.columns
|
|
382
|
+
const textColumns = table.columns
|
|
383
|
+
.filter((c) => isTextishType(c.pgType) && !redactedPii.has(c.name))
|
|
384
|
+
.map((c) => c.name);
|
|
328
385
|
const hasSearch = search.length > 0 && textColumns.length > 0;
|
|
329
386
|
const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
|
|
330
387
|
// Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
|
|
@@ -356,7 +413,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
356
413
|
sendJson(res, 200, {
|
|
357
414
|
table: table.name,
|
|
358
415
|
columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
359
|
-
rows: result.rows.map((r) => serializeRow(r)),
|
|
416
|
+
rows: result.rows.map((r) => serializeRow(redactFlatRow(r, redactedPii))),
|
|
360
417
|
total: Number(countResult.rows[0]?.count ?? 0),
|
|
361
418
|
limit,
|
|
362
419
|
offset,
|
|
@@ -433,10 +490,12 @@ async function apiBuilder(req, res, ctx) {
|
|
|
433
490
|
const result = await client.query(deferred.sql, deferred.params);
|
|
434
491
|
const elapsedMs = Date.now() - started;
|
|
435
492
|
await client.query('COMMIT');
|
|
493
|
+
const rawRows = result.rows;
|
|
494
|
+
const redactedRows = ctx.showPii ? rawRows : redactBuilderRows(rawRows, tableName, args.with, ctx.metadata);
|
|
436
495
|
sendJson(res, 200, {
|
|
437
496
|
sql: deferred.sql,
|
|
438
497
|
columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
439
|
-
rows:
|
|
498
|
+
rows: redactedRows.map((r) => serializeRow(r)),
|
|
440
499
|
rowCount: result.rowCount ?? result.rows.length,
|
|
441
500
|
elapsedMs,
|
|
442
501
|
});
|
|
@@ -454,6 +513,163 @@ async function apiBuilder(req, res, ctx) {
|
|
|
454
513
|
client.release();
|
|
455
514
|
}
|
|
456
515
|
}
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// API: /api/row/update | /api/row/insert | /api/row/delete (single-row writes)
|
|
518
|
+
//
|
|
519
|
+
// Write mode only (the routes do not exist otherwise). Every column identifier
|
|
520
|
+
// is validated against the introspected metadata and the statement is compiled
|
|
521
|
+
// through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
|
|
522
|
+
// values are $N params; there is no raw SQL. update/delete require the caller
|
|
523
|
+
// to supply the FULL primary key in `where`; the effective predicate is rebuilt
|
|
524
|
+
// from those PK values alone, so a write can only ever touch one row.
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
async function apiRowWrite(req, res, ctx, op) {
|
|
527
|
+
const body = await readJsonBody(req);
|
|
528
|
+
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
529
|
+
const table = ctx.metadata.tables[tableName];
|
|
530
|
+
if (!table) {
|
|
531
|
+
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (table.isView) {
|
|
535
|
+
sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const data = (body?.data && typeof body.data === 'object' ? body.data : {});
|
|
539
|
+
const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
|
|
540
|
+
// Validate every column name up front for a clean typed 400 (the builders
|
|
541
|
+
// would also reject unknowns, but an explicit check keeps the message clear).
|
|
542
|
+
if (op === 'insert' || op === 'update') {
|
|
543
|
+
const badKey = firstUnknownColumn(table, data);
|
|
544
|
+
if (badKey) {
|
|
545
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!Object.keys(data).some((k) => data[k] !== undefined)) {
|
|
549
|
+
sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// update/delete: require the table to have a PK and the caller to cover it.
|
|
554
|
+
let effectiveWhere = rawWhere;
|
|
555
|
+
if (op === 'update' || op === 'delete') {
|
|
556
|
+
if (table.primaryKey.length === 0) {
|
|
557
|
+
sendJson(res, 400, {
|
|
558
|
+
error: `[turbine] "${tableName}" has no primary key; single-row writes require one.`,
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const pk = extractPkWhere(table, rawWhere);
|
|
563
|
+
if ('error' in pk) {
|
|
564
|
+
sendJson(res, 400, { error: `[turbine] ${pk.error}` });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// Empty-where can never happen by construction (PK covered above); assert.
|
|
568
|
+
if (Object.keys(pk.where).length === 0) {
|
|
569
|
+
sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
effectiveWhere = pk.where;
|
|
573
|
+
}
|
|
574
|
+
let deferred;
|
|
575
|
+
try {
|
|
576
|
+
const qi = new index_js_1.QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
|
|
577
|
+
warnOnUnlimited: false,
|
|
578
|
+
sqlCache: false,
|
|
579
|
+
preparedStatements: false,
|
|
580
|
+
});
|
|
581
|
+
if (op === 'insert') {
|
|
582
|
+
deferred = qi.buildCreate({ data });
|
|
583
|
+
}
|
|
584
|
+
else if (op === 'update') {
|
|
585
|
+
deferred = qi.buildUpdate({ where: effectiveWhere, data });
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
deferred = qi.buildDelete({ where: effectiveWhere });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const client = await ctx.pool.connect();
|
|
596
|
+
try {
|
|
597
|
+
// A real write transaction, NOT `READ ONLY`. Same parameterized
|
|
598
|
+
// statement-timeout + search_path pin as the read paths.
|
|
599
|
+
await client.query('BEGIN');
|
|
600
|
+
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
601
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
|
|
602
|
+
const result = await client.query(deferred.sql, deferred.params);
|
|
603
|
+
await client.query('COMMIT');
|
|
604
|
+
const row = result.rows[0];
|
|
605
|
+
if (!row) {
|
|
606
|
+
const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
|
|
607
|
+
sendJson(res, 404, { error: `[turbine] ${msg}` });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// The echoed row is redacted the same way as any read (unless --show-pii),
|
|
611
|
+
// even though a write to a pii column is allowed.
|
|
612
|
+
const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
|
|
613
|
+
sendJson(res, 200, {
|
|
614
|
+
operation: op,
|
|
615
|
+
row: serializeRow(redactFlatRow(row, piiKeys)),
|
|
616
|
+
rowCount: result.rowCount ?? 1,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
try {
|
|
621
|
+
await client.query('ROLLBACK');
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
/* ignore */
|
|
625
|
+
}
|
|
626
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
627
|
+
}
|
|
628
|
+
finally {
|
|
629
|
+
client.release();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Return the first key in `obj` that does not resolve to a real column on
|
|
634
|
+
* `table` (accepting either the camelCase field or snake_case column name), or
|
|
635
|
+
* `null` when every key is valid. Skips `undefined` values.
|
|
636
|
+
*/
|
|
637
|
+
function firstUnknownColumn(table, obj) {
|
|
638
|
+
for (const k of Object.keys(obj)) {
|
|
639
|
+
if (obj[k] === undefined)
|
|
640
|
+
continue;
|
|
641
|
+
if (!resolveColumnName(table, k))
|
|
642
|
+
return k;
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Build a primary-key-only `where` from the caller's `where`. Every PK column
|
|
648
|
+
* must be present (as its field or column name) with a scalar value; anything
|
|
649
|
+
* else is rejected so a write can only ever target one row. Keys are emitted as
|
|
650
|
+
* the camelCase field name (the query builder accepts field or column names).
|
|
651
|
+
*/
|
|
652
|
+
function extractPkWhere(table, where) {
|
|
653
|
+
const resolved = new Map();
|
|
654
|
+
for (const [k, v] of Object.entries(where)) {
|
|
655
|
+
const col = resolveColumnName(table, k);
|
|
656
|
+
if (col)
|
|
657
|
+
resolved.set(col, v);
|
|
658
|
+
}
|
|
659
|
+
const pkWhere = {};
|
|
660
|
+
for (const pkCol of table.primaryKey) {
|
|
661
|
+
if (!resolved.has(pkCol)) {
|
|
662
|
+
return { error: `\`where\` must fully cover the primary key (missing "${pkCol}")` };
|
|
663
|
+
}
|
|
664
|
+
const v = resolved.get(pkCol);
|
|
665
|
+
if (v === undefined || v === null || typeof v === 'object') {
|
|
666
|
+
return { error: `primary key "${pkCol}" must be a scalar value in \`where\`` };
|
|
667
|
+
}
|
|
668
|
+
const field = table.reverseColumnMap[pkCol] ?? pkCol;
|
|
669
|
+
pkWhere[field] = v;
|
|
670
|
+
}
|
|
671
|
+
return { where: pkWhere };
|
|
672
|
+
}
|
|
457
673
|
function savedQueriesPath(ctx) {
|
|
458
674
|
return (0, node_path_1.resolve)(ctx.stateDir, 'studio-queries.json');
|
|
459
675
|
}
|
|
@@ -541,6 +757,94 @@ function apiDeleteSavedQuery(res, ctx, id) {
|
|
|
541
757
|
// ---------------------------------------------------------------------------
|
|
542
758
|
// Helpers
|
|
543
759
|
// ---------------------------------------------------------------------------
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// PII redaction
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
/** The literal replacement value for a redacted PII cell. */
|
|
764
|
+
exports.PII_REDACTED = '•• redacted ••';
|
|
765
|
+
/** Shared empty key set for the `--show-pii` fast path (no redaction). */
|
|
766
|
+
const NO_PII_KEYS = new Set();
|
|
767
|
+
/**
|
|
768
|
+
* The set of keys (both snake_case column and camelCase field names) for the
|
|
769
|
+
* table's PII-tagged columns. Covering both spellings means the same set works
|
|
770
|
+
* for `SELECT *` rows (snake keys) and for json_build_object relation rows
|
|
771
|
+
* (camel keys).
|
|
772
|
+
*/
|
|
773
|
+
function piiKeysForTable(table) {
|
|
774
|
+
const keys = new Set();
|
|
775
|
+
for (const col of table.columns) {
|
|
776
|
+
if (col.pii === true) {
|
|
777
|
+
keys.add(col.name);
|
|
778
|
+
keys.add(col.field);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return keys;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Redact PII keys in a single flat row. Returns the row unchanged when there is
|
|
785
|
+
* nothing to redact (no allocation); otherwise a shallow copy with each present,
|
|
786
|
+
* non-null PII value replaced by {@link PII_REDACTED}. A null/undefined value
|
|
787
|
+
* carries no PII, so it is left as-is.
|
|
788
|
+
*/
|
|
789
|
+
function redactFlatRow(row, piiKeys) {
|
|
790
|
+
if (piiKeys.size === 0)
|
|
791
|
+
return row;
|
|
792
|
+
let out = null;
|
|
793
|
+
for (const k of Object.keys(row)) {
|
|
794
|
+
if (piiKeys.has(k) && row[k] !== null && row[k] !== undefined) {
|
|
795
|
+
if (!out)
|
|
796
|
+
out = { ...row };
|
|
797
|
+
out[k] = exports.PII_REDACTED;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return out ?? row;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Redact PII in builder result rows, walking the `with` tree so nested relation
|
|
804
|
+
* rows are redacted against THEIR target table's PII columns (relation rows
|
|
805
|
+
* arrive as parsed json objects keyed by camelCase field names).
|
|
806
|
+
*/
|
|
807
|
+
function redactBuilderRows(rows, tableName, withClause, metadata) {
|
|
808
|
+
const table = metadata.tables[tableName];
|
|
809
|
+
if (!table)
|
|
810
|
+
return rows;
|
|
811
|
+
const piiKeys = piiKeysForTable(table);
|
|
812
|
+
const relEntries = withClause && typeof withClause === 'object'
|
|
813
|
+
? Object.entries(withClause).filter(([, v]) => v)
|
|
814
|
+
: [];
|
|
815
|
+
// Nothing to do at this level or below → return as-is.
|
|
816
|
+
if (piiKeys.size === 0 && relEntries.length === 0)
|
|
817
|
+
return rows;
|
|
818
|
+
return rows.map((row) => {
|
|
819
|
+
const out = { ...row };
|
|
820
|
+
for (const k of piiKeys) {
|
|
821
|
+
if (k in out && out[k] !== null && out[k] !== undefined)
|
|
822
|
+
out[k] = exports.PII_REDACTED;
|
|
823
|
+
}
|
|
824
|
+
for (const [relName, relVal] of relEntries) {
|
|
825
|
+
const rel = table.relations[relName];
|
|
826
|
+
if (!rel)
|
|
827
|
+
continue;
|
|
828
|
+
const nestedWith = relVal && typeof relVal === 'object' ? relVal.with : undefined;
|
|
829
|
+
const child = out[relName];
|
|
830
|
+
if (Array.isArray(child)) {
|
|
831
|
+
out[relName] = redactBuilderRows(child, rel.to, nestedWith, metadata);
|
|
832
|
+
}
|
|
833
|
+
else if (child && typeof child === 'object') {
|
|
834
|
+
out[relName] = redactBuilderRows([child], rel.to, nestedWith, metadata)[0];
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return out;
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* A fresh CSP nonce for one HTML response. Base64 of 16 random bytes; the value
|
|
842
|
+
* is stamped into both the `Content-Security-Policy` header and the inline
|
|
843
|
+
* `<script nonce="...">` tag(s) so `unsafe-inline` can be dropped from script-src.
|
|
844
|
+
*/
|
|
845
|
+
function cspNonce() {
|
|
846
|
+
return (0, node_crypto_1.randomBytes)(16).toString('base64');
|
|
847
|
+
}
|
|
544
848
|
function clampInt(value, fallback, min, max) {
|
|
545
849
|
if (value == null)
|
|
546
850
|
return fallback;
|
|
@@ -596,7 +900,9 @@ function sendJson(res, status, body) {
|
|
|
596
900
|
'Cache-Control': 'no-store',
|
|
597
901
|
'X-Content-Type-Options': 'nosniff',
|
|
598
902
|
'Referrer-Policy': 'no-referrer',
|
|
599
|
-
|
|
903
|
+
// JSON responses render no document; no inline script is needed, so keep
|
|
904
|
+
// script-src to 'self' with no 'unsafe-inline'.
|
|
905
|
+
'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'",
|
|
600
906
|
});
|
|
601
907
|
res.end(payload);
|
|
602
908
|
}
|
|
@@ -607,7 +913,10 @@ function sendText(res, status, body) {
|
|
|
607
913
|
});
|
|
608
914
|
res.end(body);
|
|
609
915
|
}
|
|
610
|
-
function sendHtml(res, status,
|
|
916
|
+
function sendHtml(res, status, template, nonce) {
|
|
917
|
+
// Stamp the per-request nonce into the inline <script nonce="__CSP_NONCE__">
|
|
918
|
+
// tag(s) so the CSP can use a nonce instead of 'unsafe-inline'.
|
|
919
|
+
const body = template.replaceAll('__CSP_NONCE__', nonce);
|
|
611
920
|
res.writeHead(status, {
|
|
612
921
|
'Content-Type': 'text/html; charset=utf-8',
|
|
613
922
|
'Content-Length': Buffer.byteLength(body),
|
|
@@ -615,7 +924,9 @@ function sendHtml(res, status, body) {
|
|
|
615
924
|
'X-Content-Type-Options': 'nosniff',
|
|
616
925
|
'X-Frame-Options': 'DENY',
|
|
617
926
|
'Referrer-Policy': 'no-referrer',
|
|
618
|
-
|
|
927
|
+
// style-src keeps 'unsafe-inline' (nonces don't cover style="" attributes,
|
|
928
|
+
// which the UI relies on); script-src moves to the per-request nonce.
|
|
929
|
+
'Content-Security-Policy': `default-src 'none'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'`,
|
|
619
930
|
});
|
|
620
931
|
res.end(body);
|
|
621
932
|
}
|
package/dist/cjs/cli/ui.js
CHANGED
|
@@ -229,5 +229,11 @@ function stripAnsi(s) {
|
|
|
229
229
|
// Redact password from connection URL
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
function redactUrl(url) {
|
|
232
|
-
return url
|
|
232
|
+
return (url
|
|
233
|
+
// Userinfo credentials: `:secret@` in any authority (global: a string may
|
|
234
|
+
// carry more than one URL, e.g. a primary + replica connection pair).
|
|
235
|
+
.replace(/:([^@/:]+)@/g, ':***@')
|
|
236
|
+
// Query-string password params: `password=`, `sslpassword=`, and similar,
|
|
237
|
+
// case-insensitive. Value runs up to the next `&`, `#`, or end of string.
|
|
238
|
+
.replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
|
|
233
239
|
}
|
package/dist/cjs/client.js
CHANGED
|
@@ -111,15 +111,27 @@ class TransactionClient {
|
|
|
111
111
|
schema;
|
|
112
112
|
middlewares;
|
|
113
113
|
queryOptions;
|
|
114
|
+
sourcePool;
|
|
114
115
|
tableCache = new Map();
|
|
115
116
|
savepointCounter = 0;
|
|
116
117
|
/** Active SQL dialect — owns savepoint keywords and raw-SQL placeholders. */
|
|
117
118
|
dialect;
|
|
118
|
-
constructor(client, schema, middlewares, queryOptions
|
|
119
|
+
constructor(client, schema, middlewares, queryOptions,
|
|
120
|
+
/**
|
|
121
|
+
* The parent pool this transaction runs on. Only its `readonly` and
|
|
122
|
+
* `capabilities` are read (both PowDB-only flags), so the transaction-scoped
|
|
123
|
+
* proxy pool built by {@link createTxPool} carries them through: without this
|
|
124
|
+
* a read-only client's `$transaction` writes bypass the E018 guard, and an
|
|
125
|
+
* older-engine client falls back to ALL_POWDB_CAPABILITIES inside the tx
|
|
126
|
+
* (emitting join PowQL a pre-0.13 engine rejects). Undefined / absent flags
|
|
127
|
+
* for a plain pg pool leave the proxy unchanged.
|
|
128
|
+
*/
|
|
129
|
+
sourcePool) {
|
|
119
130
|
this.client = client;
|
|
120
131
|
this.schema = schema;
|
|
121
132
|
this.middlewares = middlewares;
|
|
122
133
|
this.queryOptions = queryOptions;
|
|
134
|
+
this.sourcePool = sourcePool;
|
|
123
135
|
this.dialect = queryOptions?.dialect ?? dialect_js_1.postgresDialect;
|
|
124
136
|
// Auto-create typed table accessors for all tables in the schema
|
|
125
137
|
for (const tableName of Object.keys(schema.tables)) {
|
|
@@ -199,7 +211,7 @@ class TransactionClient {
|
|
|
199
211
|
const client = this.client;
|
|
200
212
|
// Return a minimal pool-compatible object that routes queries
|
|
201
213
|
// through the transaction client
|
|
202
|
-
|
|
214
|
+
const txPool = {
|
|
203
215
|
query: async (textOrConfig, values) => {
|
|
204
216
|
try {
|
|
205
217
|
if (typeof textOrConfig === 'string') {
|
|
@@ -216,6 +228,14 @@ class TransactionClient {
|
|
|
216
228
|
},
|
|
217
229
|
connect: () => Promise.resolve(client),
|
|
218
230
|
};
|
|
231
|
+
// Carry the parent pool's PowDB-only flags through so a transaction-scoped
|
|
232
|
+
// PowqlInterface reads the same read-only guard and capabilities it would
|
|
233
|
+
// outside the transaction (a plain pg pool has neither, so nothing changes).
|
|
234
|
+
if (this.sourcePool?.readonly !== undefined)
|
|
235
|
+
txPool.readonly = this.sourcePool.readonly;
|
|
236
|
+
if (this.sourcePool?.capabilities !== undefined)
|
|
237
|
+
txPool.capabilities = this.sourcePool.capabilities;
|
|
238
|
+
return txPool;
|
|
219
239
|
}
|
|
220
240
|
}
|
|
221
241
|
exports.TransactionClient = TransactionClient;
|
|
@@ -884,8 +904,10 @@ class TurbineClient {
|
|
|
884
904
|
await client.query(cfg.sql, cfg.params);
|
|
885
905
|
}
|
|
886
906
|
}
|
|
887
|
-
// Create the transaction client with typed table accessors
|
|
888
|
-
|
|
907
|
+
// Create the transaction client with typed table accessors. Pass the
|
|
908
|
+
// parent pool so its read-only guard + PowDB capabilities flow into the
|
|
909
|
+
// transaction-scoped proxy pool (see TransactionClient.createTxPool).
|
|
910
|
+
const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions, this.pool);
|
|
889
911
|
// Dynamically attach table accessors to tx
|
|
890
912
|
for (const tableName of Object.keys(this.schema.tables)) {
|
|
891
913
|
const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
package/dist/cjs/dialect.js
CHANGED
|
@@ -59,6 +59,7 @@ exports.postgresDialect = {
|
|
|
59
59
|
supportsRLS: true,
|
|
60
60
|
supportsAdvisoryLock: true,
|
|
61
61
|
supportsLateralJoin: true,
|
|
62
|
+
explainQuery: { prefix: 'EXPLAIN' },
|
|
62
63
|
paramPlaceholder(index) {
|
|
63
64
|
return `$${index}`;
|
|
64
65
|
},
|
|
@@ -104,7 +105,7 @@ exports.postgresDialect = {
|
|
|
104
105
|
return `COALESCE((${subquery}), ${fallback})`;
|
|
105
106
|
},
|
|
106
107
|
buildReturningClause(selection = '*') {
|
|
107
|
-
return ` RETURNING ${selection}`;
|
|
108
|
+
return ` RETURNING ${selection === '*' ? '*' : selection.join(', ')}`;
|
|
108
109
|
},
|
|
109
110
|
buildInsertStatement(input) {
|
|
110
111
|
return `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})${this.buildReturningClause(input.returning)}`;
|
package/dist/cjs/errors.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* All Turbine errors extend TurbineError which includes a `code` property.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.UnsupportedFeatureError = exports.OptimisticLockError = exports.PipelineError = exports.ExclusionConstraintError = exports.CheckConstraintError = exports.SerializationFailureError = exports.DeadlockError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
|
|
9
|
+
exports.ReadOnlyError = exports.UnsupportedFeatureError = exports.OptimisticLockError = exports.PipelineError = exports.ExclusionConstraintError = exports.CheckConstraintError = exports.SerializationFailureError = exports.DeadlockError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
|
|
10
10
|
exports.setErrorMessageMode = setErrorMessageMode;
|
|
11
11
|
exports.getErrorMessageMode = getErrorMessageMode;
|
|
12
12
|
exports.wrapPgError = wrapPgError;
|
|
@@ -29,6 +29,7 @@ exports.TurbineErrorCode = {
|
|
|
29
29
|
OPTIMISTIC_LOCK: 'TURBINE_E015',
|
|
30
30
|
EXCLUSION_VIOLATION: 'TURBINE_E016',
|
|
31
31
|
UNSUPPORTED_FEATURE: 'TURBINE_E017',
|
|
32
|
+
READ_ONLY: 'TURBINE_E018',
|
|
32
33
|
};
|
|
33
34
|
/**
|
|
34
35
|
* Prefix a human message with its stable error code so logs are greppable
|
|
@@ -503,6 +504,45 @@ class UnsupportedFeatureError extends TurbineError {
|
|
|
503
504
|
}
|
|
504
505
|
}
|
|
505
506
|
exports.UnsupportedFeatureError = UnsupportedFeatureError;
|
|
507
|
+
/**
|
|
508
|
+
* Thrown when a write or DDL statement is refused because the target is
|
|
509
|
+
* read-only. Two shapes reach here, both on PowDB:
|
|
510
|
+
* - an embedded database opened read-only for snapshot serving refuses a write
|
|
511
|
+
* with `readonly mode: statement requires a writer …`;
|
|
512
|
+
* - a networked read-only role refuses a write with `permission denied: role
|
|
513
|
+
* '<role>' cannot execute write statements` (translated by `wrapPowdbError`).
|
|
514
|
+
* It is also raised locally, before the wire, when a write is issued on a pool
|
|
515
|
+
* the caller marked read-only (fail-fast). The message carries the engine text
|
|
516
|
+
* plus a hint to route writes to a writable primary.
|
|
517
|
+
*
|
|
518
|
+
* NOT retryable: the same write against the same read-only target fails
|
|
519
|
+
* identically; route it to a writable primary instead.
|
|
520
|
+
*/
|
|
521
|
+
class ReadOnlyError extends TurbineError {
|
|
522
|
+
/**
|
|
523
|
+
* Why the write was refused. `'snapshot'`: the database itself is read-only
|
|
524
|
+
* (snapshot serving, an embedded `readonly: true` open, or the client-level
|
|
525
|
+
* fail-fast flag), so NOTHING can write here and writes must route to the
|
|
526
|
+
* primary. `'rbac'`: the database is writable but THIS connection's role may
|
|
527
|
+
* not write (per-connection permission), so re-authenticating may suffice.
|
|
528
|
+
*/
|
|
529
|
+
reason;
|
|
530
|
+
/**
|
|
531
|
+
* @param detail human-readable description of the refused write (the engine
|
|
532
|
+
* message, or a local fail-fast description). A "route writes to a writable
|
|
533
|
+
* primary" hint is always appended.
|
|
534
|
+
* @param options optional driver `cause` to preserve when wrapping a refusal,
|
|
535
|
+
* and the refusal `reason` (default `'snapshot'`).
|
|
536
|
+
*/
|
|
537
|
+
constructor(detail, options) {
|
|
538
|
+
super(exports.TurbineErrorCode.READ_ONLY, `[turbine] ${detail} Route writes to a writable primary.`, {
|
|
539
|
+
cause: options?.cause,
|
|
540
|
+
});
|
|
541
|
+
this.name = 'ReadOnlyError';
|
|
542
|
+
this.reason = options?.reason ?? 'snapshot';
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
exports.ReadOnlyError = ReadOnlyError;
|
|
506
546
|
/**
|
|
507
547
|
* Parse column names out of a pg `detail` string like:
|
|
508
548
|
* "Key (email)=(foo@bar) already exists."
|