bopodev-api 0.1.12 → 0.1.13

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.
@@ -0,0 +1,580 @@
1
+ import type {
2
+ PluginHook,
3
+ PluginInvocationResult,
4
+ PluginManifest,
5
+ PluginPromptExecutionResult,
6
+ PluginTraceEvent,
7
+ PluginWebhookRequest
8
+ } from "bopodev-contracts";
9
+ import {
10
+ PluginHookSchema,
11
+ PluginInvocationResultSchema,
12
+ PluginManifestSchema,
13
+ PluginPromptExecutionResultSchema,
14
+ PluginTraceEventSchema,
15
+ PluginWebhookRequestSchema
16
+ } from "bopodev-contracts";
17
+ import type { BopoDb } from "bopodev-db";
18
+ import {
19
+ appendAuditEvent,
20
+ appendPluginRun,
21
+ listCompanyPluginConfigs,
22
+ upsertPlugin,
23
+ updatePluginConfig
24
+ } from "bopodev-db";
25
+ import { loadFilesystemPluginManifests } from "./plugin-manifest-loader";
26
+ import { executePluginWebhooks } from "./plugin-webhook-executor";
27
+
28
+ type HookContext = {
29
+ companyId: string;
30
+ agentId: string;
31
+ runId: string;
32
+ requestId?: string;
33
+ providerType?: string;
34
+ runtime?: {
35
+ command?: string;
36
+ cwd?: string;
37
+ };
38
+ workItemCount?: number;
39
+ summary?: string;
40
+ status?: string;
41
+ trace?: unknown;
42
+ outcome?: unknown;
43
+ error?: string;
44
+ };
45
+ export type PluginHookResult = {
46
+ blocked: boolean;
47
+ applied: number;
48
+ failures: string[];
49
+ promptAppend: string | null;
50
+ };
51
+
52
+ type BuiltinPluginExecutor = (context: HookContext) => Promise<PluginInvocationResult> | PluginInvocationResult;
53
+
54
+ const HIGH_RISK_CAPABILITIES = new Set(["network", "queue_publish", "issue_write", "write_memory"]);
55
+
56
+ const builtinPluginDefinitions = [
57
+ {
58
+ id: "trace-exporter",
59
+ version: "0.1.0",
60
+ displayName: "Trace Exporter",
61
+ description: "Emit normalized heartbeat trace events for downstream observability sinks.",
62
+ kind: "lifecycle",
63
+ hooks: ["afterAdapterExecute", "onError", "afterPersist"],
64
+ capabilities: ["emit_audit"],
65
+ runtime: {
66
+ type: "builtin",
67
+ entrypoint: "builtin:trace-exporter",
68
+ timeoutMs: 5000,
69
+ retryCount: 0
70
+ }
71
+ },
72
+ {
73
+ id: "memory-enricher",
74
+ version: "0.1.0",
75
+ displayName: "Memory Enricher",
76
+ description: "Derive and dedupe memory candidate facts from heartbeat outcomes.",
77
+ kind: "lifecycle",
78
+ hooks: ["afterAdapterExecute", "afterPersist"],
79
+ capabilities: ["read_memory", "emit_audit"],
80
+ runtime: {
81
+ type: "builtin",
82
+ entrypoint: "builtin:memory-enricher",
83
+ timeoutMs: 5000,
84
+ retryCount: 0
85
+ }
86
+ },
87
+ {
88
+ id: "queue-publisher",
89
+ version: "0.1.0",
90
+ displayName: "Queue Publisher",
91
+ description: "Publish heartbeat completion/failure payloads to queue integrations.",
92
+ kind: "integration",
93
+ hooks: ["afterPersist", "onError"],
94
+ capabilities: ["queue_publish", "network", "emit_audit"],
95
+ runtime: {
96
+ type: "builtin",
97
+ entrypoint: "builtin:queue-publisher",
98
+ timeoutMs: 5000,
99
+ retryCount: 0
100
+ }
101
+ },
102
+ {
103
+ id: "heartbeat-tagger",
104
+ version: "0.1.0",
105
+ displayName: "Heartbeat Tagger",
106
+ description: "Attach a simple diagnostic tag to heartbeat plugin runs.",
107
+ kind: "lifecycle",
108
+ hooks: ["afterAdapterExecute"],
109
+ capabilities: ["emit_audit"],
110
+ runtime: {
111
+ type: "builtin",
112
+ entrypoint: "builtin:heartbeat-tagger",
113
+ timeoutMs: 3000,
114
+ retryCount: 0
115
+ }
116
+ }
117
+ ] as const;
118
+
119
+ const builtinExecutors: Record<string, BuiltinPluginExecutor> = {
120
+ "trace-exporter": async (context) => ({
121
+ status: "ok",
122
+ summary: "trace-exporter emitted heartbeat trace metadata",
123
+ blockers: [],
124
+ diagnostics: {
125
+ runId: context.runId,
126
+ providerType: context.providerType ?? null,
127
+ status: context.status ?? null
128
+ }
129
+ }),
130
+ "memory-enricher": async (context) => ({
131
+ status: "ok",
132
+ summary: "memory-enricher evaluated summary for memory candidates",
133
+ blockers: [],
134
+ diagnostics: {
135
+ runId: context.runId,
136
+ summaryPresent: typeof context.summary === "string" && context.summary.trim().length > 0
137
+ }
138
+ }),
139
+ "queue-publisher": async (context) => ({
140
+ status: "ok",
141
+ summary: "queue-publisher prepared outbound heartbeat event",
142
+ blockers: [],
143
+ diagnostics: {
144
+ runId: context.runId,
145
+ status: context.status ?? null,
146
+ eventType: context.error ? "heartbeat.failed" : "heartbeat.completed"
147
+ }
148
+ }),
149
+ "heartbeat-tagger": async (context) => ({
150
+ status: "ok",
151
+ summary: "heartbeat-tagger attached diagnostic tag",
152
+ blockers: [],
153
+ diagnostics: {
154
+ tag: "hello-plugin",
155
+ runId: context.runId,
156
+ providerType: context.providerType ?? null
157
+ }
158
+ })
159
+ };
160
+
161
+ export function pluginSystemEnabled() {
162
+ const disabled = process.env.BOPO_PLUGIN_SYSTEM_DISABLED;
163
+ if (disabled === "1" || disabled === "true") {
164
+ return false;
165
+ }
166
+ const legacyEnabled = process.env.BOPO_PLUGIN_SYSTEM_ENABLED;
167
+ if (legacyEnabled === "0" || legacyEnabled === "false") {
168
+ return false;
169
+ }
170
+ return true;
171
+ }
172
+
173
+ export async function ensureBuiltinPluginsRegistered(db: BopoDb, companyIds: string[] = []) {
174
+ const manifests = builtinPluginDefinitions.map((definition) => PluginManifestSchema.parse(definition));
175
+ const manifestIds = new Set(manifests.map((manifest) => manifest.id));
176
+ const fileManifestResult = await loadFilesystemPluginManifests();
177
+ for (const warning of fileManifestResult.warnings) {
178
+ // eslint-disable-next-line no-console
179
+ console.warn(`[plugins] ${warning}`);
180
+ }
181
+ for (const fileManifest of fileManifestResult.manifests) {
182
+ if (manifestIds.has(fileManifest.id)) {
183
+ // eslint-disable-next-line no-console
184
+ console.warn(`[plugins] Skipping filesystem plugin '${fileManifest.id}' because id already exists.`);
185
+ continue;
186
+ }
187
+ manifests.push(fileManifest);
188
+ manifestIds.add(fileManifest.id);
189
+ }
190
+
191
+ for (const manifest of manifests) {
192
+ await registerPluginManifest(db, manifest);
193
+ }
194
+ for (const companyId of companyIds) {
195
+ await ensureCompanyBuiltinPluginDefaults(db, companyId);
196
+ }
197
+ }
198
+
199
+ export async function registerPluginManifest(db: BopoDb, manifest: PluginManifest) {
200
+ await upsertPlugin(db, {
201
+ id: manifest.id,
202
+ name: manifest.displayName,
203
+ version: manifest.version,
204
+ kind: manifest.kind,
205
+ runtimeType: manifest.runtime.type,
206
+ runtimeEntrypoint: manifest.runtime.entrypoint,
207
+ hooksJson: JSON.stringify(manifest.hooks),
208
+ capabilitiesJson: JSON.stringify(manifest.capabilities),
209
+ manifestJson: JSON.stringify(manifest)
210
+ });
211
+ }
212
+
213
+ export async function ensureCompanyBuiltinPluginDefaults(db: BopoDb, companyId: string) {
214
+ const existing = await listCompanyPluginConfigs(db, companyId);
215
+ const existingIds = new Set(existing.map((row) => row.pluginId));
216
+ const defaults = [
217
+ { pluginId: "trace-exporter", enabled: true, priority: 40 },
218
+ { pluginId: "memory-enricher", enabled: true, priority: 60 },
219
+ { pluginId: "queue-publisher", enabled: false, priority: 80 },
220
+ { pluginId: "heartbeat-tagger", enabled: false, priority: 90 }
221
+ ];
222
+ for (const entry of defaults) {
223
+ if (existingIds.has(entry.pluginId)) {
224
+ continue;
225
+ }
226
+ await updatePluginConfig(db, {
227
+ companyId,
228
+ pluginId: entry.pluginId,
229
+ enabled: entry.enabled,
230
+ priority: entry.priority,
231
+ configJson: "{}",
232
+ grantedCapabilitiesJson: "[]"
233
+ });
234
+ }
235
+ }
236
+
237
+ export async function runPluginHook(
238
+ db: BopoDb,
239
+ input: {
240
+ hook: PluginHook;
241
+ context: HookContext;
242
+ failClosed?: boolean;
243
+ }
244
+ ): Promise<PluginHookResult> {
245
+ if (!pluginSystemEnabled()) {
246
+ return { blocked: false, applied: 0, failures: [], promptAppend: null };
247
+ }
248
+ const parsedHook = PluginHookSchema.parse(input.hook);
249
+ const rows = await listCompanyPluginConfigs(db, input.context.companyId);
250
+ const candidates = rows
251
+ .filter((row) => row.enabled)
252
+ .map((row) => {
253
+ const hooks = safeParseStringArray(row.hooksJson);
254
+ const caps = safeParseStringArray(row.capabilitiesJson);
255
+ const grants = safeParseStringArray(row.grantedCapabilitiesJson);
256
+ const manifest = safeParseManifest(row.manifestJson);
257
+ return {
258
+ ...row,
259
+ hooks,
260
+ caps,
261
+ grants,
262
+ manifest
263
+ };
264
+ })
265
+ .filter((row) => row.hooks.includes(parsedHook));
266
+
267
+ const failures: string[] = [];
268
+ const promptAppends: string[] = [];
269
+ let applied = 0;
270
+ for (const plugin of candidates) {
271
+ const startedAt = Date.now();
272
+ try {
273
+ const missingHighRiskCapability = plugin.caps.find(
274
+ (cap) => HIGH_RISK_CAPABILITIES.has(cap) && !plugin.grants.includes(cap)
275
+ );
276
+ if (missingHighRiskCapability) {
277
+ const msg = `plugin '${plugin.pluginId}' requires granted capability '${missingHighRiskCapability}'`;
278
+ failures.push(msg);
279
+ await appendPluginRun(db, {
280
+ companyId: input.context.companyId,
281
+ runId: input.context.runId,
282
+ pluginId: plugin.pluginId,
283
+ hook: parsedHook,
284
+ status: "blocked",
285
+ durationMs: Date.now() - startedAt,
286
+ error: msg
287
+ });
288
+ continue;
289
+ }
290
+ const promptResult = await executePromptPlugin(plugin.manifest, plugin.pluginId, input.context, {
291
+ hook: parsedHook,
292
+ pluginConfig: safeParseJsonRecord(plugin.configJson)
293
+ });
294
+ if (promptResult) {
295
+ const processed = await processPromptPluginResult(db, {
296
+ pluginId: plugin.pluginId,
297
+ companyId: input.context.companyId,
298
+ runId: input.context.runId,
299
+ requestId: input.context.requestId,
300
+ pluginCapabilities: plugin.caps,
301
+ promptResult
302
+ });
303
+ if (processed.promptAppend) {
304
+ promptAppends.push(processed.promptAppend);
305
+ }
306
+ }
307
+ const result =
308
+ promptResult && plugin.manifest?.runtime.type === "prompt"
309
+ ? ({
310
+ status: "ok",
311
+ summary: "prompt plugin applied runtime patches",
312
+ blockers: [],
313
+ diagnostics: {
314
+ source: "prompt-runtime"
315
+ }
316
+ } as PluginInvocationResult)
317
+ : await executePlugin(plugin.pluginId, input.context);
318
+ const validated = PluginInvocationResultSchema.parse(result);
319
+ await appendPluginRun(db, {
320
+ companyId: input.context.companyId,
321
+ runId: input.context.runId,
322
+ pluginId: plugin.pluginId,
323
+ hook: parsedHook,
324
+ status: validated.status,
325
+ durationMs: Date.now() - startedAt,
326
+ diagnosticsJson: JSON.stringify({
327
+ ...(validated.diagnostics ?? {}),
328
+ promptAppendApplied: promptResult?.promptAppend ?? null
329
+ }),
330
+ error: validated.status === "failed" || validated.status === "blocked" ? validated.summary : null
331
+ });
332
+ if (validated.status === "failed" || validated.status === "blocked") {
333
+ failures.push(`plugin '${plugin.pluginId}' returned ${validated.status}: ${validated.summary}`);
334
+ } else {
335
+ applied += 1;
336
+ }
337
+ } catch (error) {
338
+ const msg = String(error);
339
+ failures.push(`plugin '${plugin.pluginId}' failed: ${msg}`);
340
+ await appendPluginRun(db, {
341
+ companyId: input.context.companyId,
342
+ runId: input.context.runId,
343
+ pluginId: plugin.pluginId,
344
+ hook: parsedHook,
345
+ status: "failed",
346
+ durationMs: Date.now() - startedAt,
347
+ error: msg
348
+ });
349
+ }
350
+ }
351
+
352
+ if (failures.length > 0) {
353
+ await appendAuditEvent(db, {
354
+ companyId: input.context.companyId,
355
+ actorType: "system",
356
+ eventType: "plugin.hook.failures",
357
+ entityType: "heartbeat_run",
358
+ entityId: input.context.runId,
359
+ correlationId: input.context.requestId ?? input.context.runId,
360
+ payload: {
361
+ hook: parsedHook,
362
+ failures
363
+ }
364
+ });
365
+ }
366
+ const blocked = Boolean(input.failClosed) && failures.length > 0;
367
+ return {
368
+ blocked,
369
+ applied,
370
+ failures,
371
+ promptAppend: promptAppends.length > 0 ? promptAppends.join("\n\n") : null
372
+ };
373
+ }
374
+
375
+ function safeParseStringArray(value: string | null | undefined) {
376
+ if (!value) {
377
+ return [] as string[];
378
+ }
379
+ try {
380
+ const parsed = JSON.parse(value) as unknown;
381
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
382
+ } catch {
383
+ return [];
384
+ }
385
+ }
386
+
387
+ function safeParseJsonRecord(value: string | null | undefined) {
388
+ if (!value) {
389
+ return {} as Record<string, unknown>;
390
+ }
391
+ try {
392
+ const parsed = JSON.parse(value) as unknown;
393
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
394
+ } catch {
395
+ return {};
396
+ }
397
+ }
398
+
399
+ function safeParseManifest(value: string | null | undefined) {
400
+ if (!value) {
401
+ return null;
402
+ }
403
+ try {
404
+ const parsed = JSON.parse(value) as unknown;
405
+ const manifestParsed = PluginManifestSchema.safeParse(parsed);
406
+ return manifestParsed.success ? manifestParsed.data : null;
407
+ } catch {
408
+ return null;
409
+ }
410
+ }
411
+
412
+ async function executePromptPlugin(
413
+ manifest: PluginManifest | null,
414
+ pluginId: string,
415
+ context: HookContext,
416
+ input: {
417
+ hook: PluginHook;
418
+ pluginConfig: Record<string, unknown>;
419
+ }
420
+ ) {
421
+ if (!manifest || manifest.runtime.type !== "prompt") {
422
+ return null;
423
+ }
424
+ const promptTemplate = manifest.runtime.promptTemplate ?? "";
425
+ const webhookRequests = normalizeWebhookRequests(input.pluginConfig.webhookRequests);
426
+ const traceEvents = normalizeTraceEvents(input.pluginConfig.traceEvents);
427
+ const firstWebhookUrl = webhookRequests[0]?.url ?? "";
428
+ const renderedTemplate = renderPromptTemplate(promptTemplate, {
429
+ pluginId,
430
+ companyId: context.companyId,
431
+ agentId: context.agentId,
432
+ runId: context.runId,
433
+ hook: input.hook,
434
+ summary: context.summary ?? "",
435
+ providerType: context.providerType ?? "",
436
+ pluginConfig: input.pluginConfig,
437
+ webhookRequests,
438
+ traceEvents,
439
+ webhookUrl: firstWebhookUrl
440
+ });
441
+ return PluginPromptExecutionResultSchema.parse({
442
+ promptAppend: renderedTemplate.trim().length > 0 ? renderedTemplate : undefined,
443
+ webhookRequests,
444
+ traceEvents,
445
+ diagnostics: {
446
+ source: "prompt-runtime",
447
+ templateLength: promptTemplate.length
448
+ }
449
+ });
450
+ }
451
+
452
+ async function processPromptPluginResult(
453
+ db: BopoDb,
454
+ input: {
455
+ pluginId: string;
456
+ companyId: string;
457
+ runId: string;
458
+ requestId?: string;
459
+ pluginCapabilities: string[];
460
+ promptResult: PluginPromptExecutionResult;
461
+ }
462
+ ) {
463
+ const canEmitAudit = input.pluginCapabilities.includes("emit_audit");
464
+ const canUseWebhooks = input.pluginCapabilities.includes("network") || input.pluginCapabilities.includes("queue_publish");
465
+
466
+ if (input.promptResult.traceEvents.length > 0) {
467
+ if (!canEmitAudit) {
468
+ throw new Error(`plugin '${input.pluginId}' emitted trace events without granted 'emit_audit' capability`);
469
+ }
470
+ for (const event of input.promptResult.traceEvents) {
471
+ await appendAuditEvent(db, {
472
+ companyId: input.companyId,
473
+ actorType: "system",
474
+ eventType: event.eventType,
475
+ entityType: "heartbeat_run",
476
+ entityId: input.runId,
477
+ correlationId: input.requestId ?? input.runId,
478
+ payload: {
479
+ pluginId: input.pluginId,
480
+ ...event.payload
481
+ }
482
+ });
483
+ }
484
+ }
485
+
486
+ let webhookResults: Awaited<ReturnType<typeof executePluginWebhooks>> = [];
487
+ if (input.promptResult.webhookRequests.length > 0) {
488
+ if (!canUseWebhooks) {
489
+ throw new Error(`plugin '${input.pluginId}' requested webhooks without granted 'network/queue_publish' capability`);
490
+ }
491
+ webhookResults = await executePluginWebhooks(input.promptResult.webhookRequests, {
492
+ pluginId: input.pluginId,
493
+ companyId: input.companyId,
494
+ runId: input.runId
495
+ });
496
+ const failedWebhook = webhookResults.find((entry) => !entry.ok);
497
+ if (failedWebhook) {
498
+ throw new Error(`plugin '${input.pluginId}' webhook request failed: ${failedWebhook.url} (${failedWebhook.error ?? failedWebhook.statusCode ?? "unknown"})`);
499
+ }
500
+ }
501
+
502
+ return {
503
+ promptAppend: input.promptResult.promptAppend ?? null,
504
+ webhookResults
505
+ };
506
+ }
507
+
508
+ function normalizeWebhookRequests(value: unknown): PluginWebhookRequest[] {
509
+ if (!Array.isArray(value)) {
510
+ return [];
511
+ }
512
+ const requests: PluginWebhookRequest[] = [];
513
+ for (const entry of value) {
514
+ const parsed = PluginWebhookRequestSchema.safeParse(entry);
515
+ if (parsed.success) {
516
+ requests.push(parsed.data);
517
+ }
518
+ }
519
+ return requests;
520
+ }
521
+
522
+ function normalizeTraceEvents(value: unknown): PluginTraceEvent[] {
523
+ if (!Array.isArray(value)) {
524
+ return [];
525
+ }
526
+ const events: PluginTraceEvent[] = [];
527
+ for (const entry of value) {
528
+ const parsed = PluginTraceEventSchema.safeParse(entry);
529
+ if (parsed.success) {
530
+ events.push(parsed.data);
531
+ }
532
+ }
533
+ return events;
534
+ }
535
+
536
+ function renderPromptTemplate(
537
+ template: string,
538
+ input: {
539
+ pluginId: string;
540
+ companyId: string;
541
+ agentId: string;
542
+ runId: string;
543
+ hook: string;
544
+ summary: string;
545
+ providerType: string;
546
+ pluginConfig: Record<string, unknown>;
547
+ webhookRequests: PluginWebhookRequest[];
548
+ traceEvents: PluginTraceEvent[];
549
+ webhookUrl: string;
550
+ }
551
+ ) {
552
+ if (!template) {
553
+ return "";
554
+ }
555
+ return template
556
+ .replaceAll("{{pluginId}}", input.pluginId)
557
+ .replaceAll("{{companyId}}", input.companyId)
558
+ .replaceAll("{{agentId}}", input.agentId)
559
+ .replaceAll("{{runId}}", input.runId)
560
+ .replaceAll("{{hook}}", input.hook)
561
+ .replaceAll("{{summary}}", input.summary)
562
+ .replaceAll("{{providerType}}", input.providerType)
563
+ .replaceAll("{{pluginConfig}}", JSON.stringify(input.pluginConfig))
564
+ .replaceAll("{{webhookUrl}}", input.webhookUrl)
565
+ .replaceAll("{{webhookRequests}}", JSON.stringify(input.webhookRequests))
566
+ .replaceAll("{{traceEvents}}", JSON.stringify(input.traceEvents));
567
+ }
568
+
569
+ async function executePlugin(pluginId: string, context: HookContext): Promise<PluginInvocationResult> {
570
+ const executor = builtinExecutors[pluginId];
571
+ if (!executor) {
572
+ return {
573
+ status: "skipped",
574
+ summary: `No executor is registered for plugin '${pluginId}'.`,
575
+ blockers: [],
576
+ diagnostics: { pluginId }
577
+ };
578
+ }
579
+ return executor(context);
580
+ }
@@ -0,0 +1,94 @@
1
+ import type { PluginWebhookRequest } from "bopodev-contracts";
2
+
3
+ export type PluginWebhookExecutionResult = {
4
+ url: string;
5
+ method: string;
6
+ ok: boolean;
7
+ statusCode: number | null;
8
+ elapsedMs: number;
9
+ error?: string;
10
+ };
11
+
12
+ export async function executePluginWebhooks(
13
+ requests: PluginWebhookRequest[],
14
+ input: {
15
+ pluginId: string;
16
+ companyId: string;
17
+ runId: string;
18
+ }
19
+ ) {
20
+ const results: PluginWebhookExecutionResult[] = [];
21
+ const allowlist = resolveWebhookAllowlist();
22
+ for (const request of requests) {
23
+ if (!isWebhookAllowed(request.url, allowlist)) {
24
+ results.push({
25
+ url: request.url,
26
+ method: request.method,
27
+ ok: false,
28
+ statusCode: null,
29
+ elapsedMs: 0,
30
+ error: "Webhook URL not allowed by BOPO_PLUGIN_WEBHOOK_ALLOWLIST."
31
+ });
32
+ continue;
33
+ }
34
+ const startedAt = Date.now();
35
+ try {
36
+ const timeoutController = new AbortController();
37
+ const timeoutId = setTimeout(() => timeoutController.abort(), request.timeoutMs);
38
+ const response = await fetch(request.url, {
39
+ method: request.method,
40
+ headers: {
41
+ "content-type": "application/json",
42
+ "x-bopo-plugin-id": input.pluginId,
43
+ "x-bopo-company-id": input.companyId,
44
+ "x-bopo-run-id": input.runId,
45
+ ...request.headers
46
+ },
47
+ body: request.body ? JSON.stringify(request.body) : undefined,
48
+ signal: timeoutController.signal
49
+ });
50
+ clearTimeout(timeoutId);
51
+ results.push({
52
+ url: request.url,
53
+ method: request.method,
54
+ ok: response.ok,
55
+ statusCode: response.status,
56
+ elapsedMs: Date.now() - startedAt
57
+ });
58
+ } catch (error) {
59
+ results.push({
60
+ url: request.url,
61
+ method: request.method,
62
+ ok: false,
63
+ statusCode: null,
64
+ elapsedMs: Date.now() - startedAt,
65
+ error: String(error)
66
+ });
67
+ }
68
+ }
69
+ return results;
70
+ }
71
+
72
+ function resolveWebhookAllowlist() {
73
+ const raw = process.env.BOPO_PLUGIN_WEBHOOK_ALLOWLIST;
74
+ if (!raw || raw.trim().length === 0) {
75
+ return [] as string[];
76
+ }
77
+ return raw
78
+ .split(",")
79
+ .map((entry) => entry.trim().toLowerCase())
80
+ .filter((entry) => entry.length > 0);
81
+ }
82
+
83
+ function isWebhookAllowed(url: string, allowlist: string[]) {
84
+ if (allowlist.length === 0) {
85
+ return true;
86
+ }
87
+ try {
88
+ const parsed = new URL(url);
89
+ const host = parsed.hostname.toLowerCase();
90
+ return allowlist.includes(host);
91
+ } catch {
92
+ return false;
93
+ }
94
+ }