bunqueue-dashboard 0.0.31 → 0.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -15
- package/agent/db.ts +682 -68
- package/agent/dbQueryWorker.ts +41 -10
- package/agent/index.ts +4 -1
- package/agent/manager.ts +267 -41
- package/agent/server.ts +131 -36
- package/dist/.vite/manifest.json +215 -159
- package/dist/assets/AddJob-ytpBLTCJ.js +3 -0
- package/dist/assets/Alerts-C39RrGbQ.js +1 -0
- package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
- package/dist/assets/Benchmark-CM60tHbf.js +1 -0
- package/dist/assets/BulkAddJobs-BjL-xcQy.js +5 -0
- package/dist/assets/CopilotPanel-BGK1rw-V.js +44 -0
- package/dist/assets/Cron-s1e-jgOo.js +1 -0
- package/dist/assets/CronManager-BostU7BX.js +1 -0
- package/dist/assets/Database-ZluStWMz.js +3 -0
- package/dist/assets/Diagnostics-C0N5z5j-.js +1 -0
- package/dist/assets/Dlq-CtcLzs-b.js +1 -0
- package/dist/assets/DlqControl-B_Z8KKn5.js +1 -0
- package/dist/assets/DlqPro-B68en6AG.js +1 -0
- package/dist/assets/Flows-CD_bkCBM.js +1 -0
- package/dist/assets/JobInspector-DwRtyMz0.js +2 -0
- package/dist/assets/Jobs-BUtcBKKk.js +1 -0
- package/dist/assets/JobsPro-BK_xd4ji.js +1 -0
- package/dist/assets/Logs-CmVODYCI.js +1 -0
- package/dist/assets/LogsPro-CJ-Il3LP.js +1 -0
- package/dist/assets/McpServer-dMjyJKgT.js +22 -0
- package/dist/assets/Metrics-Dsbeplz-.js +1 -0
- package/dist/assets/MetricsPro-BqxurxrJ.js +1 -0
- package/dist/assets/Overview-DH9C2dsf.js +1 -0
- package/dist/assets/{Pagination-D4ki-pGz.js → Pagination-DpxiBnt8.js} +1 -1
- package/dist/assets/QueueActions-D_QGBaVm.js +1 -0
- package/dist/assets/QueueControl-V0DJ6qk6.js +1 -0
- package/dist/assets/QueueDetail-BgKAMJqf.js +1 -0
- package/dist/assets/QueueDetailPro-BkHYzGiq.js +1 -0
- package/dist/assets/Queues-2_v7DcNi.js +1 -0
- package/dist/assets/QueuesOverview-BySSkmB4.js +1 -0
- package/dist/assets/S3Backup-CPADzKQE.js +1 -0
- package/dist/assets/S3BackupPro-CSrQYIDL.js +2 -0
- package/dist/assets/ServerControl-C0tI1VDx.js +3 -0
- package/dist/assets/Settings-CSzoM5up.js +1 -0
- package/dist/assets/Usage-B4B7tmrc.js +1 -0
- package/dist/assets/UsagePro-CCBpgSDy.js +1 -0
- package/dist/assets/Webhooks-aJz-GisQ.js +1 -0
- package/dist/assets/Workers-Bg8xKjkg.js +1 -0
- package/dist/assets/WorkersPro-BOOCrXf8.js +1 -0
- package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
- package/dist/assets/{exportFile-tdUeW5m5.js → exportFile-C7xhE8Yh.js} +1 -1
- package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
- package/dist/assets/form-DsUq0w8s.js +1 -0
- package/dist/assets/index-BWoUj86M.css +1 -0
- package/dist/assets/index-Bn2R06qm.js +8 -0
- package/dist/assets/{install-DhTYJCUC.js → install-CUS8Kijb.js} +2 -2
- package/dist/assets/jobActions-LQD4KfCc.js +1 -0
- package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
- package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
- package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
- package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
- package/dist/assets/useThroughputSeries-tb26_lkb.js +1 -0
- package/dist/index.html +5 -4
- package/package.json +11 -3
- package/scripts/embedded.gen.ts +132 -126
- package/scripts/serve.ts +501 -108
- package/dist/assets/AddJob-CAhdg38n.js +0 -3
- package/dist/assets/Alerts-70bxf-PT.js +0 -1
- package/dist/assets/Benchmark-CFZ3WCvj.js +0 -1
- package/dist/assets/BulkAddJobs-BzgzpGg_.js +0 -5
- package/dist/assets/CopilotPanel-DZUGIf1q.js +0 -44
- package/dist/assets/Cron-ltjSoTjj.js +0 -1
- package/dist/assets/CronManager-BcFALDeT.js +0 -1
- package/dist/assets/Database-CV2yt5O2.js +0 -3
- package/dist/assets/Diagnostics-D-EK4GEi.js +0 -1
- package/dist/assets/Dlq-fZAKwQoC.js +0 -1
- package/dist/assets/DlqControl-eTrrQCrn.js +0 -1
- package/dist/assets/DlqPro-CF79339e.js +0 -1
- package/dist/assets/Flows-Ba8G7mcC.js +0 -1
- package/dist/assets/JobInspector--d_8ca9p.js +0 -2
- package/dist/assets/Jobs-DEqAc1A5.js +0 -1
- package/dist/assets/JobsPro-BcaokZIf.js +0 -1
- package/dist/assets/Logs-DBTukPIX.js +0 -1
- package/dist/assets/LogsPro-Wl_e6hZM.js +0 -1
- package/dist/assets/McpServer-CnWJsKw7.js +0 -22
- package/dist/assets/Metrics-DzeIhNL-.js +0 -1
- package/dist/assets/MetricsPro-O9eVJSH0.js +0 -1
- package/dist/assets/Overview-Bnu7rWqt.js +0 -1
- package/dist/assets/QueueActions-DHoSkWRz.js +0 -1
- package/dist/assets/QueueControl-DKqEXnkM.js +0 -1
- package/dist/assets/QueueDetail-DLRow04B.js +0 -1
- package/dist/assets/QueueDetailPro-BYmOVd4T.js +0 -1
- package/dist/assets/Queues-CoyjRFN7.js +0 -1
- package/dist/assets/QueuesOverview-BeWdJpKi.js +0 -1
- package/dist/assets/S3Backup-BaLFgl9d.js +0 -1
- package/dist/assets/S3BackupPro-DJB4oWgz.js +0 -1
- package/dist/assets/ServerControl-_0lVB33E.js +0 -3
- package/dist/assets/Settings-zKFJTtnq.js +0 -1
- package/dist/assets/Usage-CKGkvl0j.js +0 -1
- package/dist/assets/UsagePro-B-TJ6fp4.js +0 -1
- package/dist/assets/Webhooks-C3zmvY-_.js +0 -1
- package/dist/assets/Workers-jKhhXl3W.js +0 -1
- package/dist/assets/WorkersPro-mvY5VX8I.js +0 -1
- package/dist/assets/connectionStore-Ci04lJmR.js +0 -1
- package/dist/assets/form-DGinTEEb.js +0 -1
- package/dist/assets/index-BOcegTHa.js +0 -8
- package/dist/assets/index-DWV3tgrk.css +0 -1
- package/dist/assets/jobActions-CuOdMglt.js +0 -1
- package/dist/assets/promisePool-Daxkc1OB.js +0 -1
- package/dist/assets/useThroughputSeries-Chp5OLx3.js +0 -1
package/agent/server.ts
CHANGED
|
@@ -18,28 +18,45 @@
|
|
|
18
18
|
* cover a *same-origin* request: a page whose DNS name is rebound to
|
|
19
19
|
* 127.0.0.1 issues same-origin GETs that carry no `Origin` header, so it
|
|
20
20
|
* could otherwise read /control/status, /control/logs and the /db/*
|
|
21
|
-
* inspector (job payloads)
|
|
21
|
+
* inspector (job payloads). When the agent is reachable beyond loopback,
|
|
22
|
+
* every route also requires the configured token; Host validation remains
|
|
23
|
+
* necessary because it rejects the rebound request before any data leaves.
|
|
22
24
|
* When `allowedHosts` is configured, a request whose `Host` header names a
|
|
23
25
|
* hostname outside the allowlist is rejected (403) — the rebinding page's
|
|
24
26
|
* `Host` is the attacker's domain, never a loopback/allowlisted host.
|
|
25
|
-
* 4. Optional bearer token (AGENT_TOKEN): when set,
|
|
26
|
-
* must present it
|
|
27
|
+
* 4. Optional bearer token (AGENT_TOKEN): on loopback, when set, changing
|
|
28
|
+
* requests must present it. A network-facing caller must set
|
|
29
|
+
* `requireTokenForAll`, which protects reads and writes alike.
|
|
27
30
|
*/
|
|
28
|
-
import {
|
|
29
|
-
|
|
31
|
+
import {
|
|
32
|
+
type DbFilter,
|
|
33
|
+
dbCell,
|
|
34
|
+
DbExportBusyError,
|
|
35
|
+
DbExportUnavailableError,
|
|
36
|
+
dbInfo,
|
|
37
|
+
dbRows,
|
|
38
|
+
dbSchema,
|
|
39
|
+
dbTables,
|
|
40
|
+
exportWithTimeout,
|
|
41
|
+
MissingDbError,
|
|
42
|
+
queryWithTimeout,
|
|
43
|
+
} from './db';
|
|
44
|
+
import { type ProcessManager, validateConfigPatch } from './manager';
|
|
30
45
|
|
|
31
46
|
export interface AgentOptions {
|
|
32
47
|
allowedOrigins: string[];
|
|
33
48
|
/**
|
|
34
49
|
* DNS-rebinding defense: when set (non-empty), a request whose `Host` header
|
|
35
50
|
* resolves to a hostname outside this list is rejected. Compared by hostname
|
|
36
|
-
* only (port stripped). Leave undefined
|
|
37
|
-
*
|
|
38
|
-
*
|
|
51
|
+
* only (port stripped). Leave undefined only for a loopback-only integration
|
|
52
|
+
* or when a trusted front proxy has already enforced Host. Network-facing
|
|
53
|
+
* listeners must configure this allowlist.
|
|
39
54
|
*/
|
|
40
55
|
allowedHosts?: string[];
|
|
41
|
-
/**
|
|
56
|
+
/** On loopback, when set, POST/PUT requests must present this token. */
|
|
42
57
|
token?: string;
|
|
58
|
+
/** Network exposure: require `token` on every non-OPTIONS request, including reads. */
|
|
59
|
+
requireTokenForAll?: boolean;
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
const DEFAULT_ORIGINS = ['http://localhost:5273', 'http://127.0.0.1:5273'];
|
|
@@ -65,7 +82,13 @@ export function hostnameOf(host: string): string {
|
|
|
65
82
|
const v6 = s.match(/^\[([^\]]+)\]/); // [::1]:6800 → ::1
|
|
66
83
|
if (v6) return v6[1].toLowerCase();
|
|
67
84
|
const i = s.indexOf(':');
|
|
68
|
-
|
|
85
|
+
if (i === -1) return s.toLowerCase();
|
|
86
|
+
// A bare (unbracketed) IPv6 literal has several colons and no port delimiter:
|
|
87
|
+
// splitting on the first one would both lock the real host out and allowlist a
|
|
88
|
+
// bogus single label ('2001:db8::5' → '2001'). Keep it whole — that also makes
|
|
89
|
+
// hostnameOf idempotent over its own bracket-stripped output.
|
|
90
|
+
if (s.indexOf(':') !== s.lastIndexOf(':')) return s.toLowerCase();
|
|
91
|
+
return s.slice(0, i).toLowerCase();
|
|
69
92
|
}
|
|
70
93
|
|
|
71
94
|
/**
|
|
@@ -88,14 +111,14 @@ export function isOriginAllowed(origin: string | null, allowed: string[]): boole
|
|
|
88
111
|
}
|
|
89
112
|
|
|
90
113
|
/**
|
|
91
|
-
* Host-header allowlist. Disabled (always true) when `allowed` is undefined.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* own domain as Host, so it fails
|
|
114
|
+
* Host-header allowlist. Disabled (always true) when `allowed` is undefined.
|
|
115
|
+
* Once enabled it is fail-closed: a real HTTP request always has Host, and
|
|
116
|
+
* accepting a missing one would create an avoidable bypass for unusual proxy
|
|
117
|
+
* paths. A DNS-rebinding page sends its own domain as Host, so it fails too.
|
|
95
118
|
*/
|
|
96
119
|
export function isHostAllowed(host: string | null, allowed?: string[]): boolean {
|
|
97
120
|
if (!allowed) return true;
|
|
98
|
-
if (!host) return
|
|
121
|
+
if (!host) return false;
|
|
99
122
|
return allowed.includes(hostnameOf(host));
|
|
100
123
|
}
|
|
101
124
|
|
|
@@ -113,7 +136,7 @@ export function corsHeaders(origin: string | null, allowed: string[]): Record<st
|
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
function tokenOk(req: Request, token?: string): boolean {
|
|
116
|
-
if (!token) return
|
|
139
|
+
if (!token) return false;
|
|
117
140
|
const auth = req.headers.get('authorization') ?? '';
|
|
118
141
|
const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
|
119
142
|
return bearer === token || req.headers.get('x-agent-token') === token;
|
|
@@ -124,7 +147,7 @@ function tokenOk(req: Request, token?: string): boolean {
|
|
|
124
147
|
* ProcessManager and policy, returns an async (req) => Response.
|
|
125
148
|
*/
|
|
126
149
|
export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
127
|
-
const { allowedOrigins, allowedHosts, token } = opts;
|
|
150
|
+
const { allowedOrigins, allowedHosts, token, requireTokenForAll = false } = opts;
|
|
128
151
|
|
|
129
152
|
const json = (data: unknown, status: number, origin: string | null): Response =>
|
|
130
153
|
new Response(JSON.stringify(data), {
|
|
@@ -143,9 +166,15 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
143
166
|
signal: AbortSignal.timeout(1500),
|
|
144
167
|
});
|
|
145
168
|
if (res.ok) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
169
|
+
const body: unknown = await res.json();
|
|
170
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
171
|
+
const health = body as Record<string, unknown>;
|
|
172
|
+
// HTTP 2xx alone is not a Bunqueue health verdict: an unrelated
|
|
173
|
+
// process, proxy fallback, or malformed payload can also return it.
|
|
174
|
+
// Fail closed unless the semantic health flag is exactly true.
|
|
175
|
+
healthy = health.ok === true;
|
|
176
|
+
if (typeof health.version === 'string') version = health.version;
|
|
177
|
+
}
|
|
149
178
|
}
|
|
150
179
|
} catch {
|
|
151
180
|
/* not up yet */
|
|
@@ -160,10 +189,6 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
160
189
|
const method = req.method;
|
|
161
190
|
const origin = req.headers.get('origin');
|
|
162
191
|
|
|
163
|
-
if (method === 'OPTIONS') {
|
|
164
|
-
return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
|
|
165
|
-
}
|
|
166
|
-
|
|
167
192
|
// DNS-rebinding defense: reject a same-origin request whose Host header is
|
|
168
193
|
// an attacker domain rebound to loopback (no Origin header would be sent,
|
|
169
194
|
// so the Origin gate below can't see it). No-op when allowedHosts is unset.
|
|
@@ -176,16 +201,20 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
176
201
|
return json({ ok: false, error: 'Origin not allowed' }, 403, origin);
|
|
177
202
|
}
|
|
178
203
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
//
|
|
186
|
-
//
|
|
204
|
+
// Preflight never needs a bearer token, but Host and Origin are security
|
|
205
|
+
// boundaries in their own right and therefore fail closed before this 204.
|
|
206
|
+
if (method === 'OPTIONS') {
|
|
207
|
+
return new Response(null, { status: 204, headers: corsHeaders(origin, allowedOrigins) });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Preserve zero-config LOOPBACK use: there, reads stay open and a configured
|
|
211
|
+
// token gates mutations. A network-facing listener sets requireTokenForAll,
|
|
212
|
+
// because Origin alone cannot authenticate curl/no-Origin callers and the db
|
|
213
|
+
// inspector contains job payloads. Misconfiguration fails closed: enabling
|
|
214
|
+
// the all-route gate without a token makes every real route answer 401.
|
|
187
215
|
const mutating = method === 'POST' || method === 'PUT';
|
|
188
|
-
|
|
216
|
+
const authRequired = requireTokenForAll || (mutating && Boolean(token));
|
|
217
|
+
if (authRequired && !tokenOk(req, token)) {
|
|
189
218
|
return json({ ok: false, error: 'Unauthorized' }, 401, origin);
|
|
190
219
|
}
|
|
191
220
|
|
|
@@ -208,7 +237,7 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
208
237
|
|
|
209
238
|
if (pathname === '/control/config' && method === 'GET') return json(mgr.getConfig(), 200, origin);
|
|
210
239
|
if (pathname === '/control/config' && method === 'PUT') {
|
|
211
|
-
const patch = (await req.json())
|
|
240
|
+
const patch = validateConfigPatch(await req.json());
|
|
212
241
|
return json(mgr.setConfig(patch), 200, origin);
|
|
213
242
|
}
|
|
214
243
|
|
|
@@ -229,12 +258,71 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
229
258
|
const segs = pathname.slice('/db/tables/'.length).split('/');
|
|
230
259
|
const table = decodeURIComponent(segs[0] ?? '');
|
|
231
260
|
const sub = segs[1];
|
|
261
|
+
if (segs.length === 2 && sub === 'export') {
|
|
262
|
+
const sp = new URL(req.url).searchParams;
|
|
263
|
+
const allowedParams = new Set(['orderBy', 'dir', 'fcol', 'fop', 'fval']);
|
|
264
|
+
for (const key of sp.keys()) {
|
|
265
|
+
if (!allowedParams.has(key)) throw new Error(`Unknown database export option: ${key}`);
|
|
266
|
+
if (sp.getAll(key).length !== 1) {
|
|
267
|
+
throw new Error(`Duplicate database export option: ${key}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const orderByParam = sp.get('orderBy');
|
|
272
|
+
if (orderByParam === '') throw new Error('Database export orderBy must not be empty');
|
|
273
|
+
const dirParam = sp.get('dir');
|
|
274
|
+
if (dirParam !== null && dirParam !== 'asc' && dirParam !== 'desc') {
|
|
275
|
+
throw new Error('Database export dir must be "asc" or "desc"');
|
|
276
|
+
}
|
|
277
|
+
const fCol = sp.get('fcol');
|
|
278
|
+
const fOp = sp.get('fop');
|
|
279
|
+
const fVal = sp.get('fval');
|
|
280
|
+
const hasAnyFilterPart = fCol !== null || fOp !== null || fVal !== null;
|
|
281
|
+
let filter: DbFilter | undefined;
|
|
282
|
+
if (hasAnyFilterPart) {
|
|
283
|
+
if (!fCol || !fVal || (fOp !== 'contains' && fOp !== 'eq' && fOp !== 'ne')) {
|
|
284
|
+
throw new Error('Database export filter requires valid fcol, fop and fval values');
|
|
285
|
+
}
|
|
286
|
+
filter = { column: fCol, op: fOp, value: fVal };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const exported = await exportWithTimeout(
|
|
290
|
+
mgr.getConfig().dataPath,
|
|
291
|
+
table,
|
|
292
|
+
orderByParam ?? undefined,
|
|
293
|
+
dirParam === 'desc' ? 'desc' : 'asc',
|
|
294
|
+
filter,
|
|
295
|
+
req.signal
|
|
296
|
+
);
|
|
297
|
+
return new Response(exported.content, {
|
|
298
|
+
status: 200,
|
|
299
|
+
headers: {
|
|
300
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
301
|
+
'Content-Length': String(exported.bytes),
|
|
302
|
+
// Keep Content-Length equal to the authoritative export-byte contract;
|
|
303
|
+
// intermediaries must not transparently recompress this body.
|
|
304
|
+
'Cache-Control': 'no-store, no-transform',
|
|
305
|
+
'X-Content-Type-Options': 'nosniff',
|
|
306
|
+
'X-Bunqueue-Db-Export-Version': '1',
|
|
307
|
+
'X-Bunqueue-Db-Export-Table': encodeURIComponent(exported.table),
|
|
308
|
+
'X-Bunqueue-Db-Export-Rows': String(exported.rowCount),
|
|
309
|
+
'X-Bunqueue-Db-Export-Bytes': String(exported.bytes),
|
|
310
|
+
'X-Bunqueue-Db-Export-Cap': exported.cap ?? 'none',
|
|
311
|
+
'Access-Control-Expose-Headers':
|
|
312
|
+
'Content-Length, X-Bunqueue-Db-Export-Version, X-Bunqueue-Db-Export-Table, X-Bunqueue-Db-Export-Rows, X-Bunqueue-Db-Export-Bytes, X-Bunqueue-Db-Export-Cap',
|
|
313
|
+
...corsHeaders(origin, allowedOrigins),
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
232
317
|
if (segs.length === 2 && sub === 'schema') {
|
|
233
318
|
return json({ ok: true, ...dbSchema(mgr.getConfig().dataPath, table) }, 200, origin);
|
|
234
319
|
}
|
|
235
320
|
if (segs.length === 2 && sub === 'cell') {
|
|
236
321
|
const sp = new URL(req.url).searchParams;
|
|
237
|
-
|
|
322
|
+
// Keep rowid textual through the HTTP boundary. Number() rounded
|
|
323
|
+
// values above 2^53 before SQLite ever saw the bound parameter.
|
|
324
|
+
const rowid = sp.get('rowid');
|
|
325
|
+
if (rowid === null) throw new Error('rowid is required');
|
|
238
326
|
const column = sp.get('column') ?? '';
|
|
239
327
|
return json(
|
|
240
328
|
{ ok: true, ...dbCell(mgr.getConfig().dataPath, table, rowid, column) },
|
|
@@ -270,7 +358,14 @@ export function createFetchHandler(mgr: ProcessManager, opts: AgentOptions) {
|
|
|
270
358
|
} catch (e) {
|
|
271
359
|
// A missing database file is an expected pre-first-start condition — 404
|
|
272
360
|
// so the UI can show "no database yet" distinctly from a real read error.
|
|
273
|
-
const status =
|
|
361
|
+
const status =
|
|
362
|
+
e instanceof MissingDbError
|
|
363
|
+
? 404
|
|
364
|
+
: e instanceof DbExportBusyError
|
|
365
|
+
? 429
|
|
366
|
+
: e instanceof DbExportUnavailableError
|
|
367
|
+
? 503
|
|
368
|
+
: 400;
|
|
274
369
|
return json({ ok: false, error: (e as Error).message ?? String(e) }, status, origin);
|
|
275
370
|
}
|
|
276
371
|
};
|