bunqueue-dashboard 0.0.32 → 0.0.33

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 (105) hide show
  1. package/README.md +44 -15
  2. package/agent/db.ts +537 -64
  3. package/agent/dbQueryWorker.ts +34 -11
  4. package/agent/manager.ts +186 -45
  5. package/agent/server.ts +124 -35
  6. package/dist/.vite/manifest.json +212 -157
  7. package/dist/assets/AddJob-ytpBLTCJ.js +3 -0
  8. package/dist/assets/Alerts-C39RrGbQ.js +1 -0
  9. package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
  10. package/dist/assets/Benchmark-CM60tHbf.js +1 -0
  11. package/dist/assets/BulkAddJobs-BjL-xcQy.js +5 -0
  12. package/dist/assets/CopilotPanel-BGK1rw-V.js +44 -0
  13. package/dist/assets/Cron-s1e-jgOo.js +1 -0
  14. package/dist/assets/CronManager-BostU7BX.js +1 -0
  15. package/dist/assets/Database-ZluStWMz.js +3 -0
  16. package/dist/assets/Diagnostics-C0N5z5j-.js +1 -0
  17. package/dist/assets/Dlq-CtcLzs-b.js +1 -0
  18. package/dist/assets/DlqControl-B_Z8KKn5.js +1 -0
  19. package/dist/assets/DlqPro-B68en6AG.js +1 -0
  20. package/dist/assets/Flows-CD_bkCBM.js +1 -0
  21. package/dist/assets/JobInspector-DwRtyMz0.js +2 -0
  22. package/dist/assets/Jobs-BUtcBKKk.js +1 -0
  23. package/dist/assets/JobsPro-BK_xd4ji.js +1 -0
  24. package/dist/assets/Logs-CmVODYCI.js +1 -0
  25. package/dist/assets/LogsPro-CJ-Il3LP.js +1 -0
  26. package/dist/assets/McpServer-dMjyJKgT.js +22 -0
  27. package/dist/assets/Metrics-Dsbeplz-.js +1 -0
  28. package/dist/assets/MetricsPro-BqxurxrJ.js +1 -0
  29. package/dist/assets/Overview-DH9C2dsf.js +1 -0
  30. package/dist/assets/{Pagination-B9JDi5MI.js → Pagination-DpxiBnt8.js} +1 -1
  31. package/dist/assets/QueueActions-D_QGBaVm.js +1 -0
  32. package/dist/assets/QueueControl-V0DJ6qk6.js +1 -0
  33. package/dist/assets/QueueDetail-BgKAMJqf.js +1 -0
  34. package/dist/assets/QueueDetailPro-BkHYzGiq.js +1 -0
  35. package/dist/assets/Queues-2_v7DcNi.js +1 -0
  36. package/dist/assets/QueuesOverview-BySSkmB4.js +1 -0
  37. package/dist/assets/S3Backup-CPADzKQE.js +1 -0
  38. package/dist/assets/S3BackupPro-CSrQYIDL.js +2 -0
  39. package/dist/assets/ServerControl-C0tI1VDx.js +3 -0
  40. package/dist/assets/Settings-CSzoM5up.js +1 -0
  41. package/dist/assets/Usage-B4B7tmrc.js +1 -0
  42. package/dist/assets/UsagePro-CCBpgSDy.js +1 -0
  43. package/dist/assets/Webhooks-aJz-GisQ.js +1 -0
  44. package/dist/assets/Workers-Bg8xKjkg.js +1 -0
  45. package/dist/assets/WorkersPro-BOOCrXf8.js +1 -0
  46. package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
  47. package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
  48. package/dist/assets/form-DsUq0w8s.js +1 -0
  49. package/dist/assets/index-BWoUj86M.css +1 -0
  50. package/dist/assets/index-Bn2R06qm.js +8 -0
  51. package/dist/assets/{install-BIVfix28.js → install-CUS8Kijb.js} +2 -2
  52. package/dist/assets/jobActions-LQD4KfCc.js +1 -0
  53. package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
  54. package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
  55. package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
  56. package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
  57. package/dist/assets/useThroughputSeries-tb26_lkb.js +1 -0
  58. package/dist/index.html +5 -4
  59. package/package.json +11 -3
  60. package/scripts/embedded.gen.ts +132 -126
  61. package/scripts/serve.ts +259 -67
  62. package/dist/assets/AddJob-Dvpp--yq.js +0 -3
  63. package/dist/assets/Alerts-CR9jgCCs.js +0 -1
  64. package/dist/assets/Benchmark-CkL-Ee-M.js +0 -1
  65. package/dist/assets/BulkAddJobs-wI6cRZnX.js +0 -5
  66. package/dist/assets/CopilotPanel-DPZV9opm.js +0 -44
  67. package/dist/assets/Cron-gWVkYWad.js +0 -1
  68. package/dist/assets/CronManager-BvfW219u.js +0 -1
  69. package/dist/assets/Database-D2T_ZIGA.js +0 -3
  70. package/dist/assets/Diagnostics-C2goqq5h.js +0 -1
  71. package/dist/assets/Dlq-Q4TmmZrK.js +0 -1
  72. package/dist/assets/DlqControl-C5BfE7Gq.js +0 -1
  73. package/dist/assets/DlqPro-ClPNmw-X.js +0 -1
  74. package/dist/assets/Flows-DbhMbvSG.js +0 -1
  75. package/dist/assets/JobInspector-CO_zKjSX.js +0 -2
  76. package/dist/assets/Jobs-D2rDHPM_.js +0 -1
  77. package/dist/assets/JobsPro-Drdgdj3s.js +0 -1
  78. package/dist/assets/Logs-DjwGuRLi.js +0 -1
  79. package/dist/assets/LogsPro-7uTBjPWX.js +0 -1
  80. package/dist/assets/McpServer-DAklQDB5.js +0 -22
  81. package/dist/assets/Metrics-Dup9Zinh.js +0 -1
  82. package/dist/assets/MetricsPro-PP3TymeP.js +0 -1
  83. package/dist/assets/Overview-C7RCDE6r.js +0 -1
  84. package/dist/assets/QueueActions-DJTw0h2g.js +0 -1
  85. package/dist/assets/QueueControl-CxKjUjF-.js +0 -1
  86. package/dist/assets/QueueDetail-bf2n-r2h.js +0 -1
  87. package/dist/assets/QueueDetailPro-C-XLWFBa.js +0 -1
  88. package/dist/assets/Queues-CMQW89J9.js +0 -1
  89. package/dist/assets/QueuesOverview-x7-akDzf.js +0 -1
  90. package/dist/assets/S3Backup-1jAySC9R.js +0 -1
  91. package/dist/assets/S3BackupPro-BG_0OHW7.js +0 -1
  92. package/dist/assets/ServerControl-DohTB--h.js +0 -3
  93. package/dist/assets/Settings-CnS5PjNe.js +0 -1
  94. package/dist/assets/Usage-C3H0hMSA.js +0 -1
  95. package/dist/assets/UsagePro-CdnQkP9o.js +0 -1
  96. package/dist/assets/Webhooks-CAwVmIEZ.js +0 -1
  97. package/dist/assets/Workers-lvujKZwF.js +0 -1
  98. package/dist/assets/WorkersPro-CIJKl7U1.js +0 -1
  99. package/dist/assets/connectionStore-DO6xQY7h.js +0 -1
  100. package/dist/assets/form-DGinTEEb.js +0 -1
  101. package/dist/assets/index-DcVOpnSj.css +0 -1
  102. package/dist/assets/index-r1ZMLIZ9.js +0 -8
  103. package/dist/assets/jobActions-CuOdMglt.js +0 -1
  104. package/dist/assets/promisePool-Daxkc1OB.js +0 -1
  105. package/dist/assets/useThroughputSeries-CVKzWzdv.js +0 -1
@@ -1,22 +1,45 @@
1
1
  /**
2
- * Disposable worker that runs ONE read-only query and posts the result back.
2
+ * Disposable worker that runs ONE read-only database operation and posts the
3
+ * result back: an arbitrary query or an atomic table CSV export.
3
4
  *
4
- * queryWithTimeout() (agent/db.ts) spawns one of these per /db/query request and
5
- * races it against a wall clock, so a runaway O(N^k) scan can't freeze the
6
- * agent's process-control endpoints. terminate() does NOT preempt a synchronous
7
- * sqlite3_step, though — it abandons the response, and this thread keeps running
8
- * until SQLite finishes, which is why db.ts caps how many can be alive at once.
9
- * The connection is opened readonly inside dbQuery, so nothing here can mutate
10
- * the store.
5
+ * queryWithTimeout() and exportWithTimeout() (agent/db.ts) spawn one of these
6
+ * per request and race it against a wall clock, so a runaway scan/sort can't
7
+ * freeze the agent's process-control endpoints. terminate() does NOT preempt a
8
+ * synchronous sqlite3_step, though — it abandons the response, and this thread
9
+ * keeps running until SQLite finishes, which is why db.ts caps how many can be
10
+ * alive at once. Both dbQuery and dbExportCsv open readonly connections, so
11
+ * nothing here can mutate the store.
11
12
  */
12
- import { dbQuery, MissingDbError } from './db';
13
+ import { type DbFilter, dbExportCsv, dbQuery, MissingDbError } from './db';
13
14
 
14
15
  declare const self: Worker;
15
16
 
16
17
  self.addEventListener('message', (ev: MessageEvent) => {
17
- const { path, sql } = ev.data as { path: string; sql: string };
18
+ const request = ev.data as
19
+ | { kind?: 'query'; path: string; sql: string }
20
+ | {
21
+ kind: 'export';
22
+ path: string;
23
+ table: string;
24
+ orderBy?: string;
25
+ dir: 'asc' | 'desc';
26
+ filter?: DbFilter;
27
+ };
18
28
  try {
19
- self.postMessage({ ok: true, result: dbQuery(path, sql) });
29
+ if (request.kind === 'export') {
30
+ const exported = dbExportCsv(
31
+ request.path,
32
+ request.table,
33
+ request.orderBy,
34
+ request.dir,
35
+ request.filter
36
+ );
37
+ // Transfer the bounded CSV buffer instead of cloning it or JSON-encoding
38
+ // up to 16 MiB a second time in either isolate.
39
+ self.postMessage({ ok: true, export: exported }, [exported.content.buffer]);
40
+ } else {
41
+ self.postMessage({ ok: true, result: dbQuery(request.path, request.sql) });
42
+ }
20
43
  } catch (e) {
21
44
  // postMessage can't carry the error class, so flag the one the route branches
22
45
  // on (MissingDbError → 404) and let db.ts rebuild it on the parent side.
package/agent/manager.ts CHANGED
@@ -56,6 +56,10 @@ const STOP_TIMEOUT_MS = 8000;
56
56
  */
57
57
  const MAX_LINE = 8192;
58
58
 
59
+ interface CancelablePipeReader {
60
+ cancel(reason?: unknown): Promise<void>;
61
+ }
62
+
59
63
  function defaultConfig(): ServerConfig {
60
64
  return {
61
65
  command: process.env.BUNQUEUE_START_CMD || 'bunqueue start',
@@ -66,6 +70,92 @@ function defaultConfig(): ServerConfig {
66
70
  };
67
71
  }
68
72
 
73
+ const CONFIG_KEYS = new Set<keyof ServerConfig>([
74
+ 'command',
75
+ 'httpPort',
76
+ 'tcpPort',
77
+ 'dataPath',
78
+ 'extraEnv',
79
+ ]);
80
+
81
+ function isRecord(value: unknown): value is Record<string, unknown> {
82
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
83
+ const proto = Object.getPrototypeOf(value);
84
+ return proto === Object.prototype || proto === null;
85
+ }
86
+
87
+ function validatePort(name: 'httpPort' | 'tcpPort', value: unknown): number {
88
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65_535) {
89
+ throw new Error(`${name} must be an integer between 1 and 65535`);
90
+ }
91
+ return value;
92
+ }
93
+
94
+ /**
95
+ * Validate an untrusted PUT /control/config body before any field is merged.
96
+ * Returning a fresh object (including a fresh extraEnv) makes setConfig atomic:
97
+ * one invalid field cannot leave earlier fields applied, and the caller cannot
98
+ * mutate the accepted config later through a retained object reference.
99
+ */
100
+ export function validateConfigPatch(value: unknown): Partial<ServerConfig> {
101
+ if (!isRecord(value)) throw new Error('Config must be an object');
102
+
103
+ for (const key of Object.keys(value)) {
104
+ if (!CONFIG_KEYS.has(key as keyof ServerConfig)) {
105
+ throw new Error(`Unknown config key: ${key}`);
106
+ }
107
+ }
108
+
109
+ const patch: Partial<ServerConfig> = {};
110
+ if (Object.hasOwn(value, 'command')) {
111
+ if (typeof value.command !== 'string' || value.command.trim() === '') {
112
+ throw new Error('command must be a non-empty string');
113
+ }
114
+ patch.command = value.command;
115
+ }
116
+ if (Object.hasOwn(value, 'httpPort')) {
117
+ patch.httpPort = validatePort('httpPort', value.httpPort);
118
+ }
119
+ if (Object.hasOwn(value, 'tcpPort')) {
120
+ patch.tcpPort = validatePort('tcpPort', value.tcpPort);
121
+ }
122
+ if (Object.hasOwn(value, 'dataPath')) {
123
+ if (typeof value.dataPath !== 'string') throw new Error('dataPath must be a string');
124
+ patch.dataPath = value.dataPath;
125
+ }
126
+ if (Object.hasOwn(value, 'extraEnv')) {
127
+ if (!isRecord(value.extraEnv)) {
128
+ throw new Error('extraEnv must be an object containing only string values');
129
+ }
130
+ const entries = Object.entries(value.extraEnv);
131
+ for (const [key, entry] of entries) {
132
+ if (typeof entry !== 'string') {
133
+ throw new Error(`extraEnv.${key} must be a string`);
134
+ }
135
+ }
136
+ // fromEntries defines "__proto__" as ordinary data instead of invoking the
137
+ // legacy Object.prototype setter via indexed assignment.
138
+ patch.extraEnv = Object.fromEntries(entries) as Record<string, string>;
139
+ }
140
+ return patch;
141
+ }
142
+
143
+ /** Validate the complete snapshot start() is about to pass to the child. */
144
+ function validateServerConfig(value: unknown): ServerConfig {
145
+ if (!isRecord(value)) throw new Error('Config must be an object');
146
+ const config = validateConfigPatch(value);
147
+ for (const key of CONFIG_KEYS) {
148
+ if (!Object.hasOwn(value, key)) {
149
+ throw new Error(`Missing config key: ${key}`);
150
+ }
151
+ }
152
+ return config as ServerConfig;
153
+ }
154
+
155
+ function copyConfig(config: ServerConfig): ServerConfig {
156
+ return { ...config, extraEnv: { ...config.extraEnv } };
157
+ }
158
+
69
159
  export class ProcessManager {
70
160
  private proc: ReturnType<typeof Bun.spawn> | null = null;
71
161
  private status: Status = 'stopped';
@@ -91,9 +181,27 @@ export class ProcessManager {
91
181
  private stopping: Promise<StatusSnapshot> | null = null;
92
182
  /** Latched by shutdown(): once set, start() refuses to spawn a new child. */
93
183
  private shuttingDown = false;
184
+ /** Active stdout/stderr readers, grouped by the process generation they own. */
185
+ private pipeReaders = new Map<number, Set<CancelablePipeReader>>();
186
+
187
+ /**
188
+ * Actively break pending reader.read() calls for a finished generation. A
189
+ * descendant may inherit the child's pipe descriptor and keep it open after
190
+ * the managed process exits; token checks alone cannot release that reader.
191
+ */
192
+ private cancelPipes(token: number): void {
193
+ const readers = this.pipeReaders.get(token);
194
+ if (!readers) return;
195
+ this.pipeReaders.delete(token);
196
+ for (const reader of readers) {
197
+ void reader.cancel('process generation ended').catch(() => {
198
+ /* already closed/cancelled */
199
+ });
200
+ }
201
+ }
94
202
 
95
203
  getConfig(): ServerConfig {
96
- return this.config;
204
+ return copyConfig(this.config);
97
205
  }
98
206
 
99
207
  /**
@@ -103,8 +211,13 @@ export class ProcessManager {
103
211
  * changed in place.
104
212
  */
105
213
  setConfig(patch: Partial<ServerConfig>): ServerConfig {
106
- this.config = { ...this.config, ...patch, extraEnv: patch.extraEnv ?? this.config.extraEnv };
107
- return this.config;
214
+ const valid = validateConfigPatch(patch);
215
+ this.config = {
216
+ ...this.config,
217
+ ...valid,
218
+ extraEnv: valid.extraEnv ?? this.config.extraEnv,
219
+ };
220
+ return copyConfig(this.config);
108
221
  }
109
222
 
110
223
  getStatus(): StatusSnapshot {
@@ -113,8 +226,8 @@ export class ProcessManager {
113
226
  pid: this.proc?.pid ?? null,
114
227
  startedAt: this.startedAt,
115
228
  exitCode: this.exitCode,
116
- config: this.config,
117
- runningConfig: this.runningConfig,
229
+ config: copyConfig(this.config),
230
+ runningConfig: this.runningConfig ? copyConfig(this.runningConfig) : null,
118
231
  };
119
232
  }
120
233
 
@@ -173,16 +286,14 @@ export class ProcessManager {
173
286
  const statusAfterWait = this.status as Status;
174
287
  if (statusAfterWait === 'running' || statusAfterWait === 'starting') return this.getStatus();
175
288
 
176
- // Validate BEFORE mutating status / burning a proc token. Doing it after
177
- // (as this once did) means a bad command leaves the manager wedged: an
178
- // empty command stranded the previous generation's dead pid + runningConfig
179
- // (its stop()/onExit finalizers were already token-stale), and a non-string
180
- // `command` from PUT /control/config threw out of `.trim()` with status
181
- // pinned at 'starting', disabling every UI control until the agent restarts.
182
- const command = typeof this.config.command === 'string' ? this.config.command : '';
183
- const parts = command.trim().split(/\s+/);
289
+ // Validate the COMPLETE launch snapshot BEFORE mutating status / burning a
290
+ // proc token. setConfig() already validates API patches, but defaultConfig()
291
+ // also reads environment values; e.g. HTTP_PORT=70000 must not reach spawn.
292
+ // Doing this later can wedge status at 'starting' and strand stale process
293
+ // metadata when validation throws.
294
+ const launchConfig = validateServerConfig(this.config);
295
+ const parts = launchConfig.command.trim().split(/\s+/);
184
296
  const [cmd, ...args] = parts;
185
- if (!cmd) throw new Error('Empty command');
186
297
 
187
298
  this.status = 'starting';
188
299
  this.exitCode = null;
@@ -193,10 +304,10 @@ export class ProcessManager {
193
304
  // while runningConfig (and the agent's /health probe) reported the old port.
194
305
  const env: Record<string, string> = {
195
306
  ...(process.env as Record<string, string>),
196
- ...this.config.extraEnv,
197
- HTTP_PORT: String(this.config.httpPort),
198
- TCP_PORT: String(this.config.tcpPort),
199
- BUNQUEUE_DATA_PATH: this.config.dataPath,
307
+ ...launchConfig.extraEnv,
308
+ HTTP_PORT: String(launchConfig.httpPort),
309
+ TCP_PORT: String(launchConfig.tcpPort),
310
+ BUNQUEUE_DATA_PATH: launchConfig.dataPath,
200
311
  };
201
312
 
202
313
  try {
@@ -205,6 +316,9 @@ export class ProcessManager {
205
316
  stdout: 'pipe',
206
317
  stderr: 'pipe',
207
318
  onExit: (_p, code) => {
319
+ // Always tear down this generation's readers, even if shared state
320
+ // already belongs to a replacement process.
321
+ this.cancelPipes(token);
208
322
  // Ignore the exit of a process a newer start() has already replaced.
209
323
  if (this.procToken !== token) return;
210
324
  this.exitCode = code ?? null;
@@ -227,45 +341,69 @@ export class ProcessManager {
227
341
 
228
342
  this.startedAt = Date.now();
229
343
  this.status = 'running';
230
- this.runningConfig = { ...this.config, extraEnv: { ...this.config.extraEnv } };
231
- this.push('sys', `started: ${this.config.command} (pid ${this.proc.pid})`);
344
+ this.runningConfig = copyConfig(launchConfig);
345
+ this.push('sys', `started: ${launchConfig.command} (pid ${this.proc.pid})`);
232
346
  // stdout/stderr are spawned as pipes, so they are ReadableStreams at
233
347
  // runtime; Bun types them as a union that also includes a numeric fd.
234
- void this.pipe(this.proc.stdout as unknown as ReadableStream<Uint8Array>, 'stdout');
235
- void this.pipe(this.proc.stderr as unknown as ReadableStream<Uint8Array>, 'stderr');
348
+ void this.pipe(this.proc.stdout as unknown as ReadableStream<Uint8Array>, 'stdout', token);
349
+ void this.pipe(this.proc.stderr as unknown as ReadableStream<Uint8Array>, 'stderr', token);
236
350
  return this.getStatus();
237
351
  }
238
352
 
239
- private async pipe(stream: ReadableStream<Uint8Array>, name: 'stdout' | 'stderr'): Promise<void> {
353
+ private async pipe(
354
+ stream: ReadableStream<Uint8Array>,
355
+ name: 'stdout' | 'stderr',
356
+ token: number
357
+ ): Promise<void> {
240
358
  const reader = stream.getReader();
359
+ let readers = this.pipeReaders.get(token);
360
+ if (!readers) {
361
+ readers = new Set();
362
+ this.pipeReaders.set(token, readers);
363
+ }
364
+ readers.add(reader);
241
365
  const decoder = new TextDecoder();
242
366
  let buffer = '';
243
367
  try {
244
- while (true) {
245
- const { done, value } = await reader.read();
246
- if (done) break;
247
- buffer += decoder.decode(value, { stream: true });
248
- let i: number;
249
- while ((i = buffer.indexOf('\n')) !== -1) {
250
- const line = buffer.slice(0, i);
251
- buffer = buffer.slice(i + 1);
252
- if (line.trim()) this.push(name, line);
253
- }
254
- // No newline in sight: flush in MAX_LINE slices instead of letting the
255
- // buffer (and the eventual single LogLine) grow to the whole output.
256
- while (buffer.length > MAX_LINE) {
257
- this.push(name, buffer.slice(0, MAX_LINE));
258
- buffer = buffer.slice(MAX_LINE);
368
+ try {
369
+ while (true) {
370
+ const { done, value } = await reader.read();
371
+ // A process can exit while a descendant keeps its inherited pipe
372
+ // open. If a replacement has started by the time bytes arrive, those
373
+ // bytes belong to the obsolete generation and must never enter the
374
+ // current log view.
375
+ if (this.procToken !== token) return;
376
+ if (done) break;
377
+ buffer += decoder.decode(value, { stream: true });
378
+ let i: number;
379
+ while ((i = buffer.indexOf('\n')) !== -1) {
380
+ const line = buffer.slice(0, i);
381
+ buffer = buffer.slice(i + 1);
382
+ if (line.trim() && this.procToken === token) this.push(name, line);
383
+ }
384
+ // No newline in sight: flush in MAX_LINE slices instead of letting the
385
+ // buffer (and the eventual single LogLine) grow to the whole output.
386
+ while (buffer.length > MAX_LINE) {
387
+ if (this.procToken !== token) return;
388
+ this.push(name, buffer.slice(0, MAX_LINE));
389
+ buffer = buffer.slice(MAX_LINE);
390
+ }
259
391
  }
392
+ } catch {
393
+ /* stream closed */
260
394
  }
261
- } catch {
262
- /* stream closed */
395
+ if (this.procToken !== token) return;
396
+ // Flush the final chunk when the stream ends without a trailing newline
397
+ // (e.g. a crash cause written via a bare write()) — otherwise the last,
398
+ // often most important, line never reaches the log buffer.
399
+ const tail = buffer + decoder.decode();
400
+ if (tail.trim() && this.procToken === token) this.push(name, tail);
401
+ } finally {
402
+ const activeReaders = this.pipeReaders.get(token);
403
+ activeReaders?.delete(reader);
404
+ if (activeReaders?.size === 0) this.pipeReaders.delete(token);
405
+ reader.releaseLock();
263
406
  }
264
- // Flush the final chunk when the stream ends without a trailing newline
265
- // (e.g. a crash cause written via a bare write()) — otherwise the last,
266
- // often most important, line never reaches the log buffer.
267
- const tail = buffer + decoder.decode();
268
- if (tail.trim()) this.push(name, tail);
269
407
  }
270
408
 
271
409
  async stop(): Promise<StatusSnapshot> {
@@ -333,6 +471,9 @@ export class ProcessManager {
333
471
  ]).finally(() => clearTimeout(reapTimer));
334
472
  if (!reaped) this.push('sys', `pid ${proc.pid} did not exit after SIGKILL — giving up on it`);
335
473
  }
474
+ // onExit normally performs this cancellation. Repeat it idempotently for
475
+ // a process whose `exited` promise/callback never settles after SIGKILL.
476
+ this.cancelPipes(token);
336
477
  // Only finalize shared state if no newer start() replaced this generation
337
478
  // while we awaited — otherwise we'd null out the wrong (live) process.
338
479
  if (this.procToken !== token) return this.getStatus();
package/agent/server.ts CHANGED
@@ -18,28 +18,45 @@
18
18
  * cover a *same-origin* request: a page whose DNS name is rebound to
19
19
  * 127.0.0.1 issues same-origin GETs that carry no `Origin` header, so it
20
20
  * could otherwise read /control/status, /control/logs and the /db/*
21
- * inspector (job payloads) even with a token set (the token exempts reads).
21
+ * inspector (job payloads). When the agent is reachable beyond loopback,
22
+ * every route also requires the configured token; Host validation remains
23
+ * necessary because it rejects the rebound request before any data leaves.
22
24
  * When `allowedHosts` is configured, a request whose `Host` header names a
23
25
  * hostname outside the allowlist is rejected (403) — the rebinding page's
24
26
  * `Host` is the attacker's domain, never a loopback/allowlisted host.
25
- * 4. Optional bearer token (AGENT_TOKEN): when set, state-changing requests
26
- * must present it (`Authorization: Bearer <t>` or `x-agent-token: <t>`).
27
+ * 4. Optional bearer token (AGENT_TOKEN): on loopback, when set, changing
28
+ * requests must present it. A network-facing caller must set
29
+ * `requireTokenForAll`, which protects reads and writes alike.
27
30
  */
28
- import { type DbFilter, dbCell, dbInfo, dbRows, dbSchema, dbTables, MissingDbError, queryWithTimeout } from './db';
29
- import type { ProcessManager, ServerConfig } from './manager';
31
+ import {
32
+ type DbFilter,
33
+ dbCell,
34
+ DbExportBusyError,
35
+ DbExportUnavailableError,
36
+ dbInfo,
37
+ dbRows,
38
+ dbSchema,
39
+ dbTables,
40
+ exportWithTimeout,
41
+ MissingDbError,
42
+ queryWithTimeout,
43
+ } from './db';
44
+ import { type ProcessManager, validateConfigPatch } from './manager';
30
45
 
31
46
  export interface AgentOptions {
32
47
  allowedOrigins: string[];
33
48
  /**
34
49
  * DNS-rebinding defense: when set (non-empty), a request whose `Host` header
35
50
  * resolves to a hostname outside this list is rejected. Compared by hostname
36
- * only (port stripped). Leave undefined to disable the check — e.g. when the
37
- * agent is fronted by a proxy that already validates Host, or bound to a
38
- * non-loopback interface where the user opted into network exposure.
51
+ * only (port stripped). Leave undefined only for a loopback-only integration
52
+ * or when a trusted front proxy has already enforced Host. Network-facing
53
+ * listeners must configure this allowlist.
39
54
  */
40
55
  allowedHosts?: string[];
41
- /** When set, POST/PUT requests must present this token. */
56
+ /** On loopback, when set, POST/PUT requests must present this token. */
42
57
  token?: string;
58
+ /** Network exposure: require `token` on every non-OPTIONS request, including reads. */
59
+ requireTokenForAll?: boolean;
43
60
  }
44
61
 
45
62
  const DEFAULT_ORIGINS = ['http://localhost:5273', 'http://127.0.0.1:5273'];
@@ -94,14 +111,14 @@ export function isOriginAllowed(origin: string | null, allowed: string[]): boole
94
111
  }
95
112
 
96
113
  /**
97
- * Host-header allowlist. Disabled (always true) when `allowed` is undefined. A
98
- * missing Host header is a non-browser/loopback caller and is allowed; a
99
- * present Host must match by hostname. A DNS-rebinding page always sends its
100
- * own domain as Host, so it fails this check.
114
+ * Host-header allowlist. Disabled (always true) when `allowed` is undefined.
115
+ * Once enabled it is fail-closed: a real HTTP request always has Host, and
116
+ * accepting a missing one would create an avoidable bypass for unusual proxy
117
+ * paths. A DNS-rebinding page sends its own domain as Host, so it fails too.
101
118
  */
102
119
  export function isHostAllowed(host: string | null, allowed?: string[]): boolean {
103
120
  if (!allowed) return true;
104
- if (!host) return true; // non-browser caller — no Host header
121
+ if (!host) return false;
105
122
  return allowed.includes(hostnameOf(host));
106
123
  }
107
124
 
@@ -119,7 +136,7 @@ export function corsHeaders(origin: string | null, allowed: string[]): Record<st
119
136
  }
120
137
 
121
138
  function tokenOk(req: Request, token?: string): boolean {
122
- if (!token) return true;
139
+ if (!token) return false;
123
140
  const auth = req.headers.get('authorization') ?? '';
124
141
  const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
125
142
  return bearer === token || req.headers.get('x-agent-token') === token;
@@ -130,7 +147,7 @@ function tokenOk(req: Request, token?: string): boolean {
130
147
  * ProcessManager and policy, returns an async (req) => Response.
131
148
  */
132
149
  export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
133
- const { allowedOrigins, allowedHosts, token } = opts;
150
+ const { allowedOrigins, allowedHosts, token, requireTokenForAll = false } = opts;
134
151
 
135
152
  const json = (data: unknown, status: number, origin: string | null): Response =>
136
153
  new Response(JSON.stringify(data), {
@@ -149,9 +166,15 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
149
166
  signal: AbortSignal.timeout(1500),
150
167
  });
151
168
  if (res.ok) {
152
- const h = (await res.json()) as { ok?: boolean; version?: string };
153
- healthy = h.ok !== false;
154
- version = h.version;
169
+ const body: unknown = await res.json();
170
+ if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
171
+ const health = body as Record<string, unknown>;
172
+ // HTTP 2xx alone is not a Bunqueue health verdict: an unrelated
173
+ // process, proxy fallback, or malformed payload can also return it.
174
+ // Fail closed unless the semantic health flag is exactly true.
175
+ healthy = health.ok === true;
176
+ if (typeof health.version === 'string') version = health.version;
177
+ }
155
178
  }
156
179
  } catch {
157
180
  /* not up yet */
@@ -166,10 +189,6 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
166
189
  const method = req.method;
167
190
  const origin = req.headers.get('origin');
168
191
 
169
- if (method === 'OPTIONS') {
170
- return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
171
- }
172
-
173
192
  // DNS-rebinding defense: reject a same-origin request whose Host header is
174
193
  // an attacker domain rebound to loopback (no Origin header would be sent,
175
194
  // so the Origin gate below can't see it). No-op when allowedHosts is unset.
@@ -182,16 +201,20 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
182
201
  return json({ ok: false, error: 'Origin not allowed' }, 403, origin);
183
202
  }
184
203
 
185
- // The optional AGENT_TOKEN gates STATE-CHANGING requests only. Read-only
186
- // GETs (including the /db/* inspector, which can expose job payloads) are
187
- // protected by the Origin allowlist alone — a drive-by cross-origin page
188
- // can't read the response (no ACAO for its origin), which is the real
189
- // threat. Reads intentionally remain available without a token for local
190
- // dashboards that don't configure AGENT_TOKEN; when a token is configured,
191
- // the browser client does send it, but only POST/PUT require it.
192
- // Non-browser local callers on loopback are trusted for reads.
204
+ // Preflight never needs a bearer token, but Host and Origin are security
205
+ // boundaries in their own right and therefore fail closed before this 204.
206
+ if (method === 'OPTIONS') {
207
+ return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
208
+ }
209
+
210
+ // Preserve zero-config LOOPBACK use: there, reads stay open and a configured
211
+ // token gates mutations. A network-facing listener sets requireTokenForAll,
212
+ // because Origin alone cannot authenticate curl/no-Origin callers and the db
213
+ // inspector contains job payloads. Misconfiguration fails closed: enabling
214
+ // the all-route gate without a token makes every real route answer 401.
193
215
  const mutating = method === 'POST' || method === 'PUT';
194
- if (mutating && !tokenOk(req, token)) {
216
+ const authRequired = requireTokenForAll || (mutating && Boolean(token));
217
+ if (authRequired && !tokenOk(req, token)) {
195
218
  return json({ ok: false, error: 'Unauthorized' }, 401, origin);
196
219
  }
197
220
 
@@ -214,7 +237,7 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
214
237
 
215
238
  if (pathname === '/control/config' && method === 'GET') return json(mgr.getConfig(), 200, origin);
216
239
  if (pathname === '/control/config' && method === 'PUT') {
217
- const patch = (await req.json()) as Partial<ServerConfig>;
240
+ const patch = validateConfigPatch(await req.json());
218
241
  return json(mgr.setConfig(patch), 200, origin);
219
242
  }
220
243
 
@@ -235,12 +258,71 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
235
258
  const segs = pathname.slice('/db/tables/'.length).split('/');
236
259
  const table = decodeURIComponent(segs[0] ?? '');
237
260
  const sub = segs[1];
261
+ if (segs.length === 2 && sub === 'export') {
262
+ const sp = new URL(req.url).searchParams;
263
+ const allowedParams = new Set(['orderBy', 'dir', 'fcol', 'fop', 'fval']);
264
+ for (const key of sp.keys()) {
265
+ if (!allowedParams.has(key)) throw new Error(`Unknown database export option: ${key}`);
266
+ if (sp.getAll(key).length !== 1) {
267
+ throw new Error(`Duplicate database export option: ${key}`);
268
+ }
269
+ }
270
+
271
+ const orderByParam = sp.get('orderBy');
272
+ if (orderByParam === '') throw new Error('Database export orderBy must not be empty');
273
+ const dirParam = sp.get('dir');
274
+ if (dirParam !== null && dirParam !== 'asc' && dirParam !== 'desc') {
275
+ throw new Error('Database export dir must be "asc" or "desc"');
276
+ }
277
+ const fCol = sp.get('fcol');
278
+ const fOp = sp.get('fop');
279
+ const fVal = sp.get('fval');
280
+ const hasAnyFilterPart = fCol !== null || fOp !== null || fVal !== null;
281
+ let filter: DbFilter | undefined;
282
+ if (hasAnyFilterPart) {
283
+ if (!fCol || !fVal || (fOp !== 'contains' && fOp !== 'eq' && fOp !== 'ne')) {
284
+ throw new Error('Database export filter requires valid fcol, fop and fval values');
285
+ }
286
+ filter = { column: fCol, op: fOp, value: fVal };
287
+ }
288
+
289
+ const exported = await exportWithTimeout(
290
+ mgr.getConfig().dataPath,
291
+ table,
292
+ orderByParam ?? undefined,
293
+ dirParam === 'desc' ? 'desc' : 'asc',
294
+ filter,
295
+ req.signal
296
+ );
297
+ return new Response(exported.content, {
298
+ status: 200,
299
+ headers: {
300
+ 'Content-Type': 'text/csv; charset=utf-8',
301
+ 'Content-Length': String(exported.bytes),
302
+ // Keep Content-Length equal to the authoritative export-byte contract;
303
+ // intermediaries must not transparently recompress this body.
304
+ 'Cache-Control': 'no-store, no-transform',
305
+ 'X-Content-Type-Options': 'nosniff',
306
+ 'X-Bunqueue-Db-Export-Version': '1',
307
+ 'X-Bunqueue-Db-Export-Table': encodeURIComponent(exported.table),
308
+ 'X-Bunqueue-Db-Export-Rows': String(exported.rowCount),
309
+ 'X-Bunqueue-Db-Export-Bytes': String(exported.bytes),
310
+ 'X-Bunqueue-Db-Export-Cap': exported.cap ?? 'none',
311
+ 'Access-Control-Expose-Headers':
312
+ 'Content-Length, X-Bunqueue-Db-Export-Version, X-Bunqueue-Db-Export-Table, X-Bunqueue-Db-Export-Rows, X-Bunqueue-Db-Export-Bytes, X-Bunqueue-Db-Export-Cap',
313
+ ...corsHeaders(origin, allowedOrigins),
314
+ },
315
+ });
316
+ }
238
317
  if (segs.length === 2 && sub === 'schema') {
239
318
  return json({ ok: true, ...dbSchema(mgr.getConfig().dataPath, table) }, 200, origin);
240
319
  }
241
320
  if (segs.length === 2 && sub === 'cell') {
242
321
  const sp = new URL(req.url).searchParams;
243
- const rowid = Number(sp.get('rowid'));
322
+ // Keep rowid textual through the HTTP boundary. Number() rounded
323
+ // values above 2^53 before SQLite ever saw the bound parameter.
324
+ const rowid = sp.get('rowid');
325
+ if (rowid === null) throw new Error('rowid is required');
244
326
  const column = sp.get('column') ?? '';
245
327
  return json(
246
328
  { ok: true, ...dbCell(mgr.getConfig().dataPath, table, rowid, column) },
@@ -276,7 +358,14 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
276
358
  } catch (e) {
277
359
  // A missing database file is an expected pre-first-start condition — 404
278
360
  // so the UI can show "no database yet" distinctly from a real read error.
279
- const status = e instanceof MissingDbError ? 404 : 400;
361
+ const status =
362
+ e instanceof MissingDbError
363
+ ? 404
364
+ : e instanceof DbExportBusyError
365
+ ? 429
366
+ : e instanceof DbExportUnavailableError
367
+ ? 503
368
+ : 400;
280
369
  return json({ ok: false, error: (e as Error).message ?? String(e) }, status, origin);
281
370
  }
282
371
  };