bunqueue-dashboard 0.0.35 → 0.0.36

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 (173) hide show
  1. package/README.md +18 -11
  2. package/agent/backup/processExecutor.ts +90 -0
  3. package/agent/backup/resultValidation.ts +85 -0
  4. package/agent/backup/routes.ts +160 -0
  5. package/agent/backup/runner.ts +91 -0
  6. package/agent/backup/standaloneWorker.ts +23 -0
  7. package/agent/backup/workerExecutor.ts +40 -0
  8. package/agent/backup/workerFactory.ts +21 -0
  9. package/agent/backup/workerProtocol.ts +10 -0
  10. package/agent/db/core.ts +141 -0
  11. package/agent/db/csvExport.ts +172 -0
  12. package/agent/db/exportTimeout.ts +190 -0
  13. package/agent/db/query.ts +82 -0
  14. package/agent/db/queryTimeout.ts +76 -0
  15. package/agent/db/schema.ts +79 -0
  16. package/agent/db/tables.ts +157 -0
  17. package/agent/db/types.ts +118 -0
  18. package/agent/db/workerFactory.ts +25 -0
  19. package/agent/db.ts +43 -1099
  20. package/agent/errorMessage.ts +9 -0
  21. package/agent/flow/routes.ts +250 -0
  22. package/agent/flow/service.ts +266 -0
  23. package/agent/flow/types.ts +34 -0
  24. package/agent/flow/validation.ts +230 -0
  25. package/agent/index.ts +5 -17
  26. package/agent/logger.ts +121 -17
  27. package/agent/managedTarget.ts +43 -0
  28. package/agent/manager/config.ts +84 -0
  29. package/agent/manager/logs.ts +83 -0
  30. package/agent/manager/process.ts +233 -0
  31. package/agent/manager/storage.ts +28 -0
  32. package/agent/manager/types.ts +38 -0
  33. package/agent/manager.ts +10 -490
  34. package/agent/queue/routes.ts +128 -0
  35. package/agent/queue/runtime.ts +119 -0
  36. package/agent/queue/types.ts +34 -0
  37. package/agent/queue/validation.ts +100 -0
  38. package/agent/server/controlRoutes.ts +153 -0
  39. package/agent/server/databaseRoutes.ts +165 -0
  40. package/agent/server/errors.ts +48 -0
  41. package/agent/server/handler.ts +146 -0
  42. package/agent/server/jsonBody.ts +109 -0
  43. package/agent/server/lifecycle.ts +72 -0
  44. package/agent/server/managedRuntime.ts +41 -0
  45. package/agent/server/policy.ts +60 -0
  46. package/agent/server/router.ts +150 -0
  47. package/agent/server/types.ts +39 -0
  48. package/agent/server/workflowReadRoutes.ts +80 -0
  49. package/agent/server.ts +16 -441
  50. package/agent/shutdown.ts +156 -0
  51. package/agent/workflow/codec.ts +78 -0
  52. package/agent/workflow/detail.ts +63 -0
  53. package/agent/workflow/list.ts +87 -0
  54. package/agent/workflow/runtime.ts +274 -0
  55. package/agent/workflow/runtimeConfig.ts +103 -0
  56. package/agent/workflow/runtimeRoutes.ts +162 -0
  57. package/agent/workflow/sqlite.ts +52 -0
  58. package/agent/workflow/stats.ts +79 -0
  59. package/agent/workflow/types.ts +81 -0
  60. package/agent/workflow/validation.ts +114 -0
  61. package/agent/workflows.ts +14 -489
  62. package/dist/.vite/manifest.json +595 -234
  63. package/dist/assets/AddJob-UUa2fSXE.js +3 -0
  64. package/dist/assets/Alerts-7oGYfQMb.js +1 -0
  65. package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
  66. package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
  67. package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
  68. package/dist/assets/Button-BPqpRZJD.js +1 -0
  69. package/dist/assets/Card-C7NMhqUt.js +1 -0
  70. package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
  71. package/dist/assets/Cron-DfWBhoBf.js +1 -0
  72. package/dist/assets/CronManager-DAK7RSOX.js +1 -0
  73. package/dist/assets/Database-DqL7giEj.js +3 -0
  74. package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
  75. package/dist/assets/Dlq-BHmA5PTI.js +1 -0
  76. package/dist/assets/DlqControl-DihxvP-i.js +1 -0
  77. package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
  78. package/dist/assets/Flows-B5N5Cil0.js +2 -0
  79. package/dist/assets/JobInspector-B40nFiGh.js +2 -0
  80. package/dist/assets/Jobs-Bla3IEef.js +1 -0
  81. package/dist/assets/JobsPro-CnczVx7x.js +1 -0
  82. package/dist/assets/Logs-T91Q3cH0.js +1 -0
  83. package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
  84. package/dist/assets/{McpServer-CbaU52l2.js → McpServer-BQBabwW-.js} +2 -2
  85. package/dist/assets/Metrics-DxT_RI2B.js +1 -0
  86. package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
  87. package/dist/assets/Overview-XN2ej8ph.js +1 -0
  88. package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
  89. package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
  90. package/dist/assets/QueueActions-C-Aferd6.js +1 -0
  91. package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
  92. package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
  93. package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
  94. package/dist/assets/Queues-COqPMoY9.js +1 -0
  95. package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
  96. package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
  97. package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
  98. package/dist/assets/ServerControl-D3XwHr72.js +3 -0
  99. package/dist/assets/Settings-BofKZoHn.js +1 -0
  100. package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
  101. package/dist/assets/Usage-DAR9E22v.js +1 -0
  102. package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
  103. package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
  104. package/dist/assets/Workers-BqUZMJVQ.js +1 -0
  105. package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
  106. package/dist/assets/Workflows-BEFMfHmo.js +1 -0
  107. package/dist/assets/api-CQ5Z7xR7.js +1 -0
  108. package/dist/assets/bq-BeBxa743.js +1 -0
  109. package/dist/assets/cn-zFEt5SO8.js +1 -0
  110. package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
  111. package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
  112. package/dist/assets/feedback-BnGE2oW2.js +1 -0
  113. package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
  114. package/dist/assets/format-Cg7VCIZW.js +1 -0
  115. package/dist/assets/icons-CIvFfU2b.js +1 -0
  116. package/dist/assets/index-Cui9IwWy.js +8 -0
  117. package/dist/assets/index-DOze4TKg.css +1 -0
  118. package/dist/assets/install-Cd9Is10G.js +3 -0
  119. package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
  120. package/dist/assets/responses-kPO4rSu5.js +1 -0
  121. package/dist/assets/toastStore-CS_9Xndn.js +1 -0
  122. package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
  123. package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
  124. package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
  125. package/dist/index.html +16 -5
  126. package/package.json +12 -3
  127. package/scripts/embedded.gen.ts +152 -126
  128. package/scripts/serve.ts +96 -484
  129. package/scripts/serveHandler.ts +217 -0
  130. package/scripts/servePolicy.ts +133 -0
  131. package/dist/assets/AddJob-CG28MSxU.js +0 -3
  132. package/dist/assets/Alerts-D9JojJrj.js +0 -1
  133. package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
  134. package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
  135. package/dist/assets/Cron-DoEHDHwy.js +0 -1
  136. package/dist/assets/CronManager-vulcesqJ.js +0 -1
  137. package/dist/assets/Database-DlB_t04Z.js +0 -3
  138. package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
  139. package/dist/assets/Dlq-DMKrsSMP.js +0 -1
  140. package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
  141. package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
  142. package/dist/assets/Flows-C3-9nXW4.js +0 -1
  143. package/dist/assets/JobInspector-DwIfABmI.js +0 -2
  144. package/dist/assets/Jobs-j8tTS3NC.js +0 -1
  145. package/dist/assets/JobsPro-CS__CJJl.js +0 -1
  146. package/dist/assets/Logs-BB-VDPA1.js +0 -1
  147. package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
  148. package/dist/assets/Metrics-ivcddPwT.js +0 -1
  149. package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
  150. package/dist/assets/Overview-D84aD2ph.js +0 -1
  151. package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
  152. package/dist/assets/QueueActions-DTbD6uek.js +0 -1
  153. package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
  154. package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
  155. package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
  156. package/dist/assets/Queues-CKMJqFHX.js +0 -1
  157. package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
  158. package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
  159. package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
  160. package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
  161. package/dist/assets/Settings-DkqmZUIS.js +0 -1
  162. package/dist/assets/Usage-Ba20p0xd.js +0 -1
  163. package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
  164. package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
  165. package/dist/assets/Workers-C1wPU6CS.js +0 -1
  166. package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
  167. package/dist/assets/Workflows-C8Qmkflf.js +0 -1
  168. package/dist/assets/cn-CMFYpumL.js +0 -1
  169. package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
  170. package/dist/assets/index-BCNzbhhD.css +0 -1
  171. package/dist/assets/index-CAtsVmyh.js +0 -8
  172. package/dist/assets/install-D0eRLf9F.js +0 -3
  173. package/dist/assets/useThroughputSeries-DjxGN8ob.js +0 -1
package/scripts/serve.ts CHANGED
@@ -1,513 +1,139 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Standalone bunqueue-dashboard executable (built with `bun build --compile`).
4
- *
5
- * One binary, three jobs:
6
- * 1. Serves the dashboard SPA from assets embedded at compile time.
7
- * 2. Proxies /api/* to the bunqueue server (BUNQUEUE_URL, default :6790) —
8
- * same-origin like the dev proxy, so no CORS and SSE streams through.
9
- * 3. Runs the control agent (start/stop/restart bunqueue) on 127.0.0.1:6800
10
- * with the same Origin-allowlist security as agent/index.ts — and ALSO
11
- * exposes it same-origin at /agent/* (the served index.html gets
12
- * `window.__BUNQUEUE_AGENT_URL__ = '/agent'` injected), so the prebuilt
13
- * SPA finds the agent regardless of AGENT_PORT and even from a remote
14
- * browser, where loopback :6800 would be unreachable.
15
- *
16
- * Control-plane exposure rule (the agent can spawn processes):
17
- * - Truly local loopback access (the default): /agent/* keeps zero-config
18
- * reads, and the direct loopback :6800 listener remains local-only.
19
- * - Non-loopback bind (BIND_ADDR=0.0.0.0): AGENT_TOKEN is mandatory and gates
20
- * EVERY agent route, including status/log/database reads. The public
21
- * listener also keeps a Host allowlist; put every public/LAN hostname or IP
22
- * in AGENT_ALLOWED_HOSTS (or AGENT_ALLOWED_ORIGINS) to admit it. There is no
23
- * unauthenticated remote-control opt-in.
24
- * - A loopback bind reached through a reverse proxy follows the same remote
25
- * rule. TRUST_PROXY, non-loopback allowlists/origins, forwarding headers or
26
- * a non-loopback request Host all switch the bridge to all-route token auth.
27
- * - The same signals gate the administrative /api proxy independently with
28
- * BUNQUEUE_TOKEN; without it, remote/proxied /api access is disabled.
29
- *
30
- * Env: PORT (dashboard, default 8080) · BIND_ADDR (default 127.0.0.1) ·
31
- * BUNQUEUE_URL · AGENT_PORT · AGENT_ALLOWED_ORIGINS · AGENT_ALLOWED_HOSTS ·
32
- * AGENT_TOKEN · BUNQUEUE_TOKEN · TRUST_PROXY · BUNQUEUE_START_CMD ·
33
- * HTTP_PORT · TCP_PORT · BUNQUEUE_DATA_PATH · LOG_LEVEL (pino level, info)
3
+ * Standalone dashboard: serves embedded assets, proxies /api, and exposes the
4
+ * local control agent through a same-origin /agent bridge.
34
5
  */
35
- import { timingSafeEqual } from 'node:crypto';
36
6
  import { logger } from '../agent/logger';
7
+ import { BunqueueBackupRunner } from '../agent/backup/runner';
8
+ import { setBackupWorkerUrl } from '../agent/backup/workerFactory';
37
9
  import { setQueryWorkerUrl } from '../agent/db';
10
+ import { QueueOperationsRuntime } from '../agent/queue/runtime';
11
+ import { AgentLifecycleGate, createFetchHandler, resolveAllowedOrigins } from '../agent/server';
12
+ import { installAgentShutdown } from '../agent/shutdown';
13
+ import { WorkflowRuntime } from '../agent/workflow/runtime';
14
+ import { createServeHandler } from './serveHandler';
38
15
  import {
39
- createFetchHandler,
40
- hostnameOf,
41
- isHostAllowed,
42
- isOriginAllowed,
43
- resolveAllowedHosts,
44
- resolveAllowedOrigins,
45
- } from '../agent/server';
46
-
47
- export const RESPONSE_SECURITY_HEADERS = {
48
- 'Content-Security-Policy': "frame-ancestors 'none'; object-src 'none'; base-uri 'self'",
49
- 'X-Frame-Options': 'DENY',
50
- 'X-Content-Type-Options': 'nosniff',
51
- 'Referrer-Policy': 'no-referrer',
52
- } as const;
53
-
54
- export function withSecurityHeaders(response: Response): Response {
55
- const headers = new Headers(response.headers);
56
- for (const [name, value] of Object.entries(RESPONSE_SECURITY_HEADERS)) headers.set(name, value);
57
- return new Response(response.body, {
58
- status: response.status,
59
- statusText: response.statusText,
60
- headers,
61
- });
62
- }
63
-
64
- /** True loopback names/addresses. 0.0.0.0 is a wildcard, never local-only. */
65
- export function isLoopbackHost(host: string): boolean {
66
- const value = hostnameOf(host);
67
- const octets = value.split('.');
68
- const ipv4Loopback =
69
- octets.length === 4 &&
70
- octets[0] === '127' &&
71
- octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255);
72
- return (
73
- value === 'localhost' ||
74
- value === '::1' ||
75
- value === '0:0:0:0:0:0:0:1' ||
76
- ipv4Loopback
77
- );
78
- }
79
-
80
- /** Bind addresses that mean "only this machine can reach the listener". */
81
- export function isLoopbackBind(host: string): boolean {
82
- return isLoopbackHost(host);
83
- }
84
-
85
- function configuredValues(value: string | undefined): string[] {
86
- return (value ?? '')
87
- .split(',')
88
- .map((entry) => entry.trim())
89
- .filter(Boolean);
90
- }
91
-
92
- /**
93
- * A loopback socket can still be public through a reverse proxy. These are
94
- * operator-controlled signals that the same-origin /agent bridge is intended
95
- * to be reachable beyond the machine, so its reads and writes must both use
96
- * the bearer-token policy.
97
- */
98
- export function remoteBridgeRequiresToken(
99
- loopbackBind: boolean,
100
- env: Record<string, string | undefined> = process.env
101
- ): boolean {
102
- if (!loopbackBind || env.TRUST_PROXY === '1') return true;
103
- const configured = [
104
- ...configuredValues(env.AGENT_ALLOWED_HOSTS),
105
- ...configuredValues(env.AGENT_ALLOWED_ORIGINS),
106
- ];
107
- return configured.some((value) => !isLoopbackHost(value));
108
- }
109
-
110
- const PROXY_HINT_HEADERS = [
111
- 'forwarded',
112
- 'x-forwarded-for',
113
- 'x-forwarded-host',
114
- 'x-forwarded-proto',
115
- ] as const;
116
-
117
- /**
118
- * Per-request backstop for a public Host/Origin or a proxy that adds forwarding
119
- * metadata. Forwarded-header presence is only used to demand stronger auth;
120
- * values remain untrusted unless TRUST_PROXY enables the Origin comparison.
121
- */
122
- export function isRemoteBridgeRequest(req: Request, remotePolicy = false): boolean {
123
- if (remotePolicy || PROXY_HINT_HEADERS.some((name) => req.headers.has(name))) return true;
124
- const origin = req.headers.get('origin');
125
- if (origin && !isLoopbackHost(origin)) return true;
126
- const host = req.headers.get('host') ?? new URL(req.url).hostname;
127
- return !isLoopbackHost(host);
128
- }
129
-
130
- /** Exact, timing-safe bearer comparison for the standalone admin-API proxy. */
131
- export function apiTokenOk(req: Request, token: string | undefined): boolean {
132
- if (!token) return false;
133
- const authorization = req.headers.get('authorization');
134
- if (!authorization?.startsWith('Bearer ')) return false;
135
- const supplied = Buffer.from(authorization.slice('Bearer '.length));
136
- const expected = Buffer.from(token);
137
- return supplied.length === expected.length && timingSafeEqual(supplied, expected);
138
- }
139
-
140
- /** Host policy shared by main() and deployment regression tests. */
141
- export function resolveServeAllowedHosts(
142
- bindHost: string,
143
- allowedOrigins: string[],
144
- env: Record<string, string | undefined> = process.env
145
- ): string[] {
146
- const originHosts = allowedOrigins
147
- .map((origin) => {
148
- try {
149
- return new URL(origin).hostname;
150
- } catch {
151
- return '';
152
- }
153
- })
154
- .filter(Boolean);
155
- const concreteBindHosts = bindHost === '0.0.0.0' || bindHost === '::' ? [] : [bindHost];
156
- return resolveAllowedHosts(env, [...originHosts, ...concreteBindHosts]);
157
- }
158
-
159
- /**
160
- * May the process-spawning bridge be enabled? Truly local access stays
161
- * zero-config; any remote deployment policy requires a real token. This
162
- * intentionally ignores the former unauthenticated escape hatch.
163
- */
164
- export function remoteControlEnabled(
165
- remotePolicy: boolean,
166
- env: Record<string, string | undefined> = process.env
167
- ): boolean {
168
- if (!remotePolicy) return true;
169
- return Boolean(env.AGENT_TOKEN?.trim());
170
- }
171
-
172
- /**
173
- * Build the inner agent URL from the bridged path. `new URL(rest, base)` throws
174
- * on a path like `//x:y/z` (it reads `//` as an authority); assigning .pathname
175
- * never reinterprets it, so a hostile path can't crash the handler.
176
- */
177
- export function agentSubUrl(pathname: string, search: string): URL {
178
- const rest = pathname.slice('/agent'.length) || '/';
179
- const sub = new URL('http://agent.internal');
180
- sub.pathname = rest.startsWith('/') ? rest : `/${rest}`;
181
- sub.search = search;
182
- return sub;
183
- }
184
-
185
- export interface ServeHandlerOptions {
186
- /** bunqueue base URL the /api prefix proxies to (no trailing slash). */
187
- api: string;
188
- /** index.html with the agent base already injected. */
189
- indexHtml: string;
190
- /** embedded dist assets: request path → on-disk path. */
191
- assets: Record<string, string>;
192
- /** Local policy: zero-config reads, configured token on mutations. */
193
- agentHandle: (req: Request) => Response | Promise<Response>;
194
- /** Remote/proxied policy: configured token on every agent route. */
195
- remoteAgentHandle: (req: Request) => Response | Promise<Response>;
196
- allowedOrigins: string[];
197
- allowedHosts?: string[];
198
- /** false → /agent/* is refused under a remote policy without AGENT_TOKEN. */
199
- agentBridge: boolean;
200
- /** Whether the remote bridge has a non-empty AGENT_TOKEN to enforce. */
201
- agentTokenConfigured: boolean;
202
- /**
203
- * Bearer required by /api on every remote/proxied request. It is deliberately
204
- * independent of AGENT_TOKEN because the two credentials protect different
205
- * principals and can be rotated independently.
206
- */
207
- apiToken?: string;
208
- /** Deployment-level signal that even a loopback bind is externally proxied. */
209
- remoteBridgePolicy?: boolean;
210
- /**
211
- * TRUST_PROXY=1 — a reverse proxy in front of us owns X-Forwarded-Host, so it
212
- * may be believed. Off by default: the header is otherwise client-settable
213
- * and would let any direct caller declare itself same-origin.
214
- */
215
- trustProxy?: boolean;
216
- }
217
-
218
- /**
219
- * The dashboard listener: static assets + /api proxy + same-origin /agent
220
- * bridge. Factored out of the Bun.serve call so the policy is unit-testable.
221
- */
222
- export function createServeHandler(opts: ServeHandlerOptions) {
223
- const {
224
- api,
225
- indexHtml,
226
- assets,
227
- agentHandle,
228
- remoteAgentHandle,
229
- allowedOrigins,
230
- allowedHosts,
231
- agentBridge,
232
- agentTokenConfigured,
233
- apiToken,
234
- remoteBridgePolicy = false,
235
- trustProxy = false,
236
- } = opts;
237
- const secure = withSecurityHeaders;
238
- const indexResponse = () =>
239
- new Response(indexHtml, { headers: { 'content-type': 'text/html; charset=utf-8' } });
240
-
241
- return async function handle(req: Request): Promise<Response> {
242
- const url = new URL(req.url);
243
-
244
- // DNS-rebinding defense across every route (/api proxy, /agent, assets):
245
- // a rebound page sends its own domain as Host. Main configures this on both
246
- // loopback and network binds; tests/custom embedders may omit the policy.
247
- if (!isHostAllowed(req.headers.get('host'), allowedHosts)) {
248
- return secure(new Response('Host not allowed', { status: 403 }));
249
- }
250
-
251
- // A request whose Origin is the origin this listener serves from is by
252
- // definition not the cross-site case the allowlist exists for. It is what a
253
- // LAN browser sends (http://<lan-ip>:PORT), which no static allowlist can
254
- // predict — treat it as allowed everywhere below.
255
- //
256
- // Compared by HOST, not by full origin: this listener only ever speaks
257
- // plain http, so behind a TLS-terminating reverse proxy the browser sends
258
- // `https://dash.example.com` while req.url reads `http://dash.example.com`.
259
- // A full-origin compare would 403 every mutation on a proxied deployment
260
- // while read-only GETs (which carry no Origin) kept working — a dashboard
261
- // that looks healthy and fails on click. The host is the part that
262
- // identifies a cross-site page (its Origin carries its OWN host), so it is
263
- // the part worth comparing. x-forwarded-host is honoured for proxies that
264
- // rewrite Host.
265
- const origin = req.headers.get('origin');
266
- let originHost = '';
267
- try {
268
- originHost = origin ? new URL(origin).host.toLowerCase() : '';
269
- } catch {
270
- originHost = ''; // unparseable Origin — treat as cross-site
271
- }
272
- // X-Forwarded-Host is attacker-controlled unless something in front of us
273
- // is guaranteed to overwrite it: a direct caller can send
274
- // `Origin: https://evil.example` + `x-forwarded-host: evil.example` and
275
- // declare ITSELF same-origin. So it is consulted only when the operator
276
- // sets TRUST_PROXY=1, which asserts a reverse proxy owns that header.
277
- // Without it, a proxy that preserves Host still works (the Host compare
278
- // below covers it); only a Host-REWRITING proxy needs the opt-in.
279
- const fwd = trustProxy ? req.headers.get('x-forwarded-host')?.split(',') : undefined;
280
- const forwardedHost = fwd?.[fwd.length - 1]?.trim().toLowerCase();
281
- const sameOrigin =
282
- originHost !== '' &&
283
- (originHost === url.host.toLowerCase() || originHost === forwardedHost);
284
-
285
- // Same-origin bridge to the control agent: strip the /agent prefix and
286
- // hand the request to the in-process agent handler (no loopback hop).
287
- // The selected agent handler still applies Host, Origin and token policy;
288
- // this bridge changes reachability, never authorization.
289
- if (url.pathname === '/agent' || url.pathname.startsWith('/agent/')) {
290
- const remoteRequest = isRemoteBridgeRequest(req, remoteBridgePolicy);
291
- if (!agentBridge || (remoteRequest && !agentTokenConfigured)) {
292
- return secure(
293
- Response.json(
294
- {
295
- ok: false,
296
- error:
297
- 'Control agent disabled for remote or proxied access. Set AGENT_TOKEN to expose it.',
298
- },
299
- { status: 403 }
300
- )
301
- );
302
- }
303
- const sub = agentSubUrl(url.pathname, url.search);
304
- const headers = new Headers(req.headers);
305
- // Drop a same-origin Origin the agent's static allowlist cannot know
306
- // (a LAN IP / hostname alias). Loopback origins are already allowlisted,
307
- // so they are forwarded untouched and CORS behaviour is unchanged.
308
- if (sameOrigin && origin && !isOriginAllowed(origin, allowedOrigins)) headers.delete('origin');
309
- const handleAgent = remoteRequest ? remoteAgentHandle : agentHandle;
310
- return secure(
311
- await handleAgent(
312
- new Request(sub.href, {
313
- method: req.method,
314
- headers,
315
- body: req.body,
316
- signal: req.signal,
317
- })
318
- )
319
- );
320
- }
321
-
322
- // Same-origin proxy to the bunqueue server (mirrors the Vite dev proxy).
323
- if (url.pathname === '/api' || url.pathname.startsWith('/api/')) {
324
- // The proxy talks to bunqueue's admin API, so it needs the same drive-by
325
- // CSRF gate as the agent: a cross-site page must not reach it.
326
- if (!sameOrigin && !isOriginAllowed(origin, allowedOrigins)) {
327
- return secure(
328
- Response.json({ ok: false, error: 'Origin not allowed' }, { status: 403 })
329
- );
330
- }
331
- // Host/Origin checks stop browser CSRF and DNS rebinding; they do not
332
- // authenticate curl, LAN peers or callers whose upstream has AUTH_TOKENS
333
- // disabled. Fail closed for every public/proxied request at this boundary.
334
- if (isRemoteBridgeRequest(req, remoteBridgePolicy)) {
335
- if (!apiToken) {
336
- return secure(
337
- Response.json(
338
- {
339
- ok: false,
340
- error:
341
- 'Admin API proxy disabled for remote or proxied access. Set BUNQUEUE_TOKEN to expose it.',
342
- },
343
- { status: 403 }
344
- )
345
- );
346
- }
347
- if (!apiTokenOk(req, apiToken)) {
348
- return secure(
349
- Response.json(
350
- { ok: false, error: 'A valid BUNQUEUE_TOKEN bearer token is required.' },
351
- { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } }
352
- )
353
- );
354
- }
355
- }
356
- const target = api + (url.pathname.slice(4) || '/') + url.search;
357
- let res: Response;
358
- try {
359
- res = await fetch(target, {
360
- method: req.method,
361
- headers: req.headers,
362
- body: req.body,
363
- redirect: 'manual',
364
- signal: req.signal,
365
- });
366
- } catch (err) {
367
- // bunqueue not running / wrong BUNQUEUE_URL: answer in the shape the
368
- // dashboard parses instead of letting Bun render an HTML error page.
369
- return secure(
370
- Response.json(
371
- { ok: false, error: `bunqueue unreachable at ${api}: ${(err as Error).message}` },
372
- { status: 502 }
373
- )
374
- );
375
- }
376
- // Bun's fetch advertises accept-encoding upstream and transparently
377
- // DECOMPRESSES the body, but leaves the upstream headers intact. If the
378
- // bunqueue server sits behind any gzip-compressing proxy, forwarding
379
- // those headers labels plaintext as gzip and the browser fails to decode
380
- // every /api response (ERR_CONTENT_DECODING_FAILED) — strip them.
381
- const headers = new Headers(res.headers);
382
- headers.delete('content-encoding');
383
- headers.delete('content-length');
384
- headers.delete('transfer-encoding');
385
- return secure(
386
- new Response(res.body, { status: res.status, statusText: res.statusText, headers })
387
- );
388
- }
389
-
390
- // Embedded static assets with SPA history fallback. A missing fingerprinted
391
- // /assets/* file must 404 (as docker/Caddyfile does) — falling back to
392
- // index.html would feed HTML to a stale chunk import() and mask the miss.
393
- // index.html (direct or via fallback) is served from the injected copy.
394
- const key = url.pathname === '/' ? '/index.html' : url.pathname;
395
- if (key === '/index.html') return secure(indexResponse());
396
- const asset = assets[key];
397
- if (asset) return secure(new Response(Bun.file(asset)));
398
- if (url.pathname.startsWith('/assets/')) {
399
- return secure(new Response('Not found', { status: 404 }));
400
- }
401
- return secure(indexResponse());
402
- };
403
- }
16
+ isLoopbackBind,
17
+ remoteBridgeRequiresToken,
18
+ remoteControlEnabled,
19
+ resolveServeAllowedHosts,
20
+ withSecurityHeaders,
21
+ } from './servePolicy';
22
+
23
+ export { createServeHandler } from './serveHandler';
24
+ export {
25
+ agentSubUrl,
26
+ apiTokenOk,
27
+ isLoopbackBind,
28
+ isLoopbackHost,
29
+ isRemoteBridgeRequest,
30
+ RESPONSE_SECURITY_HEADERS,
31
+ remoteBridgeRequiresToken,
32
+ remoteControlEnabled,
33
+ resolveServeAllowedHosts,
34
+ type ServeHandlerOptions,
35
+ withSecurityHeaders,
36
+ } from './servePolicy';
404
37
 
405
38
  async function main(): Promise<void> {
406
- // Bun emits secondary TypeScript entrypoints under /$bunfs/root as JavaScript.
407
- // Point db.ts at that embedded path in a compiled executable; when this npm bin
408
- // runs from source, use the real TypeScript module beside agent/db.ts.
39
+ // Compiled Bun entrypoints load the query worker from the embedded bundle.
409
40
  const compiled = import.meta.url.includes('/$bunfs/');
410
41
  setQueryWorkerUrl(
411
42
  compiled
412
43
  ? new URL('/$bunfs/root/agent/dbQueryWorker.js', 'file:///').href
413
44
  : new URL('../agent/dbQueryWorker.ts', import.meta.url).href
414
45
  );
46
+ setBackupWorkerUrl(
47
+ compiled
48
+ ? new URL('/$bunfs/root/agent/backup/standaloneWorker.js', 'file:///').href
49
+ : null
50
+ );
415
51
 
416
- const PORT = Number(process.env.PORT) || 8080;
417
- // Bind loopback by default: the /api proxy forwards to bunqueue's admin API, so
418
- // listening on all interfaces would expose it to the whole network. Set
419
- // BIND_ADDR=0.0.0.0 for direct LAN access (e.g. no reverse proxy in front).
420
- const HOST = process.env.BIND_ADDR || '127.0.0.1';
421
- const API = (process.env.BUNQUEUE_URL || 'http://localhost:6790').replace(/\/$/, '');
422
- const AGENT_PORT = Number(process.env.AGENT_PORT) || 6800;
423
-
424
- // Control agent — loopback only, allowlisted CORS; the origins this binary
425
- // serves the dashboard from are allowed automatically.
52
+ const port = Number(process.env.PORT) || 8080;
53
+ const host = process.env.BIND_ADDR || '127.0.0.1';
54
+ const api = (process.env.BUNQUEUE_URL || 'http://localhost:6790').replace(/\/$/, '');
55
+ const agentPort = Number(process.env.AGENT_PORT) || 6800;
426
56
  const allowedOrigins = Array.from(
427
57
  new Set([
428
58
  ...resolveAllowedOrigins(),
429
- `http://localhost:${PORT}`,
430
- `http://127.0.0.1:${PORT}`,
59
+ `http://localhost:${port}`,
60
+ `http://127.0.0.1:${port}`,
431
61
  ])
432
62
  );
433
-
434
- // DNS-rebinding defense (see agent/server.ts). Enforced on the loopback :6800
435
- // listener AND on the dashboard listener for every bind. For a concrete bind
436
- // address, admit that address automatically. A wildcard bind cannot reveal
437
- // which LAN/public address clients use, so the operator must list those names
438
- // or IPs in AGENT_ALLOWED_HOSTS (origins in AGENT_ALLOWED_ORIGINS also count).
439
- const loopbackBind = isLoopbackBind(HOST);
440
- const remoteBridgePolicy = remoteBridgeRequiresToken(loopbackBind, process.env);
441
- const allowedHosts = resolveServeAllowedHosts(HOST, allowedOrigins, process.env);
442
-
443
- // Imported here rather than at module scope: this file's exported policy
444
- // helpers are unit-tested, and a top-level import would pull the whole
445
- // ProcessManager into every test that touches them — which, besides being
446
- // wasteful, makes Bun attribute a barely-executed copy of agent/manager.ts to
447
- // the coverage report and hides that module's real coverage.
448
- const { ProcessManager } = await import('../agent/manager');
449
- const mgr = new ProcessManager();
63
+ const remoteBridgePolicy = remoteBridgeRequiresToken(isLoopbackBind(host), process.env);
64
+ const allowedHosts = resolveServeAllowedHosts(host, allowedOrigins, process.env);
450
65
  const token = process.env.AGENT_TOKEN?.trim() || undefined;
451
66
  const apiToken = process.env.BUNQUEUE_TOKEN?.trim() || undefined;
452
- // Two handlers, same manager: the loopback :6800 listener keeps the Host gate
453
- // unconditionally (it is always reached as localhost/127.0.0.1), while the
454
- // bridged one sees the dashboard client's Host and follows `allowedHosts`.
455
- const localAgentHandle = createFetchHandler(mgr, {
456
- allowedOrigins,
457
- allowedHosts,
458
- token,
459
- });
460
- const remoteAgentHandle = createFetchHandler(mgr, {
461
- allowedOrigins,
462
- allowedHosts,
463
- token,
464
- requireTokenForAll: true,
465
- });
466
- // `error` is a backstop: without it an unexpected throw renders Bun's HTML
467
- // error page (with a stack trace) to the client instead of a plain 500.
468
- const onError = () => withSecurityHeaders(new Response('Internal error', { status: 500 }));
469
- Bun.serve({
470
- port: AGENT_PORT,
471
- hostname: '127.0.0.1',
472
- fetch: localAgentHandle,
473
- error: onError,
474
- });
475
-
476
67
  const agentBridge = remoteControlEnabled(remoteBridgePolicy, process.env);
477
68
  if (!agentBridge) {
478
69
  logger.warn(
479
- { bind: HOST, remoteBridgePolicy },
70
+ { bind: host, remoteBridgePolicy },
480
71
  'remote or proxied bridge policy without AGENT_TOKEN — /agent is disabled (403)'
481
72
  );
482
73
  }
483
74
  if (remoteBridgePolicy && !apiToken) {
484
75
  logger.warn(
485
- { apiProxy: API },
76
+ { apiProxy: api },
486
77
  'remote or proxied bridge policy without BUNQUEUE_TOKEN — /api is disabled (403)'
487
78
  );
488
79
  } else if (remoteBridgePolicy) {
489
80
  logger.info(
490
- { apiProxy: API },
81
+ { apiProxy: api },
491
82
  'remote or proxied /api requires the configured BUNQUEUE_TOKEN bearer'
492
83
  );
493
84
  }
494
85
 
495
- // index.html with the runtime agent base injected, prepared once at startup.
496
- // The SPA's lib/bq.ts reads window.__BUNQUEUE_AGENT_URL__ before its baked-in
497
- // default, so Server Control works whatever AGENT_PORT is — the /agent/*
498
- // route below forwards to the in-process agent handler.
86
+ // Complete every awaited startup step before installing handlers/listeners.
499
87
  const { ASSETS } = await import('./embedded.gen');
500
88
  const indexHtml = (await Bun.file(ASSETS['/index.html']).text()).replace(
501
89
  '</head>',
502
90
  "<script>window.__BUNQUEUE_AGENT_URL__='/agent'</script></head>"
503
91
  );
504
92
 
505
- // Dashboard + /api proxy + same-origin /agent proxy.
506
- Bun.serve({
507
- port: PORT,
508
- hostname: HOST,
93
+ // Keep ProcessManager out of policy-only test imports.
94
+ const { ProcessManager } = await import('../agent/manager');
95
+ const mgr = new ProcessManager();
96
+ const workflowRuntime = new WorkflowRuntime();
97
+ const backupRunner = new BunqueueBackupRunner();
98
+ const queueRuntime = new QueueOperationsRuntime();
99
+ const lifecycle = new AgentLifecycleGate();
100
+ const localAgentHandle = createFetchHandler(
101
+ mgr,
102
+ { allowedOrigins, allowedHosts, token },
103
+ workflowRuntime,
104
+ backupRunner,
105
+ queueRuntime,
106
+ lifecycle
107
+ );
108
+ const remoteAgentHandle = createFetchHandler(
109
+ mgr,
110
+ { allowedOrigins, allowedHosts, token, requireTokenForAll: true },
111
+ workflowRuntime,
112
+ backupRunner,
113
+ queueRuntime,
114
+ lifecycle
115
+ );
116
+ const proxyShutdown = new AbortController();
117
+ const stopAccepting: Array<() => unknown | Promise<unknown>> = [
118
+ () => proxyShutdown.abort(new Error('Dashboard is shutting down')),
119
+ ];
120
+ // Register before opening listeners so no startup window uses default signal handling.
121
+ installAgentShutdown(localAgentHandle, { stopAccepting });
122
+ const onError = () => withSecurityHeaders(new Response('Internal error', { status: 500 }));
123
+
124
+ const agentServer = Bun.serve({
125
+ port: agentPort,
126
+ hostname: '127.0.0.1',
127
+ fetch: localAgentHandle,
128
+ error: onError,
129
+ });
130
+ stopAccepting.push(() => agentServer.stop());
131
+
132
+ const dashboardServer = Bun.serve({
133
+ port,
134
+ hostname: host,
509
135
  fetch: createServeHandler({
510
- api: API,
136
+ api,
511
137
  indexHtml,
512
138
  assets: ASSETS,
513
139
  agentHandle: localAgentHandle,
@@ -517,33 +143,19 @@ async function main(): Promise<void> {
517
143
  agentBridge,
518
144
  agentTokenConfigured: Boolean(token),
519
145
  apiToken,
146
+ apiShutdownSignal: proxyShutdown.signal,
520
147
  remoteBridgePolicy,
521
148
  trustProxy: process.env.TRUST_PROXY === '1',
522
149
  }),
523
150
  error: onError,
524
151
  });
525
-
526
- // Stop the managed server before exiting — without this, Ctrl-C / SIGTERM on
527
- // the binary orphans the spawned bunqueue child (it reparents to PID 1 and
528
- // keeps holding the ports and the SQLite db, so the next start fails).
529
- let shuttingDown = false;
530
- const shutdown = (signal: string) => {
531
- if (shuttingDown) return;
532
- shuttingDown = true;
533
- logger.info({ signal }, 'signal received, stopping managed server');
534
- // shutdown(), not stop(): it latches, so a restart() already in flight
535
- // can't spawn a fresh child after we've stopped the old one (mirrors
536
- // agent/index.ts).
537
- void mgr.shutdown().finally(() => process.exit(0));
538
- };
539
- process.on('SIGINT', () => shutdown('SIGINT'));
540
- process.on('SIGTERM', () => shutdown('SIGTERM'));
152
+ stopAccepting.push(() => dashboardServer.stop());
541
153
 
542
154
  logger.info(
543
155
  {
544
- dashboard: `http://${HOST}:${PORT}`,
545
- apiProxy: API,
546
- agent: `http://127.0.0.1:${AGENT_PORT}/control`,
156
+ dashboard: `http://${host}:${port}`,
157
+ apiProxy: api,
158
+ agent: `http://127.0.0.1:${agentPort}/control`,
547
159
  agentBridge,
548
160
  remoteBridgePolicy,
549
161
  allowedHosts,