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
@@ -0,0 +1,230 @@
1
+ import type { FlowJobTarget, FlowMutationOperation } from './types';
2
+ import { readLimitedJsonBody } from '../server/jsonBody';
3
+
4
+ const MAX_PROGRESS_JSON_BYTES = 65_536;
5
+ const MAX_PROGRESS_DEPTH = 32;
6
+ const MAX_PROGRESS_VALUES = 10_000;
7
+ const UNSAFE_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
8
+ const MAX_FLOW_BODY_BYTES = 1024 * 1024;
9
+
10
+ export interface FlowProgressWireValue {
11
+ progress: number;
12
+ message?: string;
13
+ }
14
+
15
+ export function validateFlowTarget(target: FlowJobTarget): void {
16
+ if (!target.id || target.id.length > 1024) {
17
+ throw new Error('Flow job id must contain 1–1024 characters');
18
+ }
19
+ if (!target.queueName || target.queueName.length > 256) {
20
+ throw new Error('Flow queue name must contain 1–256 characters');
21
+ }
22
+ }
23
+
24
+ export function validateParentIds(value: unknown): asserts value is string[] {
25
+ if (!Array.isArray(value) || value.length < 1 || value.length > 1_000) {
26
+ throw new Error('Flow parent IDs must contain between 1 and 1000 entries');
27
+ }
28
+ for (const id of value) {
29
+ if (typeof id !== 'string' || id.length < 1 || id.length > 1024) {
30
+ throw new Error('Each Flow parent ID must contain 1–1024 characters');
31
+ }
32
+ }
33
+ }
34
+
35
+ export function validateMutationPayload(
36
+ operation: FlowMutationOperation,
37
+ payload: Record<string, unknown>
38
+ ): void {
39
+ const keys: Partial<Record<FlowMutationOperation, readonly string[]>> = {
40
+ updateData: ['data'],
41
+ updateProgress: ['progress', 'message'],
42
+ log: ['message'],
43
+ changeDelay: ['delay'],
44
+ changePriority: ['priority', 'lifo'],
45
+ clearLogs: ['keepLogs'],
46
+ };
47
+ const allowed = keys[operation] ?? [];
48
+ const unknown = Object.keys(payload).filter((key) => !allowed.includes(key));
49
+ if (unknown.length) throw new Error(`Unknown Flow ${operation} option: ${unknown[0]}`);
50
+ if (operation === 'updateData' && !Object.hasOwn(payload, 'data')) {
51
+ throw new Error('Flow updateData requires data');
52
+ }
53
+ if (operation === 'updateProgress') validateProgress(payload);
54
+ if (
55
+ operation === 'log' &&
56
+ (typeof payload.message !== 'string' || payload.message.length > 65_536)
57
+ ) {
58
+ throw new Error('Flow log message must be a string of at most 65536 characters');
59
+ }
60
+ if (operation === 'changeDelay') {
61
+ boundedInteger(payload.delay, 'delay', 10 * 365 * 24 * 60 * 60 * 1_000);
62
+ }
63
+ if (operation === 'changePriority') {
64
+ boundedInteger(payload.priority, 'priority', 2_147_483_647);
65
+ if (payload.lifo !== undefined && typeof payload.lifo !== 'boolean') {
66
+ throw new Error('Flow lifo must be boolean');
67
+ }
68
+ }
69
+ if (operation === 'clearLogs' && payload.keepLogs !== undefined) {
70
+ boundedInteger(payload.keepLogs, 'keepLogs', 1_000_000);
71
+ }
72
+ }
73
+
74
+ export function asFlowRecord(value: unknown): Record<string, unknown> {
75
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
76
+ throw new Error('Flow request must be an object');
77
+ }
78
+ return value as Record<string, unknown>;
79
+ }
80
+
81
+ export function assertExactFlowKeys(
82
+ value: Record<string, unknown>,
83
+ allowed: readonly string[],
84
+ label: string
85
+ ): void {
86
+ const unknown = Object.keys(value).find((key) => !allowed.includes(key));
87
+ if (unknown) throw new Error(`Unknown Flow ${label} option: ${unknown}`);
88
+ }
89
+
90
+ export async function readFlowJsonBody(request: Request): Promise<unknown> {
91
+ return readLimitedJsonBody(request, {
92
+ scope: 'Flow',
93
+ maxBytes: MAX_FLOW_BODY_BYTES,
94
+ limitLabel: '1 MiB',
95
+ invalidContentLengthMessage: 'Flow Content-Length must be an integer',
96
+ });
97
+ }
98
+
99
+ export function assertNoFlowBody(request: Request): void {
100
+ const declared = request.headers.get('content-length');
101
+ if (request.body !== null || (declared !== null && declared !== '0')) {
102
+ throw new Error('This Flow operation does not accept a request body');
103
+ }
104
+ }
105
+
106
+ export function assertFlowBodySize(value: unknown): void {
107
+ if (JSON.stringify(value).length > MAX_FLOW_BODY_BYTES) {
108
+ throw new Error('Flow request exceeds 1 MiB');
109
+ }
110
+ }
111
+
112
+ export function normalizeFlowProgressPayload(
113
+ payload: Record<string, unknown>
114
+ ): FlowProgressWireValue {
115
+ const value = payload.progress;
116
+ if (typeof value === 'number') {
117
+ if (!Number.isFinite(value) || value < 0 || value > 100) {
118
+ throw new Error('Flow progress must be a number from 0 to 100 or a JSON object');
119
+ }
120
+ const message = optionalProgressMessage(payload.message);
121
+ return message === undefined ? { progress: value } : { progress: value, message };
122
+ }
123
+ if (!isPlainRecord(value)) {
124
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
125
+ throw new Error('Flow progress object must use only plain JSON objects and arrays');
126
+ }
127
+ throw new Error('Flow progress must be a number from 0 to 100 or a JSON object');
128
+ }
129
+ if (payload.message !== undefined) {
130
+ throw new Error('Flow progress message is only supported with numeric progress');
131
+ }
132
+ validateJsonValue(value, new WeakSet(), { count: 0 }, 0);
133
+ const message = JSON.stringify(value);
134
+ if (new TextEncoder().encode(message).byteLength > MAX_PROGRESS_JSON_BYTES) {
135
+ throw new Error(`Flow progress object must encode to at most ${MAX_PROGRESS_JSON_BYTES} bytes`);
136
+ }
137
+ return { progress: 0, message };
138
+ }
139
+
140
+ function validateProgress(payload: Record<string, unknown>): void {
141
+ normalizeFlowProgressPayload(payload);
142
+ }
143
+
144
+ function optionalProgressMessage(value: unknown): string | undefined {
145
+ if (value === undefined) return undefined;
146
+ if (
147
+ typeof value !== 'string' ||
148
+ new TextEncoder().encode(value).byteLength > MAX_PROGRESS_JSON_BYTES
149
+ ) {
150
+ throw new Error('Flow progress message must be a string of at most 65536 bytes');
151
+ }
152
+ return value;
153
+ }
154
+
155
+ function validateJsonValue(
156
+ value: unknown,
157
+ ancestors: WeakSet<object>,
158
+ budget: { count: number },
159
+ depth: number
160
+ ): void {
161
+ budget.count += 1;
162
+ if (budget.count > MAX_PROGRESS_VALUES) {
163
+ throw new Error(`Flow progress object may contain at most ${MAX_PROGRESS_VALUES} values`);
164
+ }
165
+ if (depth > MAX_PROGRESS_DEPTH) {
166
+ throw new Error(`Flow progress object may be at most ${MAX_PROGRESS_DEPTH} levels deep`);
167
+ }
168
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
169
+ if (typeof value === 'number') {
170
+ if (!Number.isFinite(value)) throw new Error('Flow progress object must contain valid JSON');
171
+ return;
172
+ }
173
+ if (typeof value !== 'object') throw new Error('Flow progress object must contain valid JSON');
174
+ if (ancestors.has(value)) throw new Error('Flow progress object must not contain cycles');
175
+ if (!Array.isArray(value) && !isPlainRecord(value)) {
176
+ throw new Error('Flow progress object must use only plain JSON objects and arrays');
177
+ }
178
+ if (Array.isArray(value) && Object.getPrototypeOf(value) !== Array.prototype) {
179
+ throw new Error('Flow progress object must use only plain JSON objects and arrays');
180
+ }
181
+ ancestors.add(value);
182
+ validateJsonProperties(value, ancestors, budget, depth);
183
+ ancestors.delete(value);
184
+ }
185
+
186
+ function validateJsonProperties(
187
+ value: object,
188
+ ancestors: WeakSet<object>,
189
+ budget: { count: number },
190
+ depth: number
191
+ ): void {
192
+ if (Array.isArray(value)) validateJsonArrayShape(value);
193
+ const descriptors = Object.getOwnPropertyDescriptors(value);
194
+ for (const key of Reflect.ownKeys(descriptors)) {
195
+ if (Array.isArray(value) && key === 'length') continue;
196
+ if (typeof key !== 'string' || UNSAFE_JSON_KEYS.has(key)) {
197
+ throw new Error('Flow progress object contains an unsafe property name');
198
+ }
199
+ const descriptor = descriptors[key];
200
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
201
+ throw new Error('Flow progress object must contain plain enumerable JSON values');
202
+ }
203
+ validateJsonValue(descriptor.value, ancestors, budget, depth + 1);
204
+ }
205
+ }
206
+
207
+ function validateJsonArrayShape(value: unknown[]): void {
208
+ if (value.length > MAX_PROGRESS_VALUES) {
209
+ throw new Error(`Flow progress object may contain at most ${MAX_PROGRESS_VALUES} values`);
210
+ }
211
+ const keys = Object.keys(value);
212
+ if (
213
+ keys.length !== value.length ||
214
+ keys.some((key, index) => key !== String(index) || !Object.hasOwn(value, index))
215
+ ) {
216
+ throw new Error('Flow progress object must contain dense arrays without extra properties');
217
+ }
218
+ }
219
+
220
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
221
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
222
+ const prototype = Object.getPrototypeOf(value);
223
+ return prototype === Object.prototype || prototype === null;
224
+ }
225
+
226
+ function boundedInteger(value: unknown, label: string, maximum: number): void {
227
+ if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > maximum) {
228
+ throw new Error(`Flow ${label} must be an integer from 0 to ${maximum}`);
229
+ }
230
+ }
package/agent/index.ts CHANGED
@@ -9,11 +9,18 @@
9
9
  *
10
10
  * Run: bun run agent/index.ts (default port 6800)
11
11
  * Env: AGENT_PORT, AGENT_ALLOWED_ORIGINS, AGENT_TOKEN,
12
- * BUNQUEUE_START_CMD, HTTP_PORT, TCP_PORT, BUNQUEUE_DATA_PATH
12
+ * BUNQUEUE_START_CMD, BUNQUEUE_MANAGED, BUNQUEUE_URL, BUNQUEUE_TOKEN,
13
+ * HTTP_PORT, TCP_PORT, BUNQUEUE_DATA_PATH
13
14
  */
14
15
  import { logger } from './logger';
15
16
  import { ProcessManager } from './manager';
16
- import { createFetchHandler, resolveAllowedHosts, resolveAllowedOrigins } from './server';
17
+ import {
18
+ createFetchHandler,
19
+ resolveAllowedHosts,
20
+ resolveAllowedOrigins,
21
+ resolveServerControlTarget,
22
+ } from './server';
23
+ import { installAgentShutdown } from './shutdown';
17
24
 
18
25
  const mgr = new ProcessManager();
19
26
  const PORT = Number(process.env.AGENT_PORT) || 6800;
@@ -24,32 +31,26 @@ const allowedOrigins = resolveAllowedOrigins();
24
31
  // GETs. Extend via AGENT_ALLOWED_HOSTS when fronted by a proxy on another host.
25
32
  const allowedHosts = resolveAllowedHosts();
26
33
  const token = process.env.AGENT_TOKEN || undefined;
34
+ const controlTarget = resolveServerControlTarget(process.env);
27
35
 
28
- const handle = createFetchHandler(mgr, { allowedOrigins, allowedHosts, token });
36
+ const handle = createFetchHandler(mgr, { allowedOrigins, allowedHosts, token, controlTarget });
37
+ const stopAccepting: Array<() => unknown | Promise<unknown>> = [];
38
+ installAgentShutdown(handle, { stopAccepting });
29
39
 
30
- Bun.serve({
40
+ const server = Bun.serve({
31
41
  port: PORT,
32
42
  hostname: '127.0.0.1',
33
43
  fetch: handle,
34
44
  });
35
-
36
- // Stop the managed server before exiting — without this, Ctrl-C / SIGTERM on
37
- // the agent orphans the spawned bunqueue child (it reparents to PID 1 and keeps
38
- // holding the ports and the SQLite db, so the next start fails EADDRINUSE).
39
- let shuttingDown = false;
40
- const shutdown = (signal: string) => {
41
- if (shuttingDown) return;
42
- shuttingDown = true;
43
- logger.info({ signal }, 'signal received, stopping managed server');
44
- // shutdown() (not stop()) latches the manager closed first: a plain stop()
45
- // racing an in-flight restart() returns successfully *because* restart's
46
- // start() already spawned a replacement — which process.exit() would orphan.
47
- void mgr.shutdown().finally(() => process.exit(0));
48
- };
49
- process.on('SIGINT', () => shutdown('SIGINT'));
50
- process.on('SIGTERM', () => shutdown('SIGTERM'));
45
+ stopAccepting.push(() => server.stop());
51
46
 
52
47
  logger.info(
53
- { url: `http://127.0.0.1:${PORT}/control`, allowedOrigins, allowedHosts, tokenAuth: Boolean(token) },
48
+ {
49
+ url: `http://127.0.0.1:${PORT}/control`,
50
+ allowedOrigins,
51
+ allowedHosts,
52
+ tokenAuth: Boolean(token),
53
+ serverManagement: controlTarget.mode,
54
+ },
54
55
  'bunqueue dashboard control agent ready'
55
56
  );
package/agent/logger.ts CHANGED
@@ -42,29 +42,133 @@ const isTTY = !!process.stdout.isTTY;
42
42
 
43
43
  const pad2 = (n: number): string => String(n).padStart(2, '0');
44
44
  const clock = (d: Date): string => `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
45
+ const RESERVED_FIELDS = new Set(['level', 'time', 'msg', 'toJSON', 'logSerializationError']);
46
+ const UNSAFE_FIELDS = '[Unsafe log fields omitted]';
47
+ const UNSERIALIZABLE_FIELDS = '[Unserializable log fields]';
45
48
 
46
- function write(level: LogLevel, a?: unknown, b?: unknown): void {
47
- if (LEVELS[level] < threshold) return;
49
+ function json(value: unknown): string | null {
50
+ const seen = new WeakSet<object>();
51
+ try {
52
+ return (
53
+ JSON.stringify(value, (_key, current: unknown) => {
54
+ if (typeof current === 'bigint') return current.toString();
55
+ if (typeof current === 'symbol') return `[${String(current)}]`;
56
+ if (typeof current === 'function') return `[Function ${current.name || 'anonymous'}]`;
57
+ if (typeof current === 'object' && current !== null) {
58
+ if (seen.has(current)) return '[Circular]';
59
+ seen.add(current);
60
+ }
61
+ return current;
62
+ }) ?? null
63
+ );
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function safeText(value: unknown): string {
70
+ if (typeof value === 'string') return value;
71
+ try {
72
+ return value === undefined ? '' : String(value);
73
+ } catch {
74
+ return '[Unprintable log message]';
75
+ }
76
+ }
77
+
78
+ function safeFields(value: unknown): Record<string, unknown> | undefined {
79
+ if (value === undefined) return undefined;
80
+ if ((typeof value !== 'object' || value === null) && typeof value !== 'function') {
81
+ return { value };
82
+ }
83
+ const fields: Record<string, unknown> = Object.create(null);
84
+ let unsafe = false;
85
+ let keys: PropertyKey[];
86
+ try {
87
+ keys = Reflect.ownKeys(value);
88
+ } catch {
89
+ return { logSerializationError: UNSERIALIZABLE_FIELDS };
90
+ }
91
+ for (const key of keys) {
92
+ if (typeof key !== 'string') continue;
93
+ if (RESERVED_FIELDS.has(key)) {
94
+ unsafe = true;
95
+ continue;
96
+ }
97
+ let descriptor: PropertyDescriptor | undefined;
98
+ try {
99
+ descriptor = Reflect.getOwnPropertyDescriptor(value, key);
100
+ } catch {
101
+ return { logSerializationError: UNSERIALIZABLE_FIELDS };
102
+ }
103
+ if (!descriptor?.enumerable) continue;
104
+ if ('value' in descriptor) fields[key] = descriptor.value;
105
+ else {
106
+ fields[key] = '[Accessor log field omitted]';
107
+ unsafe = true;
108
+ }
109
+ }
110
+ if (unsafe) fields.logSerializationError = UNSAFE_FIELDS;
111
+ return fields;
112
+ }
113
+
114
+ function logLine(level: LogLevel, obj: unknown, msg: unknown): string {
115
+ const time = Date.now();
116
+ const record: Record<string, unknown> = Object.create(null);
117
+ record.level = LEVELS[level];
118
+ record.time = time;
119
+ const fields = safeFields(obj);
120
+ if (fields) {
121
+ for (const [key, value] of Object.entries(fields)) record[key] = value;
122
+ }
123
+ record.msg = safeText(msg);
124
+ const line = json(record);
125
+ if (line) return line;
126
+ return JSON.stringify({
127
+ level: LEVELS[level],
128
+ time,
129
+ logSerializationError: UNSERIALIZABLE_FIELDS,
130
+ msg: safeText(msg),
131
+ });
132
+ }
133
+
134
+ function logArguments(a?: unknown, b?: unknown): { obj: unknown; msg: unknown } {
48
135
  // pino signature: (mergeObject, message) or just (message). An Error as the
49
136
  // first arg gets its message/stack lifted explicitly — Error props are
50
137
  // non-enumerable, so a plain spread would silently log `{}`.
51
- const [obj, msg] =
52
- typeof a === 'string'
53
- ? [undefined, a]
54
- : a instanceof Error
55
- ? [{ err: { message: a.message, stack: a.stack } }, b as string | undefined]
56
- : [a as Record<string, unknown> | undefined, b as string | undefined];
138
+ let obj: unknown;
139
+ let msg: unknown;
140
+ if (typeof a === 'string') {
141
+ msg = a;
142
+ } else {
143
+ msg = b;
144
+ try {
145
+ obj =
146
+ a instanceof Error
147
+ ? { err: { name: a.name, message: a.message, stack: a.stack } }
148
+ : (a as Record<string, unknown> | undefined);
149
+ } catch {
150
+ obj = { err: '[Uninspectable log argument]' };
151
+ }
152
+ }
153
+ return { obj, msg };
154
+ }
57
155
 
58
- if (isTTY) {
59
- const fields = obj && Object.keys(obj).length ? ` ${DIM}${JSON.stringify(obj)}${RESET}` : '';
60
- console.log(
61
- `${DIM}${clock(new Date())}${RESET} ${COLORS[level]}${level.toUpperCase().padEnd(5)}${RESET} ${msg ?? ''}${fields}`
62
- );
63
- return;
156
+ export function formatLogEntry(level: LogLevel, a?: unknown, b?: unknown, tty = isTTY): string {
157
+ const { obj, msg } = logArguments(a, b);
158
+ if (tty) {
159
+ const fieldsObject = safeFields(obj);
160
+ const serialized = fieldsObject
161
+ ? (json(fieldsObject) ?? JSON.stringify({ logSerializationError: UNSERIALIZABLE_FIELDS }))
162
+ : null;
163
+ const fields = serialized && serialized !== '{}' ? ` ${DIM}${serialized}${RESET}` : '';
164
+ return `${DIM}${clock(new Date())}${RESET} ${COLORS[level]}${level.toUpperCase().padEnd(5)}${RESET} ${safeText(msg)}${fields}`;
64
165
  }
65
- console.log(
66
- JSON.stringify({ level: LEVELS[level], time: Date.now(), ...(obj ?? {}), msg: msg ?? '' })
67
- );
166
+ return logLine(level, obj, msg);
167
+ }
168
+
169
+ function write(level: LogLevel, a?: unknown, b?: unknown): void {
170
+ if (LEVELS[level] < threshold) return;
171
+ console.log(formatLogEntry(level, a, b));
68
172
  }
69
173
 
70
174
  export const logger = {
@@ -0,0 +1,82 @@
1
+ import type { ServerConfig } from './manager';
2
+
3
+ const MANAGED_PROXY_PATH_PATTERN = /^\/(?:[A-Za-z0-9._~-]+\/)*api$/;
4
+
5
+ export interface ManagedTargetPolicy {
6
+ /** Exact browser-visible proxy alias accepted in addition to legacy `/api`. */
7
+ proxyPath: string;
8
+ /** Local upstream represented by the alias; it still passes the anti-SSRF gate. */
9
+ proxyUrl?: string;
10
+ }
11
+
12
+ /** Validate the one browser-visible proxy path that aliases the local managed server. */
13
+ export function resolveManagedProxyPath(value: string | undefined): string {
14
+ const path = value?.trim() || '/api';
15
+ if (
16
+ !MANAGED_PROXY_PATH_PATTERN.test(path) ||
17
+ path.split('/').some((segment) => segment === '.' || segment === '..')
18
+ ) {
19
+ throw new Error('Managed proxy path must be /api or a safe root-relative path ending in /api');
20
+ }
21
+ return path;
22
+ }
23
+
24
+ /** Resolve the alias once without cloning a request or touching its body stream. */
25
+ export function resolveManagedTargetPolicy(
26
+ proxyPath: string | undefined,
27
+ proxyUrl?: string
28
+ ): ManagedTargetPolicy {
29
+ return {
30
+ proxyPath: resolveManagedProxyPath(proxyPath),
31
+ proxyUrl: proxyUrl?.trim() || undefined,
32
+ };
33
+ }
34
+
35
+ /** Refuse agent-side SDK operations for a server the agent does not manage. */
36
+ export function assertManagedTarget(
37
+ query: URLSearchParams,
38
+ config: ServerConfig,
39
+ policy: ManagedTargetPolicy | string =
40
+ process.env.BUNQUEUE_URL?.trim() || 'http://localhost:6790'
41
+ ): void {
42
+ const target = query.get('target');
43
+ if (!target) throw new Error('Managed Bunqueue target is required');
44
+ const proxyPath =
45
+ typeof policy === 'string' ? '/api' : resolveManagedProxyPath(policy.proxyPath);
46
+ const proxyTarget =
47
+ (typeof policy === 'string' ? policy : policy.proxyUrl?.trim()) ||
48
+ process.env.BUNQUEUE_URL?.trim() ||
49
+ 'http://localhost:6790';
50
+ if (target === '/api' || target === proxyPath) {
51
+ assertLocalTarget(proxyTarget, config, 'Dashboard /api proxy');
52
+ return;
53
+ }
54
+ assertLocalTarget(target, config, 'Managed target');
55
+ }
56
+
57
+ /** Resolve the exact AUTH_TOKENS value inherited by the managed child. */
58
+ export function managedAuthToken(config: ServerConfig): string | undefined {
59
+ const configured = Object.hasOwn(config.extraEnv, 'AUTH_TOKENS')
60
+ ? config.extraEnv.AUTH_TOKENS
61
+ : process.env.AUTH_TOKENS;
62
+ return configured
63
+ ?.split(',')
64
+ .map((token) => token.trim())
65
+ .find(Boolean);
66
+ }
67
+
68
+ function assertLocalTarget(target: string, config: ServerConfig, label: string): void {
69
+ let url: URL;
70
+ try {
71
+ url = new URL(target);
72
+ } catch {
73
+ throw new Error(`${label} must be an absolute local HTTP URL`);
74
+ }
75
+ const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
76
+ const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
77
+ if (!local || port !== config.httpPort || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
78
+ throw new Error(
79
+ `${label} ${target} does not match the agent-managed Bunqueue server on port ${config.httpPort}`
80
+ );
81
+ }
82
+ }
@@ -0,0 +1,84 @@
1
+ import type { ServerConfig } from './types';
2
+
3
+ const CONFIG_KEYS = new Set<keyof ServerConfig>([
4
+ 'command',
5
+ 'httpPort',
6
+ 'tcpPort',
7
+ 'dataPath',
8
+ 'extraEnv',
9
+ ]);
10
+
11
+ export function defaultConfig(): ServerConfig {
12
+ return {
13
+ command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.8.59 start',
14
+ httpPort: Number(process.env.HTTP_PORT) || 6790,
15
+ tcpPort: Number(process.env.TCP_PORT) || 6789,
16
+ dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
17
+ extraEnv: {},
18
+ };
19
+ }
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
23
+ const prototype = Object.getPrototypeOf(value);
24
+ return prototype === Object.prototype || prototype === null;
25
+ }
26
+
27
+ function validatePort(name: 'httpPort' | 'tcpPort', value: unknown): number {
28
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65_535) {
29
+ throw new Error(`${name} must be an integer between 1 and 65535`);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ /** Validate an untrusted PUT /control/config body before merging any field. */
35
+ export function validateConfigPatch(value: unknown): Partial<ServerConfig> {
36
+ if (!isRecord(value)) throw new Error('Config must be an object');
37
+ for (const key of Object.keys(value)) {
38
+ if (!CONFIG_KEYS.has(key as keyof ServerConfig)) {
39
+ throw new Error(`Unknown config key: ${key}`);
40
+ }
41
+ }
42
+
43
+ const patch: Partial<ServerConfig> = {};
44
+ if (Object.hasOwn(value, 'command')) {
45
+ if (typeof value.command !== 'string' || value.command.trim() === '') {
46
+ throw new Error('command must be a non-empty string');
47
+ }
48
+ patch.command = value.command;
49
+ }
50
+ if (Object.hasOwn(value, 'httpPort')) {
51
+ patch.httpPort = validatePort('httpPort', value.httpPort);
52
+ }
53
+ if (Object.hasOwn(value, 'tcpPort')) {
54
+ patch.tcpPort = validatePort('tcpPort', value.tcpPort);
55
+ }
56
+ if (Object.hasOwn(value, 'dataPath')) {
57
+ if (typeof value.dataPath !== 'string') throw new Error('dataPath must be a string');
58
+ patch.dataPath = value.dataPath;
59
+ }
60
+ if (Object.hasOwn(value, 'extraEnv')) {
61
+ if (!isRecord(value.extraEnv)) {
62
+ throw new Error('extraEnv must be an object containing only string values');
63
+ }
64
+ const entries = Object.entries(value.extraEnv);
65
+ for (const [key, entry] of entries) {
66
+ if (typeof entry !== 'string') throw new Error(`extraEnv.${key} must be a string`);
67
+ }
68
+ patch.extraEnv = Object.fromEntries(entries) as Record<string, string>;
69
+ }
70
+ return patch;
71
+ }
72
+
73
+ export function validateServerConfig(value: unknown): ServerConfig {
74
+ if (!isRecord(value)) throw new Error('Config must be an object');
75
+ const config = validateConfigPatch(value);
76
+ for (const key of CONFIG_KEYS) {
77
+ if (!Object.hasOwn(value, key)) throw new Error(`Missing config key: ${key}`);
78
+ }
79
+ return config as ServerConfig;
80
+ }
81
+
82
+ export function copyConfig(config: ServerConfig): ServerConfig {
83
+ return { ...config, extraEnv: { ...config.extraEnv } };
84
+ }