turbine-orm 0.8.0 → 0.9.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.
@@ -0,0 +1,641 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm CLI — Studio
4
+ *
5
+ * A local, read-only web UI for browsing databases, exploring relations,
6
+ * and running SELECT queries. Pure Node (built-in `http` module), no
7
+ * runtime dependencies beyond `pg`, bound to 127.0.0.1 only.
8
+ *
9
+ * Security model:
10
+ * • Bind 127.0.0.1 only (never 0.0.0.0 — no LAN exposure)
11
+ * • Random auth token generated per process, required in Cookie header
12
+ * • SELECT/WITH-only guard on the query endpoint
13
+ * • Every query runs in a READ ONLY transaction (belt-and-suspenders)
14
+ * • 30s statement timeout
15
+ *
16
+ * Not implemented (deliberately): row editing, DDL, destructive operations.
17
+ * Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
18
+ */
19
+ var __importDefault = (this && this.__importDefault) || function (mod) {
20
+ return (mod && mod.__esModule) ? mod : { "default": mod };
21
+ };
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.startStudio = startStudio;
24
+ exports.apiTableRows = apiTableRows;
25
+ exports.resolveColumnName = resolveColumnName;
26
+ exports.isTextishType = isTextishType;
27
+ exports.escapeLikePattern = escapeLikePattern;
28
+ exports.apiBuilder = apiBuilder;
29
+ exports.apiListSavedQueries = apiListSavedQueries;
30
+ exports.apiCreateSavedQuery = apiCreateSavedQuery;
31
+ exports.apiDeleteSavedQuery = apiDeleteSavedQuery;
32
+ exports.isReadOnlyStatement = isReadOnlyStatement;
33
+ const node_child_process_1 = require("node:child_process");
34
+ const node_crypto_1 = require("node:crypto");
35
+ const node_fs_1 = require("node:fs");
36
+ const node_http_1 = require("node:http");
37
+ const node_os_1 = require("node:os");
38
+ const node_path_1 = require("node:path");
39
+ const pg_1 = __importDefault(require("pg"));
40
+ const introspect_js_1 = require("../introspect.js");
41
+ const query_js_1 = require("../query.js");
42
+ const studio_ui_generated_js_1 = require("./studio-ui.generated.js");
43
+ // ---------------------------------------------------------------------------
44
+ // Main entry point
45
+ // ---------------------------------------------------------------------------
46
+ /**
47
+ * Start the Studio server. Returns a handle with the session token, a pre-built
48
+ * URL (including the token) that the CLI can print, and a disposer.
49
+ *
50
+ * Typical usage from the CLI:
51
+ * const studio = await startStudio(options);
52
+ * console.log(studio.url);
53
+ * process.on('SIGINT', () => studio.dispose().then(() => process.exit(0)));
54
+ */
55
+ async function startStudio(options) {
56
+ const pool = new pg_1.default.Pool({
57
+ connectionString: options.url,
58
+ max: 4, // small pool — single-user tool
59
+ idleTimeoutMillis: 10_000,
60
+ });
61
+ // Verify connectivity before starting the server — fail fast.
62
+ const probe = await pool.connect();
63
+ try {
64
+ await probe.query('SELECT 1');
65
+ }
66
+ finally {
67
+ probe.release();
68
+ }
69
+ const metadata = await (0, introspect_js_1.introspect)({
70
+ connectionString: options.url,
71
+ schema: options.schema,
72
+ include: options.include,
73
+ exclude: options.exclude,
74
+ });
75
+ const authToken = (0, node_crypto_1.randomBytes)(24).toString('hex');
76
+ const stateDir = (0, node_path_1.resolve)(options.stateDir ?? '.turbine');
77
+ const ctx = { pool, metadata, options, authToken, stateDir };
78
+ const server = (0, node_http_1.createServer)((req, res) => {
79
+ handleRequest(req, res, ctx).catch((err) => {
80
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
81
+ });
82
+ });
83
+ await new Promise((resolve, reject) => {
84
+ server.once('error', reject);
85
+ server.listen(options.port, options.host, () => {
86
+ server.off('error', reject);
87
+ resolve();
88
+ });
89
+ });
90
+ const url = buildStudioUrl(options.host, options.port, authToken);
91
+ if (options.openBrowser) {
92
+ openUrl(url);
93
+ }
94
+ return {
95
+ authToken,
96
+ url,
97
+ dispose: async () => {
98
+ await new Promise((resolve) => server.close(() => resolve()));
99
+ await pool.end();
100
+ },
101
+ };
102
+ }
103
+ /**
104
+ * Build a browser-safe URL for the given host/port/token. Wraps IPv6 addresses
105
+ * in brackets so `new URL(...)` inside the request handler works correctly.
106
+ */
107
+ function buildStudioUrl(host, port, token) {
108
+ const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
109
+ return `http://${hostPart}:${port}/?token=${token}`;
110
+ }
111
+ function originFor(host, port) {
112
+ const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
113
+ return `http://${hostPart}:${port}`;
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Request routing
117
+ // ---------------------------------------------------------------------------
118
+ async function handleRequest(req, res, ctx) {
119
+ const expectedOrigin = originFor(ctx.options.host, ctx.options.port);
120
+ // CORS: not needed — same-origin only. Explicitly refuse cross-origin.
121
+ const origin = req.headers.origin;
122
+ if (origin && origin !== expectedOrigin) {
123
+ sendJson(res, 403, { error: 'cross-origin requests not allowed' });
124
+ return;
125
+ }
126
+ const url = new URL(req.url ?? '/', expectedOrigin);
127
+ const pathname = url.pathname;
128
+ // The index route serves the HTML shell and embeds the auth token.
129
+ // All other routes require the token in the `x-turbine-token` header
130
+ // or the `token` cookie.
131
+ if (pathname === '/' || pathname === '/index.html') {
132
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
133
+ sendText(res, 405, 'Method Not Allowed');
134
+ return;
135
+ }
136
+ const queryToken = url.searchParams.get('token');
137
+ // If the URL includes a token, validate then set a cookie and redirect.
138
+ if (queryToken && constantTimeEqual(queryToken, ctx.authToken)) {
139
+ res.writeHead(302, {
140
+ Location: '/',
141
+ 'Set-Cookie': `turbine_studio_token=${ctx.authToken}; Path=/; HttpOnly; SameSite=Strict`,
142
+ });
143
+ res.end();
144
+ return;
145
+ }
146
+ sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML);
147
+ return;
148
+ }
149
+ // API routes — all require auth.
150
+ if (!isAuthorized(req, ctx.authToken)) {
151
+ sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
152
+ return;
153
+ }
154
+ if (pathname === '/api/schema' && req.method === 'GET') {
155
+ return apiSchema(res, ctx);
156
+ }
157
+ if (pathname.startsWith('/api/tables/') && req.method === 'GET') {
158
+ const rawName = decodeURIComponent(pathname.slice('/api/tables/'.length));
159
+ return apiTableRows(res, ctx, rawName, url.searchParams);
160
+ }
161
+ if (pathname === '/api/query' && req.method === 'POST') {
162
+ return apiQuery(req, res, ctx);
163
+ }
164
+ if (pathname === '/api/builder' && req.method === 'POST') {
165
+ return apiBuilder(req, res, ctx);
166
+ }
167
+ if (pathname === '/api/saved-queries' && req.method === 'GET') {
168
+ return apiListSavedQueries(res, ctx, url.searchParams);
169
+ }
170
+ if (pathname === '/api/saved-queries' && req.method === 'POST') {
171
+ return apiCreateSavedQuery(req, res, ctx);
172
+ }
173
+ if (pathname.startsWith('/api/saved-queries/') && req.method === 'DELETE') {
174
+ const id = decodeURIComponent(pathname.slice('/api/saved-queries/'.length));
175
+ return apiDeleteSavedQuery(res, ctx, id);
176
+ }
177
+ sendJson(res, 404, { error: 'not found' });
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Auth
181
+ // ---------------------------------------------------------------------------
182
+ function isAuthorized(req, expectedToken) {
183
+ const headerToken = req.headers['x-turbine-token'];
184
+ if (typeof headerToken === 'string' && constantTimeEqual(headerToken, expectedToken)) {
185
+ return true;
186
+ }
187
+ const cookieHeader = req.headers.cookie ?? '';
188
+ const match = /turbine_studio_token=([a-f0-9]+)/.exec(cookieHeader);
189
+ if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
190
+ return true;
191
+ }
192
+ return false;
193
+ }
194
+ function constantTimeEqual(a, b) {
195
+ if (a.length !== b.length)
196
+ return false;
197
+ let result = 0;
198
+ for (let i = 0; i < a.length; i++) {
199
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
200
+ }
201
+ return result === 0;
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // API: /api/schema
205
+ // ---------------------------------------------------------------------------
206
+ async function apiSchema(res, ctx) {
207
+ const tables = Object.values(ctx.metadata.tables).map((tbl) => ({
208
+ name: tbl.name,
209
+ primaryKey: tbl.primaryKey,
210
+ columns: tbl.columns.map((col) => ({
211
+ name: col.name,
212
+ field: col.field,
213
+ pgType: col.pgType,
214
+ tsType: col.tsType,
215
+ nullable: col.nullable,
216
+ hasDefault: col.hasDefault,
217
+ isPrimaryKey: tbl.primaryKey.includes(col.name),
218
+ })),
219
+ relations: Object.entries(tbl.relations).map(([name, rel]) => ({
220
+ name,
221
+ type: rel.type,
222
+ to: rel.to,
223
+ foreignKey: rel.foreignKey,
224
+ referenceKey: rel.referenceKey,
225
+ })),
226
+ }));
227
+ // Row counts — cheap enough to fetch inline. Use pg_class reltuples as
228
+ // a fast estimate so we don't hammer big tables with SELECT COUNT(*).
229
+ const countsResult = await ctx.pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
230
+ FROM pg_class c
231
+ JOIN pg_namespace n ON n.oid = c.relnamespace
232
+ WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
233
+ const counts = new Map();
234
+ for (const row of countsResult.rows) {
235
+ counts.set(row.relname, Number(row.reltuples));
236
+ }
237
+ sendJson(res, 200, {
238
+ schema: ctx.options.schema,
239
+ tables: tables.map((t) => ({ ...t, estimatedRows: counts.get(t.name) ?? 0 })),
240
+ enums: ctx.metadata.enums,
241
+ });
242
+ }
243
+ // ---------------------------------------------------------------------------
244
+ // API: /api/tables/:name?limit=&offset=&orderBy=&dir=
245
+ // ---------------------------------------------------------------------------
246
+ async function apiTableRows(res, ctx, rawTableName, params) {
247
+ const table = ctx.metadata.tables[rawTableName];
248
+ if (!table) {
249
+ sendJson(res, 404, { error: `unknown table: ${rawTableName}` });
250
+ return;
251
+ }
252
+ const limit = clampInt(params.get('limit'), 50, 1, 500);
253
+ const offset = clampInt(params.get('offset'), 0, 0, 10_000_000);
254
+ const orderByRaw = params.get('orderBy');
255
+ const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
256
+ // orderBy — accept either the Postgres column name (snake) or the TS field
257
+ // name (camel). Always emit the Postgres column in the SQL.
258
+ let orderByClause = '';
259
+ if (orderByRaw) {
260
+ const col = resolveColumnName(table, orderByRaw);
261
+ if (col)
262
+ orderByClause = `ORDER BY ${(0, query_js_1.quoteIdent)(col)} ${dir}`;
263
+ }
264
+ if (!orderByClause && table.primaryKey.length > 0 && table.primaryKey[0]) {
265
+ orderByClause = `ORDER BY ${(0, query_js_1.quoteIdent)(table.primaryKey[0])} ${dir}`;
266
+ }
267
+ // Full-text-ish search: ILIKE across text/varchar columns. The value is
268
+ // parameterized so injection is impossible. Each query gets its own
269
+ // WHERE clause with parameter indices matching that query's param array.
270
+ const search = params.get('search')?.trim() ?? '';
271
+ const textColumns = table.columns.filter((c) => isTextishType(c.pgType)).map((c) => c.name);
272
+ const hasSearch = search.length > 0 && textColumns.length > 0;
273
+ const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
274
+ // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
275
+ const mainValues = [limit, offset];
276
+ let mainWhere = '';
277
+ if (hasSearch && pattern !== null) {
278
+ mainValues.push(pattern);
279
+ const conds = textColumns.map((c) => `${(0, query_js_1.quoteIdent)(c)} ILIKE $3`);
280
+ mainWhere = `WHERE (${conds.join(' OR ')})`;
281
+ }
282
+ // Count query: $1 = pattern (if search)
283
+ const countValues = [];
284
+ let countWhere = '';
285
+ if (hasSearch && pattern !== null) {
286
+ countValues.push(pattern);
287
+ const conds = textColumns.map((c) => `${(0, query_js_1.quoteIdent)(c)} ILIKE $1`);
288
+ countWhere = `WHERE (${conds.join(' OR ')})`;
289
+ }
290
+ const qualifiedTable = `${(0, query_js_1.quoteIdent)(ctx.options.schema)}.${(0, query_js_1.quoteIdent)(table.name)}`;
291
+ const sql = `SELECT * FROM ${qualifiedTable} ${mainWhere} ${orderByClause} LIMIT $1 OFFSET $2`;
292
+ const countSql = `SELECT COUNT(*)::text AS count FROM ${qualifiedTable} ${countWhere}`;
293
+ const client = await ctx.pool.connect();
294
+ try {
295
+ await client.query('BEGIN READ ONLY');
296
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
297
+ const result = await client.query(sql, mainValues);
298
+ const countResult = await client.query(countSql, countValues);
299
+ await client.query('COMMIT');
300
+ sendJson(res, 200, {
301
+ table: table.name,
302
+ columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
303
+ rows: result.rows.map((r) => serializeRow(r)),
304
+ total: Number(countResult.rows[0]?.count ?? 0),
305
+ limit,
306
+ offset,
307
+ search,
308
+ });
309
+ }
310
+ catch (err) {
311
+ try {
312
+ await client.query('ROLLBACK');
313
+ }
314
+ catch {
315
+ /* ignore */
316
+ }
317
+ throw err;
318
+ }
319
+ finally {
320
+ client.release();
321
+ }
322
+ }
323
+ function resolveColumnName(table, nameOrField) {
324
+ for (const c of table.columns) {
325
+ if (c.name === nameOrField || c.field === nameOrField)
326
+ return c.name;
327
+ }
328
+ return null;
329
+ }
330
+ function isTextishType(pgType) {
331
+ return (pgType === 'text' ||
332
+ pgType === 'varchar' ||
333
+ pgType === 'character varying' ||
334
+ pgType === 'char' ||
335
+ pgType === 'character' ||
336
+ pgType === 'citext' ||
337
+ pgType === 'uuid');
338
+ }
339
+ function escapeLikePattern(s) {
340
+ // Escape the LIKE wildcards so user input is treated literally.
341
+ return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
342
+ }
343
+ // ---------------------------------------------------------------------------
344
+ // API: /api/query — read-only SELECT/WITH runner
345
+ // ---------------------------------------------------------------------------
346
+ async function apiQuery(req, res, ctx) {
347
+ const body = await readJsonBody(req);
348
+ const rawSql = typeof body?.sql === 'string' ? body.sql.trim() : '';
349
+ if (!rawSql) {
350
+ sendJson(res, 400, { error: 'missing sql' });
351
+ return;
352
+ }
353
+ if (!isReadOnlyStatement(rawSql)) {
354
+ sendJson(res, 400, {
355
+ error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
356
+ });
357
+ return;
358
+ }
359
+ const client = await ctx.pool.connect();
360
+ try {
361
+ await client.query('BEGIN READ ONLY');
362
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
363
+ const started = Date.now();
364
+ const result = await client.query(rawSql);
365
+ const elapsedMs = Date.now() - started;
366
+ await client.query('COMMIT');
367
+ sendJson(res, 200, {
368
+ columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
369
+ rows: result.rows.map((r) => serializeRow(r)),
370
+ rowCount: result.rowCount ?? result.rows.length,
371
+ elapsedMs,
372
+ });
373
+ }
374
+ catch (err) {
375
+ try {
376
+ await client.query('ROLLBACK');
377
+ }
378
+ catch {
379
+ /* ignore */
380
+ }
381
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
382
+ }
383
+ finally {
384
+ client.release();
385
+ }
386
+ }
387
+ // ---------------------------------------------------------------------------
388
+ // API: /api/builder — Turbine ORM findMany spec runner
389
+ // ---------------------------------------------------------------------------
390
+ async function apiBuilder(req, res, ctx) {
391
+ const body = await readJsonBody(req);
392
+ const tableName = typeof body?.table === 'string' ? body.table : '';
393
+ const args = (body?.args ?? {});
394
+ if (!tableName || !ctx.metadata.tables[tableName]) {
395
+ sendJson(res, 400, { error: `unknown table: ${tableName}` });
396
+ return;
397
+ }
398
+ let deferred;
399
+ try {
400
+ const qi = new query_js_1.QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
401
+ warnOnUnlimited: false,
402
+ sqlCache: false,
403
+ preparedStatements: false,
404
+ });
405
+ deferred = qi.buildFindMany(args);
406
+ }
407
+ catch (err) {
408
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
409
+ return;
410
+ }
411
+ const client = await ctx.pool.connect();
412
+ try {
413
+ await client.query('BEGIN READ ONLY');
414
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
415
+ const started = Date.now();
416
+ const result = await client.query(deferred.sql, deferred.params);
417
+ const elapsedMs = Date.now() - started;
418
+ await client.query('COMMIT');
419
+ sendJson(res, 200, {
420
+ sql: deferred.sql,
421
+ columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
422
+ rows: result.rows.map((r) => serializeRow(r)),
423
+ rowCount: result.rowCount ?? result.rows.length,
424
+ elapsedMs,
425
+ });
426
+ }
427
+ catch (err) {
428
+ try {
429
+ await client.query('ROLLBACK');
430
+ }
431
+ catch {
432
+ /* ignore */
433
+ }
434
+ sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
435
+ }
436
+ finally {
437
+ client.release();
438
+ }
439
+ }
440
+ function savedQueriesPath(ctx) {
441
+ return (0, node_path_1.resolve)(ctx.stateDir, 'studio-queries.json');
442
+ }
443
+ function loadSavedQueries(ctx) {
444
+ const file = savedQueriesPath(ctx);
445
+ if (!(0, node_fs_1.existsSync)(file))
446
+ return { version: 1, queries: [] };
447
+ try {
448
+ const raw = (0, node_fs_1.readFileSync)(file, 'utf8');
449
+ const parsed = JSON.parse(raw);
450
+ if (!parsed.queries || !Array.isArray(parsed.queries))
451
+ return { version: 1, queries: [] };
452
+ return { version: 1, queries: parsed.queries };
453
+ }
454
+ catch {
455
+ return { version: 1, queries: [] };
456
+ }
457
+ }
458
+ function writeSavedQueries(ctx, data) {
459
+ const file = savedQueriesPath(ctx);
460
+ const dir = (0, node_path_1.dirname)(file);
461
+ if (!(0, node_fs_1.existsSync)(dir))
462
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
463
+ (0, node_fs_1.writeFileSync)(file, JSON.stringify(data, null, 2));
464
+ }
465
+ function apiListSavedQueries(res, ctx, params) {
466
+ const { queries } = loadSavedQueries(ctx);
467
+ const table = params.get('table');
468
+ const filtered = table ? queries.filter((q) => q.table === table) : queries;
469
+ sendJson(res, 200, { queries: filtered });
470
+ }
471
+ async function apiCreateSavedQuery(req, res, ctx) {
472
+ const body = await readJsonBody(req);
473
+ const table = typeof body?.table === 'string' ? body.table : '';
474
+ const name = typeof body?.name === 'string' ? body.name.trim() : '';
475
+ const kind = body?.kind === 'builder' ? 'builder' : body?.kind === 'sql' ? 'sql' : null;
476
+ if (!table || !ctx.metadata.tables[table]) {
477
+ sendJson(res, 400, { error: `unknown table: ${table}` });
478
+ return;
479
+ }
480
+ if (!name) {
481
+ sendJson(res, 400, { error: 'name is required' });
482
+ return;
483
+ }
484
+ if (!kind) {
485
+ sendJson(res, 400, { error: 'kind must be "sql" or "builder"' });
486
+ return;
487
+ }
488
+ const sql = kind === 'sql' && typeof body?.sql === 'string' ? body.sql : undefined;
489
+ const args = kind === 'builder' ? body?.args : undefined;
490
+ if (kind === 'sql' && !sql) {
491
+ sendJson(res, 400, { error: 'sql is required for kind=sql' });
492
+ return;
493
+ }
494
+ if (kind === 'sql' && sql && !isReadOnlyStatement(sql)) {
495
+ sendJson(res, 400, { error: 'saved sql must be SELECT/WITH only' });
496
+ return;
497
+ }
498
+ const data = loadSavedQueries(ctx);
499
+ const entry = {
500
+ id: (0, node_crypto_1.randomUUID)(),
501
+ table,
502
+ name,
503
+ kind,
504
+ sql,
505
+ args,
506
+ createdAt: new Date().toISOString(),
507
+ };
508
+ data.queries.push(entry);
509
+ writeSavedQueries(ctx, data);
510
+ sendJson(res, 200, { query: entry });
511
+ }
512
+ function apiDeleteSavedQuery(res, ctx, id) {
513
+ const data = loadSavedQueries(ctx);
514
+ const before = data.queries.length;
515
+ data.queries = data.queries.filter((q) => q.id !== id);
516
+ if (data.queries.length === before) {
517
+ sendJson(res, 404, { error: 'saved query not found' });
518
+ return;
519
+ }
520
+ writeSavedQueries(ctx, data);
521
+ sendJson(res, 200, { ok: true });
522
+ }
523
+ // ---------------------------------------------------------------------------
524
+ // Helpers
525
+ // ---------------------------------------------------------------------------
526
+ function clampInt(value, fallback, min, max) {
527
+ if (value == null)
528
+ return fallback;
529
+ const n = Number.parseInt(value, 10);
530
+ if (!Number.isFinite(n))
531
+ return fallback;
532
+ return Math.min(Math.max(n, min), max);
533
+ }
534
+ /**
535
+ * Accept only SELECT or WITH (CTE) statements. Reject any statement that
536
+ * contains a semicolon followed by non-whitespace (prevents statement
537
+ * stacking), and require the first non-comment keyword to be SELECT or WITH.
538
+ *
539
+ * This is a first-line filter — the transaction's READ ONLY mode is the
540
+ * second line of defense. Both must fail before a destructive statement
541
+ * could run.
542
+ */
543
+ function isReadOnlyStatement(sql) {
544
+ const stripped = stripSqlComments(sql).trim();
545
+ if (!stripped)
546
+ return false;
547
+ // Disallow statement stacking. A single trailing `;` is fine.
548
+ const withoutTrailingSemi = stripped.replace(/;+\s*$/, '');
549
+ if (withoutTrailingSemi.includes(';'))
550
+ return false;
551
+ const firstWord = withoutTrailingSemi.slice(0, 6).toUpperCase();
552
+ if (firstWord.startsWith('SELECT'))
553
+ return true;
554
+ if (firstWord.startsWith('WITH'))
555
+ return true;
556
+ return false;
557
+ }
558
+ function stripSqlComments(sql) {
559
+ // Strip -- line comments and /* block comments */. Not a full SQL parser,
560
+ // but sufficient to catch the common bypass attempts.
561
+ return sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
562
+ }
563
+ function serializeRow(row) {
564
+ const out = {};
565
+ for (const [k, v] of Object.entries(row)) {
566
+ if (v instanceof Date) {
567
+ out[k] = v.toISOString();
568
+ }
569
+ else if (typeof v === 'bigint') {
570
+ out[k] = v.toString();
571
+ }
572
+ else if (Buffer.isBuffer(v)) {
573
+ out[k] = `\\x${v.toString('hex')}`;
574
+ }
575
+ else {
576
+ out[k] = v;
577
+ }
578
+ }
579
+ return out;
580
+ }
581
+ async function readJsonBody(req) {
582
+ const chunks = [];
583
+ let total = 0;
584
+ const MAX = 1 << 20; // 1 MB cap on query payloads
585
+ for await (const chunk of req) {
586
+ const buf = chunk;
587
+ total += buf.length;
588
+ if (total > MAX)
589
+ throw new Error('request body too large');
590
+ chunks.push(buf);
591
+ }
592
+ const raw = Buffer.concat(chunks).toString('utf8');
593
+ if (!raw)
594
+ return {};
595
+ try {
596
+ return JSON.parse(raw);
597
+ }
598
+ catch {
599
+ throw new Error('invalid json body');
600
+ }
601
+ }
602
+ function sendJson(res, status, body) {
603
+ const payload = JSON.stringify(body);
604
+ res.writeHead(status, {
605
+ 'Content-Type': 'application/json; charset=utf-8',
606
+ 'Content-Length': Buffer.byteLength(payload),
607
+ 'Cache-Control': 'no-store',
608
+ 'X-Content-Type-Options': 'nosniff',
609
+ 'Referrer-Policy': 'no-referrer',
610
+ });
611
+ res.end(payload);
612
+ }
613
+ function sendText(res, status, body) {
614
+ res.writeHead(status, {
615
+ 'Content-Type': 'text/plain; charset=utf-8',
616
+ 'Content-Length': Buffer.byteLength(body),
617
+ });
618
+ res.end(body);
619
+ }
620
+ function sendHtml(res, status, body) {
621
+ res.writeHead(status, {
622
+ 'Content-Type': 'text/html; charset=utf-8',
623
+ 'Content-Length': Buffer.byteLength(body),
624
+ 'Cache-Control': 'no-store',
625
+ 'X-Content-Type-Options': 'nosniff',
626
+ 'X-Frame-Options': 'DENY',
627
+ 'Referrer-Policy': 'no-referrer',
628
+ });
629
+ res.end(body);
630
+ }
631
+ function openUrl(url) {
632
+ const cmd = (0, node_os_1.platform)() === 'darwin' ? 'open' : (0, node_os_1.platform)() === 'win32' ? 'cmd' : 'xdg-open';
633
+ const args = (0, node_os_1.platform)() === 'win32' ? ['/c', 'start', '', url] : [url];
634
+ try {
635
+ const child = (0, node_child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: true });
636
+ child.unref();
637
+ }
638
+ catch {
639
+ // Non-fatal — user can click the URL manually.
640
+ }
641
+ }