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 {
@@ -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 { spawn } from 'node:child_process';
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 { assertNoPiiPredicates as assertNoPiiPredicatesShared } from './pii-predicate-guard.js';
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: err instanceof Error ? err.message : String(err) });
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, authToken);
223
+ const url = buildStudioUrl(options.host, options.port, ticketGate.token);
215
224
  if (options.openBrowser) {
216
- openUrl(url);
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
- const url = new URL(req.url ?? '/', expectedOrigin);
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 and embeds the auth token.
254
- // All other routes require the token in the `x-turbine-token` header
255
- // or the `token` cookie.
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
- // If the URL includes a token, validate then set a cookie and redirect.
263
- if (queryToken && constantTimeEqual(queryToken, ctx.authToken)) {
264
- res.writeHead(302, {
265
- Location: '/',
266
- 'Set-Cookie': `turbine_studio_token=${ctx.authToken}; Path=/; HttpOnly; SameSite=Strict`,
267
- });
268
- res.end();
269
- return;
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] ${err instanceof Error ? err.message : String(err)}` });
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
- deferred = qi.buildFindMany(args);
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: err instanceof Error ? err.message : String(err) });
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: err instanceof Error ? err.message : String(err) });
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, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"${rowLabel}` });
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: err instanceof Error ? err.message : String(err) });
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: err instanceof Error ? err.message : String(err) });
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
- const MAX = 1 << 20; // 1 MB cap on query payloads
1509
- for await (const chunk of req) {
1510
- const buf = chunk;
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 > MAX)
1513
- throw new Error('request body too large');
1514
- chunks.push(buf);
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
- return JSON.parse(raw);
1702
+ parsed = JSON.parse(raw);
1521
1703
  }
1522
1704
  catch {
1523
- throw new Error('invalid json body');
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: "|" | "│";