turbine-orm 0.35.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/dialect.js +1 -1
- package/dist/cjs/generate.js +23 -2
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/mssql.js +22 -5
- package/dist/cjs/powdb.js +41 -1
- package/dist/cjs/powql.js +80 -25
- package/dist/cjs/query/aggregates.js +683 -0
- package/dist/cjs/query/batched-loader.js +2 -0
- package/dist/cjs/query/builder.js +297 -4504
- 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 +1 -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/dialect.d.ts +15 -6
- package/dist/dialect.js +1 -1
- package/dist/generate.js +23 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +22 -5
- package/dist/powdb.d.ts +20 -0
- package/dist/powdb.js +40 -0
- package/dist/powql.d.ts +33 -1
- package/dist/powql.js +80 -25
- 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 +62 -829
- package/dist/query/builder.js +302 -4509
- package/dist/query/deferred.d.ts +7 -0
- 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 +15 -0
- 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 +1 -1
- package/package.json +2 -2
package/dist/cli/studio.d.ts
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* turbine-orm CLI — Studio
|
|
3
3
|
*
|
|
4
|
-
* A local
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* A local web UI for browsing databases, exploring relations, and composing
|
|
5
|
+
* queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
|
|
6
|
+
* The Query tab builds `findMany` args that are validated against introspected
|
|
7
|
+
* metadata and compiled by QueryInterface (`/api/builder`). Pure Node (built-in
|
|
8
|
+
* `http` module), no runtime dependencies beyond `pg`. CLI defaults to 127.0.0.1
|
|
9
|
+
* and refuses non-loopback hosts unless `npx turbine studio --allow-remote`.
|
|
10
|
+
*
|
|
11
|
+
* Read-only by default. `turbine studio --write` opts in to single-row writes
|
|
12
|
+
* (see the write model below); without the flag the write API routes do not
|
|
13
|
+
* exist (they 404) and the UI renders no write affordances.
|
|
11
14
|
*
|
|
12
15
|
* Security model:
|
|
13
16
|
* • Loopback by default; CLI refuses non-loopback without --allow-remote
|
|
14
17
|
* • Random auth token generated per process, required in Cookie header
|
|
15
|
-
* • No SQL input surface at all
|
|
16
|
-
* validated against the introspected schema; all values are
|
|
17
|
-
*
|
|
18
|
+
* • No SQL input surface at all: every identifier in a builder or write
|
|
19
|
+
* request is validated against the introspected schema; all values are
|
|
20
|
+
* $N params compiled through the query builders
|
|
21
|
+
* • Read routes run in a READ ONLY transaction (belt-and-suspenders)
|
|
22
|
+
* • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
|
|
23
|
+
* transaction, require a matching Origin header (CSRF), and target exactly
|
|
24
|
+
* one row by its full primary key
|
|
18
25
|
* • 30s statement timeout via parameterized set_config()
|
|
19
|
-
* • Per-session rate limiting,
|
|
26
|
+
* • Per-session rate limiting, cross-origin refusal, security headers, and a
|
|
27
|
+
* per-request CSP nonce for the inline script (no `unsafe-inline`)
|
|
28
|
+
*
|
|
29
|
+
* PII: columns tagged `pii` in code-first metadata are redacted server-side in
|
|
30
|
+
* every row-bearing response (the literal `•• redacted ••`) unless the server
|
|
31
|
+
* was started with `--show-pii`.
|
|
20
32
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
33
|
+
* Write model (opt-in): update/insert/delete a single row. DDL and multi-row or
|
|
34
|
+
* unconditional writes are deliberately unsupported. Use the CLI or migrate for
|
|
35
|
+
* schema changes and bulk operations.
|
|
23
36
|
*/
|
|
24
37
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
25
38
|
import pg from 'pg';
|
|
@@ -36,6 +49,15 @@ export interface StudioOptions {
|
|
|
36
49
|
stateDir?: string;
|
|
37
50
|
/** Database adapter for dialect-specific behavior (e.g. statement timeout syntax). */
|
|
38
51
|
adapter?: import('../adapters/index.js').DatabaseAdapter;
|
|
52
|
+
/**
|
|
53
|
+
* Opt in to single-row write routes (`/api/row/update|insert|delete`) and the
|
|
54
|
+
* write UI. Default `false`: read-only, with the write routes absent (404).
|
|
55
|
+
*/
|
|
56
|
+
write?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Reveal PII-tagged column values instead of redacting them. Default `false`.
|
|
59
|
+
*/
|
|
60
|
+
showPii?: boolean;
|
|
39
61
|
}
|
|
40
62
|
export interface StudioHandle {
|
|
41
63
|
/** Shut down the server + pool cleanly. */
|
|
@@ -61,6 +83,13 @@ export interface StudioContext {
|
|
|
61
83
|
count: number;
|
|
62
84
|
resetAt: number;
|
|
63
85
|
}>;
|
|
86
|
+
/**
|
|
87
|
+
* True when write mode is enabled (`--write`): the `/api/row/*` routes exist
|
|
88
|
+
* and the UI renders write affordances. Absent/false → read-only.
|
|
89
|
+
*/
|
|
90
|
+
writable?: boolean;
|
|
91
|
+
/** True when PII redaction is disabled (`--show-pii`). Absent/false → redact. */
|
|
92
|
+
showPii?: boolean;
|
|
64
93
|
}
|
|
65
94
|
/**
|
|
66
95
|
* Start the Studio server. Returns a handle with the session token, a pre-built
|
|
@@ -72,11 +101,15 @@ export interface StudioContext {
|
|
|
72
101
|
* process.on('SIGINT', () => studio.dispose().then(() => process.exit(0)));
|
|
73
102
|
*/
|
|
74
103
|
export declare function startStudio(options: StudioOptions): Promise<StudioHandle>;
|
|
104
|
+
export declare function handleRequest(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
|
|
75
105
|
export declare function apiTableRows(res: ServerResponse, ctx: StudioContext, rawTableName: string, params: URLSearchParams): Promise<void>;
|
|
76
106
|
export declare function resolveColumnName(table: TableMetadata, nameOrField: string): string | null;
|
|
77
107
|
export declare function isTextishType(pgType: string): boolean;
|
|
78
108
|
export declare function escapeLikePattern(s: string): string;
|
|
79
109
|
export declare function apiBuilder(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
|
|
110
|
+
export declare function apiRowWrite(req: IncomingMessage, res: ServerResponse, ctx: StudioContext, op: 'update' | 'insert' | 'delete'): Promise<void>;
|
|
80
111
|
export declare function apiListSavedQueries(res: ServerResponse, ctx: StudioContext, params: URLSearchParams): void;
|
|
81
112
|
export declare function apiCreateSavedQuery(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
|
|
82
113
|
export declare function apiDeleteSavedQuery(res: ServerResponse, ctx: StudioContext, id: string): void;
|
|
114
|
+
/** The literal replacement value for a redacted PII cell. */
|
|
115
|
+
export declare const PII_REDACTED = "\u2022\u2022 redacted \u2022\u2022";
|
package/dist/cli/studio.js
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* turbine-orm CLI — Studio
|
|
3
3
|
*
|
|
4
|
-
* A local
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* A local web UI for browsing databases, exploring relations, and composing
|
|
5
|
+
* queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
|
|
6
|
+
* The Query tab builds `findMany` args that are validated against introspected
|
|
7
|
+
* metadata and compiled by QueryInterface (`/api/builder`). Pure Node (built-in
|
|
8
|
+
* `http` module), no runtime dependencies beyond `pg`. CLI defaults to 127.0.0.1
|
|
9
|
+
* and refuses non-loopback hosts unless `npx turbine studio --allow-remote`.
|
|
10
|
+
*
|
|
11
|
+
* Read-only by default. `turbine studio --write` opts in to single-row writes
|
|
12
|
+
* (see the write model below); without the flag the write API routes do not
|
|
13
|
+
* exist (they 404) and the UI renders no write affordances.
|
|
11
14
|
*
|
|
12
15
|
* Security model:
|
|
13
16
|
* • Loopback by default; CLI refuses non-loopback without --allow-remote
|
|
14
17
|
* • Random auth token generated per process, required in Cookie header
|
|
15
|
-
* • No SQL input surface at all
|
|
16
|
-
* validated against the introspected schema; all values are
|
|
17
|
-
*
|
|
18
|
+
* • No SQL input surface at all: every identifier in a builder or write
|
|
19
|
+
* request is validated against the introspected schema; all values are
|
|
20
|
+
* $N params compiled through the query builders
|
|
21
|
+
* • Read routes run in a READ ONLY transaction (belt-and-suspenders)
|
|
22
|
+
* • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
|
|
23
|
+
* transaction, require a matching Origin header (CSRF), and target exactly
|
|
24
|
+
* one row by its full primary key
|
|
18
25
|
* • 30s statement timeout via parameterized set_config()
|
|
19
|
-
* • Per-session rate limiting,
|
|
26
|
+
* • Per-session rate limiting, cross-origin refusal, security headers, and a
|
|
27
|
+
* per-request CSP nonce for the inline script (no `unsafe-inline`)
|
|
28
|
+
*
|
|
29
|
+
* PII: columns tagged `pii` in code-first metadata are redacted server-side in
|
|
30
|
+
* every row-bearing response (the literal `•• redacted ••`) unless the server
|
|
31
|
+
* was started with `--show-pii`.
|
|
20
32
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
33
|
+
* Write model (opt-in): update/insert/delete a single row. DDL and multi-row or
|
|
34
|
+
* unconditional writes are deliberately unsupported. Use the CLI or migrate for
|
|
35
|
+
* schema changes and bulk operations.
|
|
23
36
|
*/
|
|
24
37
|
import { spawn } from 'node:child_process';
|
|
25
38
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
@@ -74,7 +87,17 @@ export async function startStudio(options) {
|
|
|
74
87
|
params: ['30s'],
|
|
75
88
|
};
|
|
76
89
|
const rateLimiter = new Map();
|
|
77
|
-
const ctx = {
|
|
90
|
+
const ctx = {
|
|
91
|
+
pool,
|
|
92
|
+
metadata,
|
|
93
|
+
options,
|
|
94
|
+
authToken,
|
|
95
|
+
stateDir,
|
|
96
|
+
statementTimeout,
|
|
97
|
+
rateLimiter,
|
|
98
|
+
writable: options.write === true,
|
|
99
|
+
showPii: options.showPii === true,
|
|
100
|
+
};
|
|
78
101
|
const server = createServer((req, res) => {
|
|
79
102
|
handleRequest(req, res, ctx).catch((err) => {
|
|
80
103
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -115,7 +138,7 @@ function originFor(host, port) {
|
|
|
115
138
|
// ---------------------------------------------------------------------------
|
|
116
139
|
// Request routing
|
|
117
140
|
// ---------------------------------------------------------------------------
|
|
118
|
-
async function handleRequest(req, res, ctx) {
|
|
141
|
+
export async function handleRequest(req, res, ctx) {
|
|
119
142
|
const expectedOrigin = originFor(ctx.options.host, ctx.options.port);
|
|
120
143
|
// CORS: not needed — same-origin only. Explicitly refuse cross-origin.
|
|
121
144
|
const origin = req.headers.origin;
|
|
@@ -143,7 +166,7 @@ async function handleRequest(req, res, ctx) {
|
|
|
143
166
|
res.end();
|
|
144
167
|
return;
|
|
145
168
|
}
|
|
146
|
-
sendHtml(res, 200, STUDIO_HTML);
|
|
169
|
+
sendHtml(res, 200, STUDIO_HTML, cspNonce());
|
|
147
170
|
return;
|
|
148
171
|
}
|
|
149
172
|
// Favicon — answered before the auth gate so the browser's automatic request
|
|
@@ -186,6 +209,26 @@ async function handleRequest(req, res, ctx) {
|
|
|
186
209
|
const id = decodeURIComponent(pathname.slice('/api/saved-queries/'.length));
|
|
187
210
|
return apiDeleteSavedQuery(res, ctx, id);
|
|
188
211
|
}
|
|
212
|
+
// Write routes: ONLY exist in write mode. In read-only mode they fall through
|
|
213
|
+
// to the 404 below (deliberately not 403: a read-only Studio has no such API).
|
|
214
|
+
if (ctx.writable && pathname.startsWith('/api/row/') && req.method === 'POST') {
|
|
215
|
+
// CSRF: a state-changing request MUST carry a same-origin Origin header. The
|
|
216
|
+
// top-of-handler check already rejects a MISMATCHED origin (403); this also
|
|
217
|
+
// rejects an ABSENT one, which read (GET) routes tolerate for curl ergonomics
|
|
218
|
+
// but a browser always sends on a cross-scheme/site POST. `fetch` from the
|
|
219
|
+
// Studio page always sets it for same-origin, so the real UI is unaffected.
|
|
220
|
+
if (origin !== expectedOrigin) {
|
|
221
|
+
sendJson(res, 403, { error: 'a matching Origin header is required for write requests' });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const op = pathname.slice('/api/row/'.length);
|
|
225
|
+
if (op === 'update')
|
|
226
|
+
return apiRowWrite(req, res, ctx, 'update');
|
|
227
|
+
if (op === 'insert')
|
|
228
|
+
return apiRowWrite(req, res, ctx, 'insert');
|
|
229
|
+
if (op === 'delete')
|
|
230
|
+
return apiRowWrite(req, res, ctx, 'delete');
|
|
231
|
+
}
|
|
189
232
|
sendJson(res, 404, { error: 'not found' });
|
|
190
233
|
}
|
|
191
234
|
// ---------------------------------------------------------------------------
|
|
@@ -255,6 +298,7 @@ async function apiSchema(res, ctx) {
|
|
|
255
298
|
nullable: col.nullable,
|
|
256
299
|
hasDefault: col.hasDefault,
|
|
257
300
|
isPrimaryKey: tbl.primaryKey.includes(col.name),
|
|
301
|
+
pii: col.pii === true,
|
|
258
302
|
})),
|
|
259
303
|
relations: Object.entries(tbl.relations).map(([name, rel]) => ({
|
|
260
304
|
name,
|
|
@@ -280,6 +324,10 @@ async function apiSchema(res, ctx) {
|
|
|
280
324
|
schema: ctx.options.schema,
|
|
281
325
|
tables: tables.map((t) => ({ ...t, estimatedRows: counts.get(t.name) ?? 0 })),
|
|
282
326
|
enums: ctx.metadata.enums,
|
|
327
|
+
// Client-config flags the UI reads to gate write affordances / PII masking.
|
|
328
|
+
// Read-only Studio reports `writable: false` so the UI renders no write UI.
|
|
329
|
+
writable: ctx.writable === true,
|
|
330
|
+
showPii: ctx.showPii === true,
|
|
283
331
|
});
|
|
284
332
|
}
|
|
285
333
|
// ---------------------------------------------------------------------------
|
|
@@ -297,10 +345,14 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
297
345
|
const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
298
346
|
// orderBy — accept either the Postgres column name (snake) or the TS field
|
|
299
347
|
// name (camel). Always emit the Postgres column in the SQL.
|
|
348
|
+
// When redaction is on, PII columns are excluded from orderBy (and from the
|
|
349
|
+
// search OR-set below): a redacted value must not be inferable through sort
|
|
350
|
+
// position or substring probing.
|
|
351
|
+
const redactedPii = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
|
|
300
352
|
let orderByClause = '';
|
|
301
353
|
if (orderByRaw) {
|
|
302
354
|
const col = resolveColumnName(table, orderByRaw);
|
|
303
|
-
if (col)
|
|
355
|
+
if (col && !redactedPii.has(col))
|
|
304
356
|
orderByClause = `ORDER BY ${quoteIdent(col)} ${dir}`;
|
|
305
357
|
}
|
|
306
358
|
if (!orderByClause && table.primaryKey.length > 0 && table.primaryKey[0]) {
|
|
@@ -310,7 +362,9 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
310
362
|
// parameterized so injection is impossible. Each query gets its own
|
|
311
363
|
// WHERE clause with parameter indices matching that query's param array.
|
|
312
364
|
const search = params.get('search')?.trim() ?? '';
|
|
313
|
-
const textColumns = table.columns
|
|
365
|
+
const textColumns = table.columns
|
|
366
|
+
.filter((c) => isTextishType(c.pgType) && !redactedPii.has(c.name))
|
|
367
|
+
.map((c) => c.name);
|
|
314
368
|
const hasSearch = search.length > 0 && textColumns.length > 0;
|
|
315
369
|
const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
|
|
316
370
|
// Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
|
|
@@ -342,7 +396,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
342
396
|
sendJson(res, 200, {
|
|
343
397
|
table: table.name,
|
|
344
398
|
columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
345
|
-
rows: result.rows.map((r) => serializeRow(r)),
|
|
399
|
+
rows: result.rows.map((r) => serializeRow(redactFlatRow(r, redactedPii))),
|
|
346
400
|
total: Number(countResult.rows[0]?.count ?? 0),
|
|
347
401
|
limit,
|
|
348
402
|
offset,
|
|
@@ -419,10 +473,12 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
419
473
|
const result = await client.query(deferred.sql, deferred.params);
|
|
420
474
|
const elapsedMs = Date.now() - started;
|
|
421
475
|
await client.query('COMMIT');
|
|
476
|
+
const rawRows = result.rows;
|
|
477
|
+
const redactedRows = ctx.showPii ? rawRows : redactBuilderRows(rawRows, tableName, args.with, ctx.metadata);
|
|
422
478
|
sendJson(res, 200, {
|
|
423
479
|
sql: deferred.sql,
|
|
424
480
|
columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
425
|
-
rows:
|
|
481
|
+
rows: redactedRows.map((r) => serializeRow(r)),
|
|
426
482
|
rowCount: result.rowCount ?? result.rows.length,
|
|
427
483
|
elapsedMs,
|
|
428
484
|
});
|
|
@@ -440,6 +496,163 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
440
496
|
client.release();
|
|
441
497
|
}
|
|
442
498
|
}
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// API: /api/row/update | /api/row/insert | /api/row/delete (single-row writes)
|
|
501
|
+
//
|
|
502
|
+
// Write mode only (the routes do not exist otherwise). Every column identifier
|
|
503
|
+
// is validated against the introspected metadata and the statement is compiled
|
|
504
|
+
// through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
|
|
505
|
+
// values are $N params; there is no raw SQL. update/delete require the caller
|
|
506
|
+
// to supply the FULL primary key in `where`; the effective predicate is rebuilt
|
|
507
|
+
// from those PK values alone, so a write can only ever touch one row.
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
export async function apiRowWrite(req, res, ctx, op) {
|
|
510
|
+
const body = await readJsonBody(req);
|
|
511
|
+
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
512
|
+
const table = ctx.metadata.tables[tableName];
|
|
513
|
+
if (!table) {
|
|
514
|
+
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (table.isView) {
|
|
518
|
+
sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const data = (body?.data && typeof body.data === 'object' ? body.data : {});
|
|
522
|
+
const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
|
|
523
|
+
// Validate every column name up front for a clean typed 400 (the builders
|
|
524
|
+
// would also reject unknowns, but an explicit check keeps the message clear).
|
|
525
|
+
if (op === 'insert' || op === 'update') {
|
|
526
|
+
const badKey = firstUnknownColumn(table, data);
|
|
527
|
+
if (badKey) {
|
|
528
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (!Object.keys(data).some((k) => data[k] !== undefined)) {
|
|
532
|
+
sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// update/delete: require the table to have a PK and the caller to cover it.
|
|
537
|
+
let effectiveWhere = rawWhere;
|
|
538
|
+
if (op === 'update' || op === 'delete') {
|
|
539
|
+
if (table.primaryKey.length === 0) {
|
|
540
|
+
sendJson(res, 400, {
|
|
541
|
+
error: `[turbine] "${tableName}" has no primary key; single-row writes require one.`,
|
|
542
|
+
});
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const pk = extractPkWhere(table, rawWhere);
|
|
546
|
+
if ('error' in pk) {
|
|
547
|
+
sendJson(res, 400, { error: `[turbine] ${pk.error}` });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// Empty-where can never happen by construction (PK covered above); assert.
|
|
551
|
+
if (Object.keys(pk.where).length === 0) {
|
|
552
|
+
sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
effectiveWhere = pk.where;
|
|
556
|
+
}
|
|
557
|
+
let deferred;
|
|
558
|
+
try {
|
|
559
|
+
const qi = new QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
|
|
560
|
+
warnOnUnlimited: false,
|
|
561
|
+
sqlCache: false,
|
|
562
|
+
preparedStatements: false,
|
|
563
|
+
});
|
|
564
|
+
if (op === 'insert') {
|
|
565
|
+
deferred = qi.buildCreate({ data });
|
|
566
|
+
}
|
|
567
|
+
else if (op === 'update') {
|
|
568
|
+
deferred = qi.buildUpdate({ where: effectiveWhere, data });
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
deferred = qi.buildDelete({ where: effectiveWhere });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const client = await ctx.pool.connect();
|
|
579
|
+
try {
|
|
580
|
+
// A real write transaction, NOT `READ ONLY`. Same parameterized
|
|
581
|
+
// statement-timeout + search_path pin as the read paths.
|
|
582
|
+
await client.query('BEGIN');
|
|
583
|
+
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
584
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
|
|
585
|
+
const result = await client.query(deferred.sql, deferred.params);
|
|
586
|
+
await client.query('COMMIT');
|
|
587
|
+
const row = result.rows[0];
|
|
588
|
+
if (!row) {
|
|
589
|
+
const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
|
|
590
|
+
sendJson(res, 404, { error: `[turbine] ${msg}` });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// The echoed row is redacted the same way as any read (unless --show-pii),
|
|
594
|
+
// even though a write to a pii column is allowed.
|
|
595
|
+
const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
|
|
596
|
+
sendJson(res, 200, {
|
|
597
|
+
operation: op,
|
|
598
|
+
row: serializeRow(redactFlatRow(row, piiKeys)),
|
|
599
|
+
rowCount: result.rowCount ?? 1,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
try {
|
|
604
|
+
await client.query('ROLLBACK');
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
/* ignore */
|
|
608
|
+
}
|
|
609
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
client.release();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Return the first key in `obj` that does not resolve to a real column on
|
|
617
|
+
* `table` (accepting either the camelCase field or snake_case column name), or
|
|
618
|
+
* `null` when every key is valid. Skips `undefined` values.
|
|
619
|
+
*/
|
|
620
|
+
function firstUnknownColumn(table, obj) {
|
|
621
|
+
for (const k of Object.keys(obj)) {
|
|
622
|
+
if (obj[k] === undefined)
|
|
623
|
+
continue;
|
|
624
|
+
if (!resolveColumnName(table, k))
|
|
625
|
+
return k;
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Build a primary-key-only `where` from the caller's `where`. Every PK column
|
|
631
|
+
* must be present (as its field or column name) with a scalar value; anything
|
|
632
|
+
* else is rejected so a write can only ever target one row. Keys are emitted as
|
|
633
|
+
* the camelCase field name (the query builder accepts field or column names).
|
|
634
|
+
*/
|
|
635
|
+
function extractPkWhere(table, where) {
|
|
636
|
+
const resolved = new Map();
|
|
637
|
+
for (const [k, v] of Object.entries(where)) {
|
|
638
|
+
const col = resolveColumnName(table, k);
|
|
639
|
+
if (col)
|
|
640
|
+
resolved.set(col, v);
|
|
641
|
+
}
|
|
642
|
+
const pkWhere = {};
|
|
643
|
+
for (const pkCol of table.primaryKey) {
|
|
644
|
+
if (!resolved.has(pkCol)) {
|
|
645
|
+
return { error: `\`where\` must fully cover the primary key (missing "${pkCol}")` };
|
|
646
|
+
}
|
|
647
|
+
const v = resolved.get(pkCol);
|
|
648
|
+
if (v === undefined || v === null || typeof v === 'object') {
|
|
649
|
+
return { error: `primary key "${pkCol}" must be a scalar value in \`where\`` };
|
|
650
|
+
}
|
|
651
|
+
const field = table.reverseColumnMap[pkCol] ?? pkCol;
|
|
652
|
+
pkWhere[field] = v;
|
|
653
|
+
}
|
|
654
|
+
return { where: pkWhere };
|
|
655
|
+
}
|
|
443
656
|
function savedQueriesPath(ctx) {
|
|
444
657
|
return pathResolve(ctx.stateDir, 'studio-queries.json');
|
|
445
658
|
}
|
|
@@ -527,6 +740,94 @@ export function apiDeleteSavedQuery(res, ctx, id) {
|
|
|
527
740
|
// ---------------------------------------------------------------------------
|
|
528
741
|
// Helpers
|
|
529
742
|
// ---------------------------------------------------------------------------
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// PII redaction
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
/** The literal replacement value for a redacted PII cell. */
|
|
747
|
+
export const PII_REDACTED = '•• redacted ••';
|
|
748
|
+
/** Shared empty key set for the `--show-pii` fast path (no redaction). */
|
|
749
|
+
const NO_PII_KEYS = new Set();
|
|
750
|
+
/**
|
|
751
|
+
* The set of keys (both snake_case column and camelCase field names) for the
|
|
752
|
+
* table's PII-tagged columns. Covering both spellings means the same set works
|
|
753
|
+
* for `SELECT *` rows (snake keys) and for json_build_object relation rows
|
|
754
|
+
* (camel keys).
|
|
755
|
+
*/
|
|
756
|
+
function piiKeysForTable(table) {
|
|
757
|
+
const keys = new Set();
|
|
758
|
+
for (const col of table.columns) {
|
|
759
|
+
if (col.pii === true) {
|
|
760
|
+
keys.add(col.name);
|
|
761
|
+
keys.add(col.field);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return keys;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Redact PII keys in a single flat row. Returns the row unchanged when there is
|
|
768
|
+
* nothing to redact (no allocation); otherwise a shallow copy with each present,
|
|
769
|
+
* non-null PII value replaced by {@link PII_REDACTED}. A null/undefined value
|
|
770
|
+
* carries no PII, so it is left as-is.
|
|
771
|
+
*/
|
|
772
|
+
function redactFlatRow(row, piiKeys) {
|
|
773
|
+
if (piiKeys.size === 0)
|
|
774
|
+
return row;
|
|
775
|
+
let out = null;
|
|
776
|
+
for (const k of Object.keys(row)) {
|
|
777
|
+
if (piiKeys.has(k) && row[k] !== null && row[k] !== undefined) {
|
|
778
|
+
if (!out)
|
|
779
|
+
out = { ...row };
|
|
780
|
+
out[k] = PII_REDACTED;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return out ?? row;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Redact PII in builder result rows, walking the `with` tree so nested relation
|
|
787
|
+
* rows are redacted against THEIR target table's PII columns (relation rows
|
|
788
|
+
* arrive as parsed json objects keyed by camelCase field names).
|
|
789
|
+
*/
|
|
790
|
+
function redactBuilderRows(rows, tableName, withClause, metadata) {
|
|
791
|
+
const table = metadata.tables[tableName];
|
|
792
|
+
if (!table)
|
|
793
|
+
return rows;
|
|
794
|
+
const piiKeys = piiKeysForTable(table);
|
|
795
|
+
const relEntries = withClause && typeof withClause === 'object'
|
|
796
|
+
? Object.entries(withClause).filter(([, v]) => v)
|
|
797
|
+
: [];
|
|
798
|
+
// Nothing to do at this level or below → return as-is.
|
|
799
|
+
if (piiKeys.size === 0 && relEntries.length === 0)
|
|
800
|
+
return rows;
|
|
801
|
+
return rows.map((row) => {
|
|
802
|
+
const out = { ...row };
|
|
803
|
+
for (const k of piiKeys) {
|
|
804
|
+
if (k in out && out[k] !== null && out[k] !== undefined)
|
|
805
|
+
out[k] = PII_REDACTED;
|
|
806
|
+
}
|
|
807
|
+
for (const [relName, relVal] of relEntries) {
|
|
808
|
+
const rel = table.relations[relName];
|
|
809
|
+
if (!rel)
|
|
810
|
+
continue;
|
|
811
|
+
const nestedWith = relVal && typeof relVal === 'object' ? relVal.with : undefined;
|
|
812
|
+
const child = out[relName];
|
|
813
|
+
if (Array.isArray(child)) {
|
|
814
|
+
out[relName] = redactBuilderRows(child, rel.to, nestedWith, metadata);
|
|
815
|
+
}
|
|
816
|
+
else if (child && typeof child === 'object') {
|
|
817
|
+
out[relName] = redactBuilderRows([child], rel.to, nestedWith, metadata)[0];
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return out;
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* A fresh CSP nonce for one HTML response. Base64 of 16 random bytes; the value
|
|
825
|
+
* is stamped into both the `Content-Security-Policy` header and the inline
|
|
826
|
+
* `<script nonce="...">` tag(s) so `unsafe-inline` can be dropped from script-src.
|
|
827
|
+
*/
|
|
828
|
+
function cspNonce() {
|
|
829
|
+
return randomBytes(16).toString('base64');
|
|
830
|
+
}
|
|
530
831
|
function clampInt(value, fallback, min, max) {
|
|
531
832
|
if (value == null)
|
|
532
833
|
return fallback;
|
|
@@ -582,7 +883,9 @@ function sendJson(res, status, body) {
|
|
|
582
883
|
'Cache-Control': 'no-store',
|
|
583
884
|
'X-Content-Type-Options': 'nosniff',
|
|
584
885
|
'Referrer-Policy': 'no-referrer',
|
|
585
|
-
|
|
886
|
+
// JSON responses render no document; no inline script is needed, so keep
|
|
887
|
+
// script-src to 'self' with no 'unsafe-inline'.
|
|
888
|
+
'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'",
|
|
586
889
|
});
|
|
587
890
|
res.end(payload);
|
|
588
891
|
}
|
|
@@ -593,7 +896,10 @@ function sendText(res, status, body) {
|
|
|
593
896
|
});
|
|
594
897
|
res.end(body);
|
|
595
898
|
}
|
|
596
|
-
function sendHtml(res, status,
|
|
899
|
+
function sendHtml(res, status, template, nonce) {
|
|
900
|
+
// Stamp the per-request nonce into the inline <script nonce="__CSP_NONCE__">
|
|
901
|
+
// tag(s) so the CSP can use a nonce instead of 'unsafe-inline'.
|
|
902
|
+
const body = template.replaceAll('__CSP_NONCE__', nonce);
|
|
597
903
|
res.writeHead(status, {
|
|
598
904
|
'Content-Type': 'text/html; charset=utf-8',
|
|
599
905
|
'Content-Length': Buffer.byteLength(body),
|
|
@@ -601,7 +907,9 @@ function sendHtml(res, status, body) {
|
|
|
601
907
|
'X-Content-Type-Options': 'nosniff',
|
|
602
908
|
'X-Frame-Options': 'DENY',
|
|
603
909
|
'Referrer-Policy': 'no-referrer',
|
|
604
|
-
|
|
910
|
+
// style-src keeps 'unsafe-inline' (nonces don't cover style="" attributes,
|
|
911
|
+
// which the UI relies on); script-src moves to the per-request nonce.
|
|
912
|
+
'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'`,
|
|
605
913
|
});
|
|
606
914
|
res.end(body);
|
|
607
915
|
}
|
package/dist/cli/ui.js
CHANGED
|
@@ -211,5 +211,11 @@ export function stripAnsi(s) {
|
|
|
211
211
|
// Redact password from connection URL
|
|
212
212
|
// ---------------------------------------------------------------------------
|
|
213
213
|
export function redactUrl(url) {
|
|
214
|
-
return url
|
|
214
|
+
return (url
|
|
215
|
+
// Userinfo credentials: `:secret@` in any authority (global: a string may
|
|
216
|
+
// carry more than one URL, e.g. a primary + replica connection pair).
|
|
217
|
+
.replace(/:([^@/:]+)@/g, ':***@')
|
|
218
|
+
// Query-string password params: `password=`, `sslpassword=`, and similar,
|
|
219
|
+
// case-insensitive. Value runs up to the next `&`, `#`, or end of string.
|
|
220
|
+
.replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
|
|
215
221
|
}
|
package/dist/dialect.d.ts
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
import type { WithOptions } from './query/types.js';
|
|
9
9
|
import { type RelationDef, type SchemaMetadata, type TableMetadata } from './schema.js';
|
|
10
10
|
export type DialectName = 'postgresql' | 'mysql' | 'sqlite' | (string & {});
|
|
11
|
+
/**
|
|
12
|
+
* A write statement's returning/output selection. `'*'` returns every column
|
|
13
|
+
* (the historical default, byte-identical SQL). A `string[]` is an explicit,
|
|
14
|
+
* SQL-ready quoted column list used to exclude PII-tagged columns from a
|
|
15
|
+
* write's returned row at the SQL level, so PII never crosses the wire
|
|
16
|
+
* unrequested. Each dialect renders it into its own returning surface
|
|
17
|
+
* (`RETURNING …`, `OUTPUT INSERTED.…`).
|
|
18
|
+
*/
|
|
19
|
+
export type ReturningSelection = '*' | readonly string[];
|
|
11
20
|
export interface InsertStatementInput {
|
|
12
21
|
/** SQL-ready quoted table name. */
|
|
13
22
|
table: string;
|
|
@@ -16,7 +25,7 @@ export interface InsertStatementInput {
|
|
|
16
25
|
/** SQL-ready parameter placeholders/expressions for VALUES. */
|
|
17
26
|
valuePlaceholders: string[];
|
|
18
27
|
/** Optional SQL-ready RETURNING selection. */
|
|
19
|
-
returning?:
|
|
28
|
+
returning?: ReturningSelection;
|
|
20
29
|
}
|
|
21
30
|
export interface BulkInsertStatementInput {
|
|
22
31
|
/** SQL-ready quoted table name. */
|
|
@@ -30,7 +39,7 @@ export interface BulkInsertStatementInput {
|
|
|
30
39
|
/** Skip duplicate rows when supported by the dialect. */
|
|
31
40
|
skipDuplicates?: boolean;
|
|
32
41
|
/** Optional SQL-ready RETURNING selection. */
|
|
33
|
-
returning?:
|
|
42
|
+
returning?: ReturningSelection;
|
|
34
43
|
}
|
|
35
44
|
export interface BuiltStatement {
|
|
36
45
|
sql: string;
|
|
@@ -56,7 +65,7 @@ export interface UpsertStatementInput {
|
|
|
56
65
|
*/
|
|
57
66
|
updateWhere?: string;
|
|
58
67
|
/** Optional SQL-ready RETURNING selection. */
|
|
59
|
-
returning?:
|
|
68
|
+
returning?: ReturningSelection;
|
|
60
69
|
}
|
|
61
70
|
export interface ColumnTypeInput {
|
|
62
71
|
/** Schema-builder column type name (PostgreSQL-native in the root package). */
|
|
@@ -136,7 +145,7 @@ export interface UpdateStatementInput {
|
|
|
136
145
|
/** SQL-ready WHERE fragment INCLUDING the leading ` WHERE ` (or '' for none). */
|
|
137
146
|
whereSql: string;
|
|
138
147
|
/** SQL-ready returning selection (default `*`). */
|
|
139
|
-
returning?:
|
|
148
|
+
returning?: ReturningSelection;
|
|
140
149
|
}
|
|
141
150
|
/**
|
|
142
151
|
* Inputs for {@link Dialect.buildDeleteStatement} — full DELETE assembly. SQL Server
|
|
@@ -148,7 +157,7 @@ export interface DeleteStatementInput {
|
|
|
148
157
|
/** SQL-ready WHERE fragment INCLUDING the leading ` WHERE ` (or '' for none). */
|
|
149
158
|
whereSql: string;
|
|
150
159
|
/** SQL-ready returning selection (default `*`). */
|
|
151
|
-
returning?:
|
|
160
|
+
returning?: ReturningSelection;
|
|
152
161
|
}
|
|
153
162
|
/**
|
|
154
163
|
* Inputs for {@link Dialect.buildLimitOffset} — the trailing pagination clause of an
|
|
@@ -316,7 +325,7 @@ export interface Dialect {
|
|
|
316
325
|
prefix: string;
|
|
317
326
|
};
|
|
318
327
|
/** Build a dialect-specific RETURNING clause. Return an empty string when unsupported. */
|
|
319
|
-
buildReturningClause(selection?:
|
|
328
|
+
buildReturningClause(selection?: ReturningSelection): string;
|
|
320
329
|
/** Build a single-row INSERT statement. Inputs are SQL-ready quoted fragments. */
|
|
321
330
|
buildInsertStatement(input: InsertStatementInput): string;
|
|
322
331
|
/** Build a multi-row bulk INSERT statement and its dialect-shaped params. */
|
package/dist/dialect.js
CHANGED
|
@@ -69,7 +69,7 @@ export const postgresDialect = {
|
|
|
69
69
|
return `COALESCE((${subquery}), ${fallback})`;
|
|
70
70
|
},
|
|
71
71
|
buildReturningClause(selection = '*') {
|
|
72
|
-
return ` RETURNING ${selection}`;
|
|
72
|
+
return ` RETURNING ${selection === '*' ? '*' : selection.join(', ')}`;
|
|
73
73
|
},
|
|
74
74
|
buildInsertStatement(input) {
|
|
75
75
|
return `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})${this.buildReturningClause(input.returning)}`;
|