bunqueue-dashboard 0.0.31 → 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 (107) hide show
  1. package/README.md +44 -15
  2. package/agent/db.ts +682 -68
  3. package/agent/dbQueryWorker.ts +41 -10
  4. package/agent/index.ts +4 -1
  5. package/agent/manager.ts +267 -41
  6. package/agent/server.ts +131 -36
  7. package/dist/.vite/manifest.json +215 -159
  8. package/dist/assets/AddJob-ytpBLTCJ.js +3 -0
  9. package/dist/assets/Alerts-C39RrGbQ.js +1 -0
  10. package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
  11. package/dist/assets/Benchmark-CM60tHbf.js +1 -0
  12. package/dist/assets/BulkAddJobs-BjL-xcQy.js +5 -0
  13. package/dist/assets/CopilotPanel-BGK1rw-V.js +44 -0
  14. package/dist/assets/Cron-s1e-jgOo.js +1 -0
  15. package/dist/assets/CronManager-BostU7BX.js +1 -0
  16. package/dist/assets/Database-ZluStWMz.js +3 -0
  17. package/dist/assets/Diagnostics-C0N5z5j-.js +1 -0
  18. package/dist/assets/Dlq-CtcLzs-b.js +1 -0
  19. package/dist/assets/DlqControl-B_Z8KKn5.js +1 -0
  20. package/dist/assets/DlqPro-B68en6AG.js +1 -0
  21. package/dist/assets/Flows-CD_bkCBM.js +1 -0
  22. package/dist/assets/JobInspector-DwRtyMz0.js +2 -0
  23. package/dist/assets/Jobs-BUtcBKKk.js +1 -0
  24. package/dist/assets/JobsPro-BK_xd4ji.js +1 -0
  25. package/dist/assets/Logs-CmVODYCI.js +1 -0
  26. package/dist/assets/LogsPro-CJ-Il3LP.js +1 -0
  27. package/dist/assets/McpServer-dMjyJKgT.js +22 -0
  28. package/dist/assets/Metrics-Dsbeplz-.js +1 -0
  29. package/dist/assets/MetricsPro-BqxurxrJ.js +1 -0
  30. package/dist/assets/Overview-DH9C2dsf.js +1 -0
  31. package/dist/assets/{Pagination-D4ki-pGz.js → Pagination-DpxiBnt8.js} +1 -1
  32. package/dist/assets/QueueActions-D_QGBaVm.js +1 -0
  33. package/dist/assets/QueueControl-V0DJ6qk6.js +1 -0
  34. package/dist/assets/QueueDetail-BgKAMJqf.js +1 -0
  35. package/dist/assets/QueueDetailPro-BkHYzGiq.js +1 -0
  36. package/dist/assets/Queues-2_v7DcNi.js +1 -0
  37. package/dist/assets/QueuesOverview-BySSkmB4.js +1 -0
  38. package/dist/assets/S3Backup-CPADzKQE.js +1 -0
  39. package/dist/assets/S3BackupPro-CSrQYIDL.js +2 -0
  40. package/dist/assets/ServerControl-C0tI1VDx.js +3 -0
  41. package/dist/assets/Settings-CSzoM5up.js +1 -0
  42. package/dist/assets/Usage-B4B7tmrc.js +1 -0
  43. package/dist/assets/UsagePro-CCBpgSDy.js +1 -0
  44. package/dist/assets/Webhooks-aJz-GisQ.js +1 -0
  45. package/dist/assets/Workers-Bg8xKjkg.js +1 -0
  46. package/dist/assets/WorkersPro-BOOCrXf8.js +1 -0
  47. package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
  48. package/dist/assets/{exportFile-tdUeW5m5.js → exportFile-C7xhE8Yh.js} +1 -1
  49. package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
  50. package/dist/assets/form-DsUq0w8s.js +1 -0
  51. package/dist/assets/index-BWoUj86M.css +1 -0
  52. package/dist/assets/index-Bn2R06qm.js +8 -0
  53. package/dist/assets/{install-DhTYJCUC.js → install-CUS8Kijb.js} +2 -2
  54. package/dist/assets/jobActions-LQD4KfCc.js +1 -0
  55. package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
  56. package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
  57. package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
  58. package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
  59. package/dist/assets/useThroughputSeries-tb26_lkb.js +1 -0
  60. package/dist/index.html +5 -4
  61. package/package.json +11 -3
  62. package/scripts/embedded.gen.ts +132 -126
  63. package/scripts/serve.ts +501 -108
  64. package/dist/assets/AddJob-CAhdg38n.js +0 -3
  65. package/dist/assets/Alerts-70bxf-PT.js +0 -1
  66. package/dist/assets/Benchmark-CFZ3WCvj.js +0 -1
  67. package/dist/assets/BulkAddJobs-BzgzpGg_.js +0 -5
  68. package/dist/assets/CopilotPanel-DZUGIf1q.js +0 -44
  69. package/dist/assets/Cron-ltjSoTjj.js +0 -1
  70. package/dist/assets/CronManager-BcFALDeT.js +0 -1
  71. package/dist/assets/Database-CV2yt5O2.js +0 -3
  72. package/dist/assets/Diagnostics-D-EK4GEi.js +0 -1
  73. package/dist/assets/Dlq-fZAKwQoC.js +0 -1
  74. package/dist/assets/DlqControl-eTrrQCrn.js +0 -1
  75. package/dist/assets/DlqPro-CF79339e.js +0 -1
  76. package/dist/assets/Flows-Ba8G7mcC.js +0 -1
  77. package/dist/assets/JobInspector--d_8ca9p.js +0 -2
  78. package/dist/assets/Jobs-DEqAc1A5.js +0 -1
  79. package/dist/assets/JobsPro-BcaokZIf.js +0 -1
  80. package/dist/assets/Logs-DBTukPIX.js +0 -1
  81. package/dist/assets/LogsPro-Wl_e6hZM.js +0 -1
  82. package/dist/assets/McpServer-CnWJsKw7.js +0 -22
  83. package/dist/assets/Metrics-DzeIhNL-.js +0 -1
  84. package/dist/assets/MetricsPro-O9eVJSH0.js +0 -1
  85. package/dist/assets/Overview-Bnu7rWqt.js +0 -1
  86. package/dist/assets/QueueActions-DHoSkWRz.js +0 -1
  87. package/dist/assets/QueueControl-DKqEXnkM.js +0 -1
  88. package/dist/assets/QueueDetail-DLRow04B.js +0 -1
  89. package/dist/assets/QueueDetailPro-BYmOVd4T.js +0 -1
  90. package/dist/assets/Queues-CoyjRFN7.js +0 -1
  91. package/dist/assets/QueuesOverview-BeWdJpKi.js +0 -1
  92. package/dist/assets/S3Backup-BaLFgl9d.js +0 -1
  93. package/dist/assets/S3BackupPro-DJB4oWgz.js +0 -1
  94. package/dist/assets/ServerControl-_0lVB33E.js +0 -3
  95. package/dist/assets/Settings-zKFJTtnq.js +0 -1
  96. package/dist/assets/Usage-CKGkvl0j.js +0 -1
  97. package/dist/assets/UsagePro-B-TJ6fp4.js +0 -1
  98. package/dist/assets/Webhooks-C3zmvY-_.js +0 -1
  99. package/dist/assets/Workers-jKhhXl3W.js +0 -1
  100. package/dist/assets/WorkersPro-mvY5VX8I.js +0 -1
  101. package/dist/assets/connectionStore-Ci04lJmR.js +0 -1
  102. package/dist/assets/form-DGinTEEb.js +0 -1
  103. package/dist/assets/index-BOcegTHa.js +0 -8
  104. package/dist/assets/index-DWV3tgrk.css +0 -1
  105. package/dist/assets/jobActions-CuOdMglt.js +0 -1
  106. package/dist/assets/promisePool-Daxkc1OB.js +0 -1
  107. package/dist/assets/useThroughputSeries-Chp5OLx3.js +0 -1
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'];
@@ -65,7 +82,13 @@ export function hostnameOf(host: string): string {
65
82
  const v6 = s.match(/^\[([^\]]+)\]/); // [::1]:6800 → ::1
66
83
  if (v6) return v6[1].toLowerCase();
67
84
  const i = s.indexOf(':');
68
- return (i === -1 ? s : s.slice(0, i)).toLowerCase();
85
+ if (i === -1) return s.toLowerCase();
86
+ // A bare (unbracketed) IPv6 literal has several colons and no port delimiter:
87
+ // splitting on the first one would both lock the real host out and allowlist a
88
+ // bogus single label ('2001:db8::5' → '2001'). Keep it whole — that also makes
89
+ // hostnameOf idempotent over its own bracket-stripped output.
90
+ if (s.indexOf(':') !== s.lastIndexOf(':')) return s.toLowerCase();
91
+ return s.slice(0, i).toLowerCase();
69
92
  }
70
93
 
71
94
  /**
@@ -88,14 +111,14 @@ export function isOriginAllowed(origin: string | null, allowed: string[]): boole
88
111
  }
89
112
 
90
113
  /**
91
- * Host-header allowlist. Disabled (always true) when `allowed` is undefined. A
92
- * missing Host header is a non-browser/loopback caller and is allowed; a
93
- * present Host must match by hostname. A DNS-rebinding page always sends its
94
- * 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.
95
118
  */
96
119
  export function isHostAllowed(host: string | null, allowed?: string[]): boolean {
97
120
  if (!allowed) return true;
98
- if (!host) return true; // non-browser caller — no Host header
121
+ if (!host) return false;
99
122
  return allowed.includes(hostnameOf(host));
100
123
  }
101
124
 
@@ -113,7 +136,7 @@ export function corsHeaders(origin: string | null, allowed: string[]): Record<st
113
136
  }
114
137
 
115
138
  function tokenOk(req: Request, token?: string): boolean {
116
- if (!token) return true;
139
+ if (!token) return false;
117
140
  const auth = req.headers.get('authorization') ?? '';
118
141
  const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
119
142
  return bearer === token || req.headers.get('x-agent-token') === token;
@@ -124,7 +147,7 @@ function tokenOk(req: Request, token?: string): boolean {
124
147
  * ProcessManager and policy, returns an async (req) => Response.
125
148
  */
126
149
  export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
127
- const { allowedOrigins, allowedHosts, token } = opts;
150
+ const { allowedOrigins, allowedHosts, token, requireTokenForAll = false } = opts;
128
151
 
129
152
  const json = (data: unknown, status: number, origin: string | null): Response =>
130
153
  new Response(JSON.stringify(data), {
@@ -143,9 +166,15 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
143
166
  signal: AbortSignal.timeout(1500),
144
167
  });
145
168
  if (res.ok) {
146
- const h = (await res.json()) as { ok?: boolean; version?: string };
147
- healthy = h.ok !== false;
148
- 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
+ }
149
178
  }
150
179
  } catch {
151
180
  /* not up yet */
@@ -160,10 +189,6 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
160
189
  const method = req.method;
161
190
  const origin = req.headers.get('origin');
162
191
 
163
- if (method === 'OPTIONS') {
164
- return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
165
- }
166
-
167
192
  // DNS-rebinding defense: reject a same-origin request whose Host header is
168
193
  // an attacker domain rebound to loopback (no Origin header would be sent,
169
194
  // so the Origin gate below can't see it). No-op when allowedHosts is unset.
@@ -176,16 +201,20 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
176
201
  return json({ ok: false, error: 'Origin not allowed' }, 403, origin);
177
202
  }
178
203
 
179
- // The optional AGENT_TOKEN gates STATE-CHANGING requests only. Read-only
180
- // GETs (including the /db/* inspector, which can expose job payloads) are
181
- // protected by the Origin allowlist alone — a drive-by cross-origin page
182
- // can't read the response (no ACAO for its origin), which is the real
183
- // threat. Reads intentionally remain available without a token for local
184
- // dashboards that don't configure AGENT_TOKEN; when a token is configured,
185
- // the browser client does send it, but only POST/PUT require it.
186
- // 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.
187
215
  const mutating = method === 'POST' || method === 'PUT';
188
- if (mutating && !tokenOk(req, token)) {
216
+ const authRequired = requireTokenForAll || (mutating && Boolean(token));
217
+ if (authRequired && !tokenOk(req, token)) {
189
218
  return json({ ok: false, error: 'Unauthorized' }, 401, origin);
190
219
  }
191
220
 
@@ -208,7 +237,7 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
208
237
 
209
238
  if (pathname === '/control/config' && method === 'GET') return json(mgr.getConfig(), 200, origin);
210
239
  if (pathname === '/control/config' && method === 'PUT') {
211
- const patch = (await req.json()) as Partial<ServerConfig>;
240
+ const patch = validateConfigPatch(await req.json());
212
241
  return json(mgr.setConfig(patch), 200, origin);
213
242
  }
214
243
 
@@ -229,12 +258,71 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
229
258
  const segs = pathname.slice('/db/tables/'.length).split('/');
230
259
  const table = decodeURIComponent(segs[0] ?? '');
231
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
+ }
232
317
  if (segs.length === 2 && sub === 'schema') {
233
318
  return json({ ok: true, ...dbSchema(mgr.getConfig().dataPath, table) }, 200, origin);
234
319
  }
235
320
  if (segs.length === 2 && sub === 'cell') {
236
321
  const sp = new URL(req.url).searchParams;
237
- 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');
238
326
  const column = sp.get('column') ?? '';
239
327
  return json(
240
328
  { ok: true, ...dbCell(mgr.getConfig().dataPath, table, rowid, column) },
@@ -270,7 +358,14 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
270
358
  } catch (e) {
271
359
  // A missing database file is an expected pre-first-start condition — 404
272
360
  // so the UI can show "no database yet" distinctly from a real read error.
273
- 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;
274
369
  return json({ ok: false, error: (e as Error).message ?? String(e) }, status, origin);
275
370
  }
276
371
  };