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.
Files changed (295) hide show
  1. package/README.md +26 -25
  2. package/dist/checkout.d.ts +27 -2
  3. package/dist/checkout.js +75 -4
  4. package/dist/cjs/checkout.d.ts +27 -2
  5. package/dist/cjs/checkout.js +76 -3
  6. package/dist/cjs/cli/code-first-tags.d.ts +62 -0
  7. package/dist/cjs/cli/code-first-tags.js +124 -0
  8. package/dist/cjs/cli/compile-query.js +9 -0
  9. package/dist/cjs/cli/config.d.ts +35 -6
  10. package/dist/cjs/cli/config.js +17 -1
  11. package/dist/cjs/cli/destructive.d.ts +7 -4
  12. package/dist/cjs/cli/destructive.js +224 -20
  13. package/dist/cjs/cli/error-catalog.js +36 -0
  14. package/dist/cjs/cli/error-text.d.ts +15 -0
  15. package/dist/cjs/cli/error-text.js +40 -0
  16. package/dist/cjs/cli/index.d.ts +62 -4
  17. package/dist/cjs/cli/index.js +568 -108
  18. package/dist/cjs/cli/local-server-guard.d.ts +53 -0
  19. package/dist/cjs/cli/local-server-guard.js +119 -0
  20. package/dist/cjs/cli/local-server-output.d.ts +41 -0
  21. package/dist/cjs/cli/local-server-output.js +103 -0
  22. package/dist/cjs/cli/mcp.d.ts +8 -0
  23. package/dist/cjs/cli/mcp.js +121 -32
  24. package/dist/cjs/cli/migrate.d.ts +113 -5
  25. package/dist/cjs/cli/migrate.js +270 -21
  26. package/dist/cjs/cli/observe.d.ts +6 -1
  27. package/dist/cjs/cli/observe.js +85 -55
  28. package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
  29. package/dist/cjs/cli/pii-predicate-guard.js +98 -37
  30. package/dist/cjs/cli/prisma-resolve.js +17 -13
  31. package/dist/cjs/cli/prisma-schema.js +12 -4
  32. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  33. package/dist/cjs/cli/studio.d.ts +14 -2
  34. package/dist/cjs/cli/studio.js +244 -69
  35. package/dist/cjs/cli/ui.d.ts +1 -1
  36. package/dist/cjs/client.d.ts +558 -85
  37. package/dist/cjs/client.js +1270 -395
  38. package/dist/cjs/connection-guard.d.ts +13 -0
  39. package/dist/cjs/connection-guard.js +28 -0
  40. package/dist/cjs/connection-url.d.ts +51 -16
  41. package/dist/cjs/connection-url.js +67 -34
  42. package/dist/cjs/diagnostics.d.ts +81 -0
  43. package/dist/cjs/diagnostics.js +147 -0
  44. package/dist/cjs/dialect.d.ts +449 -20
  45. package/dist/cjs/dialect.js +117 -76
  46. package/dist/cjs/engine-config.d.ts +7 -6
  47. package/dist/cjs/errors.d.ts +126 -6
  48. package/dist/cjs/errors.js +256 -13
  49. package/dist/cjs/generate.d.ts +57 -7
  50. package/dist/cjs/generate.js +635 -279
  51. package/dist/cjs/global-filters-config.d.ts +59 -0
  52. package/dist/cjs/global-filters-config.js +367 -0
  53. package/dist/cjs/index.d.ts +9 -4
  54. package/dist/cjs/index.js +10 -2
  55. package/dist/cjs/introspect.d.ts +31 -6
  56. package/dist/cjs/introspect.js +108 -50
  57. package/dist/cjs/log-codes.d.ts +12 -0
  58. package/dist/cjs/log-codes.js +77 -0
  59. package/dist/cjs/logger.d.ts +87 -0
  60. package/dist/cjs/logger.js +98 -0
  61. package/dist/cjs/mssql.d.ts +8 -5
  62. package/dist/cjs/mssql.js +352 -65
  63. package/dist/cjs/mysql.d.ts +3 -5
  64. package/dist/cjs/mysql.js +420 -70
  65. package/dist/cjs/nested-write.d.ts +3 -0
  66. package/dist/cjs/nested-write.js +179 -114
  67. package/dist/cjs/pipeline-submittable.d.ts +12 -0
  68. package/dist/cjs/pipeline-submittable.js +118 -44
  69. package/dist/cjs/pipeline.js +21 -26
  70. package/dist/cjs/powdb-introspect.d.ts +5 -2
  71. package/dist/cjs/powdb-introspect.js +20 -15
  72. package/dist/cjs/powdb-shared.d.ts +36 -5
  73. package/dist/cjs/powdb-shared.js +67 -8
  74. package/dist/cjs/powdb.d.ts +4 -0
  75. package/dist/cjs/powdb.js +183 -51
  76. package/dist/cjs/powql.d.ts +394 -51
  77. package/dist/cjs/powql.js +1829 -284
  78. package/dist/cjs/prisma-compat.d.ts +23 -15
  79. package/dist/cjs/prisma-compat.js +507 -372
  80. package/dist/cjs/query/aggregates.d.ts +17 -1
  81. package/dist/cjs/query/aggregates.js +261 -29
  82. package/dist/cjs/query/batched-loader.d.ts +25 -6
  83. package/dist/cjs/query/batched-loader.js +103 -20
  84. package/dist/cjs/query/builder.d.ts +325 -184
  85. package/dist/cjs/query/builder.js +1452 -490
  86. package/dist/cjs/query/column-ref.d.ts +61 -0
  87. package/dist/cjs/query/column-ref.js +73 -0
  88. package/dist/cjs/query/compound-unique.d.ts +21 -0
  89. package/dist/cjs/query/compound-unique.js +98 -18
  90. package/dist/cjs/query/deferred.d.ts +53 -16
  91. package/dist/cjs/query/expr.d.ts +217 -0
  92. package/dist/cjs/query/expr.js +859 -0
  93. package/dist/cjs/query/filters.d.ts +155 -53
  94. package/dist/cjs/query/filters.js +613 -76
  95. package/dist/cjs/query/index.d.ts +2 -0
  96. package/dist/cjs/query/index.js +7 -1
  97. package/dist/cjs/query/json-value.d.ts +43 -0
  98. package/dist/cjs/query/json-value.js +59 -0
  99. package/dist/cjs/query/normalize-args.d.ts +87 -0
  100. package/dist/cjs/query/normalize-args.js +527 -0
  101. package/dist/cjs/query/option-surface.d.ts +16 -4
  102. package/dist/cjs/query/option-surface.js +57 -14
  103. package/dist/cjs/query/relation-names.d.ts +14 -0
  104. package/dist/cjs/query/relation-names.js +77 -18
  105. package/dist/cjs/query/relations.d.ts +30 -3
  106. package/dist/cjs/query/relations.js +219 -63
  107. package/dist/cjs/query/row-lock.d.ts +49 -0
  108. package/dist/cjs/query/row-lock.js +138 -0
  109. package/dist/cjs/query/sql-comment.d.ts +161 -0
  110. package/dist/cjs/query/sql-comment.js +381 -0
  111. package/dist/cjs/query/stream-idle.d.ts +132 -0
  112. package/dist/cjs/query/stream-idle.js +212 -0
  113. package/dist/cjs/query/table-client.d.ts +101 -0
  114. package/dist/cjs/query/table-client.js +74 -0
  115. package/dist/cjs/query/types.d.ts +677 -298
  116. package/dist/cjs/query/utils.d.ts +105 -6
  117. package/dist/cjs/query/utils.js +426 -19
  118. package/dist/cjs/query/warn-registry.d.ts +18 -0
  119. package/dist/cjs/query/warn-registry.js +18 -0
  120. package/dist/cjs/query/where-compile.d.ts +138 -14
  121. package/dist/cjs/query/where-compile.js +513 -29
  122. package/dist/cjs/query/where.d.ts +159 -59
  123. package/dist/cjs/query/where.js +716 -246
  124. package/dist/cjs/query/writes.d.ts +112 -50
  125. package/dist/cjs/query/writes.js +1498 -200
  126. package/dist/cjs/query-events.d.ts +4 -2
  127. package/dist/cjs/query-events.js +3 -1
  128. package/dist/cjs/realtime.d.ts +7 -3
  129. package/dist/cjs/realtime.js +13 -2
  130. package/dist/cjs/schema-builder.d.ts +55 -4
  131. package/dist/cjs/schema-builder.js +382 -18
  132. package/dist/cjs/schema-metadata.d.ts +21 -1
  133. package/dist/cjs/schema-metadata.js +29 -7
  134. package/dist/cjs/schema-sql.d.ts +75 -5
  135. package/dist/cjs/schema-sql.js +497 -45
  136. package/dist/cjs/schema.d.ts +26 -5
  137. package/dist/cjs/schema.js +110 -13
  138. package/dist/cjs/sql-placeholders.d.ts +56 -0
  139. package/dist/cjs/sql-placeholders.js +204 -0
  140. package/dist/cjs/sqlite.d.ts +75 -20
  141. package/dist/cjs/sqlite.js +593 -95
  142. package/dist/cjs/supplied-pool-types.d.ts +48 -0
  143. package/dist/cjs/supplied-pool-types.js +256 -0
  144. package/dist/cjs/transaction-control.d.ts +51 -0
  145. package/dist/cjs/transaction-control.js +316 -0
  146. package/dist/cjs/transaction-state.d.ts +342 -0
  147. package/dist/cjs/transaction-state.js +707 -0
  148. package/dist/cjs/typed-sql.d.ts +37 -3
  149. package/dist/cjs/typed-sql.js +102 -8
  150. package/dist/cli/code-first-tags.d.ts +62 -0
  151. package/dist/cli/code-first-tags.js +120 -0
  152. package/dist/cli/compile-query.js +9 -0
  153. package/dist/cli/config.d.ts +35 -6
  154. package/dist/cli/config.js +16 -1
  155. package/dist/cli/destructive.d.ts +7 -4
  156. package/dist/cli/destructive.js +224 -20
  157. package/dist/cli/error-catalog.js +36 -0
  158. package/dist/cli/error-text.d.ts +15 -0
  159. package/dist/cli/error-text.js +37 -0
  160. package/dist/cli/index.d.ts +62 -4
  161. package/dist/cli/index.js +569 -112
  162. package/dist/cli/local-server-guard.d.ts +53 -0
  163. package/dist/cli/local-server-guard.js +113 -0
  164. package/dist/cli/local-server-output.d.ts +41 -0
  165. package/dist/cli/local-server-output.js +98 -0
  166. package/dist/cli/mcp.d.ts +8 -0
  167. package/dist/cli/mcp.js +121 -34
  168. package/dist/cli/migrate.d.ts +113 -5
  169. package/dist/cli/migrate.js +261 -21
  170. package/dist/cli/observe.d.ts +6 -1
  171. package/dist/cli/observe.js +84 -54
  172. package/dist/cli/pii-predicate-guard.d.ts +43 -23
  173. package/dist/cli/pii-predicate-guard.js +100 -35
  174. package/dist/cli/prisma-resolve.js +15 -11
  175. package/dist/cli/prisma-schema.js +12 -4
  176. package/dist/cli/studio-ui.generated.js +1 -1
  177. package/dist/cli/studio.d.ts +14 -2
  178. package/dist/cli/studio.js +246 -71
  179. package/dist/cli/ui.d.ts +1 -1
  180. package/dist/client.d.ts +558 -85
  181. package/dist/client.js +1275 -400
  182. package/dist/connection-guard.d.ts +13 -0
  183. package/dist/connection-guard.js +27 -0
  184. package/dist/connection-url.d.ts +51 -16
  185. package/dist/connection-url.js +63 -34
  186. package/dist/diagnostics.d.ts +81 -0
  187. package/dist/diagnostics.js +144 -0
  188. package/dist/dialect.d.ts +449 -20
  189. package/dist/dialect.js +115 -43
  190. package/dist/engine-config.d.ts +7 -6
  191. package/dist/errors.d.ts +126 -6
  192. package/dist/errors.js +252 -12
  193. package/dist/generate.d.ts +57 -7
  194. package/dist/generate.js +635 -281
  195. package/dist/global-filters-config.d.ts +59 -0
  196. package/dist/global-filters-config.js +361 -0
  197. package/dist/index.d.ts +9 -4
  198. package/dist/index.js +4 -2
  199. package/dist/introspect.d.ts +31 -6
  200. package/dist/introspect.js +106 -50
  201. package/dist/log-codes.d.ts +12 -0
  202. package/dist/log-codes.js +74 -0
  203. package/dist/logger.d.ts +87 -0
  204. package/dist/logger.js +93 -0
  205. package/dist/mssql.d.ts +8 -5
  206. package/dist/mssql.js +354 -67
  207. package/dist/mysql.d.ts +3 -5
  208. package/dist/mysql.js +423 -73
  209. package/dist/nested-write.d.ts +3 -0
  210. package/dist/nested-write.js +177 -112
  211. package/dist/pipeline-submittable.d.ts +12 -0
  212. package/dist/pipeline-submittable.js +117 -44
  213. package/dist/pipeline.js +21 -26
  214. package/dist/powdb-introspect.d.ts +5 -2
  215. package/dist/powdb-introspect.js +21 -16
  216. package/dist/powdb-shared.d.ts +36 -5
  217. package/dist/powdb-shared.js +65 -8
  218. package/dist/powdb.d.ts +4 -0
  219. package/dist/powdb.js +183 -51
  220. package/dist/powql.d.ts +394 -51
  221. package/dist/powql.js +1836 -291
  222. package/dist/prisma-compat.d.ts +23 -15
  223. package/dist/prisma-compat.js +508 -373
  224. package/dist/query/aggregates.d.ts +17 -1
  225. package/dist/query/aggregates.js +263 -32
  226. package/dist/query/batched-loader.d.ts +25 -6
  227. package/dist/query/batched-loader.js +101 -21
  228. package/dist/query/builder.d.ts +325 -184
  229. package/dist/query/builder.js +1458 -495
  230. package/dist/query/column-ref.d.ts +61 -0
  231. package/dist/query/column-ref.js +66 -0
  232. package/dist/query/compound-unique.d.ts +21 -0
  233. package/dist/query/compound-unique.js +97 -19
  234. package/dist/query/deferred.d.ts +53 -16
  235. package/dist/query/expr.d.ts +217 -0
  236. package/dist/query/expr.js +848 -0
  237. package/dist/query/filters.d.ts +155 -53
  238. package/dist/query/filters.js +597 -72
  239. package/dist/query/index.d.ts +2 -0
  240. package/dist/query/index.js +4 -0
  241. package/dist/query/json-value.d.ts +43 -0
  242. package/dist/query/json-value.js +54 -0
  243. package/dist/query/normalize-args.d.ts +87 -0
  244. package/dist/query/normalize-args.js +517 -0
  245. package/dist/query/option-surface.d.ts +16 -4
  246. package/dist/query/option-surface.js +56 -13
  247. package/dist/query/relation-names.d.ts +14 -0
  248. package/dist/query/relation-names.js +77 -19
  249. package/dist/query/relations.d.ts +30 -3
  250. package/dist/query/relations.js +220 -67
  251. package/dist/query/row-lock.d.ts +49 -0
  252. package/dist/query/row-lock.js +131 -0
  253. package/dist/query/sql-comment.d.ts +161 -0
  254. package/dist/query/sql-comment.js +374 -0
  255. package/dist/query/stream-idle.d.ts +132 -0
  256. package/dist/query/stream-idle.js +203 -0
  257. package/dist/query/table-client.d.ts +101 -0
  258. package/dist/query/table-client.js +70 -0
  259. package/dist/query/types.d.ts +677 -298
  260. package/dist/query/utils.d.ts +105 -6
  261. package/dist/query/utils.js +414 -18
  262. package/dist/query/warn-registry.d.ts +18 -0
  263. package/dist/query/warn-registry.js +18 -0
  264. package/dist/query/where-compile.d.ts +138 -14
  265. package/dist/query/where-compile.js +508 -31
  266. package/dist/query/where.d.ts +159 -59
  267. package/dist/query/where.js +711 -248
  268. package/dist/query/writes.d.ts +112 -50
  269. package/dist/query/writes.js +1496 -202
  270. package/dist/query-events.d.ts +4 -2
  271. package/dist/query-events.js +3 -1
  272. package/dist/realtime.d.ts +7 -3
  273. package/dist/realtime.js +13 -2
  274. package/dist/schema-builder.d.ts +55 -4
  275. package/dist/schema-builder.js +381 -18
  276. package/dist/schema-metadata.d.ts +21 -1
  277. package/dist/schema-metadata.js +28 -7
  278. package/dist/schema-sql.d.ts +75 -5
  279. package/dist/schema-sql.js +494 -44
  280. package/dist/schema.d.ts +26 -5
  281. package/dist/schema.js +109 -13
  282. package/dist/sql-placeholders.d.ts +56 -0
  283. package/dist/sql-placeholders.js +200 -0
  284. package/dist/sqlite.d.ts +75 -20
  285. package/dist/sqlite.js +597 -99
  286. package/dist/supplied-pool-types.d.ts +48 -0
  287. package/dist/supplied-pool-types.js +247 -0
  288. package/dist/transaction-control.d.ts +51 -0
  289. package/dist/transaction-control.js +311 -0
  290. package/dist/transaction-state.d.ts +342 -0
  291. package/dist/transaction-state.js +696 -0
  292. package/dist/typed-sql.d.ts +37 -3
  293. package/dist/typed-sql.js +100 -8
  294. package/package.json +7 -4
  295. package/skills/turbine-orm/SKILL.md +56 -6
@@ -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
- /** Full URL including `?token=...`, safe to print for the user. */
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 {
@@ -41,7 +41,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
41
41
  return (mod && mod.__esModule) ? mod : { "default": mod };
42
42
  };
43
43
  Object.defineProperty(exports, "__esModule", { value: true });
44
- exports.PII_REDACTED = void 0;
44
+ exports.PII_REDACTED = exports.MAX_BUILDER_ROWS = void 0;
45
45
  exports.startStudio = startStudio;
46
46
  exports.handleRequest = handleRequest;
47
47
  exports.apiDemoMode = apiDemoMode;
@@ -55,11 +55,9 @@ exports.apiListSavedQueries = apiListSavedQueries;
55
55
  exports.apiCreateSavedQuery = apiCreateSavedQuery;
56
56
  exports.apiDeleteSavedQuery = apiDeleteSavedQuery;
57
57
  exports.forceTagAllColumnsPii = forceTagAllColumnsPii;
58
- const node_child_process_1 = require("node:child_process");
59
58
  const node_crypto_1 = require("node:crypto");
60
59
  const node_fs_1 = require("node:fs");
61
60
  const node_http_1 = require("node:http");
62
- const node_os_1 = require("node:os");
63
61
  const node_path_1 = require("node:path");
64
62
  const pg_1 = __importDefault(require("pg"));
65
63
  const connection_guard_js_1 = require("../connection-guard.js");
@@ -73,12 +71,21 @@ const index_js_1 = require("../query/index.js");
73
71
  // redaction walk that answered that differently would redact a different query
74
72
  // than the one that ran.
75
73
  const utils_js_1 = require("../query/utils.js");
74
+ const local_server_guard_js_1 = require("./local-server-guard.js");
75
+ const local_server_output_js_1 = require("./local-server-output.js");
76
76
  const pii_predicate_guard_js_1 = require("./pii-predicate-guard.js");
77
77
  const pii_tags_js_1 = require("./pii-tags.js");
78
78
  const rate_limit_js_1 = require("./rate-limit.js");
79
79
  const studio_demo_js_1 = require("./studio-demo.js");
80
80
  const studio_ui_generated_js_1 = require("./studio-ui.generated.js");
81
81
  const ui_js_1 = require("./ui.js");
82
+ /**
83
+ * A name made safe to quote in a message. Catalog names (and any name echoed from
84
+ * a request) can carry control or bidi characters, and a message can end up in a
85
+ * terminal, a log or the UI's error box. Same helper `generate`, `status` and
86
+ * `doctor` print through.
87
+ */
88
+ const shown = introspect_js_1.printableCatalogName;
82
89
  // ---------------------------------------------------------------------------
83
90
  // Main entry point
84
91
  // ---------------------------------------------------------------------------
@@ -197,6 +204,7 @@ async function startStudio(options) {
197
204
  };
198
205
  }
199
206
  const authToken = (0, node_crypto_1.randomBytes)(24).toString('hex');
207
+ const ticketGate = (0, local_server_guard_js_1.createTicketGate)();
200
208
  const stateDir = (0, node_path_1.resolve)(options.stateDir ?? '.turbine');
201
209
  const rateLimiter = new Map();
202
210
  const ctx = {
@@ -204,6 +212,7 @@ async function startStudio(options) {
204
212
  metadata,
205
213
  options,
206
214
  authToken,
215
+ ticketGate,
207
216
  stateDir,
208
217
  statementTimeout,
209
218
  rateLimiter,
@@ -220,7 +229,7 @@ async function startStudio(options) {
220
229
  };
221
230
  const server = (0, node_http_1.createServer)((req, res) => {
222
231
  handleRequest(req, res, ctx).catch((err) => {
223
- sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
232
+ sendJson(res, 500, { error: (0, local_server_output_js_1.responseErrorText)(err) });
224
233
  });
225
234
  });
226
235
  await new Promise((resolve, reject) => {
@@ -230,9 +239,9 @@ async function startStudio(options) {
230
239
  resolve();
231
240
  });
232
241
  });
233
- const url = buildStudioUrl(options.host, options.port, authToken);
242
+ const url = buildStudioUrl(options.host, options.port, ticketGate.token);
234
243
  if (options.openBrowser) {
235
- openUrl(url);
244
+ (0, local_server_output_js_1.openInBrowser)(url);
236
245
  }
237
246
  return {
238
247
  authToken,
@@ -261,31 +270,64 @@ function originFor(host, port) {
261
270
  // ---------------------------------------------------------------------------
262
271
  async function handleRequest(req, res, ctx) {
263
272
  const expectedOrigin = originFor(ctx.options.host, ctx.options.port);
273
+ // DNS rebinding: a page on another domain that re-resolves to this loopback
274
+ // port still sends ITS hostname in `Host`. Checked first, before any route or
275
+ // token logic, so nothing about this server is observable to such a page.
276
+ if (!(0, local_server_guard_js_1.isAllowedHostHeader)(req.headers.host, ctx.options.host, ctx.options.port)) {
277
+ sendJson(res, 403, { error: 'host not allowed: open Studio at the address printed in the terminal' });
278
+ return;
279
+ }
264
280
  // CORS: not needed, same-origin only. Explicitly refuse cross-origin.
265
281
  const origin = req.headers.origin;
266
282
  if (origin && origin !== expectedOrigin) {
267
283
  sendJson(res, 403, { error: 'cross-origin requests not allowed' });
268
284
  return;
269
285
  }
270
- const url = new URL(req.url ?? '/', expectedOrigin);
286
+ // A request target that is not a URL (`GET //`) is a client error, not a 500.
287
+ let url;
288
+ try {
289
+ url = new URL(req.url ?? '/', expectedOrigin);
290
+ }
291
+ catch {
292
+ sendJson(res, 400, { error: 'malformed request target' });
293
+ return;
294
+ }
271
295
  const pathname = url.pathname;
272
- // The index route serves the HTML shell and embeds the auth token.
273
- // All other routes require the token in the `x-turbine-token` header
274
- // or the `token` cookie.
296
+ // The index route serves the HTML shell. Every other route requires the
297
+ // session token in the `x-turbine-token` header or the `turbine_studio_token`
298
+ // cookie. The printed URL's `?token=` is a ONE-TIME login ticket, not that
299
+ // token: the first GET carrying it is exchanged for the cookie, and the ticket
300
+ // is dead afterwards, so a copy left in browser history, `ps` output or a log
301
+ // opens nothing.
275
302
  if (pathname === '/' || pathname === '/index.html') {
276
303
  if (req.method !== 'GET' && req.method !== 'HEAD') {
277
304
  sendText(res, 405, 'Method Not Allowed');
278
305
  return;
279
306
  }
280
307
  const queryToken = url.searchParams.get('token');
281
- // If the URL includes a token, validate then set a cookie and redirect.
282
- if (queryToken && constantTimeEqual(queryToken, ctx.authToken)) {
283
- res.writeHead(302, {
284
- Location: '/',
285
- 'Set-Cookie': `turbine_studio_token=${ctx.authToken}; Path=/; HttpOnly; SameSite=Strict`,
286
- });
287
- res.end();
288
- return;
308
+ if (queryToken) {
309
+ ctx.ticketGate ??= (0, local_server_guard_js_1.createTicketGate)(ctx.authToken);
310
+ // HEAD must not spend the ticket: only the navigation that sets the cookie may.
311
+ const outcome = req.method === 'GET' ? (0, local_server_guard_js_1.redeemTicket)(ctx.ticketGate, queryToken) : 'invalid';
312
+ if (outcome === 'granted') {
313
+ res.writeHead(302, {
314
+ Location: '/',
315
+ 'Set-Cookie': `turbine_studio_token=${ctx.authToken}; Path=/; HttpOnly; SameSite=Strict`,
316
+ });
317
+ res.end();
318
+ return;
319
+ }
320
+ if (outcome === 'used') {
321
+ if (isAuthorized(req, ctx.authToken)) {
322
+ // Already signed in (a reload of the printed URL): drop the ticket from the address bar.
323
+ res.writeHead(302, { Location: '/' });
324
+ res.end();
325
+ }
326
+ else {
327
+ sendText(res, 403, 'This login link was already used. Restart Studio to get a new one.');
328
+ }
329
+ return;
330
+ }
289
331
  }
290
332
  sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML, cspNonce());
291
333
  return;
@@ -316,6 +358,22 @@ async function handleRequest(req, res, ctx) {
316
358
  sendJson(res, 401, { error: 'unauthorized: use the URL printed in the terminal' });
317
359
  return;
318
360
  }
361
+ try {
362
+ await routeAuthorized(req, res, ctx, url, origin, expectedOrigin);
363
+ }
364
+ catch (err) {
365
+ // A bad request body is the caller's mistake and gets its own status; every
366
+ // other failure is still the dispatcher's 500.
367
+ if (err instanceof RequestError) {
368
+ sendJson(res, err.status, { error: err.message });
369
+ return;
370
+ }
371
+ throw err;
372
+ }
373
+ }
374
+ /** The routes behind the auth gate. */
375
+ async function routeAuthorized(req, res, ctx, url, origin, expectedOrigin) {
376
+ const pathname = url.pathname;
319
377
  if (pathname === '/api/schema' && req.method === 'GET') {
320
378
  return apiSchema(res, ctx);
321
379
  }
@@ -394,7 +452,7 @@ async function apiDemoMode(req, res, ctx) {
394
452
  // ---------------------------------------------------------------------------
395
453
  function isAuthorized(req, expectedToken) {
396
454
  const headerToken = req.headers['x-turbine-token'];
397
- if (typeof headerToken === 'string' && constantTimeEqual(headerToken, expectedToken)) {
455
+ if (typeof headerToken === 'string' && (0, local_server_guard_js_1.constantTimeEqual)(headerToken, expectedToken)) {
398
456
  return true;
399
457
  }
400
458
  const cookieHeader = req.headers.cookie ?? '';
@@ -402,30 +460,22 @@ function isAuthorized(req, expectedToken) {
402
460
  // `x_turbine_studio_token=...` matched first and the real cookie was never
403
461
  // compared, locking the legitimate session out of its own Studio.
404
462
  const match = /(?:^|;\s*)turbine_studio_token=([a-f0-9]+)/.exec(cookieHeader);
405
- if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
463
+ if (match?.[1] && (0, local_server_guard_js_1.constantTimeEqual)(match[1], expectedToken)) {
406
464
  return true;
407
465
  }
408
466
  return false;
409
467
  }
410
- function constantTimeEqual(a, b) {
411
- // Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
412
- // This makes the comparison constant-length (timingSafeEqual never throws on a
413
- // length mismatch) and leaks neither length nor content via timing.
414
- const ah = (0, node_crypto_1.createHash)('sha256').update(a).digest();
415
- const bh = (0, node_crypto_1.createHash)('sha256').update(b).digest();
416
- return (0, node_crypto_1.timingSafeEqual)(ah, bh);
417
- }
418
468
  /**
419
469
  * Build a helpful "unknown table" error that lists the available tables so the
420
470
  * caller can spot a typo or schema mismatch immediately.
421
471
  */
422
472
  function unknownTableMessage(name, ctx) {
423
- const available = Object.keys(ctx.metadata.tables);
473
+ const available = Object.keys(ctx.metadata.tables).map(shown);
424
474
  const list = available.length ? available.join(', ') : '(none)';
425
475
  // turbine-prefix-ok: every caller passes this straight to sendJson as an HTTP
426
476
  // body, never to a TurbineError, so nothing prepends a `[TURBINE_EXXX]` tag
427
477
  // and the prefix is the browser's only signal of who produced the message.
428
- return `[turbine] Unknown table "${name}" in schema "${ctx.options.schema}". Available: ${list}`;
478
+ return `[turbine] Unknown table "${shown(name)}" in schema "${shown(ctx.options.schema)}". Available: ${list}`;
429
479
  }
430
480
  // ---------------------------------------------------------------------------
431
481
  // API: /api/schema
@@ -631,7 +681,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
631
681
  filters = parseTableFilters(params.get('filters'), table, redactedPii);
632
682
  }
633
683
  catch (err) {
634
- sendJson(res, 400, { error: `[turbine] ${err instanceof Error ? err.message : String(err)}` });
684
+ sendJson(res, 400, { error: `[turbine] ${(0, local_server_output_js_1.responseErrorText)(err)}` });
635
685
  return;
636
686
  }
637
687
  // Parameter placeholder + case-insensitive LIKE condition differ by engine.
@@ -791,14 +841,14 @@ function parseTableFilters(raw, table, redactedPii) {
791
841
  const f = item;
792
842
  const col = typeof f.column === 'string' ? resolveColumnName(table, f.column) : null;
793
843
  if (!col) {
794
- throw new Error(`unknown filter column "${String(f.column)}" on table "${table.name}"`);
844
+ throw new Error(`unknown filter column "${shown(String(f.column))}" on table "${shown(table.name)}"`);
795
845
  }
796
846
  if (redactedPii.has(col)) {
797
- throw new Error(`column "${col}" is PII-redacted; filtering on it is disabled (run with --show-pii to enable)`);
847
+ throw new Error(`column "${shown(col)}" is PII-redacted; filtering on it is disabled (run with --show-pii to enable)`);
798
848
  }
799
849
  const op = typeof f.op === 'string' ? f.op : '';
800
850
  if (!FILTER_OP_NAMES.has(op)) {
801
- throw new Error(`unknown filter op "${op}" (expected one of: ${[...FILTER_OP_NAMES].join(', ')})`);
851
+ throw new Error(`unknown filter op "${shown(String(op))}" (expected one of: ${[...FILTER_OP_NAMES].join(', ')})`);
802
852
  }
803
853
  if (op === 'isNull' || op === 'notNull') {
804
854
  out.push({ column: col, op });
@@ -807,12 +857,12 @@ function parseTableFilters(raw, table, redactedPii) {
807
857
  const value = f.value;
808
858
  const t = typeof value;
809
859
  if (value === null || value === undefined || (t !== 'string' && t !== 'number' && t !== 'boolean')) {
810
- throw new Error(`filter on "${col}" needs a scalar value (use isNull/notNull for null checks)`);
860
+ throw new Error(`filter on "${shown(col)}" needs a scalar value (use isNull/notNull for null checks)`);
811
861
  }
812
862
  if (op === 'contains') {
813
863
  const colMeta = table.columns.find((c) => c.name === col);
814
864
  if (!colMeta || !isTextishType(colMeta.pgType)) {
815
- throw new Error(`contains only applies to text columns ("${col}" is ${colMeta?.pgType ?? 'unknown'})`);
865
+ throw new Error(`contains only applies to text columns ("${shown(col)}" is ${shown(colMeta?.pgType ?? 'unknown')})`);
816
866
  }
817
867
  }
818
868
  out.push({ column: col, op, value });
@@ -853,7 +903,7 @@ function assertNoPiiPredicates(args, tableName, metadata, showPii) {
853
903
  metadata,
854
904
  hiddenReason: (table, column) => (isRedactedColumn(table, column, showPii) ? 'is PII-tagged and redacted' : null),
855
905
  refuseColumn: (table, column, reason) => {
856
- throw new errors_js_1.ValidationError(`Column "${column}" on "${table.name}" ${reason}, so it cannot be used ` +
906
+ throw new errors_js_1.ValidationError(`Column "${shown(column)}" on "${shown(table.name)}" ${reason}, so it cannot be used ` +
857
907
  `in a where, orderBy, cursor, or distinct: filtering, sorting, paging, or de-duplicating on a hidden ` +
858
908
  `value reveals it. Restart Studio with --show-pii to query it.`);
859
909
  },
@@ -863,12 +913,69 @@ function assertNoPiiPredicates(args, tableName, metadata, showPii) {
863
913
  `Flatten the query, or restart Studio with --show-pii.`);
864
914
  },
865
915
  refuseShape: (table, key) => {
866
- throw new errors_js_1.ValidationError(`Studio does not recognize "${key}" in a query on "${table.name}", so it cannot prove the ` +
916
+ throw new errors_js_1.ValidationError(`Studio does not recognize "${shown(key)}" in a query on "${shown(table.name)}", so it cannot prove the ` +
867
917
  `query does not filter or sort on a PII-tagged and redacted column, and refuses it rather than ` +
868
918
  `guessing. Remove it, or restart Studio with --show-pii.`);
869
919
  },
870
920
  });
871
921
  }
922
+ /** Most rows one Query-tab request returns, however large its `limit` or however absent. */
923
+ exports.MAX_BUILDER_ROWS = 1000;
924
+ /**
925
+ * The args with `limit` / `take` held to {@link MAX_BUILDER_ROWS}, and a `limit`
926
+ * supplied when neither was. The Query tab renders every row it is sent, so an
927
+ * unlimited `findMany` on a large table would load the whole table into this
928
+ * process and then into the browser.
929
+ */
930
+ function capBuilderRows(args) {
931
+ const next = { ...args };
932
+ for (const key of ['limit', 'take']) {
933
+ const value = next[key];
934
+ if (typeof value === 'number' && value > exports.MAX_BUILDER_ROWS)
935
+ next[key] = exports.MAX_BUILDER_ROWS;
936
+ }
937
+ if (next.limit === undefined && next.take === undefined)
938
+ next.limit = exports.MAX_BUILDER_ROWS;
939
+ const asked = typeof next.limit === 'number' ? next.limit : typeof next.take === 'number' ? next.take : undefined;
940
+ return { args: next, effectiveLimit: asked ?? exports.MAX_BUILDER_ROWS };
941
+ }
942
+ /**
943
+ * Does a `select` anywhere in these args name a PII-tagged column? Walks the
944
+ * top-level `select` and every `with` level against its own target table.
945
+ *
946
+ * Why it exists: core refuses a PII column named in `select` unless the call
947
+ * passes `includePii: UNSAFE`, because `select` is ordinary request data. Studio
948
+ * is the one caller that may say yes to it, since every row it serves is
949
+ * redacted server-side before it leaves (see {@link redactBuilderRows}), so the
950
+ * flag is passed exactly when the query asks for such a column.
951
+ *
952
+ * It errs toward NOT finding one: past the depth cap, or on a shape it does not
953
+ * recognize, it answers false and core's own refusal reaches the user, which is
954
+ * a failed query and never a leaked column.
955
+ */
956
+ function selectNamesPii(args, table, metadata, depth = 0) {
957
+ if (depth > 10 || args === null || typeof args !== 'object')
958
+ return false;
959
+ const { select, with: withClause } = args;
960
+ if (select && typeof select === 'object' && !Array.isArray(select)) {
961
+ for (const [key, wanted] of Object.entries(select)) {
962
+ if (!wanted)
963
+ continue;
964
+ const column = (0, utils_js_1.resolveColumnName)(table, key);
965
+ if (column !== undefined && table.columns.some((c) => c.name === column && c.pii === true))
966
+ return true;
967
+ }
968
+ }
969
+ if (withClause && typeof withClause === 'object' && !Array.isArray(withClause)) {
970
+ for (const [relKey, spec] of Object.entries(withClause)) {
971
+ const resolved = (0, utils_js_1.resolveRelation)(table.relations, relKey);
972
+ const target = resolved ? (0, utils_js_1.ownLookup)(metadata.tables, resolved.def.to) : undefined;
973
+ if (target && selectNamesPii(spec, target, metadata, depth + 1))
974
+ return true;
975
+ }
976
+ }
977
+ return false;
978
+ }
872
979
  async function apiBuilder(req, res, ctx) {
873
980
  const body = await readJsonBody(req);
874
981
  const tableName = typeof body?.table === 'string' ? body.table : '';
@@ -877,6 +984,19 @@ async function apiBuilder(req, res, ctx) {
877
984
  sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
878
985
  return;
879
986
  }
987
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) {
988
+ sendJson(res, 400, { error: '`args` must be an object of findMany options' });
989
+ return;
990
+ }
991
+ // A locking read takes row locks, and every read here runs inside
992
+ // `BEGIN READ ONLY`, which PostgreSQL answers with a raw 25006. Refused by name
993
+ // instead, before anything else is judged: the verdict must not depend on the
994
+ // PII state (the walk below is skipped under --show-pii) or on the database.
995
+ const locked = (0, pii_predicate_guard_js_1.findRowLockOption)(args);
996
+ if (locked !== null) {
997
+ sendJson(res, 400, { error: (0, pii_predicate_guard_js_1.rowLockRefusalMessage)(shown(locked), "Studio's Query tab") });
998
+ return;
999
+ }
880
1000
  // Fail-closed posture: the generated metadata exists but its PII tags could
881
1001
  // not be read, so every column is treated as PII. The Query tab is then
882
1002
  // REFUSED outright rather than half-served. A default projection that
@@ -901,12 +1021,13 @@ async function apiBuilder(req, res, ctx) {
901
1021
  const target = (0, utils_js_1.ownLookup)(ctx.metadata.tables, tableName);
902
1022
  if (target && !ctx.showPii && args.select === undefined && target.columns.every((c) => c.pii === true)) {
903
1023
  sendJson(res, 400, {
904
- error: `Every column of "${tableName}" is PII-tagged, so the default projection is empty. Name the columns you ` +
1024
+ error: `Every column of "${shown(tableName)}" is PII-tagged, so the default projection is empty. Name the columns you ` +
905
1025
  'want in `select`, or run Studio with --show-pii.',
906
1026
  });
907
1027
  return;
908
1028
  }
909
1029
  let deferred;
1030
+ let effectiveLimit = exports.MAX_BUILDER_ROWS;
910
1031
  try {
911
1032
  const qi = new index_js_1.QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
912
1033
  warnOnUnlimited: false,
@@ -917,10 +1038,15 @@ async function apiBuilder(req, res, ctx) {
917
1038
  dialect: ctx.dialect,
918
1039
  });
919
1040
  assertNoPiiPredicates(args, tableName, ctx.metadata, ctx.showPii === true);
920
- deferred = qi.buildFindMany(args);
1041
+ const capped = capBuilderRows(args);
1042
+ effectiveLimit = capped.effectiveLimit;
1043
+ // The flag is the request's own `includePii` untouched unless the query names a PII
1044
+ // column in a `select`, in which case Studio (which redacts every row) grants it.
1045
+ const grant = target && selectNamesPii(args, target, ctx.metadata) ? { includePii: index_js_1.UNSAFE } : {};
1046
+ deferred = qi.buildFindMany({ ...capped.args, ...grant });
921
1047
  }
922
1048
  catch (err) {
923
- sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
1049
+ sendJson(res, 400, { error: (0, local_server_output_js_1.responseErrorText)(err) });
924
1050
  return;
925
1051
  }
926
1052
  const client = await ctx.pool.connect();
@@ -976,6 +1102,9 @@ async function apiBuilder(req, res, ctx) {
976
1102
  rows: redactedRows.map((r) => serializeRow(r)),
977
1103
  rowCount: result.rowCount ?? result.rows.length,
978
1104
  elapsedMs,
1105
+ // True when the row cap, not the query, may be what ended the list.
1106
+ truncated: effectiveLimit >= exports.MAX_BUILDER_ROWS && redactedRows.length >= exports.MAX_BUILDER_ROWS,
1107
+ rowLimit: exports.MAX_BUILDER_ROWS,
979
1108
  });
980
1109
  }
981
1110
  catch (err) {
@@ -987,7 +1116,7 @@ async function apiBuilder(req, res, ctx) {
987
1116
  /* ignore */
988
1117
  }
989
1118
  }
990
- sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
1119
+ sendJson(res, 400, { error: (0, local_server_output_js_1.responseErrorText)(err) });
991
1120
  }
992
1121
  finally {
993
1122
  client.release();
@@ -1021,7 +1150,7 @@ async function apiRowWrite(req, res, ctx, op) {
1021
1150
  return;
1022
1151
  }
1023
1152
  if (table.isView) {
1024
- sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
1153
+ sendJson(res, 400, { error: `[turbine] "${shown(tableName)}" is a view; Studio cannot write to it.` });
1025
1154
  return;
1026
1155
  }
1027
1156
  // Bulk form: `rows` replaces `data` (insert) / `where` (delete).
@@ -1059,7 +1188,9 @@ async function apiRowWrite(req, res, ctx, op) {
1059
1188
  const rec = rowData;
1060
1189
  const badKey = firstUnknownColumn(table, rec);
1061
1190
  if (badKey) {
1062
- sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"${rowLabel}` });
1191
+ sendJson(res, 400, {
1192
+ error: `[turbine] unknown column "${shown(badKey)}" on table "${shown(tableName)}"${rowLabel}`,
1193
+ });
1063
1194
  return;
1064
1195
  }
1065
1196
  if (!Object.keys(rec).some((k) => rec[k] !== undefined)) {
@@ -1072,7 +1203,7 @@ async function apiRowWrite(req, res, ctx, op) {
1072
1203
  if (op === 'update' || op === 'delete') {
1073
1204
  if (table.primaryKey.length === 0) {
1074
1205
  sendJson(res, 400, {
1075
- error: `[turbine] "${tableName}" has no primary key; single-row writes require one.`,
1206
+ error: `[turbine] "${shown(tableName)}" has no primary key; single-row writes require one.`,
1076
1207
  });
1077
1208
  return;
1078
1209
  }
@@ -1100,7 +1231,7 @@ async function apiRowWrite(req, res, ctx, op) {
1100
1231
  if (op === 'update') {
1101
1232
  const badKey = firstUnknownColumn(table, data);
1102
1233
  if (badKey) {
1103
- sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
1234
+ sendJson(res, 400, { error: `[turbine] unknown column "${shown(badKey)}" on table "${shown(tableName)}"` });
1104
1235
  return;
1105
1236
  }
1106
1237
  if (!Object.keys(data).some((k) => data[k] !== undefined)) {
@@ -1128,7 +1259,7 @@ async function apiRowWrite(req, res, ctx, op) {
1128
1259
  }
1129
1260
  }
1130
1261
  catch (err) {
1131
- sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
1262
+ sendJson(res, 400, { error: (0, local_server_output_js_1.responseErrorText)(err) });
1132
1263
  return;
1133
1264
  }
1134
1265
  const client = await ctx.pool.connect();
@@ -1190,7 +1321,7 @@ async function apiRowWrite(req, res, ctx, op) {
1190
1321
  catch {
1191
1322
  /* ignore */
1192
1323
  }
1193
- sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
1324
+ sendJson(res, 400, { error: (0, local_server_output_js_1.responseErrorText)(err) });
1194
1325
  }
1195
1326
  finally {
1196
1327
  client.release();
@@ -1226,11 +1357,11 @@ function extractPkWhere(table, where) {
1226
1357
  const pkWhere = {};
1227
1358
  for (const pkCol of table.primaryKey) {
1228
1359
  if (!resolved.has(pkCol)) {
1229
- return { error: `\`where\` must fully cover the primary key (missing "${pkCol}")` };
1360
+ return { error: `\`where\` must fully cover the primary key (missing "${shown(pkCol)}")` };
1230
1361
  }
1231
1362
  const v = resolved.get(pkCol);
1232
1363
  if (v === undefined || v === null || typeof v === 'object') {
1233
- return { error: `primary key "${pkCol}" must be a scalar value in \`where\`` };
1364
+ return { error: `primary key "${shown(pkCol)}" must be a scalar value in \`where\`` };
1234
1365
  }
1235
1366
  const field = table.reverseColumnMap[pkCol] ?? pkCol;
1236
1367
  pkWhere[field] = v;
@@ -1291,6 +1422,8 @@ function apiListSavedQueries(res, ctx, params) {
1291
1422
  const filtered = table ? queries.filter((q) => q.table === table) : queries;
1292
1423
  sendJson(res, 200, { queries: filtered });
1293
1424
  }
1425
+ /** Most saved queries Studio will keep; each is bounded by the 1 MB body limit. */
1426
+ const MAX_SAVED_QUERIES = 500;
1294
1427
  async function apiCreateSavedQuery(req, res, ctx) {
1295
1428
  const body = await readJsonBody(req);
1296
1429
  const table = typeof body?.table === 'string' ? body.table : '';
@@ -1309,6 +1442,12 @@ async function apiCreateSavedQuery(req, res, ctx) {
1309
1442
  return;
1310
1443
  }
1311
1444
  const data = loadSavedQueries(ctx);
1445
+ if (data.queries.length >= MAX_SAVED_QUERIES) {
1446
+ sendJson(res, 400, {
1447
+ error: `Studio keeps at most ${MAX_SAVED_QUERIES} saved queries. Delete one before saving another.`,
1448
+ });
1449
+ return;
1450
+ }
1312
1451
  const entry = {
1313
1452
  id: (0, node_crypto_1.randomUUID)(),
1314
1453
  table,
@@ -1521,26 +1660,73 @@ function serializeRow(row) {
1521
1660
  }
1522
1661
  return out;
1523
1662
  }
1663
+ /** A request the caller got wrong, carrying the HTTP status it deserves. */
1664
+ class RequestError extends Error {
1665
+ status;
1666
+ constructor(status, message) {
1667
+ super(message);
1668
+ this.status = status;
1669
+ this.name = 'RequestError';
1670
+ }
1671
+ }
1672
+ /** Largest JSON request body Studio will read. */
1673
+ const MAX_BODY_BYTES = 1 << 20;
1674
+ /**
1675
+ * How much of an oversized body Studio will read and throw away before it gives
1676
+ * up and drops the connection. A client that is still sending when the server
1677
+ * answers and closes gets a TCP reset instead of the response, so the 413 would
1678
+ * be lost; discarding the rest first lets it arrive. Bounded so a body of
1679
+ * unbounded length cannot hold the connection open.
1680
+ */
1681
+ const MAX_DISCARD_BYTES = 16 * 1024 * 1024;
1682
+ /**
1683
+ * Read a JSON request body, refusing one over {@link MAX_BODY_BYTES} (413) or
1684
+ * malformed (400). Nothing past the limit is ever buffered: an oversized body,
1685
+ * whether declared by `Content-Length` or discovered while streaming, is counted
1686
+ * and discarded chunk by chunk, then answered with 413.
1687
+ */
1524
1688
  async function readJsonBody(req) {
1689
+ const declared = Number(req.headers?.['content-length']);
1690
+ let tooBig = Number.isFinite(declared) && declared > MAX_BODY_BYTES;
1525
1691
  const chunks = [];
1526
1692
  let total = 0;
1527
- const MAX = 1 << 20; // 1 MB cap on query payloads
1528
- for await (const chunk of req) {
1529
- const buf = chunk;
1693
+ // destroyOnReturn: false, because destroying a half-read request tears down
1694
+ // its socket and the response with it.
1695
+ const source = typeof req.iterator === 'function' ? req.iterator({ destroyOnReturn: false }) : req;
1696
+ const iterator = source[Symbol.asyncIterator]();
1697
+ for (;;) {
1698
+ const next = await iterator.next();
1699
+ if (next.done)
1700
+ break;
1701
+ const buf = next.value;
1530
1702
  total += buf.length;
1531
- if (total > MAX)
1532
- throw new Error('request body too large');
1533
- chunks.push(buf);
1703
+ if (!tooBig && total > MAX_BODY_BYTES) {
1704
+ tooBig = true;
1705
+ chunks.length = 0;
1706
+ }
1707
+ if (!tooBig)
1708
+ chunks.push(buf);
1709
+ else if (total > MAX_DISCARD_BYTES) {
1710
+ req.destroy?.();
1711
+ break;
1712
+ }
1534
1713
  }
1714
+ if (tooBig)
1715
+ throw new RequestError(413, `request body too large (limit ${MAX_BODY_BYTES} bytes)`);
1535
1716
  const raw = Buffer.concat(chunks).toString('utf8');
1536
1717
  if (!raw)
1537
1718
  return {};
1719
+ let parsed;
1538
1720
  try {
1539
- return JSON.parse(raw);
1721
+ parsed = JSON.parse(raw);
1540
1722
  }
1541
1723
  catch {
1542
- throw new Error('invalid json body');
1724
+ throw new RequestError(400, 'invalid json body');
1543
1725
  }
1726
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1727
+ throw new RequestError(400, 'request body must be a JSON object');
1728
+ }
1729
+ return parsed;
1544
1730
  }
1545
1731
  function sendJson(res, status, body) {
1546
1732
  const payload = JSON.stringify(body);
@@ -1580,14 +1766,3 @@ function sendHtml(res, status, template, nonce) {
1580
1766
  });
1581
1767
  res.end(body);
1582
1768
  }
1583
- function openUrl(url) {
1584
- const cmd = (0, node_os_1.platform)() === 'darwin' ? 'open' : (0, node_os_1.platform)() === 'win32' ? 'cmd' : 'xdg-open';
1585
- const args = (0, node_os_1.platform)() === 'win32' ? ['/c', 'start', '', url] : [url];
1586
- try {
1587
- const child = (0, node_child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: true });
1588
- child.unref();
1589
- }
1590
- catch {
1591
- // Non-fatal: user can click the URL manually.
1592
- }
1593
- }
@@ -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: "|" | "│";