@andreprado/agentkit 0.1.0-alpha.23 → 0.1.0-alpha.24

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.
@@ -1,9 +1,16 @@
1
- import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
-
3
- import type { ChannelAdapter, ChannelSendInput, RawWebhookEvent } from "../channels";
1
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { lookup as dnsLookup } from "node:dns/promises";
3
+ import { request as httpsRequest } from "node:https";
4
+ import type { LookupFunction } from "node:net";
5
+ import { Readable } from "node:stream";
6
+
7
+ import type { GenericWebhookOutputChannelConfig } from "../../index";
8
+ import type { ChannelAdapter, ChannelSendInput, ChannelSendResult, RawWebhookEvent } from "../channels";
4
9
  import { AgentKitError } from "../errors";
5
10
 
6
11
  const DEFAULT_SECRET_NAME = "AGENTKIT_WEBHOOK_SECRET";
12
+ const DEFAULT_OUTPUT_HMAC_HEADER = "X-AgentKit-Webhook-Signature";
13
+ const MAX_OUTPUT_RESPONSE_BYTES = 64_000;
7
14
 
8
15
  export const genericWebhookChannelAdapter: ChannelAdapter = {
9
16
  type: "webhook",
@@ -115,6 +122,10 @@ export const genericWebhookChannelAdapter: ChannelAdapter = {
115
122
  ];
116
123
  },
117
124
  async sendMessage(input) {
125
+ if (isGenericWebhookOutputChannel(input.channel)) {
126
+ return sendGenericWebhookOutputMessage(input, input.channel);
127
+ }
128
+
118
129
  return {
119
130
  ok: true,
120
131
  status: "stubbed",
@@ -132,10 +143,748 @@ export const genericWebhookChannelAdapter: ChannelAdapter = {
132
143
  },
133
144
  };
134
145
 
146
+ type GenericWebhookOutputRequest = {
147
+ method: "POST";
148
+ url: string;
149
+ headers: Record<string, string>;
150
+ body: Record<string, unknown>;
151
+ idempotencyKey: string;
152
+ };
153
+
154
+ type GenericWebhookOutputFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
155
+ type GenericWebhookOutputResolver = (hostname: string) => Promise<string[]>;
156
+ type GenericWebhookOutputResolvedUrl = {
157
+ href: string;
158
+ address: string;
159
+ family: 4 | 6;
160
+ };
161
+ type GenericWebhookOutputRequestInit = {
162
+ method: "POST";
163
+ headers: Record<string, string>;
164
+ body: string;
165
+ redirect: "manual";
166
+ signal: AbortSignal;
167
+ };
168
+
169
+ export function buildGenericWebhookOutputRequest(
170
+ input: ChannelSendInput,
171
+ channel: GenericWebhookOutputChannelConfig,
172
+ ): GenericWebhookOutputRequest {
173
+ if (input.message.contentType !== "text") {
174
+ throw new AgentKitError("channel_payload_invalid", "Generic webhook output supports only text channel messages.");
175
+ }
176
+
177
+ const rawUrl = input.secrets[channel.urlSecret];
178
+
179
+ if (!rawUrl) {
180
+ throw new AgentKitError("channel_secret_missing", `Secret ${channel.urlSecret} is missing.`);
181
+ }
182
+
183
+ const idempotencyKey = buildGenericWebhookOutputRequestId(input);
184
+ const body = {
185
+ event_id: idempotencyKey,
186
+ conversation_id: input.conversationId,
187
+ channel_id: input.channelId,
188
+ external_identity: input.externalIdentity.key,
189
+ message: {
190
+ role: "assistant",
191
+ content_type: "text",
192
+ content: input.message.content,
193
+ },
194
+ };
195
+ const headers: Record<string, string> = {
196
+ "Content-Type": "application/json",
197
+ Accept: "application/json",
198
+ "X-AgentKit-Event-ID": idempotencyKey,
199
+ };
200
+
201
+ applyGenericWebhookOutputAuthHeaders({
202
+ auth: channel.auth,
203
+ secrets: input.secrets,
204
+ headers,
205
+ body: JSON.stringify(body),
206
+ });
207
+
208
+ return {
209
+ method: "POST",
210
+ url: validateGenericWebhookOutputUrl(rawUrl),
211
+ headers,
212
+ body,
213
+ idempotencyKey,
214
+ };
215
+ }
216
+
217
+ export async function sendGenericWebhookOutputMessage(
218
+ input: ChannelSendInput,
219
+ channel: GenericWebhookOutputChannelConfig,
220
+ fetcher?: GenericWebhookOutputFetch,
221
+ resolver: GenericWebhookOutputResolver = resolveGenericWebhookOutputAddresses,
222
+ ): Promise<ChannelSendResult> {
223
+ let request: GenericWebhookOutputRequest;
224
+ let resolvedUrl: GenericWebhookOutputResolvedUrl;
225
+
226
+ try {
227
+ request = buildGenericWebhookOutputRequest(input, channel);
228
+
229
+ if (input.secrets.AGENTKIT_CHANNEL_SEND_DRY_RUN === "1") {
230
+ return {
231
+ ok: true,
232
+ status: "stubbed",
233
+ providerRequestId: request.idempotencyKey,
234
+ providerMetadata: redactedGenericWebhookOutputRequest(request),
235
+ };
236
+ }
237
+
238
+ resolvedUrl = await validateGenericWebhookOutputResolvedUrl(request.url, resolver);
239
+ } catch (error) {
240
+ return genericWebhookOutputFailureFromError(error);
241
+ }
242
+
243
+ let response: Response;
244
+ const requestInit: GenericWebhookOutputRequestInit = {
245
+ method: request.method,
246
+ headers: request.headers,
247
+ body: JSON.stringify(request.body),
248
+ redirect: "manual",
249
+ signal: AbortSignal.timeout(15_000),
250
+ };
251
+
252
+ try {
253
+ response = fetcher
254
+ ? await fetcher(resolvedUrl.href, requestInit)
255
+ : await fetchGenericWebhookOutputResponse(resolvedUrl, requestInit);
256
+ } catch (error) {
257
+ if (error instanceof AgentKitError) {
258
+ return {
259
+ ...genericWebhookOutputFailureFromError(error),
260
+ providerRequestId: request.idempotencyKey,
261
+ providerMetadata: redactedGenericWebhookOutputRequest(request),
262
+ };
263
+ }
264
+
265
+ return {
266
+ ok: false,
267
+ retryable: true,
268
+ code: "channel_provider_unavailable",
269
+ message: `Generic webhook output request failed before a response: ${redactGenericWebhookOutputText(
270
+ error instanceof Error ? error.message : String(error),
271
+ input.secrets,
272
+ )}`,
273
+ providerRequestId: request.idempotencyKey,
274
+ providerMetadata: redactedGenericWebhookOutputRequest(request),
275
+ };
276
+ }
277
+
278
+ const payload = await readGenericWebhookOutputResponseBody(response);
279
+ const providerMessageId = readGenericWebhookOutputProviderMessageId(payload) ?? request.idempotencyKey;
280
+
281
+ if (!response.ok) {
282
+ return {
283
+ ok: false,
284
+ retryable: response.status === 429 || response.status >= 500,
285
+ code: "channel_send_failed",
286
+ message: redactGenericWebhookOutputText(readGenericWebhookOutputErrorMessage(payload, response.status), input.secrets),
287
+ providerRequestId: request.idempotencyKey,
288
+ providerMetadata: {
289
+ ...redactedGenericWebhookOutputRequest(request),
290
+ status: response.status,
291
+ body: redactGenericWebhookOutputValue(payload, input.secrets),
292
+ },
293
+ };
294
+ }
295
+
296
+ return {
297
+ ok: true,
298
+ status: "sent",
299
+ providerRequestId: request.idempotencyKey,
300
+ providerMessageId,
301
+ providerMetadata: {
302
+ ...redactedGenericWebhookOutputRequest(request),
303
+ status: response.status,
304
+ body: redactGenericWebhookOutputValue(payload, input.secrets),
305
+ },
306
+ };
307
+ }
308
+
135
309
  function buildGenericWebhookProviderRequestId(input: ChannelSendInput): string {
136
310
  return `webhook:generic:${input.channelId}:${input.conversationId}`;
137
311
  }
138
312
 
313
+ function buildGenericWebhookOutputRequestId(input: ChannelSendInput): string {
314
+ return `webhook:generic:${input.channelId}:${input.deliveryId ?? input.jobId ?? randomUUID()}`;
315
+ }
316
+
317
+ function isGenericWebhookOutputChannel(value: unknown): value is GenericWebhookOutputChannelConfig {
318
+ return (
319
+ isRecord(value) &&
320
+ value.type === "webhook" &&
321
+ value.provider === "generic" &&
322
+ value.direction === "outbound" &&
323
+ typeof value.urlSecret === "string" &&
324
+ isRecord(value.auth)
325
+ );
326
+ }
327
+
328
+ function applyGenericWebhookOutputAuthHeaders(input: {
329
+ auth: GenericWebhookOutputChannelConfig["auth"];
330
+ secrets: Record<string, string>;
331
+ headers: Record<string, string>;
332
+ body: string;
333
+ }): void {
334
+ if (input.auth.type === "none") {
335
+ return;
336
+ }
337
+
338
+ if (input.auth.type === "bearer") {
339
+ const token = input.secrets[input.auth.tokenSecret];
340
+
341
+ if (!token) {
342
+ throw new AgentKitError("channel_secret_missing", `Secret ${input.auth.tokenSecret} is missing.`);
343
+ }
344
+
345
+ input.headers.Authorization = `Bearer ${token}`;
346
+ return;
347
+ }
348
+
349
+ if (input.auth.type === "header") {
350
+ const value = input.secrets[input.auth.valueSecret];
351
+
352
+ if (!value) {
353
+ throw new AgentKitError("channel_secret_missing", `Secret ${input.auth.valueSecret} is missing.`);
354
+ }
355
+
356
+ input.headers[input.auth.headerName] = value;
357
+ return;
358
+ }
359
+
360
+ const secret = input.secrets[input.auth.secret];
361
+
362
+ if (!secret) {
363
+ throw new AgentKitError("channel_secret_missing", `Secret ${input.auth.secret} is missing.`);
364
+ }
365
+
366
+ input.headers[input.auth.headerName ?? DEFAULT_OUTPUT_HMAC_HEADER] =
367
+ `sha256=${createHmac("sha256", secret).update(input.body).digest("hex")}`;
368
+ }
369
+
370
+ function genericWebhookOutputFailureFromError(error: unknown): ChannelSendResult {
371
+ if (error instanceof AgentKitError) {
372
+ if (error.code === "channel_provider_unavailable") {
373
+ return {
374
+ ok: false,
375
+ retryable: true,
376
+ code: "channel_provider_unavailable",
377
+ message: error.message,
378
+ };
379
+ }
380
+
381
+ return {
382
+ ok: false,
383
+ retryable: false,
384
+ code: error.code === "channel_secret_missing" ? "channel_secret_missing" : "channel_send_failed",
385
+ message: error.message,
386
+ };
387
+ }
388
+
389
+ return {
390
+ ok: false,
391
+ retryable: false,
392
+ code: "channel_send_failed",
393
+ message: error instanceof Error ? error.message : String(error),
394
+ };
395
+ }
396
+
397
+ function validateGenericWebhookOutputUrl(rawUrl: string): string {
398
+ let url: URL;
399
+
400
+ try {
401
+ url = new URL(rawUrl);
402
+ } catch {
403
+ throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL must be a valid HTTPS URL.");
404
+ }
405
+
406
+ if (url.protocol !== "https:") {
407
+ throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL must use HTTPS.");
408
+ }
409
+
410
+ if (url.username || url.password) {
411
+ throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL must not include credentials.");
412
+ }
413
+
414
+ if (isBlockedGenericWebhookOutputHost(url.hostname)) {
415
+ throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL host is not allowed.");
416
+ }
417
+
418
+ return url.href;
419
+ }
420
+
421
+ async function validateGenericWebhookOutputResolvedUrl(
422
+ rawUrl: string,
423
+ resolver: GenericWebhookOutputResolver,
424
+ ): Promise<GenericWebhookOutputResolvedUrl> {
425
+ const href = validateGenericWebhookOutputUrl(rawUrl);
426
+ const url = new URL(href);
427
+ let addresses: string[];
428
+
429
+ try {
430
+ addresses = await resolver(url.hostname);
431
+ } catch (error) {
432
+ throw new AgentKitError(
433
+ "channel_provider_unavailable",
434
+ `Generic webhook output URL host could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
435
+ );
436
+ }
437
+
438
+ if (addresses.length === 0) {
439
+ throw new AgentKitError("channel_provider_unavailable", "Generic webhook output URL host did not resolve.");
440
+ }
441
+
442
+ for (const address of addresses) {
443
+ if (isBlockedGenericWebhookOutputHost(address)) {
444
+ throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL host resolved to a blocked address.");
445
+ }
446
+ }
447
+
448
+ const address = normalizeGenericWebhookOutputHost(addresses[0]);
449
+
450
+ return {
451
+ href,
452
+ address,
453
+ family: parseIpv4Octets(address) ? 4 : 6,
454
+ };
455
+ }
456
+
457
+ function fetchGenericWebhookOutputResponse(
458
+ resolvedUrl: GenericWebhookOutputResolvedUrl,
459
+ init: GenericWebhookOutputRequestInit,
460
+ ): Promise<Response> {
461
+ const url = new URL(resolvedUrl.href);
462
+ const lookup: LookupFunction = (_hostname, _options, callback) => {
463
+ callback(null, resolvedUrl.address, resolvedUrl.family);
464
+ };
465
+
466
+ return new Promise((resolve, reject) => {
467
+ const request = httpsRequest(
468
+ url,
469
+ {
470
+ method: init.method,
471
+ headers: init.headers,
472
+ lookup,
473
+ signal: init.signal,
474
+ },
475
+ (response) => {
476
+ try {
477
+ assertAllowedGenericWebhookOutputRemoteAddress(response.socket.remoteAddress);
478
+ } catch (error) {
479
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
480
+ reject(error);
481
+ return;
482
+ }
483
+
484
+ resolve(
485
+ new Response(Readable.toWeb(response) as unknown as ReadableStream<Uint8Array>, {
486
+ status: response.statusCode ?? 502,
487
+ headers: headersFromIncomingMessage(response.headers),
488
+ }),
489
+ );
490
+ },
491
+ );
492
+
493
+ request.on("socket", (socket) => {
494
+ const validateSocket = () => {
495
+ try {
496
+ assertAllowedGenericWebhookOutputRemoteAddress(socket.remoteAddress);
497
+ } catch (error) {
498
+ request.destroy(error instanceof Error ? error : new Error(String(error)));
499
+ }
500
+ };
501
+
502
+ socket.once("connect", validateSocket);
503
+ socket.once("secureConnect", validateSocket);
504
+ });
505
+ request.once("error", reject);
506
+ request.end(init.body);
507
+ });
508
+ }
509
+
510
+ function assertAllowedGenericWebhookOutputRemoteAddress(address: string | undefined): void {
511
+ if (!address || isBlockedGenericWebhookOutputHost(address)) {
512
+ throw new AgentKitError("channel_payload_invalid", "Generic webhook output URL connected to a blocked address.");
513
+ }
514
+ }
515
+
516
+ function headersFromIncomingMessage(headers: Record<string, string | string[] | number | undefined>): Headers {
517
+ const result = new Headers();
518
+
519
+ for (const [key, value] of Object.entries(headers)) {
520
+ if (Array.isArray(value)) {
521
+ for (const item of value) {
522
+ result.append(key, item);
523
+ }
524
+ continue;
525
+ }
526
+
527
+ if (value !== undefined) {
528
+ result.set(key, String(value));
529
+ }
530
+ }
531
+
532
+ return result;
533
+ }
534
+
535
+ async function resolveGenericWebhookOutputAddresses(hostname: string): Promise<string[]> {
536
+ const host = normalizeGenericWebhookOutputHost(hostname);
537
+
538
+ if (parseIpv4Octets(host) || parseIpv6Segments(host)) {
539
+ return [host];
540
+ }
541
+
542
+ const records = await dnsLookup(host, {
543
+ all: true,
544
+ verbatim: true,
545
+ });
546
+
547
+ return records.map((record) => record.address);
548
+ }
549
+
550
+ function isBlockedGenericWebhookOutputHost(hostname: string): boolean {
551
+ const host = normalizeGenericWebhookOutputHost(hostname);
552
+
553
+ if (
554
+ host === "localhost" ||
555
+ host.endsWith(".localhost") ||
556
+ host === "metadata.google.internal"
557
+ ) {
558
+ return true;
559
+ }
560
+
561
+ const ipv4 = parseIpv4Octets(host);
562
+
563
+ if (ipv4) {
564
+ return isBlockedIpv4Octets(ipv4);
565
+ }
566
+
567
+ const ipv6 = parseIpv6Segments(host);
568
+
569
+ if (ipv6) {
570
+ return isBlockedIpv6Segments(ipv6);
571
+ }
572
+
573
+ return false;
574
+ }
575
+
576
+ function normalizeGenericWebhookOutputHost(hostname: string): string {
577
+ return hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
578
+ }
579
+
580
+ function parseIpv4Octets(host: string): number[] | undefined {
581
+ const parts = host.split(".");
582
+
583
+ if (parts.length !== 4) {
584
+ return undefined;
585
+ }
586
+
587
+ const octets = parts.map((part) => Number(part));
588
+
589
+ if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
590
+ return undefined;
591
+ }
592
+
593
+ return octets;
594
+ }
595
+
596
+ function isBlockedIpv4Octets(parts: number[]): boolean {
597
+ const [a, b] = parts;
598
+
599
+ return (
600
+ a === 0 ||
601
+ a === 10 ||
602
+ a === 127 ||
603
+ (a === 100 && b >= 64 && b <= 127) ||
604
+ (a === 169 && b === 254) ||
605
+ (a === 172 && b >= 16 && b <= 31) ||
606
+ (a === 192 && b === 168) ||
607
+ (a === 198 && (b === 18 || b === 19)) ||
608
+ a >= 224
609
+ );
610
+ }
611
+
612
+ function parseIpv6Segments(host: string): number[] | undefined {
613
+ if (!host.includes(":")) {
614
+ return undefined;
615
+ }
616
+
617
+ if (host === "::") {
618
+ return new Array(8).fill(0);
619
+ }
620
+
621
+ const [leftRaw, rightRaw, extra] = host.split("::");
622
+
623
+ if (extra !== undefined) {
624
+ return undefined;
625
+ }
626
+
627
+ const left = parseIpv6SegmentSide(leftRaw ?? "");
628
+ const right = parseIpv6SegmentSide(rightRaw ?? "");
629
+
630
+ if (!left || !right) {
631
+ return undefined;
632
+ }
633
+
634
+ if (rightRaw === undefined) {
635
+ return left.length === 8 ? left : undefined;
636
+ }
637
+
638
+ const missing = 8 - left.length - right.length;
639
+
640
+ if (missing < 1) {
641
+ return undefined;
642
+ }
643
+
644
+ return [...left, ...new Array(missing).fill(0), ...right];
645
+ }
646
+
647
+ function parseIpv6SegmentSide(value: string): number[] | undefined {
648
+ if (!value) {
649
+ return [];
650
+ }
651
+
652
+ const segments: number[] = [];
653
+
654
+ const parts = value.split(":");
655
+
656
+ for (const [index, part] of parts.entries()) {
657
+ if (part.includes(".")) {
658
+ if (index !== parts.length - 1) {
659
+ return undefined;
660
+ }
661
+
662
+ const octets = parseIpv4Octets(part);
663
+
664
+ if (!octets) {
665
+ return undefined;
666
+ }
667
+
668
+ segments.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
669
+ continue;
670
+ }
671
+
672
+ if (!/^[0-9a-f]{1,4}$/i.test(part)) {
673
+ return undefined;
674
+ }
675
+
676
+ segments.push(parseInt(part, 16));
677
+ }
678
+
679
+ return segments;
680
+ }
681
+
682
+ function isBlockedIpv6Segments(parts: number[]): boolean {
683
+ if (parts.length !== 8) {
684
+ return true;
685
+ }
686
+
687
+ const embeddedIpv4 = ipv4OctetsFromIpv6Segments(parts);
688
+
689
+ if (embeddedIpv4) {
690
+ return isBlockedIpv4Octets(embeddedIpv4);
691
+ }
692
+
693
+ const isAllZero = parts.every((part) => part === 0);
694
+
695
+ if (isAllZero || (parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1)) {
696
+ return true;
697
+ }
698
+
699
+ const first = parts[0];
700
+
701
+ return (
702
+ (first & 0xfe00) === 0xfc00 ||
703
+ (first & 0xffc0) === 0xfe80 ||
704
+ (first & 0xff00) === 0xff00
705
+ );
706
+ }
707
+
708
+ function ipv4OctetsFromIpv6Segments(parts: number[]): number[] | undefined {
709
+ const trailingIpv4 = [
710
+ (parts[6] >> 8) & 0xff,
711
+ parts[6] & 0xff,
712
+ (parts[7] >> 8) & 0xff,
713
+ parts[7] & 0xff,
714
+ ];
715
+
716
+ if (parts.slice(0, 5).every((part) => part === 0) && (parts[5] === 0xffff || parts[5] === 0)) {
717
+ return trailingIpv4;
718
+ }
719
+
720
+ if (parts[0] === 0x64 && parts[1] === 0xff9b && parts.slice(2, 6).every((part) => part === 0)) {
721
+ return trailingIpv4;
722
+ }
723
+
724
+ if (parts[0] === 0x2002) {
725
+ return [
726
+ (parts[1] >> 8) & 0xff,
727
+ parts[1] & 0xff,
728
+ (parts[2] >> 8) & 0xff,
729
+ parts[2] & 0xff,
730
+ ];
731
+ }
732
+
733
+ return undefined;
734
+ }
735
+
736
+ function redactedGenericWebhookOutputRequest(request: GenericWebhookOutputRequest): Record<string, unknown> {
737
+ return {
738
+ method: request.method,
739
+ url: redactGenericWebhookOutputUrl(request.url),
740
+ body: request.body,
741
+ };
742
+ }
743
+
744
+ function redactGenericWebhookOutputUrl(rawUrl: string): string {
745
+ try {
746
+ const url = new URL(rawUrl);
747
+ url.search = url.search ? "?<redacted>" : "";
748
+ return url.href;
749
+ } catch {
750
+ return "<redacted>";
751
+ }
752
+ }
753
+
754
+ async function readGenericWebhookOutputResponseBody(response: Response): Promise<unknown> {
755
+ const text = await readGenericWebhookOutputResponseText(response, MAX_OUTPUT_RESPONSE_BYTES);
756
+
757
+ if (!text) {
758
+ return null;
759
+ }
760
+
761
+ try {
762
+ return JSON.parse(text) as unknown;
763
+ } catch {
764
+ return text;
765
+ }
766
+ }
767
+
768
+ async function readGenericWebhookOutputResponseText(response: Response, maxBytes: number): Promise<string> {
769
+ const reader = response.body?.getReader();
770
+
771
+ if (!reader) {
772
+ return "";
773
+ }
774
+
775
+ const chunks: Uint8Array[] = [];
776
+ let totalBytes = 0;
777
+
778
+ try {
779
+ while (true) {
780
+ const chunk = await reader.read();
781
+
782
+ if (chunk.done) {
783
+ break;
784
+ }
785
+
786
+ totalBytes += chunk.value.byteLength;
787
+
788
+ if (totalBytes > maxBytes) {
789
+ break;
790
+ }
791
+
792
+ chunks.push(chunk.value);
793
+ }
794
+ } finally {
795
+ reader.releaseLock();
796
+ }
797
+
798
+ return new TextDecoder().decode(concatUint8Arrays(chunks));
799
+ }
800
+
801
+ function concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {
802
+ const totalBytes = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
803
+ const output = new Uint8Array(totalBytes);
804
+ let offset = 0;
805
+
806
+ for (const chunk of chunks) {
807
+ output.set(chunk, offset);
808
+ offset += chunk.byteLength;
809
+ }
810
+
811
+ return output;
812
+ }
813
+
814
+ function readGenericWebhookOutputProviderMessageId(payload: unknown): string | undefined {
815
+ if (!isRecord(payload)) {
816
+ return undefined;
817
+ }
818
+
819
+ return readFirstScalar(payload, ["message_id", "messageId", "event_id", "eventId", "id"]);
820
+ }
821
+
822
+ function readGenericWebhookOutputErrorMessage(payload: unknown, status: number): string {
823
+ if (isRecord(payload)) {
824
+ const error = payload.error;
825
+
826
+ if (typeof error === "string") {
827
+ return error;
828
+ }
829
+
830
+ if (isRecord(error)) {
831
+ const message = readFirstScalar(error, ["message", "detail", "code"]);
832
+
833
+ if (message) {
834
+ return message;
835
+ }
836
+ }
837
+
838
+ const message = readFirstScalar(payload, ["message", "detail", "code"]);
839
+
840
+ if (message) {
841
+ return message;
842
+ }
843
+ }
844
+
845
+ if (typeof payload === "string" && payload.trim()) {
846
+ return payload.trim();
847
+ }
848
+
849
+ return `Generic webhook output returned HTTP ${status}.`;
850
+ }
851
+
852
+ function redactGenericWebhookOutputText(value: string, secrets: Record<string, string>): string {
853
+ let redacted = value;
854
+
855
+ for (const secret of Object.values(secrets)) {
856
+ if (secret) {
857
+ redacted = redacted.split(secret).join("<redacted>");
858
+ }
859
+ }
860
+
861
+ return redacted;
862
+ }
863
+
864
+ function redactGenericWebhookOutputValue(value: unknown, secrets: Record<string, string>): unknown {
865
+ if (typeof value === "string") {
866
+ return redactGenericWebhookOutputText(value, secrets);
867
+ }
868
+
869
+ if (Array.isArray(value)) {
870
+ return value.map((item) => redactGenericWebhookOutputValue(item, secrets));
871
+ }
872
+
873
+ if (isRecord(value)) {
874
+ const redacted: Record<string, unknown> = {};
875
+
876
+ for (const [key, entry] of Object.entries(value)) {
877
+ redacted[key] = /secret|token|authorization|password|api[_-]?key/i.test(key)
878
+ ? "<redacted>"
879
+ : redactGenericWebhookOutputValue(entry, secrets);
880
+ }
881
+
882
+ return redacted;
883
+ }
884
+
885
+ return value;
886
+ }
887
+
139
888
  function parseJsonObject(input: RawWebhookEvent): Record<string, unknown> {
140
889
  const contentType = input.headers.get("content-type") ?? "";
141
890