bunqueue-dashboard 0.0.36 → 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 (76) hide show
  1. package/README.md +20 -7
  2. package/agent/backup/resultValidation.ts +1 -1
  3. package/agent/backup/routes.ts +4 -3
  4. package/agent/flow/routes.ts +7 -6
  5. package/agent/index.ts +17 -4
  6. package/agent/managedTarget.ts +41 -2
  7. package/agent/manager/config.ts +1 -1
  8. package/agent/queue/routes.ts +21 -14
  9. package/agent/server/controlRoutes.ts +39 -7
  10. package/agent/server/controlTarget.ts +116 -0
  11. package/agent/server/errors.ts +1 -0
  12. package/agent/server/handler.ts +10 -1
  13. package/agent/server/router.ts +19 -8
  14. package/agent/server/types.ts +7 -0
  15. package/agent/server.ts +10 -0
  16. package/agent/workflow/runtimeRoutes.ts +5 -4
  17. package/agent/workflows.ts +1 -1
  18. package/dist/.vite/manifest.json +207 -207
  19. package/dist/assets/{AddJob-UUa2fSXE.js → AddJob-CWkkVHmf.js} +1 -1
  20. package/dist/assets/{Alerts-7oGYfQMb.js → Alerts-Gh1pVbqj.js} +1 -1
  21. package/dist/assets/{Benchmark-C2NFp_kj.js → Benchmark-Dir2A1CQ.js} +1 -1
  22. package/dist/assets/{BulkAddJobs-CL4yBae-.js → BulkAddJobs-Di_IgRSH.js} +1 -1
  23. package/dist/assets/{CopilotPanel-BQ2Cgo9p.js → CopilotPanel-Db5nYpo1.js} +1 -1
  24. package/dist/assets/{Cron-DfWBhoBf.js → Cron-BRgWcBGk.js} +1 -1
  25. package/dist/assets/{CronManager-DAK7RSOX.js → CronManager-CbM0x_s0.js} +1 -1
  26. package/dist/assets/{Database-DqL7giEj.js → Database-BeoEreW0.js} +1 -1
  27. package/dist/assets/{Diagnostics-D98YwN-6.js → Diagnostics-Cw9iJ84f.js} +1 -1
  28. package/dist/assets/{Dlq-BHmA5PTI.js → Dlq-DfCg04Jd.js} +1 -1
  29. package/dist/assets/{DlqControl-DihxvP-i.js → DlqControl-BRCkUXvb.js} +1 -1
  30. package/dist/assets/{DlqPro-ncgnDtpE.js → DlqPro-BHJwBYsN.js} +1 -1
  31. package/dist/assets/{Flows-B5N5Cil0.js → Flows-BAzbFP_W.js} +2 -2
  32. package/dist/assets/{JobInspector-B40nFiGh.js → JobInspector-qvW_APO6.js} +2 -2
  33. package/dist/assets/{Jobs-Bla3IEef.js → Jobs-URpnFjC6.js} +1 -1
  34. package/dist/assets/{JobsPro-CnczVx7x.js → JobsPro-pQ_RFSU3.js} +1 -1
  35. package/dist/assets/{Logs-T91Q3cH0.js → Logs-C5jX2OPE.js} +1 -1
  36. package/dist/assets/{LogsPro-CMn6-Wwv.js → LogsPro-Cq_npmmS.js} +1 -1
  37. package/dist/assets/{McpServer-BQBabwW-.js → McpServer-DtCTmQBu.js} +2 -2
  38. package/dist/assets/{Metrics-DxT_RI2B.js → Metrics-BdxitePr.js} +1 -1
  39. package/dist/assets/{MetricsPro-Cjen-nAP.js → MetricsPro-orHfW0mg.js} +1 -1
  40. package/dist/assets/{Overview-XN2ej8ph.js → Overview-DaNns1aj.js} +1 -1
  41. package/dist/assets/{QueueActions-C-Aferd6.js → QueueActions-jiTzndPH.js} +1 -1
  42. package/dist/assets/{QueueControl-B8Q9NX6t.js → QueueControl-l5bSdMgb.js} +1 -1
  43. package/dist/assets/{QueueDetail-BMhG0jUB.js → QueueDetail-URK2pjtH.js} +1 -1
  44. package/dist/assets/{QueueDetailPro-BKTTyLR4.js → QueueDetailPro-C9M5CV4K.js} +1 -1
  45. package/dist/assets/{Queues-COqPMoY9.js → Queues-DeLXlww2.js} +1 -1
  46. package/dist/assets/{QueuesOverview-pWdrPy8Z.js → QueuesOverview-DoQ-MUfT.js} +1 -1
  47. package/dist/assets/{S3Backup-BmsZ8xtq.js → S3Backup-D2agV9_z.js} +1 -1
  48. package/dist/assets/{S3BackupPro-AtlsPUJL.js → S3BackupPro--WSZWTQn.js} +3 -3
  49. package/dist/assets/{ServerControl-D3XwHr72.js → ServerControl-QVd6m_iR.js} +3 -3
  50. package/dist/assets/{Settings-BofKZoHn.js → Settings-CiJRj_SM.js} +1 -1
  51. package/dist/assets/{Usage-DAR9E22v.js → Usage-DbHJgA4t.js} +1 -1
  52. package/dist/assets/{UsagePro-Bc30N2Lo.js → UsagePro-LoPo3k1L.js} +1 -1
  53. package/dist/assets/{Webhooks-Bg2k1PU4.js → Webhooks-CuzIJHwk.js} +1 -1
  54. package/dist/assets/{Workers-BqUZMJVQ.js → Workers-BpUgpwUH.js} +1 -1
  55. package/dist/assets/{WorkersPro-C9aSadUh.js → WorkersPro-BYY0P1Pt.js} +1 -1
  56. package/dist/assets/{Workflows-BEFMfHmo.js → Workflows-ByJw4FFH.js} +1 -1
  57. package/dist/assets/{api-CQ5Z7xR7.js → api-V_sVpHDo.js} +1 -1
  58. package/dist/assets/{bq-BeBxa743.js → bq-DYmbVkgR.js} +1 -1
  59. package/dist/assets/{connectionStore-DbBYqHEY.js → connectionStore-CKFQrxDo.js} +1 -1
  60. package/dist/assets/{controlConnectionEpoch-CFSO6237.js → controlConnectionEpoch-JM4omZ6j.js} +1 -1
  61. package/dist/assets/{flowMutationSafety-CoOp94wS.js → flowMutationSafety-DObp9nJo.js} +1 -1
  62. package/dist/assets/index-D64v2vNO.js +8 -0
  63. package/dist/assets/{install-Cd9Is10G.js → install-jXi1SOYY.js} +1 -1
  64. package/dist/assets/{responses-kPO4rSu5.js → responses-CBLyy1Ob.js} +1 -1
  65. package/dist/assets/{toastStore-CS_9Xndn.js → toastStore-B7n0zS0y.js} +1 -1
  66. package/dist/assets/{upstreamPaths-D_LPsv7b.js → upstreamPaths-CDVsu6IM.js} +1 -1
  67. package/dist/assets/{usePolledData-DzlUpBOO.js → usePolledData-c53tx1jw.js} +1 -1
  68. package/dist/assets/{useServerActionGuard-BlH-Kso1.js → useServerActionGuard-OTv6_fU0.js} +1 -1
  69. package/dist/assets/{useThroughputSeries-C2pa1Wd_.js → useThroughputSeries-DWHQl7cM.js} +1 -1
  70. package/dist/index.html +7 -7
  71. package/package.json +5 -3
  72. package/scripts/embedded.gen.ts +160 -160
  73. package/scripts/serve.ts +38 -7
  74. package/scripts/serveHandler.ts +27 -7
  75. package/scripts/servePolicy.ts +54 -0
  76. package/dist/assets/index-Cui9IwWy.js +0 -8
package/README.md CHANGED
@@ -90,13 +90,13 @@ The full dashboard running on sample data, no server needed.
90
90
  bunqueue exposes a rich HTTP API, but operating it by hand (curl, ad-hoc scripts) is slow and
91
91
  error-prone. This dashboard is a production-oriented operator console: it exposes the verified,
92
92
  safe subset of the API, fails closed where
93
- [v2.8.57](https://github.com/egeominotti/bunqueue/releases/tag/v2.8.57) lacks atomic
93
+ [v2.8.59](https://github.com/egeominotti/bunqueue/releases/tag/v2.8.59) lacks atomic
94
94
  flow-safety guarantees, and also manages the
95
95
  server *process* through a separate guarded agent.
96
96
 
97
97
  It uses Bunqueue's public HTTP API (`:6790`) for ordinary remote observability and a small local
98
98
  **control agent** for process, FlowProducer, Workflow Engine, database, and backup operations. The
99
- agent is pinned to the server it manages and uses the exact installed Bunqueue 2.8.57 client
99
+ agent is pinned to the server it manages and uses the exact installed Bunqueue 2.8.59 client
100
100
  contracts; it never patches Bunqueue internals.
101
101
 
102
102
  ## Features
@@ -117,7 +117,7 @@ contracts; it never patches Bunqueue internals.
117
117
  | **S3 backup** | Management ▸ S3 Backup | Configure, inspect, list, create and guarded-restore official snapshots |
118
118
  | **Browse** | Queues / Jobs / DLQ / Cron / Metrics / Workers / Logs | Read-only browsing with basic actions |
119
119
 
120
- > Job actions are gated by the v2.8.57 flow contract. Every DLQ retry and completed-job requeue is
120
+ > Job actions are gated by the v2.8.59 flow contract. Every DLQ retry and completed-job requeue is
121
121
  > unavailable: the DLQ GET + POST sequence has no atomic generation/state/topology precondition and
122
122
  > can target a job recreated under the same ID, while `retryCompleted` does not rebuild dependency
123
123
  > registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
@@ -140,7 +140,7 @@ http://127.0.0.1:8080, proxies `/api/*` to your bunqueue server (`BUNQUEUE_URL`,
140
140
  `http://localhost:6790`), and runs the control agent on `127.0.0.1:6800`. Same env knobs as the
141
141
  standalone binaries: `PORT` · `BIND_ADDR` · `BUNQUEUE_URL` · `AGENT_PORT` ·
142
142
  `AGENT_ALLOWED_ORIGINS` · `AGENT_ALLOWED_HOSTS` · `AGENT_TOKEN` · `BUNQUEUE_TOKEN` ·
143
- `TRUST_PROXY` · `BUNQUEUE_START_CMD`.
143
+ `TRUST_PROXY` · `BASE_PATH` · `BUNQUEUE_MANAGED` · `BUNQUEUE_START_CMD`.
144
144
 
145
145
  Install it permanently instead of running via `bunx`:
146
146
 
@@ -196,14 +196,17 @@ Two HTTP clients coexist on purpose: `src/lib/api.ts` (first-generation view pag
196
196
 
197
197
  ## Configuration
198
198
 
199
- All dashboard variables are build-time (`VITE_*`) and can **also** be changed at runtime from the
200
- in-app **Settings** page. Copy [`.env.example`](.env.example) to `.env` to set defaults.
199
+ The `VITE_*` dashboard defaults are build-time values and can **also** be changed at runtime from
200
+ the in-app **Settings** page. The all-in-one server reads the non-`VITE_*` runtime variables below.
201
+ Copy [`.env.example`](.env.example) to `.env` to set defaults.
201
202
 
202
203
  | Variable | Purpose | Default |
203
204
  | --- | --- | --- |
204
205
  | `VITE_BUNQUEUE_URL` | bunqueue server origin | `/api` (dev proxy → `:6790`) |
205
206
  | `VITE_BUNQUEUE_AGENT_URL` | Control-agent origin | `http://localhost:6800` |
206
207
  | `BIND_ADDR` | All-in-one dashboard bind address | `127.0.0.1` |
208
+ | `BASE_PATH` | Runtime mount for the all-in-one server (for example `/internal/queue`) | `/` |
209
+ | `BUNQUEUE_MANAGED` | `0` attaches to the externally supervised `BUNQUEUE_URL`; `1` enables local lifecycle controls | `1` |
207
210
  | `AGENT_PORT` | Control-agent port | `6800` |
208
211
  | `AGENT_ALLOWED_ORIGINS` | Extra browser origins allowed to drive the agent (comma-separated) | dev defaults |
209
212
  | `AGENT_ALLOWED_HOSTS` | Extra Host names/IPs accepted by the all-in-one server and agent | loopback names |
@@ -224,6 +227,16 @@ proxy, allowlist the public origin; if it rewrites `Host`, also allowlist the
224
227
  rewritten Host and use `TRUST_PROXY=1` only when the proxy overwrites
225
228
  `X-Forwarded-Host`.
226
229
 
230
+ For a broker already owned by systemd, Docker or Kubernetes, run the all-in-one
231
+ dashboard with `BUNQUEUE_MANAGED=0`. **Control ▸ Server** then reports health
232
+ from `BUNQUEUE_URL` and cannot spawn, stop, restart or reconfigure a child.
233
+ Stopped-only backup restore is also rejected because the dashboard cannot prove
234
+ that an externally supervised broker is inactive.
235
+ To mount the same process below a reverse-proxy prefix, set for example
236
+ `BASE_PATH=/internal/queue` and preserve that prefix when proxying; the SPA,
237
+ assets, `/api` proxy and `/agent` bridge all move below it, including the
238
+ target-pinned Flow, Workflow, Queue and Backup agent operations.
239
+
227
240
  ## Scripts
228
241
 
229
242
  | Command | Does |
@@ -282,7 +295,7 @@ The canonical gate must be green before a change is considered done; CI runs the
282
295
  bun run quality # architecture, lint/build, size, docs, coverage, real E2E, packed-bin smoke, audit
283
296
  ```
284
297
 
285
- The E2E stage starts disposable Bunqueue 2.8.57 processes and exercises the complete FlowProducer,
298
+ The E2E stage starts disposable Bunqueue 2.8.59 processes and exercises the complete FlowProducer,
286
299
  Workflow Engine, and Queue SDK operator bridges. Run it alone with `bun run test:e2e`.
287
300
 
288
301
  CI enforces this on every push and pull request. See [Contributing](#contributing).
@@ -81,5 +81,5 @@ function validIsoDate(value: string): boolean {
81
81
  }
82
82
 
83
83
  function invalid(label: string): never {
84
- throw new Error(`Bunqueue ${label} has an invalid 2.8.57 contract`);
84
+ throw new Error(`Bunqueue ${label} has an invalid 2.8.59 contract`);
85
85
  }
@@ -1,4 +1,4 @@
1
- import { assertManagedTarget } from '../managedTarget';
1
+ import { assertManagedTarget, type ManagedTargetPolicy } from '../managedTarget';
2
2
  import type { DbStats, ServerConfig } from '../manager';
3
3
  import { readLimitedJsonBody } from '../server/jsonBody';
4
4
  import type { BackupRunnerPort } from './runner';
@@ -18,12 +18,13 @@ export async function routeBackupRequest(
18
18
  serverRunning: boolean,
19
19
  database: DbStats,
20
20
  runner: BackupRunnerPort,
21
- configure?: (extraEnv: Record<string, string>) => void
21
+ configure?: (extraEnv: Record<string, string>) => void,
22
+ targetPolicy?: ManagedTargetPolicy
22
23
  ): Promise<BackupRouteResponse | null> {
23
24
  const operation = routeOperation(pathname, method);
24
25
  if (!operation) return null;
25
26
  const query = exactTargetQuery(request.url);
26
- assertManagedTarget(query, config);
27
+ assertManagedTarget(query, config, targetPolicy);
27
28
  if (operation === 'configure') {
28
29
  if (!configure) throw new Error('Backup configuration is unavailable');
29
30
  const body = await boundedRecord(request, ['environment']);
@@ -1,5 +1,5 @@
1
1
  import type { ServerConfig } from '../manager';
2
- import { assertManagedTarget } from '../managedTarget';
2
+ import { assertManagedTarget, type ManagedTargetPolicy } from '../managedTarget';
3
3
  import type {
4
4
  ManagedRuntimeAdmission,
5
5
  ManagedRuntimeSnapshot,
@@ -27,6 +27,7 @@ export interface FlowOperationsPort {
27
27
  export interface FlowRouteDependencies {
28
28
  admission?: ManagedRuntimeAdmission;
29
29
  operations?: FlowOperationsPort;
30
+ targetPolicy?: ManagedTargetPolicy;
30
31
  }
31
32
 
32
33
  const DEFAULT_OPERATIONS: FlowOperationsPort = {
@@ -95,7 +96,7 @@ export async function routeFlowRequest(
95
96
  }
96
97
  if (pathname === '/flows/create' && method === 'POST') {
97
98
  const query = exactQuery(request.url, ['target']);
98
- assertManagedTarget(query, config);
99
+ assertManagedTarget(query, config, dependencies.targetPolicy);
99
100
  const body = await readFlowJsonBody(request);
100
101
  return success(
101
102
  await admitted(query, config, serverRunning, dependencies, ({ config: current }) =>
@@ -105,7 +106,7 @@ export async function routeFlowRequest(
105
106
  }
106
107
  if (pathname === '/flows/tree' && method === 'GET') {
107
108
  const query = exactQuery(request.url, ['id', 'queueName', 'depth', 'maxChildren', 'target']);
108
- assertManagedTarget(query, config);
109
+ assertManagedTarget(query, config, dependencies.targetPolicy);
109
110
  const target = {
110
111
  id: required(query, 'id'),
111
112
  queueName: required(query, 'queueName'),
@@ -120,7 +121,7 @@ export async function routeFlowRequest(
120
121
  }
121
122
  if (pathname === '/flows/results' && method === 'POST') {
122
123
  const query = exactQuery(request.url, ['target']);
123
- assertManagedTarget(query, config);
124
+ assertManagedTarget(query, config, dependencies.targetPolicy);
124
125
  const body = await readFlowJsonBody(request);
125
126
  return success(
126
127
  await admitted(query, config, serverRunning, dependencies, ({ config: current }) =>
@@ -136,7 +137,7 @@ export async function routeFlowRequest(
136
137
  ? ['queueName', 'target', 'ttl']
137
138
  : ['queueName', 'target']
138
139
  );
139
- assertManagedTarget(query, config);
140
+ assertManagedTarget(query, config, dependencies.targetPolicy);
140
141
  const target = { id: decodeURIComponent(match[1]), queueName: required(query, 'queueName') };
141
142
  const operation = match[2];
142
143
  if (method === 'GET' && operation === 'waitUntilFinished') {
@@ -176,7 +177,7 @@ function admitted<T>(
176
177
  operation: (snapshot: ManagedRuntimeSnapshot) => Promise<T>
177
178
  ): Promise<T> {
178
179
  const execute = async (snapshot: ManagedRuntimeSnapshot) => {
179
- assertManagedTarget(query, snapshot.config);
180
+ assertManagedTarget(query, snapshot.config, dependencies.targetPolicy);
180
181
  assertRunning(snapshot.running);
181
182
  return operation(snapshot);
182
183
  };
package/agent/index.ts CHANGED
@@ -9,11 +9,17 @@
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';
17
23
  import { installAgentShutdown } from './shutdown';
18
24
 
19
25
  const mgr = new ProcessManager();
@@ -25,8 +31,9 @@ const allowedOrigins = resolveAllowedOrigins();
25
31
  // GETs. Extend via AGENT_ALLOWED_HOSTS when fronted by a proxy on another host.
26
32
  const allowedHosts = resolveAllowedHosts();
27
33
  const token = process.env.AGENT_TOKEN || undefined;
34
+ const controlTarget = resolveServerControlTarget(process.env);
28
35
 
29
- const handle = createFetchHandler(mgr, { allowedOrigins, allowedHosts, token });
36
+ const handle = createFetchHandler(mgr, { allowedOrigins, allowedHosts, token, controlTarget });
30
37
  const stopAccepting: Array<() => unknown | Promise<unknown>> = [];
31
38
  installAgentShutdown(handle, { stopAccepting });
32
39
 
@@ -38,6 +45,12 @@ const server = Bun.serve({
38
45
  stopAccepting.push(() => server.stop());
39
46
 
40
47
  logger.info(
41
- { 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
+ },
42
55
  'bunqueue dashboard control agent ready'
43
56
  );
@@ -1,14 +1,53 @@
1
1
  import type { ServerConfig } from './manager';
2
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
+
3
35
  /** Refuse agent-side SDK operations for a server the agent does not manage. */
4
36
  export function assertManagedTarget(
5
37
  query: URLSearchParams,
6
38
  config: ServerConfig,
7
- proxyTarget = process.env.BUNQUEUE_URL?.trim() || 'http://localhost:6790'
39
+ policy: ManagedTargetPolicy | string =
40
+ process.env.BUNQUEUE_URL?.trim() || 'http://localhost:6790'
8
41
  ): void {
9
42
  const target = query.get('target');
10
43
  if (!target) throw new Error('Managed Bunqueue target is required');
11
- if (target === '/api') {
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) {
12
51
  assertLocalTarget(proxyTarget, config, 'Dashboard /api proxy');
13
52
  return;
14
53
  }
@@ -10,7 +10,7 @@ const CONFIG_KEYS = new Set<keyof ServerConfig>([
10
10
 
11
11
  export function defaultConfig(): ServerConfig {
12
12
  return {
13
- command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.8.57 start',
13
+ command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.8.59 start',
14
14
  httpPort: Number(process.env.HTTP_PORT) || 6790,
15
15
  tcpPort: Number(process.env.TCP_PORT) || 6789,
16
16
  dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
@@ -1,6 +1,6 @@
1
1
  import type { QueueMetricType } from 'bunqueue/client';
2
2
  import type { ServerConfig } from '../manager';
3
- import { assertManagedTarget } from '../managedTarget';
3
+ import { assertManagedTarget, type ManagedTargetPolicy } from '../managedTarget';
4
4
  import type {
5
5
  ManagedRuntimeAdmission,
6
6
  ManagedRuntimeSnapshot,
@@ -29,32 +29,33 @@ export async function routeQueueOperationsRequest(
29
29
  config: ServerConfig,
30
30
  runtime: QueueOperationsPort,
31
31
  running: boolean,
32
- admission?: ManagedRuntimeAdmission
32
+ admission?: ManagedRuntimeAdmission,
33
+ targetPolicy?: ManagedTargetPolicy
33
34
  ): Promise<RouteResponse | null> {
34
35
  const read = pathname.match(READ_ROUTE);
35
36
  if (read && method === 'GET') {
36
37
  const queue = decodedQueue(read[1]);
37
38
  if (read[2] === 'limits') {
38
- const query = pinnedQuery(request, config, ['target', 'maxJobs']);
39
+ const query = pinnedQuery(request, config, ['target', 'maxJobs'], targetPolicy);
39
40
  assertRunning(running);
40
41
  const maxJobs = optionalMaxJobs(query);
41
- return admitted(query, config, running, admission, async ({ config: current }) =>
42
+ return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
42
43
  ok({ limits: await runtime.limits(current, queue, maxJobs) })
43
44
  );
44
45
  }
45
46
  if (read[2] === 'deduplication') {
46
- const query = pinnedQuery(request, config, ['target', 'deduplicationId']);
47
+ const query = pinnedQuery(request, config, ['target', 'deduplicationId'], targetPolicy);
47
48
  const id = requiredDeduplicationId(query.get('deduplicationId'));
48
49
  assertRunning(running);
49
- return admitted(query, config, running, admission, async ({ config: current }) =>
50
+ return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
50
51
  ok({ jobId: await runtime.deduplicationJobId(current, queue, id) })
51
52
  );
52
53
  }
53
- const query = pinnedQuery(request, config, ['target', 'type', 'start', 'end']);
54
+ const query = pinnedQuery(request, config, ['target', 'type', 'start', 'end'], targetPolicy);
54
55
  const type = metricType(query.get('type'));
55
56
  const range = metricsRange(query);
56
57
  assertRunning(running);
57
- return admitted(query, config, running, admission, async ({ config: current }) =>
58
+ return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
58
59
  ok({ metrics: await runtime.metrics(current, queue, type, range.start, range.end) })
59
60
  );
60
61
  }
@@ -62,20 +63,20 @@ export async function routeQueueOperationsRequest(
62
63
  const mutation = pathname.match(MUTATION_ROUTE);
63
64
  if (mutation && method === 'POST') {
64
65
  const queue = decodedQueue(mutation[1]);
65
- const query = pinnedQuery(request, config, ['target']);
66
+ const query = pinnedQuery(request, config, ['target'], targetPolicy);
66
67
  assertRunning(running);
67
68
  if (mutation[2] === 'deduplication/remove') {
68
69
  const body = await exactJsonBody(request, ['deduplicationId']);
69
70
  const id = requiredDeduplicationId(
70
71
  typeof body.deduplicationId === 'string' ? body.deduplicationId : null
71
72
  );
72
- return admitted(query, config, running, admission, async ({ config: current }) =>
73
+ return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
73
74
  ok({ removed: await runtime.removeDeduplicationKey(current, queue, id) })
74
75
  );
75
76
  }
76
77
  const body = await exactJsonBody(request, ['maxLength']);
77
78
  const retention = eventRetention(body.maxLength);
78
- return admitted(query, config, running, admission, async ({ config: current }) =>
79
+ return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
79
80
  ok({ removed: await runtime.trimEvents(current, queue, retention) })
80
81
  );
81
82
  }
@@ -90,19 +91,25 @@ function admitted<T>(
90
91
  config: ServerConfig,
91
92
  running: boolean,
92
93
  admission: ManagedRuntimeAdmission | undefined,
94
+ targetPolicy: ManagedTargetPolicy | undefined,
93
95
  operation: (snapshot: ManagedRuntimeSnapshot) => Promise<T>
94
96
  ): Promise<T> {
95
97
  const execute = async (snapshot: ManagedRuntimeSnapshot) => {
96
- assertManagedTarget(query, snapshot.config);
98
+ assertManagedTarget(query, snapshot.config, targetPolicy);
97
99
  assertRunning(snapshot.running);
98
100
  return operation(snapshot);
99
101
  };
100
102
  return admission ? admission(execute) : execute({ config, running });
101
103
  }
102
104
 
103
- function pinnedQuery(request: Request, config: ServerConfig, allowed: string[]) {
105
+ function pinnedQuery(
106
+ request: Request,
107
+ config: ServerConfig,
108
+ allowed: string[],
109
+ targetPolicy?: ManagedTargetPolicy
110
+ ) {
104
111
  const query = exactQuery(request.url, allowed);
105
- assertManagedTarget(query, config);
112
+ assertManagedTarget(query, config, targetPolicy);
106
113
  return query;
107
114
  }
108
115
 
@@ -5,13 +5,32 @@ import {
5
5
  type WorkflowRuntimePort,
6
6
  } from '../workflow/runtime';
7
7
  import { readLimitedJsonBody } from './jsonBody';
8
+ import {
9
+ MANAGED_CONTROL_TARGET,
10
+ probeExternalHealth,
11
+ type ServerControlTarget,
12
+ } from './controlTarget';
8
13
  import type { AgentLifecyclePort } from './lifecycle';
9
14
  import type { RouteResponse } from './types';
10
15
 
11
16
  const MAX_CONFIG_BODY_BYTES = 64 * 1024;
12
17
 
13
- async function statusWithHealth(manager: ProcessManager) {
18
+ async function statusWithHealth(manager: ProcessManager, controlTarget: ServerControlTarget) {
14
19
  const snapshot = manager.getStatus();
20
+ if (controlTarget.mode === 'external') {
21
+ const health = await probeExternalHealth(controlTarget);
22
+ return {
23
+ ...snapshot,
24
+ managementMode: 'external' as const,
25
+ healthy: health.healthy,
26
+ version: health.version,
27
+ reachable: health.reachable,
28
+ externalUrl: controlTarget.url,
29
+ healthStatus: health.statusCode,
30
+ healthError: health.error,
31
+ db: null,
32
+ };
33
+ }
15
34
  let healthy = false;
16
35
  let version: string | undefined;
17
36
  if (snapshot.status === 'running') {
@@ -33,7 +52,7 @@ async function statusWithHealth(manager: ProcessManager) {
33
52
  }
34
53
  }
35
54
  const database = await manager.dbStats().catch(() => null);
36
- return { ...snapshot, healthy, version, db: database };
55
+ return { ...snapshot, managementMode: 'managed' as const, healthy, version, db: database };
37
56
  }
38
57
 
39
58
  export async function routeControlRequest(
@@ -42,40 +61,45 @@ export async function routeControlRequest(
42
61
  method: string,
43
62
  manager: ProcessManager,
44
63
  runtime: WorkflowRuntimePort,
45
- lifecycle?: AgentLifecyclePort
64
+ lifecycle?: AgentLifecyclePort,
65
+ controlTarget: ServerControlTarget = MANAGED_CONTROL_TARGET
46
66
  ): Promise<RouteResponse | null> {
47
67
  if (pathname === '/control/status') {
48
- return { status: 200, body: await statusWithHealth(manager) };
68
+ return { status: 200, body: await statusWithHealth(manager, controlTarget) };
49
69
  }
50
70
  if (pathname === '/control/logs') {
51
71
  return { status: 200, body: { lines: manager.getLogs() } };
52
72
  }
53
73
  if (pathname === '/control/start' && method === 'POST') {
74
+ assertManagedControl(controlTarget);
54
75
  return coordinated(lifecycle, async () => {
55
76
  await manager.start();
56
- return { status: 200, body: await statusWithHealth(manager) };
77
+ return { status: 200, body: await statusWithHealth(manager, controlTarget) };
57
78
  });
58
79
  }
59
80
  if (pathname === '/control/stop' && method === 'POST') {
81
+ assertManagedControl(controlTarget);
60
82
  return coordinated(lifecycle, async () => {
61
83
  await stopWithRuntimeCleanup(manager, runtime);
62
- return { status: 200, body: await statusWithHealth(manager) };
84
+ return { status: 200, body: await statusWithHealth(manager, controlTarget) };
63
85
  });
64
86
  }
65
87
  if (pathname === '/control/restart' && method === 'POST') {
88
+ assertManagedControl(controlTarget);
66
89
  return coordinated(lifecycle, async () => {
67
90
  const closeResult = await settle(() => runtime.close());
68
91
  if (!closeResult.ok) {
69
92
  await stopAfterCloseFailure(manager, workflowCloseError(closeResult.error));
70
93
  }
71
94
  await manager.restart();
72
- return { status: 200, body: await statusWithHealth(manager) };
95
+ return { status: 200, body: await statusWithHealth(manager, controlTarget) };
73
96
  });
74
97
  }
75
98
  if (pathname === '/control/config' && method === 'GET') {
76
99
  return { status: 200, body: manager.getConfig() };
77
100
  }
78
101
  if (pathname === '/control/config' && method === 'PUT') {
102
+ assertManagedControl(controlTarget);
79
103
  const patch = validateConfigPatch(
80
104
  await readLimitedJsonBody(request, {
81
105
  scope: 'Agent configuration',
@@ -88,6 +112,14 @@ export async function routeControlRequest(
88
112
  return null;
89
113
  }
90
114
 
115
+ export function assertManagedControl(controlTarget: ServerControlTarget): void {
116
+ if (controlTarget.mode === 'external') {
117
+ throw new Error(
118
+ 'Server lifecycle is disabled in external mode (BUNQUEUE_MANAGED=0); manage this broker with its external supervisor'
119
+ );
120
+ }
121
+ }
122
+
91
123
  async function stopWithRuntimeCleanup(
92
124
  manager: ProcessManager,
93
125
  runtime: WorkflowRuntimePort
@@ -0,0 +1,116 @@
1
+ import { safeErrorMessage } from '../errorMessage';
2
+
3
+ export type ServerManagementMode = 'managed' | 'external';
4
+
5
+ export interface ServerControlTarget {
6
+ mode: ServerManagementMode;
7
+ /** Canonical external HTTP base; present only in attach-only mode. */
8
+ url?: string;
9
+ /** Server bearer used only by the agent-side health probe. */
10
+ token?: string;
11
+ }
12
+
13
+ export interface ExternalHealthProbe {
14
+ reachable: boolean;
15
+ healthy: boolean;
16
+ statusCode: number | null;
17
+ version?: string;
18
+ error?: string;
19
+ }
20
+
21
+ export const MANAGED_CONTROL_TARGET: ServerControlTarget = Object.freeze({ mode: 'managed' });
22
+
23
+ /** Resolve the immutable lifecycle mode once, before the agent starts listening. */
24
+ export function resolveServerControlTarget(
25
+ env: Record<string, string | undefined> = process.env
26
+ ): ServerControlTarget {
27
+ const configured = env.BUNQUEUE_MANAGED?.trim().toLowerCase();
28
+ if (!configured || ['1', 'true', 'yes', 'on'].includes(configured)) {
29
+ return MANAGED_CONTROL_TARGET;
30
+ }
31
+ if (!['0', 'false', 'no', 'off'].includes(configured)) {
32
+ throw new Error('BUNQUEUE_MANAGED must be 1/0, true/false, yes/no, or on/off');
33
+ }
34
+ return Object.freeze({
35
+ mode: 'external',
36
+ url: normalizeExternalUrl(env.BUNQUEUE_URL),
37
+ token: env.BUNQUEUE_TOKEN?.trim() || undefined,
38
+ });
39
+ }
40
+
41
+ function normalizeExternalUrl(value: string | undefined): string {
42
+ const candidate = value?.trim() || 'http://localhost:6790';
43
+ let parsed: URL;
44
+ try {
45
+ parsed = new URL(candidate);
46
+ } catch {
47
+ throw new Error('BUNQUEUE_URL must be an absolute HTTP(S) URL in external mode');
48
+ }
49
+ if (
50
+ (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
51
+ !parsed.hostname ||
52
+ parsed.username ||
53
+ parsed.password ||
54
+ parsed.search ||
55
+ parsed.hash
56
+ ) {
57
+ throw new Error(
58
+ 'BUNQUEUE_URL must be a credential-free HTTP(S) URL without query or fragment in external mode'
59
+ );
60
+ }
61
+ const path = parsed.pathname.replace(/\/+$/, '');
62
+ return `${parsed.origin}${path}`;
63
+ }
64
+
65
+ /** Probe an externally supervised broker without following credential-bearing redirects. */
66
+ export async function probeExternalHealth(target: ServerControlTarget): Promise<ExternalHealthProbe> {
67
+ if (target.mode !== 'external' || !target.url) {
68
+ throw new Error('External health probe requires an external server target');
69
+ }
70
+ let response: Response;
71
+ try {
72
+ response = await fetch(`${target.url}/health`, {
73
+ headers: target.token ? { Authorization: `Bearer ${target.token}` } : undefined,
74
+ redirect: 'error',
75
+ signal: AbortSignal.timeout(1500),
76
+ });
77
+ } catch (error) {
78
+ return {
79
+ reachable: false,
80
+ healthy: false,
81
+ statusCode: null,
82
+ error: safeErrorMessage(error),
83
+ };
84
+ }
85
+
86
+ let body: unknown;
87
+ try {
88
+ body = await response.json();
89
+ } catch {
90
+ return {
91
+ reachable: true,
92
+ healthy: false,
93
+ statusCode: response.status,
94
+ error: `Health endpoint returned non-JSON HTTP ${response.status}`,
95
+ };
96
+ }
97
+ const health = isRecord(body) ? body : {};
98
+ const healthy = response.ok && health.ok === true;
99
+ const version = typeof health.version === 'string' ? health.version : undefined;
100
+ const reportedStatus = typeof health.status === 'string' ? health.status : undefined;
101
+ return {
102
+ reachable: true,
103
+ healthy,
104
+ statusCode: response.status,
105
+ version,
106
+ error: healthy
107
+ ? undefined
108
+ : reportedStatus
109
+ ? `Health reported ${reportedStatus} (HTTP ${response.status})`
110
+ : `Health endpoint returned HTTP ${response.status}`,
111
+ };
112
+ }
113
+
114
+ function isRecord(value: unknown): value is Record<string, unknown> {
115
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
116
+ }
@@ -18,6 +18,7 @@ const CONFLICT_MESSAGES = [
18
18
  'BUNQUEUE_WORKFLOW_QUEUE_NAME must',
19
19
  'BUNQUEUE_WORKFLOW_CONCURRENCY must',
20
20
  'Managed Bunqueue server restarted',
21
+ 'Server lifecycle is disabled in external mode',
21
22
  ];
22
23
 
23
24
  export function errorStatus(error: unknown): number {
@@ -3,7 +3,9 @@ import type { ProcessManager } from '../manager';
3
3
  import { QueueOperationsRuntime } from '../queue/runtime';
4
4
  import type { QueueOperationsPort } from '../queue/types';
5
5
  import { WorkflowRuntime, type WorkflowRuntimePort } from '../workflow/runtime';
6
+ import { resolveManagedTargetPolicy } from '../managedTarget';
6
7
  import { errorMessage, errorStatus } from './errors';
8
+ import { MANAGED_CONTROL_TARGET } from './controlTarget';
7
9
  import { AgentLifecycleGate, type AgentLifecyclePort } from './lifecycle';
8
10
  import { corsHeaders, isHostAllowed, isOriginAllowed, tokenOk } from './policy';
9
11
  import { routeAgentRequest } from './router';
@@ -39,7 +41,12 @@ export function createFetchHandler(
39
41
  allowedHosts,
40
42
  token,
41
43
  requireTokenForAll = false,
44
+ controlTarget = MANAGED_CONTROL_TARGET,
42
45
  } = options;
46
+ const managedTargetPolicy = resolveManagedTargetPolicy(
47
+ options.managedProxyPath,
48
+ options.managedProxyUrl
49
+ );
43
50
  const json = (body: unknown, status: number, origin: string | null) =>
44
51
  responseOf({ body, status }, origin, allowedOrigins);
45
52
 
@@ -74,7 +81,9 @@ export function createFetchHandler(
74
81
  queueRuntime,
75
82
  lifecycle,
76
83
  origin,
77
- allowedOrigins
84
+ allowedOrigins,
85
+ controlTarget,
86
+ managedTargetPolicy
78
87
  );
79
88
  return responseOf(result, origin, allowedOrigins);
80
89
  } catch (error) {