turbine-orm 0.81.0 → 0.90.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -25
- package/dist/checkout.d.ts +27 -2
- package/dist/checkout.js +75 -4
- package/dist/cjs/checkout.d.ts +27 -2
- package/dist/cjs/checkout.js +76 -3
- package/dist/cjs/cli/code-first-tags.d.ts +62 -0
- package/dist/cjs/cli/code-first-tags.js +124 -0
- package/dist/cjs/cli/compile-query.js +9 -0
- package/dist/cjs/cli/config.d.ts +35 -6
- package/dist/cjs/cli/config.js +17 -1
- package/dist/cjs/cli/destructive.d.ts +7 -4
- package/dist/cjs/cli/destructive.js +224 -20
- package/dist/cjs/cli/error-catalog.js +36 -0
- package/dist/cjs/cli/error-text.d.ts +15 -0
- package/dist/cjs/cli/error-text.js +40 -0
- package/dist/cjs/cli/index.d.ts +62 -4
- package/dist/cjs/cli/index.js +568 -108
- package/dist/cjs/cli/local-server-guard.d.ts +53 -0
- package/dist/cjs/cli/local-server-guard.js +119 -0
- package/dist/cjs/cli/local-server-output.d.ts +41 -0
- package/dist/cjs/cli/local-server-output.js +103 -0
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +121 -32
- package/dist/cjs/cli/migrate.d.ts +113 -5
- package/dist/cjs/cli/migrate.js +270 -21
- package/dist/cjs/cli/observe.d.ts +6 -1
- package/dist/cjs/cli/observe.js +85 -55
- package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cjs/cli/pii-predicate-guard.js +98 -37
- package/dist/cjs/cli/prisma-resolve.js +17 -13
- package/dist/cjs/cli/prisma-schema.js +12 -4
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +14 -2
- package/dist/cjs/cli/studio.js +244 -69
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +558 -85
- package/dist/cjs/client.js +1270 -395
- package/dist/cjs/connection-guard.d.ts +13 -0
- package/dist/cjs/connection-guard.js +28 -0
- package/dist/cjs/connection-url.d.ts +51 -16
- package/dist/cjs/connection-url.js +67 -34
- package/dist/cjs/diagnostics.d.ts +81 -0
- package/dist/cjs/diagnostics.js +147 -0
- package/dist/cjs/dialect.d.ts +449 -20
- package/dist/cjs/dialect.js +117 -76
- package/dist/cjs/engine-config.d.ts +7 -6
- package/dist/cjs/errors.d.ts +126 -6
- package/dist/cjs/errors.js +256 -13
- package/dist/cjs/generate.d.ts +57 -7
- package/dist/cjs/generate.js +635 -279
- package/dist/cjs/global-filters-config.d.ts +59 -0
- package/dist/cjs/global-filters-config.js +367 -0
- package/dist/cjs/index.d.ts +9 -4
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.d.ts +31 -6
- package/dist/cjs/introspect.js +108 -50
- package/dist/cjs/log-codes.d.ts +12 -0
- package/dist/cjs/log-codes.js +77 -0
- package/dist/cjs/logger.d.ts +87 -0
- package/dist/cjs/logger.js +98 -0
- package/dist/cjs/mssql.d.ts +8 -5
- package/dist/cjs/mssql.js +352 -65
- package/dist/cjs/mysql.d.ts +3 -5
- package/dist/cjs/mysql.js +420 -70
- package/dist/cjs/nested-write.d.ts +3 -0
- package/dist/cjs/nested-write.js +179 -114
- package/dist/cjs/pipeline-submittable.d.ts +12 -0
- package/dist/cjs/pipeline-submittable.js +118 -44
- package/dist/cjs/pipeline.js +21 -26
- package/dist/cjs/powdb-introspect.d.ts +5 -2
- package/dist/cjs/powdb-introspect.js +20 -15
- package/dist/cjs/powdb-shared.d.ts +36 -5
- package/dist/cjs/powdb-shared.js +67 -8
- package/dist/cjs/powdb.d.ts +4 -0
- package/dist/cjs/powdb.js +183 -51
- package/dist/cjs/powql.d.ts +394 -51
- package/dist/cjs/powql.js +1829 -284
- package/dist/cjs/prisma-compat.d.ts +23 -15
- package/dist/cjs/prisma-compat.js +507 -372
- package/dist/cjs/query/aggregates.d.ts +17 -1
- package/dist/cjs/query/aggregates.js +261 -29
- package/dist/cjs/query/batched-loader.d.ts +25 -6
- package/dist/cjs/query/batched-loader.js +103 -20
- package/dist/cjs/query/builder.d.ts +325 -184
- package/dist/cjs/query/builder.js +1452 -490
- package/dist/cjs/query/column-ref.d.ts +61 -0
- package/dist/cjs/query/column-ref.js +73 -0
- package/dist/cjs/query/compound-unique.d.ts +21 -0
- package/dist/cjs/query/compound-unique.js +98 -18
- package/dist/cjs/query/deferred.d.ts +53 -16
- package/dist/cjs/query/expr.d.ts +217 -0
- package/dist/cjs/query/expr.js +859 -0
- package/dist/cjs/query/filters.d.ts +155 -53
- package/dist/cjs/query/filters.js +613 -76
- package/dist/cjs/query/index.d.ts +2 -0
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/json-value.d.ts +43 -0
- package/dist/cjs/query/json-value.js +59 -0
- package/dist/cjs/query/normalize-args.d.ts +87 -0
- package/dist/cjs/query/normalize-args.js +527 -0
- package/dist/cjs/query/option-surface.d.ts +16 -4
- package/dist/cjs/query/option-surface.js +57 -14
- package/dist/cjs/query/relation-names.d.ts +14 -0
- package/dist/cjs/query/relation-names.js +77 -18
- package/dist/cjs/query/relations.d.ts +30 -3
- package/dist/cjs/query/relations.js +219 -63
- package/dist/cjs/query/row-lock.d.ts +49 -0
- package/dist/cjs/query/row-lock.js +138 -0
- package/dist/cjs/query/sql-comment.d.ts +161 -0
- package/dist/cjs/query/sql-comment.js +381 -0
- package/dist/cjs/query/stream-idle.d.ts +132 -0
- package/dist/cjs/query/stream-idle.js +212 -0
- package/dist/cjs/query/table-client.d.ts +101 -0
- package/dist/cjs/query/table-client.js +74 -0
- package/dist/cjs/query/types.d.ts +677 -298
- package/dist/cjs/query/utils.d.ts +105 -6
- package/dist/cjs/query/utils.js +426 -19
- package/dist/cjs/query/warn-registry.d.ts +18 -0
- package/dist/cjs/query/warn-registry.js +18 -0
- package/dist/cjs/query/where-compile.d.ts +138 -14
- package/dist/cjs/query/where-compile.js +513 -29
- package/dist/cjs/query/where.d.ts +159 -59
- package/dist/cjs/query/where.js +716 -246
- package/dist/cjs/query/writes.d.ts +112 -50
- package/dist/cjs/query/writes.js +1498 -200
- package/dist/cjs/query-events.d.ts +4 -2
- package/dist/cjs/query-events.js +3 -1
- package/dist/cjs/realtime.d.ts +7 -3
- package/dist/cjs/realtime.js +13 -2
- package/dist/cjs/schema-builder.d.ts +55 -4
- package/dist/cjs/schema-builder.js +382 -18
- package/dist/cjs/schema-metadata.d.ts +21 -1
- package/dist/cjs/schema-metadata.js +29 -7
- package/dist/cjs/schema-sql.d.ts +75 -5
- package/dist/cjs/schema-sql.js +497 -45
- package/dist/cjs/schema.d.ts +26 -5
- package/dist/cjs/schema.js +110 -13
- package/dist/cjs/sql-placeholders.d.ts +56 -0
- package/dist/cjs/sql-placeholders.js +204 -0
- package/dist/cjs/sqlite.d.ts +75 -20
- package/dist/cjs/sqlite.js +593 -95
- package/dist/cjs/supplied-pool-types.d.ts +48 -0
- package/dist/cjs/supplied-pool-types.js +256 -0
- package/dist/cjs/transaction-control.d.ts +51 -0
- package/dist/cjs/transaction-control.js +316 -0
- package/dist/cjs/transaction-state.d.ts +342 -0
- package/dist/cjs/transaction-state.js +707 -0
- package/dist/cjs/typed-sql.d.ts +37 -3
- package/dist/cjs/typed-sql.js +102 -8
- package/dist/cli/code-first-tags.d.ts +62 -0
- package/dist/cli/code-first-tags.js +120 -0
- package/dist/cli/compile-query.js +9 -0
- package/dist/cli/config.d.ts +35 -6
- package/dist/cli/config.js +16 -1
- package/dist/cli/destructive.d.ts +7 -4
- package/dist/cli/destructive.js +224 -20
- package/dist/cli/error-catalog.js +36 -0
- package/dist/cli/error-text.d.ts +15 -0
- package/dist/cli/error-text.js +37 -0
- package/dist/cli/index.d.ts +62 -4
- package/dist/cli/index.js +569 -112
- package/dist/cli/local-server-guard.d.ts +53 -0
- package/dist/cli/local-server-guard.js +113 -0
- package/dist/cli/local-server-output.d.ts +41 -0
- package/dist/cli/local-server-output.js +98 -0
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +121 -34
- package/dist/cli/migrate.d.ts +113 -5
- package/dist/cli/migrate.js +261 -21
- package/dist/cli/observe.d.ts +6 -1
- package/dist/cli/observe.js +84 -54
- package/dist/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cli/pii-predicate-guard.js +100 -35
- package/dist/cli/prisma-resolve.js +15 -11
- package/dist/cli/prisma-schema.js +12 -4
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +14 -2
- package/dist/cli/studio.js +246 -71
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +558 -85
- package/dist/client.js +1275 -400
- package/dist/connection-guard.d.ts +13 -0
- package/dist/connection-guard.js +27 -0
- package/dist/connection-url.d.ts +51 -16
- package/dist/connection-url.js +63 -34
- package/dist/diagnostics.d.ts +81 -0
- package/dist/diagnostics.js +144 -0
- package/dist/dialect.d.ts +449 -20
- package/dist/dialect.js +115 -43
- package/dist/engine-config.d.ts +7 -6
- package/dist/errors.d.ts +126 -6
- package/dist/errors.js +252 -12
- package/dist/generate.d.ts +57 -7
- package/dist/generate.js +635 -281
- package/dist/global-filters-config.d.ts +59 -0
- package/dist/global-filters-config.js +361 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +4 -2
- package/dist/introspect.d.ts +31 -6
- package/dist/introspect.js +106 -50
- package/dist/log-codes.d.ts +12 -0
- package/dist/log-codes.js +74 -0
- package/dist/logger.d.ts +87 -0
- package/dist/logger.js +93 -0
- package/dist/mssql.d.ts +8 -5
- package/dist/mssql.js +354 -67
- package/dist/mysql.d.ts +3 -5
- package/dist/mysql.js +423 -73
- package/dist/nested-write.d.ts +3 -0
- package/dist/nested-write.js +177 -112
- package/dist/pipeline-submittable.d.ts +12 -0
- package/dist/pipeline-submittable.js +117 -44
- package/dist/pipeline.js +21 -26
- package/dist/powdb-introspect.d.ts +5 -2
- package/dist/powdb-introspect.js +21 -16
- package/dist/powdb-shared.d.ts +36 -5
- package/dist/powdb-shared.js +65 -8
- package/dist/powdb.d.ts +4 -0
- package/dist/powdb.js +183 -51
- package/dist/powql.d.ts +394 -51
- package/dist/powql.js +1836 -291
- package/dist/prisma-compat.d.ts +23 -15
- package/dist/prisma-compat.js +508 -373
- package/dist/query/aggregates.d.ts +17 -1
- package/dist/query/aggregates.js +263 -32
- package/dist/query/batched-loader.d.ts +25 -6
- package/dist/query/batched-loader.js +101 -21
- package/dist/query/builder.d.ts +325 -184
- package/dist/query/builder.js +1458 -495
- package/dist/query/column-ref.d.ts +61 -0
- package/dist/query/column-ref.js +66 -0
- package/dist/query/compound-unique.d.ts +21 -0
- package/dist/query/compound-unique.js +97 -19
- package/dist/query/deferred.d.ts +53 -16
- package/dist/query/expr.d.ts +217 -0
- package/dist/query/expr.js +848 -0
- package/dist/query/filters.d.ts +155 -53
- package/dist/query/filters.js +597 -72
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +4 -0
- package/dist/query/json-value.d.ts +43 -0
- package/dist/query/json-value.js +54 -0
- package/dist/query/normalize-args.d.ts +87 -0
- package/dist/query/normalize-args.js +517 -0
- package/dist/query/option-surface.d.ts +16 -4
- package/dist/query/option-surface.js +56 -13
- package/dist/query/relation-names.d.ts +14 -0
- package/dist/query/relation-names.js +77 -19
- package/dist/query/relations.d.ts +30 -3
- package/dist/query/relations.js +220 -67
- package/dist/query/row-lock.d.ts +49 -0
- package/dist/query/row-lock.js +131 -0
- package/dist/query/sql-comment.d.ts +161 -0
- package/dist/query/sql-comment.js +374 -0
- package/dist/query/stream-idle.d.ts +132 -0
- package/dist/query/stream-idle.js +203 -0
- package/dist/query/table-client.d.ts +101 -0
- package/dist/query/table-client.js +70 -0
- package/dist/query/types.d.ts +677 -298
- package/dist/query/utils.d.ts +105 -6
- package/dist/query/utils.js +414 -18
- package/dist/query/warn-registry.d.ts +18 -0
- package/dist/query/warn-registry.js +18 -0
- package/dist/query/where-compile.d.ts +138 -14
- package/dist/query/where-compile.js +508 -31
- package/dist/query/where.d.ts +159 -59
- package/dist/query/where.js +711 -248
- package/dist/query/writes.d.ts +112 -50
- package/dist/query/writes.js +1496 -202
- package/dist/query-events.d.ts +4 -2
- package/dist/query-events.js +3 -1
- package/dist/realtime.d.ts +7 -3
- package/dist/realtime.js +13 -2
- package/dist/schema-builder.d.ts +55 -4
- package/dist/schema-builder.js +381 -18
- package/dist/schema-metadata.d.ts +21 -1
- package/dist/schema-metadata.js +28 -7
- package/dist/schema-sql.d.ts +75 -5
- package/dist/schema-sql.js +494 -44
- package/dist/schema.d.ts +26 -5
- package/dist/schema.js +109 -13
- package/dist/sql-placeholders.d.ts +56 -0
- package/dist/sql-placeholders.js +200 -0
- package/dist/sqlite.d.ts +75 -20
- package/dist/sqlite.js +597 -99
- package/dist/supplied-pool-types.d.ts +48 -0
- package/dist/supplied-pool-types.js +247 -0
- package/dist/transaction-control.d.ts +51 -0
- package/dist/transaction-control.js +311 -0
- package/dist/transaction-state.d.ts +342 -0
- package/dist/transaction-state.js +696 -0
- package/dist/typed-sql.d.ts +37 -3
- package/dist/typed-sql.js +100 -8
- package/package.json +7 -4
- package/skills/turbine-orm/SKILL.md +56 -6
package/dist/cli/studio.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
|
40
40
|
import type { PgCompatPool } from '../client.js';
|
|
41
41
|
import type { Dialect } from '../dialect.js';
|
|
42
42
|
import type { SchemaMetadata, TableMetadata } from '../schema.js';
|
|
43
|
+
import { type TicketGate } from './local-server-guard.js';
|
|
43
44
|
export interface StudioOptions {
|
|
44
45
|
url: string;
|
|
45
46
|
schema: string;
|
|
@@ -88,9 +89,13 @@ export interface StudioHandle {
|
|
|
88
89
|
path: string;
|
|
89
90
|
applied: number;
|
|
90
91
|
} | null;
|
|
91
|
-
/** Random per-process session token the UI sends via cookie. */
|
|
92
|
+
/** Random per-process session token the UI sends via cookie. Never part of `url`. */
|
|
92
93
|
authToken: string;
|
|
93
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Full URL including a ONE-TIME `?token=...` login ticket, safe to print for
|
|
96
|
+
* the user. The first GET that carries it is exchanged for the session cookie;
|
|
97
|
+
* the ticket is refused afterwards and is not the session credential.
|
|
98
|
+
*/
|
|
94
99
|
url: string;
|
|
95
100
|
}
|
|
96
101
|
export interface StudioContext {
|
|
@@ -154,6 +159,11 @@ export interface StudioContext {
|
|
|
154
159
|
reason: string;
|
|
155
160
|
columns: number;
|
|
156
161
|
};
|
|
162
|
+
/**
|
|
163
|
+
* The one-time login ticket behind the printed URL. Absent on a hand-built
|
|
164
|
+
* context, which then gets a gate whose ticket is `authToken` on first use.
|
|
165
|
+
*/
|
|
166
|
+
ticketGate?: TicketGate;
|
|
157
167
|
}
|
|
158
168
|
/**
|
|
159
169
|
* Start the Studio server. Returns a handle with the session token, a pre-built
|
|
@@ -196,6 +206,8 @@ export declare function relationLinksForTable(table: TableMetadata, metadata: Sc
|
|
|
196
206
|
export declare function apiTableRows(res: ServerResponse, ctx: StudioContext, rawTableName: string, params: URLSearchParams): Promise<void>;
|
|
197
207
|
export declare function resolveColumnName(table: TableMetadata, nameOrField: string): string | null;
|
|
198
208
|
export declare function isTextishType(pgType: string): boolean;
|
|
209
|
+
/** Most rows one Query-tab request returns, however large its `limit` or however absent. */
|
|
210
|
+
export declare const MAX_BUILDER_ROWS = 1000;
|
|
199
211
|
export declare function apiBuilder(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
|
|
200
212
|
export declare function apiRowWrite(req: IncomingMessage, res: ServerResponse, ctx: StudioContext, op: 'update' | 'insert' | 'delete'): Promise<void>;
|
|
201
213
|
interface SavedQuery {
|
package/dist/cli/studio.js
CHANGED
|
@@ -36,30 +36,37 @@
|
|
|
36
36
|
* predicate-based (unconditional) writes are deliberately unsupported. Use the
|
|
37
37
|
* CLI or migrate for schema changes and true bulk operations.
|
|
38
38
|
*/
|
|
39
|
-
import {
|
|
40
|
-
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
39
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
41
40
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
42
41
|
import { createServer } from 'node:http';
|
|
43
|
-
import { platform } from 'node:os';
|
|
44
42
|
import { dirname, resolve as pathResolve } from 'node:path';
|
|
45
43
|
import pg from 'pg';
|
|
46
44
|
import { absorbCheckedOutErrors } from '../connection-guard.js';
|
|
47
45
|
import { ValidationError } from '../errors.js';
|
|
48
|
-
import { introspect } from '../introspect.js';
|
|
49
|
-
import { QueryInterface, quoteIdent } from '../query/index.js';
|
|
46
|
+
import { introspect, printableCatalogName } from '../introspect.js';
|
|
47
|
+
import { QueryInterface, quoteIdent, UNSAFE } from '../query/index.js';
|
|
50
48
|
// `ownLookup` and `resolveRelation` are not re-exported from the query barrel,
|
|
51
49
|
// so they are imported from their defining leaf module rather than duplicated
|
|
52
50
|
// here. `resolveRelation` in particular MUST be the compiler's own copy: it is
|
|
53
51
|
// what decides that `blog_posts` and `blogPosts` name the same relation, and a
|
|
54
52
|
// redaction walk that answered that differently would redact a different query
|
|
55
53
|
// than the one that ran.
|
|
56
|
-
import { escapeLike, ownLookup, registerUtcTemporalParsers, resolveRelation } from '../query/utils.js';
|
|
57
|
-
import {
|
|
54
|
+
import { escapeLike, ownLookup, registerUtcTemporalParsers, resolveColumnName as resolveBuilderColumn, resolveRelation, } from '../query/utils.js';
|
|
55
|
+
import { constantTimeEqual, createTicketGate, isAllowedHostHeader, redeemTicket, } from './local-server-guard.js';
|
|
56
|
+
import { openInBrowser, responseErrorText } from './local-server-output.js';
|
|
57
|
+
import { assertNoPiiPredicates as assertNoPiiPredicatesShared, findRowLockOption, rowLockRefusalMessage, } from './pii-predicate-guard.js';
|
|
58
58
|
import { applyPiiTags, loadPiiTags } from './pii-tags.js';
|
|
59
59
|
import { callerKey, checkRateLimit } from './rate-limit.js';
|
|
60
60
|
import { createDemoContext } from './studio-demo.js';
|
|
61
61
|
import { STUDIO_HTML } from './studio-ui.generated.js';
|
|
62
62
|
import { redactUrl } from './ui.js';
|
|
63
|
+
/**
|
|
64
|
+
* A name made safe to quote in a message. Catalog names (and any name echoed from
|
|
65
|
+
* a request) can carry control or bidi characters, and a message can end up in a
|
|
66
|
+
* terminal, a log or the UI's error box. Same helper `generate`, `status` and
|
|
67
|
+
* `doctor` print through.
|
|
68
|
+
*/
|
|
69
|
+
const shown = printableCatalogName;
|
|
63
70
|
// ---------------------------------------------------------------------------
|
|
64
71
|
// Main entry point
|
|
65
72
|
// ---------------------------------------------------------------------------
|
|
@@ -178,6 +185,7 @@ export async function startStudio(options) {
|
|
|
178
185
|
};
|
|
179
186
|
}
|
|
180
187
|
const authToken = randomBytes(24).toString('hex');
|
|
188
|
+
const ticketGate = createTicketGate();
|
|
181
189
|
const stateDir = pathResolve(options.stateDir ?? '.turbine');
|
|
182
190
|
const rateLimiter = new Map();
|
|
183
191
|
const ctx = {
|
|
@@ -185,6 +193,7 @@ export async function startStudio(options) {
|
|
|
185
193
|
metadata,
|
|
186
194
|
options,
|
|
187
195
|
authToken,
|
|
196
|
+
ticketGate,
|
|
188
197
|
stateDir,
|
|
189
198
|
statementTimeout,
|
|
190
199
|
rateLimiter,
|
|
@@ -201,7 +210,7 @@ export async function startStudio(options) {
|
|
|
201
210
|
};
|
|
202
211
|
const server = createServer((req, res) => {
|
|
203
212
|
handleRequest(req, res, ctx).catch((err) => {
|
|
204
|
-
sendJson(res, 500, { error:
|
|
213
|
+
sendJson(res, 500, { error: responseErrorText(err) });
|
|
205
214
|
});
|
|
206
215
|
});
|
|
207
216
|
await new Promise((resolve, reject) => {
|
|
@@ -211,9 +220,9 @@ export async function startStudio(options) {
|
|
|
211
220
|
resolve();
|
|
212
221
|
});
|
|
213
222
|
});
|
|
214
|
-
const url = buildStudioUrl(options.host, options.port,
|
|
223
|
+
const url = buildStudioUrl(options.host, options.port, ticketGate.token);
|
|
215
224
|
if (options.openBrowser) {
|
|
216
|
-
|
|
225
|
+
openInBrowser(url);
|
|
217
226
|
}
|
|
218
227
|
return {
|
|
219
228
|
authToken,
|
|
@@ -242,31 +251,64 @@ function originFor(host, port) {
|
|
|
242
251
|
// ---------------------------------------------------------------------------
|
|
243
252
|
export async function handleRequest(req, res, ctx) {
|
|
244
253
|
const expectedOrigin = originFor(ctx.options.host, ctx.options.port);
|
|
254
|
+
// DNS rebinding: a page on another domain that re-resolves to this loopback
|
|
255
|
+
// port still sends ITS hostname in `Host`. Checked first, before any route or
|
|
256
|
+
// token logic, so nothing about this server is observable to such a page.
|
|
257
|
+
if (!isAllowedHostHeader(req.headers.host, ctx.options.host, ctx.options.port)) {
|
|
258
|
+
sendJson(res, 403, { error: 'host not allowed: open Studio at the address printed in the terminal' });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
245
261
|
// CORS: not needed, same-origin only. Explicitly refuse cross-origin.
|
|
246
262
|
const origin = req.headers.origin;
|
|
247
263
|
if (origin && origin !== expectedOrigin) {
|
|
248
264
|
sendJson(res, 403, { error: 'cross-origin requests not allowed' });
|
|
249
265
|
return;
|
|
250
266
|
}
|
|
251
|
-
|
|
267
|
+
// A request target that is not a URL (`GET //`) is a client error, not a 500.
|
|
268
|
+
let url;
|
|
269
|
+
try {
|
|
270
|
+
url = new URL(req.url ?? '/', expectedOrigin);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
sendJson(res, 400, { error: 'malformed request target' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
252
276
|
const pathname = url.pathname;
|
|
253
|
-
// The index route serves the HTML shell
|
|
254
|
-
//
|
|
255
|
-
//
|
|
277
|
+
// The index route serves the HTML shell. Every other route requires the
|
|
278
|
+
// session token in the `x-turbine-token` header or the `turbine_studio_token`
|
|
279
|
+
// cookie. The printed URL's `?token=` is a ONE-TIME login ticket, not that
|
|
280
|
+
// token: the first GET carrying it is exchanged for the cookie, and the ticket
|
|
281
|
+
// is dead afterwards, so a copy left in browser history, `ps` output or a log
|
|
282
|
+
// opens nothing.
|
|
256
283
|
if (pathname === '/' || pathname === '/index.html') {
|
|
257
284
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
258
285
|
sendText(res, 405, 'Method Not Allowed');
|
|
259
286
|
return;
|
|
260
287
|
}
|
|
261
288
|
const queryToken = url.searchParams.get('token');
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
289
|
+
if (queryToken) {
|
|
290
|
+
ctx.ticketGate ??= createTicketGate(ctx.authToken);
|
|
291
|
+
// HEAD must not spend the ticket: only the navigation that sets the cookie may.
|
|
292
|
+
const outcome = req.method === 'GET' ? redeemTicket(ctx.ticketGate, queryToken) : 'invalid';
|
|
293
|
+
if (outcome === 'granted') {
|
|
294
|
+
res.writeHead(302, {
|
|
295
|
+
Location: '/',
|
|
296
|
+
'Set-Cookie': `turbine_studio_token=${ctx.authToken}; Path=/; HttpOnly; SameSite=Strict`,
|
|
297
|
+
});
|
|
298
|
+
res.end();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (outcome === 'used') {
|
|
302
|
+
if (isAuthorized(req, ctx.authToken)) {
|
|
303
|
+
// Already signed in (a reload of the printed URL): drop the ticket from the address bar.
|
|
304
|
+
res.writeHead(302, { Location: '/' });
|
|
305
|
+
res.end();
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
sendText(res, 403, 'This login link was already used. Restart Studio to get a new one.');
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
270
312
|
}
|
|
271
313
|
sendHtml(res, 200, STUDIO_HTML, cspNonce());
|
|
272
314
|
return;
|
|
@@ -297,6 +339,22 @@ export async function handleRequest(req, res, ctx) {
|
|
|
297
339
|
sendJson(res, 401, { error: 'unauthorized: use the URL printed in the terminal' });
|
|
298
340
|
return;
|
|
299
341
|
}
|
|
342
|
+
try {
|
|
343
|
+
await routeAuthorized(req, res, ctx, url, origin, expectedOrigin);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
// A bad request body is the caller's mistake and gets its own status; every
|
|
347
|
+
// other failure is still the dispatcher's 500.
|
|
348
|
+
if (err instanceof RequestError) {
|
|
349
|
+
sendJson(res, err.status, { error: err.message });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
throw err;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** The routes behind the auth gate. */
|
|
356
|
+
async function routeAuthorized(req, res, ctx, url, origin, expectedOrigin) {
|
|
357
|
+
const pathname = url.pathname;
|
|
300
358
|
if (pathname === '/api/schema' && req.method === 'GET') {
|
|
301
359
|
return apiSchema(res, ctx);
|
|
302
360
|
}
|
|
@@ -388,25 +446,17 @@ function isAuthorized(req, expectedToken) {
|
|
|
388
446
|
}
|
|
389
447
|
return false;
|
|
390
448
|
}
|
|
391
|
-
function constantTimeEqual(a, b) {
|
|
392
|
-
// Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
|
|
393
|
-
// This makes the comparison constant-length (timingSafeEqual never throws on a
|
|
394
|
-
// length mismatch) and leaks neither length nor content via timing.
|
|
395
|
-
const ah = createHash('sha256').update(a).digest();
|
|
396
|
-
const bh = createHash('sha256').update(b).digest();
|
|
397
|
-
return timingSafeEqual(ah, bh);
|
|
398
|
-
}
|
|
399
449
|
/**
|
|
400
450
|
* Build a helpful "unknown table" error that lists the available tables so the
|
|
401
451
|
* caller can spot a typo or schema mismatch immediately.
|
|
402
452
|
*/
|
|
403
453
|
function unknownTableMessage(name, ctx) {
|
|
404
|
-
const available = Object.keys(ctx.metadata.tables);
|
|
454
|
+
const available = Object.keys(ctx.metadata.tables).map(shown);
|
|
405
455
|
const list = available.length ? available.join(', ') : '(none)';
|
|
406
456
|
// turbine-prefix-ok: every caller passes this straight to sendJson as an HTTP
|
|
407
457
|
// body, never to a TurbineError, so nothing prepends a `[TURBINE_EXXX]` tag
|
|
408
458
|
// and the prefix is the browser's only signal of who produced the message.
|
|
409
|
-
return `[turbine] Unknown table "${name}" in schema "${ctx.options.schema}". Available: ${list}`;
|
|
459
|
+
return `[turbine] Unknown table "${shown(name)}" in schema "${shown(ctx.options.schema)}". Available: ${list}`;
|
|
410
460
|
}
|
|
411
461
|
// ---------------------------------------------------------------------------
|
|
412
462
|
// API: /api/schema
|
|
@@ -612,7 +662,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
612
662
|
filters = parseTableFilters(params.get('filters'), table, redactedPii);
|
|
613
663
|
}
|
|
614
664
|
catch (err) {
|
|
615
|
-
sendJson(res, 400, { error: `[turbine] ${
|
|
665
|
+
sendJson(res, 400, { error: `[turbine] ${responseErrorText(err)}` });
|
|
616
666
|
return;
|
|
617
667
|
}
|
|
618
668
|
// Parameter placeholder + case-insensitive LIKE condition differ by engine.
|
|
@@ -772,14 +822,14 @@ function parseTableFilters(raw, table, redactedPii) {
|
|
|
772
822
|
const f = item;
|
|
773
823
|
const col = typeof f.column === 'string' ? resolveColumnName(table, f.column) : null;
|
|
774
824
|
if (!col) {
|
|
775
|
-
throw new Error(`unknown filter column "${String(f.column)}" on table "${table.name}"`);
|
|
825
|
+
throw new Error(`unknown filter column "${shown(String(f.column))}" on table "${shown(table.name)}"`);
|
|
776
826
|
}
|
|
777
827
|
if (redactedPii.has(col)) {
|
|
778
|
-
throw new Error(`column "${col}" is PII-redacted; filtering on it is disabled (run with --show-pii to enable)`);
|
|
828
|
+
throw new Error(`column "${shown(col)}" is PII-redacted; filtering on it is disabled (run with --show-pii to enable)`);
|
|
779
829
|
}
|
|
780
830
|
const op = typeof f.op === 'string' ? f.op : '';
|
|
781
831
|
if (!FILTER_OP_NAMES.has(op)) {
|
|
782
|
-
throw new Error(`unknown filter op "${op}" (expected one of: ${[...FILTER_OP_NAMES].join(', ')})`);
|
|
832
|
+
throw new Error(`unknown filter op "${shown(String(op))}" (expected one of: ${[...FILTER_OP_NAMES].join(', ')})`);
|
|
783
833
|
}
|
|
784
834
|
if (op === 'isNull' || op === 'notNull') {
|
|
785
835
|
out.push({ column: col, op });
|
|
@@ -788,12 +838,12 @@ function parseTableFilters(raw, table, redactedPii) {
|
|
|
788
838
|
const value = f.value;
|
|
789
839
|
const t = typeof value;
|
|
790
840
|
if (value === null || value === undefined || (t !== 'string' && t !== 'number' && t !== 'boolean')) {
|
|
791
|
-
throw new Error(`filter on "${col}" needs a scalar value (use isNull/notNull for null checks)`);
|
|
841
|
+
throw new Error(`filter on "${shown(col)}" needs a scalar value (use isNull/notNull for null checks)`);
|
|
792
842
|
}
|
|
793
843
|
if (op === 'contains') {
|
|
794
844
|
const colMeta = table.columns.find((c) => c.name === col);
|
|
795
845
|
if (!colMeta || !isTextishType(colMeta.pgType)) {
|
|
796
|
-
throw new Error(`contains only applies to text columns ("${col}" is ${colMeta?.pgType ?? 'unknown'})`);
|
|
846
|
+
throw new Error(`contains only applies to text columns ("${shown(col)}" is ${shown(colMeta?.pgType ?? 'unknown')})`);
|
|
797
847
|
}
|
|
798
848
|
}
|
|
799
849
|
out.push({ column: col, op, value });
|
|
@@ -834,7 +884,7 @@ function assertNoPiiPredicates(args, tableName, metadata, showPii) {
|
|
|
834
884
|
metadata,
|
|
835
885
|
hiddenReason: (table, column) => (isRedactedColumn(table, column, showPii) ? 'is PII-tagged and redacted' : null),
|
|
836
886
|
refuseColumn: (table, column, reason) => {
|
|
837
|
-
throw new ValidationError(`Column "${column}" on "${table.name}" ${reason}, so it cannot be used ` +
|
|
887
|
+
throw new ValidationError(`Column "${shown(column)}" on "${shown(table.name)}" ${reason}, so it cannot be used ` +
|
|
838
888
|
`in a where, orderBy, cursor, or distinct: filtering, sorting, paging, or de-duplicating on a hidden ` +
|
|
839
889
|
`value reveals it. Restart Studio with --show-pii to query it.`);
|
|
840
890
|
},
|
|
@@ -844,12 +894,69 @@ function assertNoPiiPredicates(args, tableName, metadata, showPii) {
|
|
|
844
894
|
`Flatten the query, or restart Studio with --show-pii.`);
|
|
845
895
|
},
|
|
846
896
|
refuseShape: (table, key) => {
|
|
847
|
-
throw new ValidationError(`Studio does not recognize "${key}" in a query on "${table.name}", so it cannot prove the ` +
|
|
897
|
+
throw new ValidationError(`Studio does not recognize "${shown(key)}" in a query on "${shown(table.name)}", so it cannot prove the ` +
|
|
848
898
|
`query does not filter or sort on a PII-tagged and redacted column, and refuses it rather than ` +
|
|
849
899
|
`guessing. Remove it, or restart Studio with --show-pii.`);
|
|
850
900
|
},
|
|
851
901
|
});
|
|
852
902
|
}
|
|
903
|
+
/** Most rows one Query-tab request returns, however large its `limit` or however absent. */
|
|
904
|
+
export const MAX_BUILDER_ROWS = 1000;
|
|
905
|
+
/**
|
|
906
|
+
* The args with `limit` / `take` held to {@link MAX_BUILDER_ROWS}, and a `limit`
|
|
907
|
+
* supplied when neither was. The Query tab renders every row it is sent, so an
|
|
908
|
+
* unlimited `findMany` on a large table would load the whole table into this
|
|
909
|
+
* process and then into the browser.
|
|
910
|
+
*/
|
|
911
|
+
function capBuilderRows(args) {
|
|
912
|
+
const next = { ...args };
|
|
913
|
+
for (const key of ['limit', 'take']) {
|
|
914
|
+
const value = next[key];
|
|
915
|
+
if (typeof value === 'number' && value > MAX_BUILDER_ROWS)
|
|
916
|
+
next[key] = MAX_BUILDER_ROWS;
|
|
917
|
+
}
|
|
918
|
+
if (next.limit === undefined && next.take === undefined)
|
|
919
|
+
next.limit = MAX_BUILDER_ROWS;
|
|
920
|
+
const asked = typeof next.limit === 'number' ? next.limit : typeof next.take === 'number' ? next.take : undefined;
|
|
921
|
+
return { args: next, effectiveLimit: asked ?? MAX_BUILDER_ROWS };
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Does a `select` anywhere in these args name a PII-tagged column? Walks the
|
|
925
|
+
* top-level `select` and every `with` level against its own target table.
|
|
926
|
+
*
|
|
927
|
+
* Why it exists: core refuses a PII column named in `select` unless the call
|
|
928
|
+
* passes `includePii: UNSAFE`, because `select` is ordinary request data. Studio
|
|
929
|
+
* is the one caller that may say yes to it, since every row it serves is
|
|
930
|
+
* redacted server-side before it leaves (see {@link redactBuilderRows}), so the
|
|
931
|
+
* flag is passed exactly when the query asks for such a column.
|
|
932
|
+
*
|
|
933
|
+
* It errs toward NOT finding one: past the depth cap, or on a shape it does not
|
|
934
|
+
* recognize, it answers false and core's own refusal reaches the user, which is
|
|
935
|
+
* a failed query and never a leaked column.
|
|
936
|
+
*/
|
|
937
|
+
function selectNamesPii(args, table, metadata, depth = 0) {
|
|
938
|
+
if (depth > 10 || args === null || typeof args !== 'object')
|
|
939
|
+
return false;
|
|
940
|
+
const { select, with: withClause } = args;
|
|
941
|
+
if (select && typeof select === 'object' && !Array.isArray(select)) {
|
|
942
|
+
for (const [key, wanted] of Object.entries(select)) {
|
|
943
|
+
if (!wanted)
|
|
944
|
+
continue;
|
|
945
|
+
const column = resolveBuilderColumn(table, key);
|
|
946
|
+
if (column !== undefined && table.columns.some((c) => c.name === column && c.pii === true))
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (withClause && typeof withClause === 'object' && !Array.isArray(withClause)) {
|
|
951
|
+
for (const [relKey, spec] of Object.entries(withClause)) {
|
|
952
|
+
const resolved = resolveRelation(table.relations, relKey);
|
|
953
|
+
const target = resolved ? ownLookup(metadata.tables, resolved.def.to) : undefined;
|
|
954
|
+
if (target && selectNamesPii(spec, target, metadata, depth + 1))
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
853
960
|
export async function apiBuilder(req, res, ctx) {
|
|
854
961
|
const body = await readJsonBody(req);
|
|
855
962
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
@@ -858,6 +965,19 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
858
965
|
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
859
966
|
return;
|
|
860
967
|
}
|
|
968
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
|
|
969
|
+
sendJson(res, 400, { error: '`args` must be an object of findMany options' });
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
// A locking read takes row locks, and every read here runs inside
|
|
973
|
+
// `BEGIN READ ONLY`, which PostgreSQL answers with a raw 25006. Refused by name
|
|
974
|
+
// instead, before anything else is judged: the verdict must not depend on the
|
|
975
|
+
// PII state (the walk below is skipped under --show-pii) or on the database.
|
|
976
|
+
const locked = findRowLockOption(args);
|
|
977
|
+
if (locked !== null) {
|
|
978
|
+
sendJson(res, 400, { error: rowLockRefusalMessage(shown(locked), "Studio's Query tab") });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
861
981
|
// Fail-closed posture: the generated metadata exists but its PII tags could
|
|
862
982
|
// not be read, so every column is treated as PII. The Query tab is then
|
|
863
983
|
// REFUSED outright rather than half-served. A default projection that
|
|
@@ -882,12 +1002,13 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
882
1002
|
const target = ownLookup(ctx.metadata.tables, tableName);
|
|
883
1003
|
if (target && !ctx.showPii && args.select === undefined && target.columns.every((c) => c.pii === true)) {
|
|
884
1004
|
sendJson(res, 400, {
|
|
885
|
-
error: `Every column of "${tableName}" is PII-tagged, so the default projection is empty. Name the columns you ` +
|
|
1005
|
+
error: `Every column of "${shown(tableName)}" is PII-tagged, so the default projection is empty. Name the columns you ` +
|
|
886
1006
|
'want in `select`, or run Studio with --show-pii.',
|
|
887
1007
|
});
|
|
888
1008
|
return;
|
|
889
1009
|
}
|
|
890
1010
|
let deferred;
|
|
1011
|
+
let effectiveLimit = MAX_BUILDER_ROWS;
|
|
891
1012
|
try {
|
|
892
1013
|
const qi = new QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
|
|
893
1014
|
warnOnUnlimited: false,
|
|
@@ -898,10 +1019,15 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
898
1019
|
dialect: ctx.dialect,
|
|
899
1020
|
});
|
|
900
1021
|
assertNoPiiPredicates(args, tableName, ctx.metadata, ctx.showPii === true);
|
|
901
|
-
|
|
1022
|
+
const capped = capBuilderRows(args);
|
|
1023
|
+
effectiveLimit = capped.effectiveLimit;
|
|
1024
|
+
// The flag is the request's own `includePii` untouched unless the query names a PII
|
|
1025
|
+
// column in a `select`, in which case Studio (which redacts every row) grants it.
|
|
1026
|
+
const grant = target && selectNamesPii(args, target, ctx.metadata) ? { includePii: UNSAFE } : {};
|
|
1027
|
+
deferred = qi.buildFindMany({ ...capped.args, ...grant });
|
|
902
1028
|
}
|
|
903
1029
|
catch (err) {
|
|
904
|
-
sendJson(res, 400, { error:
|
|
1030
|
+
sendJson(res, 400, { error: responseErrorText(err) });
|
|
905
1031
|
return;
|
|
906
1032
|
}
|
|
907
1033
|
const client = await ctx.pool.connect();
|
|
@@ -957,6 +1083,9 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
957
1083
|
rows: redactedRows.map((r) => serializeRow(r)),
|
|
958
1084
|
rowCount: result.rowCount ?? result.rows.length,
|
|
959
1085
|
elapsedMs,
|
|
1086
|
+
// True when the row cap, not the query, may be what ended the list.
|
|
1087
|
+
truncated: effectiveLimit >= MAX_BUILDER_ROWS && redactedRows.length >= MAX_BUILDER_ROWS,
|
|
1088
|
+
rowLimit: MAX_BUILDER_ROWS,
|
|
960
1089
|
});
|
|
961
1090
|
}
|
|
962
1091
|
catch (err) {
|
|
@@ -968,7 +1097,7 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
968
1097
|
/* ignore */
|
|
969
1098
|
}
|
|
970
1099
|
}
|
|
971
|
-
sendJson(res, 400, { error:
|
|
1100
|
+
sendJson(res, 400, { error: responseErrorText(err) });
|
|
972
1101
|
}
|
|
973
1102
|
finally {
|
|
974
1103
|
client.release();
|
|
@@ -1002,7 +1131,7 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
1002
1131
|
return;
|
|
1003
1132
|
}
|
|
1004
1133
|
if (table.isView) {
|
|
1005
|
-
sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
|
|
1134
|
+
sendJson(res, 400, { error: `[turbine] "${shown(tableName)}" is a view; Studio cannot write to it.` });
|
|
1006
1135
|
return;
|
|
1007
1136
|
}
|
|
1008
1137
|
// Bulk form: `rows` replaces `data` (insert) / `where` (delete).
|
|
@@ -1040,7 +1169,9 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
1040
1169
|
const rec = rowData;
|
|
1041
1170
|
const badKey = firstUnknownColumn(table, rec);
|
|
1042
1171
|
if (badKey) {
|
|
1043
|
-
sendJson(res, 400, {
|
|
1172
|
+
sendJson(res, 400, {
|
|
1173
|
+
error: `[turbine] unknown column "${shown(badKey)}" on table "${shown(tableName)}"${rowLabel}`,
|
|
1174
|
+
});
|
|
1044
1175
|
return;
|
|
1045
1176
|
}
|
|
1046
1177
|
if (!Object.keys(rec).some((k) => rec[k] !== undefined)) {
|
|
@@ -1053,7 +1184,7 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
1053
1184
|
if (op === 'update' || op === 'delete') {
|
|
1054
1185
|
if (table.primaryKey.length === 0) {
|
|
1055
1186
|
sendJson(res, 400, {
|
|
1056
|
-
error: `[turbine] "${tableName}" has no primary key; single-row writes require one.`,
|
|
1187
|
+
error: `[turbine] "${shown(tableName)}" has no primary key; single-row writes require one.`,
|
|
1057
1188
|
});
|
|
1058
1189
|
return;
|
|
1059
1190
|
}
|
|
@@ -1081,7 +1212,7 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
1081
1212
|
if (op === 'update') {
|
|
1082
1213
|
const badKey = firstUnknownColumn(table, data);
|
|
1083
1214
|
if (badKey) {
|
|
1084
|
-
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
1215
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${shown(badKey)}" on table "${shown(tableName)}"` });
|
|
1085
1216
|
return;
|
|
1086
1217
|
}
|
|
1087
1218
|
if (!Object.keys(data).some((k) => data[k] !== undefined)) {
|
|
@@ -1109,7 +1240,7 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
1109
1240
|
}
|
|
1110
1241
|
}
|
|
1111
1242
|
catch (err) {
|
|
1112
|
-
sendJson(res, 400, { error:
|
|
1243
|
+
sendJson(res, 400, { error: responseErrorText(err) });
|
|
1113
1244
|
return;
|
|
1114
1245
|
}
|
|
1115
1246
|
const client = await ctx.pool.connect();
|
|
@@ -1171,7 +1302,7 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
1171
1302
|
catch {
|
|
1172
1303
|
/* ignore */
|
|
1173
1304
|
}
|
|
1174
|
-
sendJson(res, 400, { error:
|
|
1305
|
+
sendJson(res, 400, { error: responseErrorText(err) });
|
|
1175
1306
|
}
|
|
1176
1307
|
finally {
|
|
1177
1308
|
client.release();
|
|
@@ -1207,11 +1338,11 @@ function extractPkWhere(table, where) {
|
|
|
1207
1338
|
const pkWhere = {};
|
|
1208
1339
|
for (const pkCol of table.primaryKey) {
|
|
1209
1340
|
if (!resolved.has(pkCol)) {
|
|
1210
|
-
return { error: `\`where\` must fully cover the primary key (missing "${pkCol}")` };
|
|
1341
|
+
return { error: `\`where\` must fully cover the primary key (missing "${shown(pkCol)}")` };
|
|
1211
1342
|
}
|
|
1212
1343
|
const v = resolved.get(pkCol);
|
|
1213
1344
|
if (v === undefined || v === null || typeof v === 'object') {
|
|
1214
|
-
return { error: `primary key "${pkCol}" must be a scalar value in \`where\`` };
|
|
1345
|
+
return { error: `primary key "${shown(pkCol)}" must be a scalar value in \`where\`` };
|
|
1215
1346
|
}
|
|
1216
1347
|
const field = table.reverseColumnMap[pkCol] ?? pkCol;
|
|
1217
1348
|
pkWhere[field] = v;
|
|
@@ -1272,6 +1403,8 @@ export function apiListSavedQueries(res, ctx, params) {
|
|
|
1272
1403
|
const filtered = table ? queries.filter((q) => q.table === table) : queries;
|
|
1273
1404
|
sendJson(res, 200, { queries: filtered });
|
|
1274
1405
|
}
|
|
1406
|
+
/** Most saved queries Studio will keep; each is bounded by the 1 MB body limit. */
|
|
1407
|
+
const MAX_SAVED_QUERIES = 500;
|
|
1275
1408
|
export async function apiCreateSavedQuery(req, res, ctx) {
|
|
1276
1409
|
const body = await readJsonBody(req);
|
|
1277
1410
|
const table = typeof body?.table === 'string' ? body.table : '';
|
|
@@ -1290,6 +1423,12 @@ export async function apiCreateSavedQuery(req, res, ctx) {
|
|
|
1290
1423
|
return;
|
|
1291
1424
|
}
|
|
1292
1425
|
const data = loadSavedQueries(ctx);
|
|
1426
|
+
if (data.queries.length >= MAX_SAVED_QUERIES) {
|
|
1427
|
+
sendJson(res, 400, {
|
|
1428
|
+
error: `Studio keeps at most ${MAX_SAVED_QUERIES} saved queries. Delete one before saving another.`,
|
|
1429
|
+
});
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1293
1432
|
const entry = {
|
|
1294
1433
|
id: randomUUID(),
|
|
1295
1434
|
table,
|
|
@@ -1502,26 +1641,73 @@ function serializeRow(row) {
|
|
|
1502
1641
|
}
|
|
1503
1642
|
return out;
|
|
1504
1643
|
}
|
|
1644
|
+
/** A request the caller got wrong, carrying the HTTP status it deserves. */
|
|
1645
|
+
class RequestError extends Error {
|
|
1646
|
+
status;
|
|
1647
|
+
constructor(status, message) {
|
|
1648
|
+
super(message);
|
|
1649
|
+
this.status = status;
|
|
1650
|
+
this.name = 'RequestError';
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
/** Largest JSON request body Studio will read. */
|
|
1654
|
+
const MAX_BODY_BYTES = 1 << 20;
|
|
1655
|
+
/**
|
|
1656
|
+
* How much of an oversized body Studio will read and throw away before it gives
|
|
1657
|
+
* up and drops the connection. A client that is still sending when the server
|
|
1658
|
+
* answers and closes gets a TCP reset instead of the response, so the 413 would
|
|
1659
|
+
* be lost; discarding the rest first lets it arrive. Bounded so a body of
|
|
1660
|
+
* unbounded length cannot hold the connection open.
|
|
1661
|
+
*/
|
|
1662
|
+
const MAX_DISCARD_BYTES = 16 * 1024 * 1024;
|
|
1663
|
+
/**
|
|
1664
|
+
* Read a JSON request body, refusing one over {@link MAX_BODY_BYTES} (413) or
|
|
1665
|
+
* malformed (400). Nothing past the limit is ever buffered: an oversized body,
|
|
1666
|
+
* whether declared by `Content-Length` or discovered while streaming, is counted
|
|
1667
|
+
* and discarded chunk by chunk, then answered with 413.
|
|
1668
|
+
*/
|
|
1505
1669
|
async function readJsonBody(req) {
|
|
1670
|
+
const declared = Number(req.headers?.['content-length']);
|
|
1671
|
+
let tooBig = Number.isFinite(declared) && declared > MAX_BODY_BYTES;
|
|
1506
1672
|
const chunks = [];
|
|
1507
1673
|
let total = 0;
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1674
|
+
// destroyOnReturn: false, because destroying a half-read request tears down
|
|
1675
|
+
// its socket and the response with it.
|
|
1676
|
+
const source = typeof req.iterator === 'function' ? req.iterator({ destroyOnReturn: false }) : req;
|
|
1677
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
1678
|
+
for (;;) {
|
|
1679
|
+
const next = await iterator.next();
|
|
1680
|
+
if (next.done)
|
|
1681
|
+
break;
|
|
1682
|
+
const buf = next.value;
|
|
1511
1683
|
total += buf.length;
|
|
1512
|
-
if (total >
|
|
1513
|
-
|
|
1514
|
-
|
|
1684
|
+
if (!tooBig && total > MAX_BODY_BYTES) {
|
|
1685
|
+
tooBig = true;
|
|
1686
|
+
chunks.length = 0;
|
|
1687
|
+
}
|
|
1688
|
+
if (!tooBig)
|
|
1689
|
+
chunks.push(buf);
|
|
1690
|
+
else if (total > MAX_DISCARD_BYTES) {
|
|
1691
|
+
req.destroy?.();
|
|
1692
|
+
break;
|
|
1693
|
+
}
|
|
1515
1694
|
}
|
|
1695
|
+
if (tooBig)
|
|
1696
|
+
throw new RequestError(413, `request body too large (limit ${MAX_BODY_BYTES} bytes)`);
|
|
1516
1697
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
1517
1698
|
if (!raw)
|
|
1518
1699
|
return {};
|
|
1700
|
+
let parsed;
|
|
1519
1701
|
try {
|
|
1520
|
-
|
|
1702
|
+
parsed = JSON.parse(raw);
|
|
1521
1703
|
}
|
|
1522
1704
|
catch {
|
|
1523
|
-
throw new
|
|
1705
|
+
throw new RequestError(400, 'invalid json body');
|
|
1524
1706
|
}
|
|
1707
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1708
|
+
throw new RequestError(400, 'request body must be a JSON object');
|
|
1709
|
+
}
|
|
1710
|
+
return parsed;
|
|
1525
1711
|
}
|
|
1526
1712
|
function sendJson(res, status, body) {
|
|
1527
1713
|
const payload = JSON.stringify(body);
|
|
@@ -1561,14 +1747,3 @@ function sendHtml(res, status, template, nonce) {
|
|
|
1561
1747
|
});
|
|
1562
1748
|
res.end(body);
|
|
1563
1749
|
}
|
|
1564
|
-
function openUrl(url) {
|
|
1565
|
-
const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'cmd' : 'xdg-open';
|
|
1566
|
-
const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
1567
|
-
try {
|
|
1568
|
-
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
1569
|
-
child.unref();
|
|
1570
|
-
}
|
|
1571
|
-
catch {
|
|
1572
|
-
// Non-fatal: user can click the URL manually.
|
|
1573
|
-
}
|
|
1574
|
-
}
|
package/dist/cli/ui.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export declare const symbols: {
|
|
|
31
31
|
readonly arrow: "→" | "->";
|
|
32
32
|
readonly arrowRight: ">" | "▸";
|
|
33
33
|
readonly info: "i" | "ℹ";
|
|
34
|
-
readonly warning: "
|
|
34
|
+
readonly warning: "!" | "⚠";
|
|
35
35
|
readonly dot: "." | "∙";
|
|
36
36
|
readonly line: "-" | "─";
|
|
37
37
|
readonly vertLine: "|" | "│";
|