bunqueue-dashboard 0.0.35 → 0.0.37

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 (176) hide show
  1. package/README.md +36 -16
  2. package/agent/backup/processExecutor.ts +90 -0
  3. package/agent/backup/resultValidation.ts +85 -0
  4. package/agent/backup/routes.ts +161 -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 +251 -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 +22 -21
  26. package/agent/logger.ts +121 -17
  27. package/agent/managedTarget.ts +82 -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 +135 -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 +185 -0
  39. package/agent/server/controlTarget.ts +116 -0
  40. package/agent/server/databaseRoutes.ts +165 -0
  41. package/agent/server/errors.ts +49 -0
  42. package/agent/server/handler.ts +155 -0
  43. package/agent/server/jsonBody.ts +109 -0
  44. package/agent/server/lifecycle.ts +72 -0
  45. package/agent/server/managedRuntime.ts +41 -0
  46. package/agent/server/policy.ts +60 -0
  47. package/agent/server/router.ts +161 -0
  48. package/agent/server/types.ts +46 -0
  49. package/agent/server/workflowReadRoutes.ts +80 -0
  50. package/agent/server.ts +26 -441
  51. package/agent/shutdown.ts +156 -0
  52. package/agent/workflow/codec.ts +78 -0
  53. package/agent/workflow/detail.ts +63 -0
  54. package/agent/workflow/list.ts +87 -0
  55. package/agent/workflow/runtime.ts +274 -0
  56. package/agent/workflow/runtimeConfig.ts +103 -0
  57. package/agent/workflow/runtimeRoutes.ts +163 -0
  58. package/agent/workflow/sqlite.ts +52 -0
  59. package/agent/workflow/stats.ts +79 -0
  60. package/agent/workflow/types.ts +81 -0
  61. package/agent/workflow/validation.ts +114 -0
  62. package/agent/workflows.ts +14 -489
  63. package/dist/.vite/manifest.json +611 -250
  64. package/dist/assets/AddJob-CWkkVHmf.js +3 -0
  65. package/dist/assets/Alerts-Gh1pVbqj.js +1 -0
  66. package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
  67. package/dist/assets/Benchmark-Dir2A1CQ.js +1 -0
  68. package/dist/assets/BulkAddJobs-Di_IgRSH.js +5 -0
  69. package/dist/assets/Button-BPqpRZJD.js +1 -0
  70. package/dist/assets/Card-C7NMhqUt.js +1 -0
  71. package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-Db5nYpo1.js} +3 -3
  72. package/dist/assets/Cron-BRgWcBGk.js +1 -0
  73. package/dist/assets/CronManager-CbM0x_s0.js +1 -0
  74. package/dist/assets/Database-BeoEreW0.js +3 -0
  75. package/dist/assets/Diagnostics-Cw9iJ84f.js +1 -0
  76. package/dist/assets/Dlq-DfCg04Jd.js +1 -0
  77. package/dist/assets/DlqControl-BRCkUXvb.js +1 -0
  78. package/dist/assets/DlqPro-BHJwBYsN.js +1 -0
  79. package/dist/assets/Flows-BAzbFP_W.js +2 -0
  80. package/dist/assets/JobInspector-qvW_APO6.js +2 -0
  81. package/dist/assets/Jobs-URpnFjC6.js +1 -0
  82. package/dist/assets/JobsPro-pQ_RFSU3.js +1 -0
  83. package/dist/assets/Logs-C5jX2OPE.js +1 -0
  84. package/dist/assets/LogsPro-Cq_npmmS.js +1 -0
  85. package/dist/assets/{McpServer-CbaU52l2.js → McpServer-DtCTmQBu.js} +2 -2
  86. package/dist/assets/Metrics-BdxitePr.js +1 -0
  87. package/dist/assets/MetricsPro-orHfW0mg.js +1 -0
  88. package/dist/assets/Overview-DaNns1aj.js +1 -0
  89. package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
  90. package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
  91. package/dist/assets/QueueActions-jiTzndPH.js +1 -0
  92. package/dist/assets/QueueControl-l5bSdMgb.js +1 -0
  93. package/dist/assets/QueueDetail-URK2pjtH.js +1 -0
  94. package/dist/assets/QueueDetailPro-C9M5CV4K.js +1 -0
  95. package/dist/assets/Queues-DeLXlww2.js +1 -0
  96. package/dist/assets/QueuesOverview-DoQ-MUfT.js +1 -0
  97. package/dist/assets/S3Backup-D2agV9_z.js +1 -0
  98. package/dist/assets/S3BackupPro--WSZWTQn.js +3 -0
  99. package/dist/assets/ServerControl-QVd6m_iR.js +3 -0
  100. package/dist/assets/Settings-CiJRj_SM.js +1 -0
  101. package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
  102. package/dist/assets/Usage-DbHJgA4t.js +1 -0
  103. package/dist/assets/UsagePro-LoPo3k1L.js +1 -0
  104. package/dist/assets/Webhooks-CuzIJHwk.js +1 -0
  105. package/dist/assets/Workers-BpUgpwUH.js +1 -0
  106. package/dist/assets/WorkersPro-BYY0P1Pt.js +1 -0
  107. package/dist/assets/Workflows-ByJw4FFH.js +1 -0
  108. package/dist/assets/api-V_sVpHDo.js +1 -0
  109. package/dist/assets/bq-DYmbVkgR.js +1 -0
  110. package/dist/assets/cn-zFEt5SO8.js +1 -0
  111. package/dist/assets/connectionStore-CKFQrxDo.js +1 -0
  112. package/dist/assets/controlConnectionEpoch-JM4omZ6j.js +1 -0
  113. package/dist/assets/feedback-BnGE2oW2.js +1 -0
  114. package/dist/assets/{flowMutationSafety-CoOp94wS.js → flowMutationSafety-DObp9nJo.js} +1 -1
  115. package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
  116. package/dist/assets/format-Cg7VCIZW.js +1 -0
  117. package/dist/assets/icons-CIvFfU2b.js +1 -0
  118. package/dist/assets/index-D64v2vNO.js +8 -0
  119. package/dist/assets/index-DOze4TKg.css +1 -0
  120. package/dist/assets/install-jXi1SOYY.js +3 -0
  121. package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
  122. package/dist/assets/responses-CBLyy1Ob.js +1 -0
  123. package/dist/assets/toastStore-B7n0zS0y.js +1 -0
  124. package/dist/assets/{upstreamPaths-D_LPsv7b.js → upstreamPaths-CDVsu6IM.js} +1 -1
  125. package/dist/assets/usePolledData-c53tx1jw.js +1 -0
  126. package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-OTv6_fU0.js} +1 -1
  127. package/dist/assets/useThroughputSeries-DWHQl7cM.js +1 -0
  128. package/dist/index.html +17 -6
  129. package/package.json +15 -4
  130. package/scripts/embedded.gen.ts +160 -134
  131. package/scripts/serve.ts +127 -484
  132. package/scripts/serveHandler.ts +237 -0
  133. package/scripts/servePolicy.ts +187 -0
  134. package/dist/assets/AddJob-CG28MSxU.js +0 -3
  135. package/dist/assets/Alerts-D9JojJrj.js +0 -1
  136. package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
  137. package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
  138. package/dist/assets/Cron-DoEHDHwy.js +0 -1
  139. package/dist/assets/CronManager-vulcesqJ.js +0 -1
  140. package/dist/assets/Database-DlB_t04Z.js +0 -3
  141. package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
  142. package/dist/assets/Dlq-DMKrsSMP.js +0 -1
  143. package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
  144. package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
  145. package/dist/assets/Flows-C3-9nXW4.js +0 -1
  146. package/dist/assets/JobInspector-DwIfABmI.js +0 -2
  147. package/dist/assets/Jobs-j8tTS3NC.js +0 -1
  148. package/dist/assets/JobsPro-CS__CJJl.js +0 -1
  149. package/dist/assets/Logs-BB-VDPA1.js +0 -1
  150. package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
  151. package/dist/assets/Metrics-ivcddPwT.js +0 -1
  152. package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
  153. package/dist/assets/Overview-D84aD2ph.js +0 -1
  154. package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
  155. package/dist/assets/QueueActions-DTbD6uek.js +0 -1
  156. package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
  157. package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
  158. package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
  159. package/dist/assets/Queues-CKMJqFHX.js +0 -1
  160. package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
  161. package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
  162. package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
  163. package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
  164. package/dist/assets/Settings-DkqmZUIS.js +0 -1
  165. package/dist/assets/Usage-Ba20p0xd.js +0 -1
  166. package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
  167. package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
  168. package/dist/assets/Workers-C1wPU6CS.js +0 -1
  169. package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
  170. package/dist/assets/Workflows-C8Qmkflf.js +0 -1
  171. package/dist/assets/cn-CMFYpumL.js +0 -1
  172. package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
  173. package/dist/assets/index-BCNzbhhD.css +0 -1
  174. package/dist/assets/index-CAtsVmyh.js +0 -8
  175. package/dist/assets/install-D0eRLf9F.js +0 -3
  176. package/dist/assets/useThroughputSeries-DjxGN8ob.js +0 -1
package/agent/server.ts CHANGED
@@ -1,444 +1,29 @@
1
1
  /**
2
- * Request handling + origin/auth policy for the control agent, factored out of
3
- * index.ts so it can be unit-tested without binding a port.
2
+ * Request handling and origin/auth policy for the control agent.
4
3
  *
5
- * Threat model: the agent can spawn arbitrary processes (`/control/config` sets
6
- * the launch command, `/control/start` runs it). It binds 127.0.0.1, but that
7
- * does NOT stop a malicious web page the user is visiting from issuing a
8
- * cross-origin request to http://127.0.0.1:6800 (CSRF → RCE). Defenses:
9
- *
10
- * 1. CORS is locked to an explicit allowlist (never `*`); the browser is only
11
- * told a cross-origin response is readable for known dashboard origins.
12
- * 2. Any request carrying a disallowed `Origin` header is rejected (403)
13
- * before it can reach the ProcessManager — a cross-origin browser request
14
- * always sends `Origin`, so a drive-by page cannot start/stop/reconfigure
15
- * the server. Non-browser callers (curl, same-process) send no Origin and
16
- * still work for local use.
17
- * 3. Host-header allowlist (DNS-rebinding defense). The Origin gate does NOT
18
- * cover a *same-origin* request: a page whose DNS name is rebound to
19
- * 127.0.0.1 issues same-origin GETs that carry no `Origin` header, so it
20
- * could otherwise read /control/status, /control/logs and the /db/*
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.
24
- * When `allowedHosts` is configured, a request whose `Host` header names a
25
- * hostname outside the allowlist is rejected (403) — the rebinding page's
26
- * `Host` is the attacker's domain, never a loopback/allowlisted host.
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.
30
- */
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';
45
- import {
46
- type WorkflowStateFilter,
47
- type WorkflowStoreKind,
48
- workflowExecution,
49
- workflowExecutions,
50
- workflowStats,
51
- WORKFLOW_STATES,
52
- } from './workflows';
53
-
54
- export interface AgentOptions {
55
- allowedOrigins: string[];
56
- /**
57
- * DNS-rebinding defense: when set (non-empty), a request whose `Host` header
58
- * resolves to a hostname outside this list is rejected. Compared by hostname
59
- * only (port stripped). Leave undefined only for a loopback-only integration
60
- * or when a trusted front proxy has already enforced Host. Network-facing
61
- * listeners must configure this allowlist.
62
- */
63
- allowedHosts?: string[];
64
- /** On loopback, when set, POST/PUT requests must present this token. */
65
- token?: string;
66
- /** Network exposure: require `token` on every non-OPTIONS request, including reads. */
67
- requireTokenForAll?: boolean;
68
- }
69
-
70
- const DEFAULT_ORIGINS = ['http://localhost:5273', 'http://127.0.0.1:5273'];
71
-
72
- /** Hostnames always trusted as loopback for the Host-header allowlist. */
73
- const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '::1', '0.0.0.0'];
74
-
75
- /** Parse AGENT_ALLOWED_ORIGINS (comma-separated) merged with sane dev defaults. */
76
- export function resolveAllowedOrigins(env = process.env): string[] {
77
- const extra = (env.AGENT_ALLOWED_ORIGINS ?? '')
78
- .split(',')
79
- .map((s) => s.trim().replace(/\/$/, ''))
80
- .filter(Boolean);
81
- return Array.from(new Set([...DEFAULT_ORIGINS, ...extra]));
82
- }
83
-
84
- /**
85
- * Extract the bare hostname from a `Host` header or an origin/URL (strips a
86
- * leading scheme and any port, unwraps IPv6 brackets, lowercases).
87
- */
88
- export function hostnameOf(host: string): string {
89
- const s = host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // drop scheme:// if present
90
- const v6 = s.match(/^\[([^\]]+)\]/); // [::1]:6800 → ::1
91
- if (v6) return v6[1].toLowerCase();
92
- const i = s.indexOf(':');
93
- if (i === -1) return s.toLowerCase();
94
- // A bare (unbracketed) IPv6 literal has several colons and no port delimiter:
95
- // splitting on the first one would both lock the real host out and allowlist a
96
- // bogus single label ('2001:db8::5' → '2001'). Keep it whole — that also makes
97
- // hostnameOf idempotent over its own bracket-stripped output.
98
- if (s.indexOf(':') !== s.lastIndexOf(':')) return s.toLowerCase();
99
- return s.slice(0, i).toLowerCase();
100
- }
101
-
102
- /**
103
- * Loopback hostnames plus any from AGENT_ALLOWED_HOSTS (comma-separated) and
104
- * `extra` (hostnames or full origins of the served/allowlisted origins).
105
- * Reduced to bare hostnames, deduped.
106
- */
107
- export function resolveAllowedHosts(env = process.env, extra: string[] = []): string[] {
108
- const fromEnv = (env.AGENT_ALLOWED_HOSTS ?? '')
109
- .split(',')
110
- .map((s) => hostnameOf(s.trim()))
111
- .filter(Boolean);
112
- const extraHosts = extra.map((s) => hostnameOf(s)).filter(Boolean);
113
- return Array.from(new Set([...LOOPBACK_HOSTS, ...extraHosts, ...fromEnv]));
114
- }
115
-
116
- export function isOriginAllowed(origin: string | null, allowed: string[]): boolean {
117
- if (!origin) return true; // non-browser caller (curl / same process) — no Origin header
118
- return allowed.includes(origin.replace(/\/$/, ''));
119
- }
120
-
121
- /**
122
- * Host-header allowlist. Disabled (always true) when `allowed` is undefined.
123
- * Once enabled it is fail-closed: a real HTTP request always has Host, and
124
- * accepting a missing one would create an avoidable bypass for unusual proxy
125
- * paths. A DNS-rebinding page sends its own domain as Host, so it fails too.
126
- */
127
- export function isHostAllowed(host: string | null, allowed?: string[]): boolean {
128
- if (!allowed) return true;
129
- if (!host) return false;
130
- return allowed.includes(hostnameOf(host));
131
- }
132
-
133
- /** CORS headers. ACAO is reflected only for an allowed origin (never `*`). */
134
- export function corsHeaders(origin: string | null, allowed: string[]): Record<string, string> {
135
- const h: Record<string, string> = {
136
- 'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
137
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Agent-Token',
138
- Vary: 'Origin',
139
- };
140
- if (origin && allowed.includes(origin.replace(/\/$/, ''))) {
141
- h['Access-Control-Allow-Origin'] = origin;
142
- }
143
- return h;
144
- }
145
-
146
- function tokenOk(req: Request, token?: string): boolean {
147
- if (!token) return false;
148
- const auth = req.headers.get('authorization') ?? '';
149
- const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
150
- return bearer === token || req.headers.get('x-agent-token') === token;
151
- }
152
-
153
- /**
154
- * Build the fetch handler for the agent. Pure w.r.t. the network — takes a
155
- * ProcessManager and policy, returns an async (req) => Response.
4
+ * The agent can spawn configurable commands, so callers must retain both the
5
+ * Origin and Host gates when binding it. Implementations are split under
6
+ * agent/server/; this file preserves the existing public import path.
156
7
  */
157
- export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
158
- const { allowedOrigins, allowedHosts, token, requireTokenForAll = false } = opts;
159
-
160
- const json = (data: unknown, status: number, origin: string | null): Response =>
161
- new Response(JSON.stringify(data), {
162
- status,
163
- headers: { 'Content-Type': 'application/json', ...corsHeaders(origin, allowedOrigins) },
164
- });
165
-
166
- async function statusWithHealth() {
167
- const snap = mgr.getStatus();
168
- let healthy = false;
169
- let version: string | undefined;
170
- if (snap.status === 'running') {
171
- const port = snap.runningConfig?.httpPort ?? snap.config.httpPort;
172
- try {
173
- const res = await fetch(`http://127.0.0.1:${port}/health`, {
174
- signal: AbortSignal.timeout(1500),
175
- });
176
- if (res.ok) {
177
- const body: unknown = await res.json();
178
- if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
179
- const health = body as Record<string, unknown>;
180
- // HTTP 2xx alone is not a Bunqueue health verdict: an unrelated
181
- // process, proxy fallback, or malformed payload can also return it.
182
- // Fail closed unless the semantic health flag is exactly true.
183
- healthy = health.ok === true;
184
- if (typeof health.version === 'string') version = health.version;
185
- }
186
- }
187
- } catch {
188
- /* not up yet */
189
- }
190
- }
191
- const db = await mgr.dbStats().catch(() => null);
192
- return { ...snap, healthy, version, db };
193
- }
194
-
195
- return async function handle(req: Request): Promise<Response> {
196
- const { pathname } = new URL(req.url);
197
- const method = req.method;
198
- const origin = req.headers.get('origin');
199
-
200
- // DNS-rebinding defense: reject a same-origin request whose Host header is
201
- // an attacker domain rebound to loopback (no Origin header would be sent,
202
- // so the Origin gate below can't see it). No-op when allowedHosts is unset.
203
- if (!isHostAllowed(req.headers.get('host'), allowedHosts)) {
204
- return json({ ok: false, error: 'Host not allowed' }, 403, origin);
205
- }
206
-
207
- // Block any request from a disallowed browser origin before it can act.
208
- if (!isOriginAllowed(origin, allowedOrigins)) {
209
- return json({ ok: false, error: 'Origin not allowed' }, 403, origin);
210
- }
211
-
212
- // Preflight never needs a bearer token, but Host and Origin are security
213
- // boundaries in their own right and therefore fail closed before this 204.
214
- if (method === 'OPTIONS') {
215
- return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
216
- }
217
-
218
- // Preserve zero-config LOOPBACK use: there, reads stay open and a configured
219
- // token gates mutations. A network-facing listener sets requireTokenForAll,
220
- // because Origin alone cannot authenticate curl/no-Origin callers and the db
221
- // inspector contains job payloads. Misconfiguration fails closed: enabling
222
- // the all-route gate without a token makes every real route answer 401.
223
- const mutating = method === 'POST' || method === 'PUT';
224
- const authRequired = requireTokenForAll || (mutating && Boolean(token));
225
- if (authRequired && !tokenOk(req, token)) {
226
- return json({ ok: false, error: 'Unauthorized' }, 401, origin);
227
- }
228
-
229
- try {
230
- if (pathname === '/control/status') return json(await statusWithHealth(), 200, origin);
231
- if (pathname === '/control/logs') return json({ lines: mgr.getLogs() }, 200, origin);
232
-
233
- if (pathname === '/control/start' && method === 'POST') {
234
- await mgr.start();
235
- return json(await statusWithHealth(), 200, origin);
236
- }
237
- if (pathname === '/control/stop' && method === 'POST') {
238
- await mgr.stop();
239
- return json(await statusWithHealth(), 200, origin);
240
- }
241
- if (pathname === '/control/restart' && method === 'POST') {
242
- await mgr.restart();
243
- return json(await statusWithHealth(), 200, origin);
244
- }
245
-
246
- if (pathname === '/control/config' && method === 'GET') return json(mgr.getConfig(), 200, origin);
247
- if (pathname === '/control/config' && method === 'PUT') {
248
- const patch = validateConfigPatch(await req.json());
249
- return json(mgr.setConfig(patch), 200, origin);
250
- }
251
-
252
- // Bunqueue 2.8.57 persists Workflow Engine executions in the same
253
- // dataPath. These endpoints are intentionally read-only: control methods
254
- // need the live Engine instance and its registered workflow definitions.
255
- if (pathname === '/workflows/stats' && method === 'GET') {
256
- return json({ ok: true, ...workflowStats(mgr.getConfig().dataPath) }, 200, origin);
257
- }
258
- if (pathname === '/workflows' && method === 'GET') {
259
- const sp = new URL(req.url).searchParams;
260
- const allowed = new Set(['kind', 'workflowName', 'state', 'limit', 'offset']);
261
- for (const key of sp.keys()) {
262
- if (!allowed.has(key)) throw new Error(`Unknown workflow option: ${key}`);
263
- if (sp.getAll(key).length !== 1) throw new Error(`Duplicate workflow option: ${key}`);
264
- }
265
- const kind = sp.get('kind') ?? 'active';
266
- if (kind !== 'active' && kind !== 'archive') {
267
- throw new Error('Workflow kind must be "active" or "archive"');
268
- }
269
- const state = sp.get('state') || undefined;
270
- if (state && state !== 'compensation' && !(WORKFLOW_STATES as readonly string[]).includes(state)) {
271
- throw new Error('Unknown workflow execution state');
272
- }
273
- const integer = (name: 'limit' | 'offset', fallback: number): number => {
274
- const raw = sp.get(name);
275
- if (raw === null) return fallback;
276
- if (!/^\d+$/.test(raw)) throw new Error(`Workflow ${name} must be an integer`);
277
- return Number(raw);
278
- };
279
- return json(
280
- {
281
- ok: true,
282
- ...workflowExecutions(mgr.getConfig().dataPath, {
283
- kind: kind as WorkflowStoreKind,
284
- workflowName: sp.get('workflowName') || undefined,
285
- state: state as WorkflowStateFilter | undefined,
286
- limit: integer('limit', 50),
287
- offset: integer('offset', 0),
288
- }),
289
- },
290
- 200,
291
- origin
292
- );
293
- }
294
- if (pathname.startsWith('/workflows/') && method === 'GET') {
295
- const rawId = pathname.slice('/workflows/'.length);
296
- if (!rawId || rawId.includes('/')) return json({ ok: false, error: 'Not found' }, 404, origin);
297
- const sp = new URL(req.url).searchParams;
298
- for (const key of sp.keys()) {
299
- if (key !== 'kind') throw new Error(`Unknown workflow detail option: ${key}`);
300
- if (sp.getAll(key).length !== 1) throw new Error(`Duplicate workflow detail option: ${key}`);
301
- }
302
- const kind = sp.get('kind') ?? 'active';
303
- if (kind !== 'active' && kind !== 'archive') {
304
- throw new Error('Workflow kind must be "active" or "archive"');
305
- }
306
- const execution = workflowExecution(
307
- mgr.getConfig().dataPath,
308
- decodeURIComponent(rawId),
309
- kind
310
- );
311
- return execution
312
- ? json({ ok: true, execution }, 200, origin)
313
- : json({ ok: false, error: 'Workflow execution not found' }, 404, origin);
314
- }
315
-
316
- // Read-only SQLite inspector (agent/db.ts opens every connection
317
- // readonly, so none of these can mutate the store). The POST query
318
- // endpoint rides the same token gate as other mutating methods.
319
- if (pathname === '/db/info' && method === 'GET') {
320
- return json({ ok: true, ...dbInfo(mgr.getConfig().dataPath) }, 200, origin);
321
- }
322
- if (pathname === '/db/tables' && method === 'GET') {
323
- return json({ ok: true, tables: dbTables(mgr.getConfig().dataPath) }, 200, origin);
324
- }
325
- // Segment-based routing (the client percent-encodes the table name, so it
326
- // never contains a literal '/'): a single trailing segment is the rows
327
- // route; `<table>/schema` and `<table>/cell` are sub-resources. This is
328
- // unambiguous even for a table literally named "schema" or "cell".
329
- if (pathname.startsWith('/db/tables/') && method === 'GET') {
330
- const segs = pathname.slice('/db/tables/'.length).split('/');
331
- const table = decodeURIComponent(segs[0] ?? '');
332
- const sub = segs[1];
333
- if (segs.length === 2 && sub === 'export') {
334
- const sp = new URL(req.url).searchParams;
335
- const allowedParams = new Set(['orderBy', 'dir', 'fcol', 'fop', 'fval']);
336
- for (const key of sp.keys()) {
337
- if (!allowedParams.has(key)) throw new Error(`Unknown database export option: ${key}`);
338
- if (sp.getAll(key).length !== 1) {
339
- throw new Error(`Duplicate database export option: ${key}`);
340
- }
341
- }
342
-
343
- const orderByParam = sp.get('orderBy');
344
- if (orderByParam === '') throw new Error('Database export orderBy must not be empty');
345
- const dirParam = sp.get('dir');
346
- if (dirParam !== null && dirParam !== 'asc' && dirParam !== 'desc') {
347
- throw new Error('Database export dir must be "asc" or "desc"');
348
- }
349
- const fCol = sp.get('fcol');
350
- const fOp = sp.get('fop');
351
- const fVal = sp.get('fval');
352
- const hasAnyFilterPart = fCol !== null || fOp !== null || fVal !== null;
353
- let filter: DbFilter | undefined;
354
- if (hasAnyFilterPart) {
355
- if (!fCol || !fVal || (fOp !== 'contains' && fOp !== 'eq' && fOp !== 'ne')) {
356
- throw new Error('Database export filter requires valid fcol, fop and fval values');
357
- }
358
- filter = { column: fCol, op: fOp, value: fVal };
359
- }
360
-
361
- const exported = await exportWithTimeout(
362
- mgr.getConfig().dataPath,
363
- table,
364
- orderByParam ?? undefined,
365
- dirParam === 'desc' ? 'desc' : 'asc',
366
- filter,
367
- req.signal
368
- );
369
- return new Response(exported.content, {
370
- status: 200,
371
- headers: {
372
- 'Content-Type': 'text/csv; charset=utf-8',
373
- 'Content-Length': String(exported.bytes),
374
- // Keep Content-Length equal to the authoritative export-byte contract;
375
- // intermediaries must not transparently recompress this body.
376
- 'Cache-Control': 'no-store, no-transform',
377
- 'X-Content-Type-Options': 'nosniff',
378
- 'X-Bunqueue-Db-Export-Version': '1',
379
- 'X-Bunqueue-Db-Export-Table': encodeURIComponent(exported.table),
380
- 'X-Bunqueue-Db-Export-Rows': String(exported.rowCount),
381
- 'X-Bunqueue-Db-Export-Bytes': String(exported.bytes),
382
- 'X-Bunqueue-Db-Export-Cap': exported.cap ?? 'none',
383
- 'Access-Control-Expose-Headers':
384
- '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',
385
- ...corsHeaders(origin, allowedOrigins),
386
- },
387
- });
388
- }
389
- if (segs.length === 2 && sub === 'schema') {
390
- return json({ ok: true, ...dbSchema(mgr.getConfig().dataPath, table) }, 200, origin);
391
- }
392
- if (segs.length === 2 && sub === 'cell') {
393
- const sp = new URL(req.url).searchParams;
394
- // Keep rowid textual through the HTTP boundary. Number() rounded
395
- // values above 2^53 before SQLite ever saw the bound parameter.
396
- const rowid = sp.get('rowid');
397
- if (rowid === null) throw new Error('rowid is required');
398
- const column = sp.get('column') ?? '';
399
- return json(
400
- { ok: true, ...dbCell(mgr.getConfig().dataPath, table, rowid, column) },
401
- 200,
402
- origin
403
- );
404
- }
405
- if (segs.length !== 1) return json({ ok: false, error: 'Not found' }, 404, origin);
406
- const sp = new URL(req.url).searchParams;
407
- const limit = Number(sp.get('limit')) || 50;
408
- const offset = Number(sp.get('offset')) || 0;
409
- const orderBy = sp.get('orderBy') || undefined;
410
- const dir = sp.get('dir') === 'desc' ? 'desc' : 'asc';
411
- const fCol = sp.get('fcol');
412
- const fOp = sp.get('fop');
413
- const fVal = sp.get('fval');
414
- const filter: DbFilter | undefined =
415
- fCol && fVal
416
- ? { column: fCol, op: fOp === 'eq' ? 'eq' : fOp === 'ne' ? 'ne' : 'contains', value: fVal }
417
- : undefined;
418
- return json(
419
- { ok: true, ...dbRows(mgr.getConfig().dataPath, table, limit, offset, orderBy, dir, filter) },
420
- 200,
421
- origin
422
- );
423
- }
424
- if (pathname === '/db/query' && method === 'POST') {
425
- const { sql } = (await req.json()) as { sql?: string };
426
- return json({ ok: true, ...(await queryWithTimeout(mgr.getConfig().dataPath, sql ?? '')) }, 200, origin);
427
- }
428
-
429
- return json({ ok: false, error: 'Not found' }, 404, origin);
430
- } catch (e) {
431
- // A missing database file is an expected pre-first-start condition — 404
432
- // so the UI can show "no database yet" distinctly from a real read error.
433
- const status =
434
- e instanceof MissingDbError
435
- ? 404
436
- : e instanceof DbExportBusyError
437
- ? 429
438
- : e instanceof DbExportUnavailableError
439
- ? 503
440
- : 400;
441
- return json({ ok: false, error: (e as Error).message ?? String(e) }, status, origin);
442
- }
443
- };
444
- }
8
+ export { createFetchHandler } from './server/handler';
9
+ export {
10
+ MANAGED_CONTROL_TARGET,
11
+ probeExternalHealth,
12
+ resolveServerControlTarget,
13
+ } from './server/controlTarget';
14
+ export type {
15
+ ExternalHealthProbe,
16
+ ServerControlTarget,
17
+ ServerManagementMode,
18
+ } from './server/controlTarget';
19
+ export { AgentLifecycleGate } from './server/lifecycle';
20
+ export type { AgentLifecyclePort } from './server/lifecycle';
21
+ export {
22
+ corsHeaders,
23
+ hostnameOf,
24
+ isHostAllowed,
25
+ isOriginAllowed,
26
+ resolveAllowedHosts,
27
+ resolveAllowedOrigins,
28
+ } from './server/policy';
29
+ export type { AgentFetchHandler, AgentOptions } from './server/types';
@@ -0,0 +1,156 @@
1
+ import { logger } from './logger';
2
+ import { safeErrorMessage } from './errorMessage';
3
+ import type { AgentFetchHandler } from './server';
4
+
5
+ const DEFAULT_GRACE_MS = 30_000;
6
+
7
+ export interface AgentShutdownOptions {
8
+ /** Stop listeners synchronously; returned promises drain in-flight HTTP requests. */
9
+ stopAccepting?: Array<() => unknown | Promise<unknown>>;
10
+ graceMs?: number;
11
+ /** Test seam; production uses process.exit. */
12
+ exit?: (code: number) => void;
13
+ }
14
+
15
+ /** Install one idempotent terminal shutdown path for agent entrypoints. */
16
+ export function installAgentShutdown(
17
+ handle: AgentFetchHandler,
18
+ options: AgentShutdownOptions = {}
19
+ ): void {
20
+ const shutdown = createAgentShutdown(handle, options);
21
+ process.on('SIGINT', () => shutdown('SIGINT'));
22
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
23
+ }
24
+
25
+ /** Build the signal callback separately so ordering and exit codes are testable. */
26
+ export function createAgentShutdown(
27
+ handle: AgentFetchHandler,
28
+ options: AgentShutdownOptions = {}
29
+ ): (signal: string) => void {
30
+ const exit = options.exit ?? ((code: number) => process.exit(code));
31
+ const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
32
+ let shuttingDown = false;
33
+ let finished = false;
34
+ let graceTimer: ReturnType<typeof setTimeout> | null = null;
35
+
36
+ return (signal) => {
37
+ if (shuttingDown) {
38
+ logger.error({ signal }, 'second signal received, forcing agent shutdown');
39
+ force();
40
+ return;
41
+ }
42
+ shuttingDown = true;
43
+ logger.info({ signal }, 'signal received, draining agent and managed server');
44
+
45
+ const drains = (options.stopAccepting ?? []).map(invoke);
46
+ const beginning = invoke(() => handle.beginShutdown());
47
+ void beginning.catch(() => undefined);
48
+ graceTimer = setTimeout(() => {
49
+ logger.error({ graceMs }, 'agent shutdown grace period expired');
50
+ force();
51
+ }, graceMs);
52
+
53
+ void finishShutdown(handle, beginning, drains).then(
54
+ () => finish(0),
55
+ (error) => {
56
+ logger.error({ err: errorDetails(error) }, 'agent shutdown completed with errors');
57
+ finish(1);
58
+ }
59
+ );
60
+ };
61
+
62
+ function finish(code: number): void {
63
+ if (finished) return;
64
+ finished = true;
65
+ if (graceTimer) clearTimeout(graceTimer);
66
+ graceTimer = null;
67
+ exit(code);
68
+ }
69
+
70
+ function force(): void {
71
+ if (finished) return;
72
+ try {
73
+ handle.forceShutdown();
74
+ } catch (error) {
75
+ logger.error({ err: errorDetails(error) }, 'failed to force-stop managed server');
76
+ }
77
+ finish(1);
78
+ }
79
+ }
80
+
81
+ async function finishShutdown(
82
+ handle: AgentFetchHandler,
83
+ beginning: Promise<unknown>,
84
+ drains: Promise<unknown>[]
85
+ ): Promise<void> {
86
+ // Start managed shutdown immediately: long-lived /api event streams only
87
+ // finish after their upstream Bunqueue child stops, so waiting for HTTP
88
+ // drain first would deadlock terminal shutdown.
89
+ const shutdown = settled(() => handle.shutdown());
90
+ const drained = await Promise.allSettled([beginning, ...drains]);
91
+ const shutdownResult = await shutdown;
92
+ const failures: unknown[] = [];
93
+ for (const result of drained) {
94
+ if (result.status === 'rejected') addFailure(failures, result.reason);
95
+ }
96
+ if (!shutdownResult.ok) addFailure(failures, shutdownResult.error);
97
+ if (failures.length === 1) throw failures[0];
98
+ if (failures.length > 1) {
99
+ throw new AggregateError(failures, 'HTTP drain and agent shutdown failed');
100
+ }
101
+ }
102
+
103
+ function addFailure(failures: unknown[], failure: unknown): void {
104
+ if (!failures.some((current) => Object.is(current, failure))) failures.push(failure);
105
+ }
106
+
107
+ function invoke(operation: () => unknown | Promise<unknown>): Promise<unknown> {
108
+ try {
109
+ return Promise.resolve(operation());
110
+ } catch (error) {
111
+ return Promise.reject(error);
112
+ }
113
+ }
114
+
115
+ type SettledOperation = { ok: true } | { ok: false; error: unknown };
116
+
117
+ async function settled(operation: () => Promise<unknown>): Promise<SettledOperation> {
118
+ try {
119
+ await operation();
120
+ return { ok: true };
121
+ } catch (error) {
122
+ return { ok: false, error };
123
+ }
124
+ }
125
+
126
+ function errorDetails(error: unknown, seen = new Set<object>()): unknown {
127
+ try {
128
+ if (typeof error === 'object' && error !== null) {
129
+ if (seen.has(error)) return { type: 'circular', value: '[Circular rejection]' };
130
+ seen.add(error);
131
+ }
132
+ if (error instanceof AggregateError) {
133
+ return {
134
+ name: safeField(error.name),
135
+ message: safeErrorMessage(error),
136
+ stack: safeField(error.stack),
137
+ errors: error.errors.map((cause) => errorDetails(cause, seen)),
138
+ };
139
+ }
140
+ if (error instanceof Error) {
141
+ return {
142
+ name: safeField(error.name),
143
+ message: safeErrorMessage(error),
144
+ stack: safeField(error.stack),
145
+ };
146
+ }
147
+ return { type: error === null ? 'null' : typeof error, value: safeErrorMessage(error) };
148
+ } catch {
149
+ return { type: 'uninspectable', value: safeErrorMessage(error) };
150
+ }
151
+ }
152
+
153
+ function safeField(value: unknown): string | undefined {
154
+ if (value === undefined) return undefined;
155
+ return safeErrorMessage(value);
156
+ }