@hasna/events 0.1.16 → 0.1.18

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/dist/commander.js CHANGED
@@ -99,16 +99,114 @@ function channelMatchesEvent(channel, event) {
99
99
  // src/storage.ts
100
100
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
101
101
  import { Buffer as Buffer2 } from "buffer";
102
+ import { existsSync as existsSync2 } from "fs";
103
+ import { join as join2 } from "path";
104
+
105
+ // src/app-home.ts
102
106
  import { existsSync } from "fs";
103
107
  import { homedir } from "os";
104
- import { join } from "path";
108
+ import { join, resolve } from "path";
109
+ import { homedir as pathsResolverHomedir } from "os";
110
+ import { join as pathsResolverJoin } from "path";
111
+ var PATHS_RESOLVER_KIND_ENV = {
112
+ config: "HASNA_CONFIG_HOME",
113
+ data: "HASNA_DATA_HOME",
114
+ state: "HASNA_STATE_HOME",
115
+ cache: "HASNA_CACHE_HOME"
116
+ };
117
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
118
+ function pathsResolverAssertApp(app) {
119
+ if (typeof app !== "string" || app.length === 0) {
120
+ throw new TypeError("paths: app must be a non-empty string");
121
+ }
122
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
123
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
124
+ }
125
+ }
126
+ function pathsResolverAssertKind(kind) {
127
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
128
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
129
+ }
130
+ }
131
+ function pathsResolverBaseDir(kind, options) {
132
+ pathsResolverAssertKind(kind);
133
+ const env = options.env ?? process.env;
134
+ const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
135
+ if (typeof override === "string" && override.length > 0)
136
+ return override;
137
+ const home = options.home ?? pathsResolverHomedir();
138
+ const platform = options.platform ?? process.platform;
139
+ if (platform === "darwin") {
140
+ switch (kind) {
141
+ case "config":
142
+ case "data":
143
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
144
+ case "cache":
145
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
146
+ case "state":
147
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
148
+ }
149
+ }
150
+ switch (kind) {
151
+ case "config":
152
+ return pathsResolverJoin(home, ".config", "hasna");
153
+ case "data":
154
+ return pathsResolverJoin(home, ".local", "share", "hasna");
155
+ case "state":
156
+ return pathsResolverJoin(home, ".local", "state", "hasna");
157
+ case "cache":
158
+ return pathsResolverJoin(home, ".cache", "hasna");
159
+ }
160
+ }
161
+ function pathsResolverResolve(kind, options) {
162
+ pathsResolverAssertApp(options.app);
163
+ const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
164
+ return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
165
+ }
166
+ function dataDir(options) {
167
+ return pathsResolverResolve("data", options);
168
+ }
105
169
  var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
106
170
  var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
171
+ var EVENTS_STORE_SENTINEL_FILE = "events.json";
172
+ function effectiveHome() {
173
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir();
174
+ }
175
+ function legacyHomeDir() {
176
+ return join(effectiveHome(), ".hasna", "events");
177
+ }
178
+ function resolverHome() {
179
+ return dataDir({ app: "events", home: effectiveHome() || undefined });
180
+ }
181
+ function adoptResolverHome(resolved, env = process.env) {
182
+ const dataOverride = env.HASNA_DATA_HOME;
183
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
184
+ return true;
185
+ return existsSync(join(resolved, EVENTS_STORE_SENTINEL_FILE));
186
+ }
187
+ function exactEventsHome() {
188
+ const dir = process.env[HASNA_EVENTS_DIR_ENV];
189
+ if (dir && dir.trim())
190
+ return dir.trim();
191
+ const home = process.env[HASNA_EVENTS_HOME_ENV];
192
+ if (home && home.trim())
193
+ return home.trim();
194
+ return;
195
+ }
196
+ function getEventsHome() {
197
+ const exact = exactEventsHome();
198
+ if (exact)
199
+ return resolve(exact);
200
+ const resolved = resolverHome();
201
+ return adoptResolverHome(resolved) ? resolve(resolved) : resolve(legacyHomeDir());
202
+ }
203
+
204
+ // src/storage.ts
107
205
  var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
108
206
  var DEFAULT_EVENT_PAGE_LIMIT = 100;
109
207
  var MAX_EVENT_PAGE_LIMIT = 1000;
110
208
  function getEventsDataDir(override) {
111
- return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
209
+ return override || getEventsHome();
112
210
  }
113
211
  function getActiveEventsDirEnv() {
114
212
  if (process.env[HASNA_EVENTS_DIR_ENV])
@@ -124,12 +222,12 @@ class JsonEventsStore {
124
222
  channelsPath;
125
223
  eventsPath;
126
224
  deliveriesPath;
127
- constructor(dataDir = getEventsDataDir()) {
128
- this.dataDir = dataDir;
129
- this.runtime = localJsonRuntime(dataDir);
130
- this.channelsPath = join(dataDir, "channels.json");
131
- this.eventsPath = join(dataDir, "events.json");
132
- this.deliveriesPath = join(dataDir, "deliveries.json");
225
+ constructor(dataDir2 = getEventsDataDir()) {
226
+ this.dataDir = dataDir2;
227
+ this.runtime = localJsonRuntime(dataDir2);
228
+ this.channelsPath = join2(dataDir2, "channels.json");
229
+ this.eventsPath = join2(dataDir2, "events.json");
230
+ this.deliveriesPath = join2(dataDir2, "deliveries.json");
133
231
  }
134
232
  async init() {
135
233
  await mkdir(this.dataDir, { recursive: true, mode: 448 });
@@ -246,7 +344,7 @@ class JsonEventsStore {
246
344
  };
247
345
  }
248
346
  async ensureArrayFile(path) {
249
- if (!existsSync(path)) {
347
+ if (!existsSync2(path)) {
250
348
  await writeFile(path, `[]
251
349
  `, { encoding: "utf-8", mode: 384 });
252
350
  }
@@ -276,7 +374,7 @@ class JsonEventsStore {
276
374
  });
277
375
  }
278
376
  }
279
- function localJsonRuntime(dataDir = getEventsDataDir()) {
377
+ function localJsonRuntime(dataDir2 = getEventsDataDir()) {
280
378
  return {
281
379
  mode: "local-files",
282
380
  name: "json-events-store",
@@ -289,7 +387,7 @@ function localJsonRuntime(dataDir = getEventsDataDir()) {
289
387
  durable: true,
290
388
  idempotency: "best-effort-local",
291
389
  replayCursors: true,
292
- description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
390
+ description: `Local JSON files in ${dataDir2}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
293
391
  };
294
392
  }
295
393
  function encodeLocalJsonEventCursor(offset, options = {}) {
@@ -353,8 +451,8 @@ function assertCursorFilter(name, cursorValue, optionValue) {
353
451
  function findEventByIdentity(events, identity) {
354
452
  return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
355
453
  }
356
- async function getEventsStatus(dataDir) {
357
- const store = new JsonEventsStore(dataDir);
454
+ async function getEventsStatus(dataDir2) {
455
+ const store = new JsonEventsStore(dataDir2);
358
456
  await store.init();
359
457
  const [channels, events, deliveries] = await Promise.all([
360
458
  store.listChannels(),
@@ -396,9 +494,9 @@ async function getEventsStatus(dataDir) {
396
494
  }
397
495
  };
398
496
  }
399
- function statusFile(dataDir, fileName, records) {
400
- const path = join(dataDir, fileName);
401
- return { path, exists: existsSync(path), records };
497
+ function statusFile(dataDir2, fileName, records) {
498
+ const path = join2(dataDir2, fileName);
499
+ return { path, exists: existsSync2(path), records };
402
500
  }
403
501
 
404
502
  // src/signing.ts
@@ -435,9 +533,226 @@ function verifyWebhookSignature(secret, timestamp, body, signature, options = {}
435
533
  return verifyPayloadSignature(secret, timestamp, body, signature);
436
534
  }
437
535
 
536
+ // src/ssrf.ts
537
+ import { lookup as dnsLookup } from "dns/promises";
538
+ import { isIP } from "net";
539
+ var DEFAULT_MAX_REDIRECTS = 5;
540
+ var IPV4_PRIVATE_RANGES = [
541
+ [0, 16777215],
542
+ [167772160, 184549375],
543
+ [1681915904, 1686110207],
544
+ [2130706432, 2147483647],
545
+ [2851995648, 2852061183],
546
+ [2886729728, 2887778303],
547
+ [3221225472, 3221225727],
548
+ [3221225984, 3221226239],
549
+ [3227017984, 3227018239],
550
+ [3232235520, 3232301055],
551
+ [3323068416, 3323199487],
552
+ [3325256704, 3325256959],
553
+ [3405803776, 3405804031],
554
+ [3758096384, 4294967295]
555
+ ];
556
+ var IPV6_SPECIAL_PREFIXES = [
557
+ { groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
558
+ { groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
559
+ { groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
560
+ { groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
561
+ { groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
562
+ { groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
563
+ { groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
564
+ { groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
565
+ { groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
566
+ { groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
567
+ { groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
568
+ { groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
569
+ { groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
570
+ { groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
571
+ { groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
572
+ ];
573
+ function isPrivateAddress(address) {
574
+ const normalized = stripZoneId(address);
575
+ const version = isIP(normalized);
576
+ if (version === 4) {
577
+ const integer = ipv4ToInt(normalized);
578
+ if (integer === undefined)
579
+ return true;
580
+ return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
581
+ }
582
+ if (version === 6) {
583
+ const groups = ipv6Groups(normalized);
584
+ if (!groups)
585
+ return true;
586
+ for (const prefix of IPV6_SPECIAL_PREFIXES) {
587
+ if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
588
+ continue;
589
+ if (prefix.bits === 96 && groups[5] === 65535) {
590
+ return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
591
+ }
592
+ if (prefix.bits === 16 && groups[0] === 8194) {
593
+ return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
594
+ }
595
+ return true;
596
+ }
597
+ return false;
598
+ }
599
+ return true;
600
+ }
601
+ async function resolveWebhookTarget(url, policy = {}) {
602
+ const hostname = normalizeHostname(url.hostname);
603
+ const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
604
+ if (allowlist.includes(hostname)) {
605
+ const version2 = isIP(hostname);
606
+ if (version2 === 4 || version2 === 6) {
607
+ return { hostname, addresses: [hostname] };
608
+ }
609
+ const lookup2 = policy.lookup ?? defaultTargetLookup;
610
+ let resolved2;
611
+ try {
612
+ resolved2 = await lookup2(hostname);
613
+ } catch {
614
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
615
+ }
616
+ if (!Array.isArray(resolved2) || resolved2.length === 0) {
617
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
618
+ }
619
+ const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
620
+ return { hostname, addresses };
621
+ }
622
+ const version = isIP(hostname);
623
+ if (version === 4 || version === 6) {
624
+ if (isPrivateAddress(hostname)) {
625
+ throw new Error(`Webhook target ${hostname} is a private or special-use address`);
626
+ }
627
+ return { hostname, addresses: [hostname] };
628
+ }
629
+ const lookup = policy.lookup ?? defaultTargetLookup;
630
+ let resolved;
631
+ try {
632
+ resolved = await lookup(hostname);
633
+ } catch {
634
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
635
+ }
636
+ if (!Array.isArray(resolved) || resolved.length === 0) {
637
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
638
+ }
639
+ const allowed = [];
640
+ for (const entry of resolved) {
641
+ const address = normalizeHostname(entry.address);
642
+ if (isPrivateAddress(address)) {
643
+ if (allowlist.includes(address)) {
644
+ allowed.push(address);
645
+ continue;
646
+ }
647
+ throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
648
+ }
649
+ allowed.push(address);
650
+ }
651
+ if (allowed.length === 0) {
652
+ throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
653
+ }
654
+ return { hostname, addresses: allowed };
655
+ }
656
+ async function assertWebhookTargetAllowed(url, policy = {}) {
657
+ await resolveWebhookTarget(url, policy);
658
+ }
659
+ function normalizeMaxRedirects(value) {
660
+ if (value === undefined)
661
+ return DEFAULT_MAX_REDIRECTS;
662
+ if (!Number.isInteger(value) || value < 0)
663
+ throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
664
+ return value;
665
+ }
666
+ var defaultTargetLookup = async (hostname) => {
667
+ return dnsLookup(hostname, { all: true, verbatim: false });
668
+ };
669
+ function normalizeHostname(hostname) {
670
+ const lower = hostname.toLowerCase();
671
+ if (lower.startsWith("[") && lower.endsWith("]"))
672
+ return lower.slice(1, -1);
673
+ return lower;
674
+ }
675
+ function stripZoneId(address) {
676
+ const percent = address.indexOf("%");
677
+ return percent === -1 ? address : address.slice(0, percent);
678
+ }
679
+ function ipv4ToInt(address) {
680
+ const parts = address.split(".");
681
+ if (parts.length !== 4)
682
+ return;
683
+ let value = 0;
684
+ for (const part of parts) {
685
+ if (!/^\d{1,3}$/.test(part))
686
+ return;
687
+ const octet = Number(part);
688
+ if (octet > 255)
689
+ return;
690
+ value = value << 8 | octet;
691
+ }
692
+ return value >>> 0;
693
+ }
694
+ function ipv4IntToString(integer) {
695
+ return [
696
+ integer >>> 24 & 255,
697
+ integer >>> 16 & 255,
698
+ integer >>> 8 & 255,
699
+ integer & 255
700
+ ].join(".");
701
+ }
702
+ function ipv6Groups(address) {
703
+ const raw = stripZoneId(address);
704
+ const doubleColon = raw.indexOf("::");
705
+ const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
706
+ const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
707
+ const parseGroups = (text) => {
708
+ if (text === "")
709
+ return [];
710
+ const out = [];
711
+ for (const part of text.split(":")) {
712
+ if (part.includes(".")) {
713
+ const v4 = ipv4ToInt(part);
714
+ if (v4 === undefined)
715
+ return;
716
+ out.push(v4 >>> 16 & 65535, v4 & 65535);
717
+ } else {
718
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
719
+ return;
720
+ out.push(parseInt(part, 16));
721
+ }
722
+ }
723
+ return out;
724
+ };
725
+ const head = parseGroups(headText);
726
+ if (!head)
727
+ return;
728
+ const tail = parseGroups(tailText);
729
+ if (!tail)
730
+ return;
731
+ const total = head.length + tail.length;
732
+ if (doubleColon === -1) {
733
+ return total === 8 ? head : undefined;
734
+ }
735
+ if (total >= 8)
736
+ return;
737
+ return [...head, ...new Array(8 - total).fill(0), ...tail];
738
+ }
739
+ function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
740
+ let remaining = prefixBits;
741
+ for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
742
+ const take = Math.min(16, remaining);
743
+ const mask = 65535 << 16 - take & 65535;
744
+ if ((groups[index] & mask) !== (prefixGroups[index] & mask))
745
+ return false;
746
+ remaining -= take;
747
+ }
748
+ return true;
749
+ }
750
+
438
751
  // src/transports.ts
439
752
  import { randomUUID } from "crypto";
440
753
  import { spawn } from "child_process";
754
+ import { request as nodeHttpRequest } from "http";
755
+ import { request as nodeHttpsRequest } from "https";
441
756
  function now() {
442
757
  return new Date().toISOString();
443
758
  }
@@ -468,9 +783,18 @@ function buildWebhookRequest(event, channel, options = {}) {
468
783
  }
469
784
  return { body, headers };
470
785
  }
786
+ function normalizeWebhookUrl(raw) {
787
+ const url = new URL(raw);
788
+ if (url.username !== "" || url.password !== "") {
789
+ url.username = "";
790
+ url.password = "";
791
+ }
792
+ return url.toString();
793
+ }
471
794
  async function dispatchWebhook(event, channel, options = {}) {
472
795
  if (!channel.webhook)
473
796
  throw new Error(`Channel ${channel.id} has no webhook config`);
797
+ const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
474
798
  const startedAt = now();
475
799
  let secret = channel.webhook.secret;
476
800
  if (channel.webhook.secretRef) {
@@ -487,10 +811,14 @@ async function dispatchWebhook(event, channel, options = {}) {
487
811
  }
488
812
  const timestamp = (options.now?.() ?? new Date).toISOString();
489
813
  const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
814
+ const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
815
+ if (validateTargets) {
816
+ return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
817
+ }
490
818
  const controller = new AbortController;
491
819
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
492
820
  try {
493
- const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
821
+ const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
494
822
  method: "POST",
495
823
  headers,
496
824
  body,
@@ -518,6 +846,130 @@ async function dispatchWebhook(event, channel, options = {}) {
518
846
  clearTimeout(timeout);
519
847
  }
520
848
  }
849
+ function isRedirectStatus(status) {
850
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
851
+ }
852
+ function redirectKeepsBody(status) {
853
+ return status === 307 || status === 308;
854
+ }
855
+ async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
856
+ const isHttps = target.protocol === "https:";
857
+ if (!isHttps && target.protocol !== "http:") {
858
+ throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
859
+ }
860
+ const defaultPort = isHttps ? 443 : 80;
861
+ const port = target.port ? Number(target.port) : defaultPort;
862
+ const requestOptions = {
863
+ hostname: target.hostname,
864
+ port,
865
+ path: `${target.pathname}${target.search}`,
866
+ method,
867
+ headers,
868
+ ...tls?.ca ? { ca: tls.ca } : {},
869
+ lookup: (hostname, _options, callback) => {
870
+ const entries = addresses.map((address) => ({
871
+ address,
872
+ family: address.includes(":") ? 6 : 4
873
+ }));
874
+ callback(null, entries);
875
+ }
876
+ };
877
+ return new Promise((resolve2, reject) => {
878
+ const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
879
+ const onAbort = () => {
880
+ const error = new Error("The operation was aborted.");
881
+ error.name = "AbortError";
882
+ request.destroy(error);
883
+ };
884
+ if (signal.aborted)
885
+ onAbort();
886
+ else
887
+ signal.addEventListener("abort", onAbort, { once: true });
888
+ request.on("error", reject);
889
+ if (body !== undefined)
890
+ request.write(body);
891
+ request.end();
892
+ function onResponse(response) {
893
+ const chunks = [];
894
+ response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
895
+ response.on("error", reject);
896
+ response.on("end", () => {
897
+ const headersRecord = {};
898
+ for (const [name, value] of Object.entries(response.headers)) {
899
+ if (typeof value === "string")
900
+ headersRecord[name] = value;
901
+ else if (Array.isArray(value))
902
+ headersRecord[name] = value.join(", ");
903
+ }
904
+ resolve2(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
905
+ });
906
+ }
907
+ });
908
+ }
909
+ async function dispatchValidatedWebhook(event, channel, input) {
910
+ const { body, headers, startedAt, options } = input;
911
+ const webhook = channel.webhook;
912
+ if (!webhook)
913
+ throw new Error(`Channel ${channel.id} has no webhook config`);
914
+ const policy = options.webhookTargetPolicy ?? {};
915
+ const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
916
+ const controller = new AbortController;
917
+ const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
918
+ try {
919
+ let target = new URL(normalizeWebhookUrl(webhook.url));
920
+ let requestHeaders = headers;
921
+ let method = "POST";
922
+ let requestBody = body;
923
+ let redirectsFollowed = 0;
924
+ for (;; ) {
925
+ const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
926
+ throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
927
+ });
928
+ const response = options.fetchImpl ? await options.fetchImpl(target, {
929
+ method,
930
+ headers: requestHeaders,
931
+ body: requestBody,
932
+ signal: controller.signal,
933
+ redirect: "manual"
934
+ }) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
935
+ const location = response.headers.get("location");
936
+ if (isRedirectStatus(response.status) && location) {
937
+ if (redirectsFollowed >= maxRedirects) {
938
+ return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
939
+ }
940
+ redirectsFollowed += 1;
941
+ const next = new URL(location, target);
942
+ target = next;
943
+ if (!redirectKeepsBody(response.status)) {
944
+ method = "GET";
945
+ requestBody = undefined;
946
+ requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
947
+ }
948
+ continue;
949
+ }
950
+ const responseBody = truncate(await response.text());
951
+ return {
952
+ attempt: 1,
953
+ status: response.ok ? "success" : "failed",
954
+ startedAt,
955
+ completedAt: now(),
956
+ responseStatus: response.status,
957
+ responseBody,
958
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
959
+ };
960
+ }
961
+ } catch (error) {
962
+ return {
963
+ attempt: 1,
964
+ status: "failed",
965
+ startedAt,
966
+ completedAt: now(),
967
+ error: error instanceof Error ? error.message : String(error)
968
+ };
969
+ } finally {
970
+ clearTimeout(timeout);
971
+ }
972
+ }
521
973
  function failedAttempt(startedAt, error) {
522
974
  return {
523
975
  attempt: 1,
@@ -546,7 +998,7 @@ async function dispatchCommand(event, channel) {
546
998
  HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
547
999
  HASNA_EVENT_JSON: eventJson
548
1000
  };
549
- return new Promise((resolve) => {
1001
+ return new Promise((resolve2) => {
550
1002
  const child = spawn(channel.command.command, channel.command.args ?? [], {
551
1003
  cwd: channel.command.cwd,
552
1004
  env,
@@ -564,7 +1016,7 @@ async function dispatchCommand(event, channel) {
564
1016
  });
565
1017
  child.on("error", (error) => {
566
1018
  clearTimeout(timeout);
567
- resolve({
1019
+ resolve2({
568
1020
  attempt: 1,
569
1021
  status: "failed",
570
1022
  startedAt,
@@ -577,7 +1029,7 @@ async function dispatchCommand(event, channel) {
577
1029
  child.on("close", (code, signal) => {
578
1030
  clearTimeout(timeout);
579
1031
  const success = code === 0;
580
- resolve({
1032
+ resolve2({
581
1033
  attempt: 1,
582
1034
  status: success ? "success" : "failed",
583
1035
  startedAt,
@@ -1229,7 +1681,9 @@ class EventsClient {
1229
1681
  this.transportOptions = {
1230
1682
  fetchImpl: options.fetchImpl,
1231
1683
  secretResolver: options.secretResolver,
1232
- now: options.now
1684
+ now: options.now,
1685
+ tls: options.tls,
1686
+ webhookTargetPolicy: options.webhookTargetPolicy
1233
1687
  };
1234
1688
  this.catalog = options.catalog ?? defaultEventTypeCatalog;
1235
1689
  this.validateCatalogTypes = options.validateCatalogTypes ?? false;
@@ -1519,6 +1973,15 @@ function parseMatcherExpression(value, label) {
1519
1973
  };
1520
1974
  }
1521
1975
 
1976
+ // src/cli-webhook-policy.ts
1977
+ function webhookTargetPolicyFromEnv() {
1978
+ const value = process.env.HASNA_EVENTS_ALLOW_PRIVATE_WEBHOOK_TARGETS;
1979
+ if (!value)
1980
+ return;
1981
+ const hosts = value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
1982
+ return hosts.length > 0 ? { allowPrivateHosts: hosts } : undefined;
1983
+ }
1984
+
1522
1985
  // src/commander.ts
1523
1986
  var DEFAULT_EVENT_LIST_LIMIT = 100;
1524
1987
  function parseJsonObject(value, fallback) {
@@ -1545,7 +2008,7 @@ function parseHeaders(values) {
1545
2008
  function createClient(options) {
1546
2009
  if (options.createClient)
1547
2010
  return options.createClient();
1548
- return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
2011
+ return new EventsClient({ store: new JsonEventsStore(options.dataDir), webhookTargetPolicy: webhookTargetPolicyFromEnv() });
1549
2012
  }
1550
2013
  function print(value, json, text) {
1551
2014
  if (json)
@@ -1626,6 +2089,8 @@ function registerChannelCommands(program, options) {
1626
2089
  metadata: parseJsonObject(actionOptions.metadata, {})
1627
2090
  }, { honorFilters: actionOptions.honorFilters });
1628
2091
  print(result, json, `${result.status}: ${result.channelId}`);
2092
+ if (result.status === "failed")
2093
+ process.exitCode = 1;
1629
2094
  } catch (error) {
1630
2095
  fail(error, json);
1631
2096
  }
@@ -1715,8 +2180,8 @@ function replaySummary(events, deliveries, nextCursor) {
1715
2180
  return `Replayed ${events} event(s), ${deliveries} delivery result(s)${suffix}`;
1716
2181
  }
1717
2182
  export {
1718
- DEFAULT_EVENT_LIST_LIMIT,
1719
- registerChannelCommands,
2183
+ registerEventsCommands,
1720
2184
  registerEventCommands,
1721
- registerEventsCommands
2185
+ registerChannelCommands,
2186
+ DEFAULT_EVENT_LIST_LIMIT
1722
2187
  };