@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/cli/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  // src/cli/index.ts
5
5
  import { readFileSync as readFileSync2 } from "fs";
6
- import { dirname, join as join5 } from "path";
6
+ import { dirname, join as join6 } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
 
9
9
  // src/index.ts
@@ -109,16 +109,114 @@ function channelMatchesEvent(channel, event) {
109
109
  // src/storage.ts
110
110
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
111
111
  import { Buffer as Buffer2 } from "buffer";
112
+ import { existsSync as existsSync2 } from "fs";
113
+ import { join as join2 } from "path";
114
+
115
+ // src/app-home.ts
112
116
  import { existsSync } from "fs";
113
117
  import { homedir } from "os";
114
- import { join } from "path";
118
+ import { join, resolve } from "path";
119
+ import { homedir as pathsResolverHomedir } from "os";
120
+ import { join as pathsResolverJoin } from "path";
121
+ var PATHS_RESOLVER_KIND_ENV = {
122
+ config: "HASNA_CONFIG_HOME",
123
+ data: "HASNA_DATA_HOME",
124
+ state: "HASNA_STATE_HOME",
125
+ cache: "HASNA_CACHE_HOME"
126
+ };
127
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
128
+ function pathsResolverAssertApp(app) {
129
+ if (typeof app !== "string" || app.length === 0) {
130
+ throw new TypeError("paths: app must be a non-empty string");
131
+ }
132
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
133
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
134
+ }
135
+ }
136
+ function pathsResolverAssertKind(kind) {
137
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
138
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
139
+ }
140
+ }
141
+ function pathsResolverBaseDir(kind, options) {
142
+ pathsResolverAssertKind(kind);
143
+ const env = options.env ?? process.env;
144
+ const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
145
+ if (typeof override === "string" && override.length > 0)
146
+ return override;
147
+ const home = options.home ?? pathsResolverHomedir();
148
+ const platform = options.platform ?? process.platform;
149
+ if (platform === "darwin") {
150
+ switch (kind) {
151
+ case "config":
152
+ case "data":
153
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
154
+ case "cache":
155
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
156
+ case "state":
157
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
158
+ }
159
+ }
160
+ switch (kind) {
161
+ case "config":
162
+ return pathsResolverJoin(home, ".config", "hasna");
163
+ case "data":
164
+ return pathsResolverJoin(home, ".local", "share", "hasna");
165
+ case "state":
166
+ return pathsResolverJoin(home, ".local", "state", "hasna");
167
+ case "cache":
168
+ return pathsResolverJoin(home, ".cache", "hasna");
169
+ }
170
+ }
171
+ function pathsResolverResolve(kind, options) {
172
+ pathsResolverAssertApp(options.app);
173
+ const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
174
+ return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
175
+ }
176
+ function dataDir(options) {
177
+ return pathsResolverResolve("data", options);
178
+ }
115
179
  var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
116
180
  var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
181
+ var EVENTS_STORE_SENTINEL_FILE = "events.json";
182
+ function effectiveHome() {
183
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir();
184
+ }
185
+ function legacyHomeDir() {
186
+ return join(effectiveHome(), ".hasna", "events");
187
+ }
188
+ function resolverHome() {
189
+ return dataDir({ app: "events", home: effectiveHome() || undefined });
190
+ }
191
+ function adoptResolverHome(resolved, env = process.env) {
192
+ const dataOverride = env.HASNA_DATA_HOME;
193
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
194
+ return true;
195
+ return existsSync(join(resolved, EVENTS_STORE_SENTINEL_FILE));
196
+ }
197
+ function exactEventsHome() {
198
+ const dir = process.env[HASNA_EVENTS_DIR_ENV];
199
+ if (dir && dir.trim())
200
+ return dir.trim();
201
+ const home = process.env[HASNA_EVENTS_HOME_ENV];
202
+ if (home && home.trim())
203
+ return home.trim();
204
+ return;
205
+ }
206
+ function getEventsHome() {
207
+ const exact = exactEventsHome();
208
+ if (exact)
209
+ return resolve(exact);
210
+ const resolved = resolverHome();
211
+ return adoptResolverHome(resolved) ? resolve(resolved) : resolve(legacyHomeDir());
212
+ }
213
+
214
+ // src/storage.ts
117
215
  var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
118
216
  var DEFAULT_EVENT_PAGE_LIMIT = 100;
119
217
  var MAX_EVENT_PAGE_LIMIT = 1000;
120
218
  function getEventsDataDir(override) {
121
- return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
219
+ return override || getEventsHome();
122
220
  }
123
221
  function getActiveEventsDirEnv() {
124
222
  if (process.env[HASNA_EVENTS_DIR_ENV])
@@ -134,12 +232,12 @@ class JsonEventsStore {
134
232
  channelsPath;
135
233
  eventsPath;
136
234
  deliveriesPath;
137
- constructor(dataDir = getEventsDataDir()) {
138
- this.dataDir = dataDir;
139
- this.runtime = localJsonRuntime(dataDir);
140
- this.channelsPath = join(dataDir, "channels.json");
141
- this.eventsPath = join(dataDir, "events.json");
142
- this.deliveriesPath = join(dataDir, "deliveries.json");
235
+ constructor(dataDir2 = getEventsDataDir()) {
236
+ this.dataDir = dataDir2;
237
+ this.runtime = localJsonRuntime(dataDir2);
238
+ this.channelsPath = join2(dataDir2, "channels.json");
239
+ this.eventsPath = join2(dataDir2, "events.json");
240
+ this.deliveriesPath = join2(dataDir2, "deliveries.json");
143
241
  }
144
242
  async init() {
145
243
  await mkdir(this.dataDir, { recursive: true, mode: 448 });
@@ -256,7 +354,7 @@ class JsonEventsStore {
256
354
  };
257
355
  }
258
356
  async ensureArrayFile(path) {
259
- if (!existsSync(path)) {
357
+ if (!existsSync2(path)) {
260
358
  await writeFile(path, `[]
261
359
  `, { encoding: "utf-8", mode: 384 });
262
360
  }
@@ -286,7 +384,7 @@ class JsonEventsStore {
286
384
  });
287
385
  }
288
386
  }
289
- function localJsonRuntime(dataDir = getEventsDataDir()) {
387
+ function localJsonRuntime(dataDir2 = getEventsDataDir()) {
290
388
  return {
291
389
  mode: "local-files",
292
390
  name: "json-events-store",
@@ -299,7 +397,7 @@ function localJsonRuntime(dataDir = getEventsDataDir()) {
299
397
  durable: true,
300
398
  idempotency: "best-effort-local",
301
399
  replayCursors: true,
302
- description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
400
+ description: `Local JSON files in ${dataDir2}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
303
401
  };
304
402
  }
305
403
  function encodeLocalJsonEventCursor(offset, options = {}) {
@@ -363,8 +461,8 @@ function assertCursorFilter(name, cursorValue, optionValue) {
363
461
  function findEventByIdentity(events, identity) {
364
462
  return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
365
463
  }
366
- async function getEventsStatus(dataDir) {
367
- const store = new JsonEventsStore(dataDir);
464
+ async function getEventsStatus(dataDir2) {
465
+ const store = new JsonEventsStore(dataDir2);
368
466
  await store.init();
369
467
  const [channels, events, deliveries] = await Promise.all([
370
468
  store.listChannels(),
@@ -406,14 +504,16 @@ async function getEventsStatus(dataDir) {
406
504
  }
407
505
  };
408
506
  }
409
- function statusFile(dataDir, fileName, records) {
410
- const path = join(dataDir, fileName);
411
- return { path, exists: existsSync(path), records };
507
+ function statusFile(dataDir2, fileName, records) {
508
+ const path = join2(dataDir2, fileName);
509
+ return { path, exists: existsSync2(path), records };
412
510
  }
413
511
 
414
512
  // src/transports.ts
415
513
  import { randomUUID } from "crypto";
416
514
  import { spawn } from "child_process";
515
+ import { request as nodeHttpRequest } from "http";
516
+ import { request as nodeHttpsRequest } from "https";
417
517
 
418
518
  // src/signing.ts
419
519
  import { createHmac, timingSafeEqual } from "crypto";
@@ -426,6 +526,218 @@ function signPayload(secret, timestamp, body) {
426
526
  return `sha256=${digest}`;
427
527
  }
428
528
 
529
+ // src/ssrf.ts
530
+ import { lookup as dnsLookup } from "dns/promises";
531
+ import { isIP } from "net";
532
+ var DEFAULT_MAX_REDIRECTS = 5;
533
+ var IPV4_PRIVATE_RANGES = [
534
+ [0, 16777215],
535
+ [167772160, 184549375],
536
+ [1681915904, 1686110207],
537
+ [2130706432, 2147483647],
538
+ [2851995648, 2852061183],
539
+ [2886729728, 2887778303],
540
+ [3221225472, 3221225727],
541
+ [3221225984, 3221226239],
542
+ [3227017984, 3227018239],
543
+ [3232235520, 3232301055],
544
+ [3323068416, 3323199487],
545
+ [3325256704, 3325256959],
546
+ [3405803776, 3405804031],
547
+ [3758096384, 4294967295]
548
+ ];
549
+ var IPV6_SPECIAL_PREFIXES = [
550
+ { groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
551
+ { groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
552
+ { groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
553
+ { groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
554
+ { groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
555
+ { groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
556
+ { groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
557
+ { groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
558
+ { groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
559
+ { groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
560
+ { groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
561
+ { groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
562
+ { groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
563
+ { groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
564
+ { groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
565
+ ];
566
+ function isPrivateAddress(address) {
567
+ const normalized = stripZoneId(address);
568
+ const version = isIP(normalized);
569
+ if (version === 4) {
570
+ const integer = ipv4ToInt(normalized);
571
+ if (integer === undefined)
572
+ return true;
573
+ return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
574
+ }
575
+ if (version === 6) {
576
+ const groups = ipv6Groups(normalized);
577
+ if (!groups)
578
+ return true;
579
+ for (const prefix of IPV6_SPECIAL_PREFIXES) {
580
+ if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
581
+ continue;
582
+ if (prefix.bits === 96 && groups[5] === 65535) {
583
+ return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
584
+ }
585
+ if (prefix.bits === 16 && groups[0] === 8194) {
586
+ return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
587
+ }
588
+ return true;
589
+ }
590
+ return false;
591
+ }
592
+ return true;
593
+ }
594
+ async function resolveWebhookTarget(url, policy = {}) {
595
+ const hostname = normalizeHostname(url.hostname);
596
+ const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
597
+ if (allowlist.includes(hostname)) {
598
+ const version2 = isIP(hostname);
599
+ if (version2 === 4 || version2 === 6) {
600
+ return { hostname, addresses: [hostname] };
601
+ }
602
+ const lookup2 = policy.lookup ?? defaultTargetLookup;
603
+ let resolved2;
604
+ try {
605
+ resolved2 = await lookup2(hostname);
606
+ } catch {
607
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
608
+ }
609
+ if (!Array.isArray(resolved2) || resolved2.length === 0) {
610
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
611
+ }
612
+ const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
613
+ return { hostname, addresses };
614
+ }
615
+ const version = isIP(hostname);
616
+ if (version === 4 || version === 6) {
617
+ if (isPrivateAddress(hostname)) {
618
+ throw new Error(`Webhook target ${hostname} is a private or special-use address`);
619
+ }
620
+ return { hostname, addresses: [hostname] };
621
+ }
622
+ const lookup = policy.lookup ?? defaultTargetLookup;
623
+ let resolved;
624
+ try {
625
+ resolved = await lookup(hostname);
626
+ } catch {
627
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
628
+ }
629
+ if (!Array.isArray(resolved) || resolved.length === 0) {
630
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
631
+ }
632
+ const allowed = [];
633
+ for (const entry of resolved) {
634
+ const address = normalizeHostname(entry.address);
635
+ if (isPrivateAddress(address)) {
636
+ if (allowlist.includes(address)) {
637
+ allowed.push(address);
638
+ continue;
639
+ }
640
+ throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
641
+ }
642
+ allowed.push(address);
643
+ }
644
+ if (allowed.length === 0) {
645
+ throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
646
+ }
647
+ return { hostname, addresses: allowed };
648
+ }
649
+ function normalizeMaxRedirects(value) {
650
+ if (value === undefined)
651
+ return DEFAULT_MAX_REDIRECTS;
652
+ if (!Number.isInteger(value) || value < 0)
653
+ throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
654
+ return value;
655
+ }
656
+ var defaultTargetLookup = async (hostname) => {
657
+ return dnsLookup(hostname, { all: true, verbatim: false });
658
+ };
659
+ function normalizeHostname(hostname) {
660
+ const lower = hostname.toLowerCase();
661
+ if (lower.startsWith("[") && lower.endsWith("]"))
662
+ return lower.slice(1, -1);
663
+ return lower;
664
+ }
665
+ function stripZoneId(address) {
666
+ const percent = address.indexOf("%");
667
+ return percent === -1 ? address : address.slice(0, percent);
668
+ }
669
+ function ipv4ToInt(address) {
670
+ const parts = address.split(".");
671
+ if (parts.length !== 4)
672
+ return;
673
+ let value = 0;
674
+ for (const part of parts) {
675
+ if (!/^\d{1,3}$/.test(part))
676
+ return;
677
+ const octet = Number(part);
678
+ if (octet > 255)
679
+ return;
680
+ value = value << 8 | octet;
681
+ }
682
+ return value >>> 0;
683
+ }
684
+ function ipv4IntToString(integer) {
685
+ return [
686
+ integer >>> 24 & 255,
687
+ integer >>> 16 & 255,
688
+ integer >>> 8 & 255,
689
+ integer & 255
690
+ ].join(".");
691
+ }
692
+ function ipv6Groups(address) {
693
+ const raw = stripZoneId(address);
694
+ const doubleColon = raw.indexOf("::");
695
+ const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
696
+ const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
697
+ const parseGroups = (text) => {
698
+ if (text === "")
699
+ return [];
700
+ const out = [];
701
+ for (const part of text.split(":")) {
702
+ if (part.includes(".")) {
703
+ const v4 = ipv4ToInt(part);
704
+ if (v4 === undefined)
705
+ return;
706
+ out.push(v4 >>> 16 & 65535, v4 & 65535);
707
+ } else {
708
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
709
+ return;
710
+ out.push(parseInt(part, 16));
711
+ }
712
+ }
713
+ return out;
714
+ };
715
+ const head = parseGroups(headText);
716
+ if (!head)
717
+ return;
718
+ const tail = parseGroups(tailText);
719
+ if (!tail)
720
+ return;
721
+ const total = head.length + tail.length;
722
+ if (doubleColon === -1) {
723
+ return total === 8 ? head : undefined;
724
+ }
725
+ if (total >= 8)
726
+ return;
727
+ return [...head, ...new Array(8 - total).fill(0), ...tail];
728
+ }
729
+ function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
730
+ let remaining = prefixBits;
731
+ for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
732
+ const take = Math.min(16, remaining);
733
+ const mask = 65535 << 16 - take & 65535;
734
+ if ((groups[index] & mask) !== (prefixGroups[index] & mask))
735
+ return false;
736
+ remaining -= take;
737
+ }
738
+ return true;
739
+ }
740
+
429
741
  // src/transports.ts
430
742
  function now() {
431
743
  return new Date().toISOString();
@@ -457,9 +769,18 @@ function buildWebhookRequest(event, channel, options = {}) {
457
769
  }
458
770
  return { body, headers };
459
771
  }
772
+ function normalizeWebhookUrl(raw) {
773
+ const url = new URL(raw);
774
+ if (url.username !== "" || url.password !== "") {
775
+ url.username = "";
776
+ url.password = "";
777
+ }
778
+ return url.toString();
779
+ }
460
780
  async function dispatchWebhook(event, channel, options = {}) {
461
781
  if (!channel.webhook)
462
782
  throw new Error(`Channel ${channel.id} has no webhook config`);
783
+ const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
463
784
  const startedAt = now();
464
785
  let secret = channel.webhook.secret;
465
786
  if (channel.webhook.secretRef) {
@@ -476,10 +797,14 @@ async function dispatchWebhook(event, channel, options = {}) {
476
797
  }
477
798
  const timestamp = (options.now?.() ?? new Date).toISOString();
478
799
  const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
800
+ const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
801
+ if (validateTargets) {
802
+ return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
803
+ }
479
804
  const controller = new AbortController;
480
805
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
481
806
  try {
482
- const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
807
+ const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
483
808
  method: "POST",
484
809
  headers,
485
810
  body,
@@ -507,6 +832,130 @@ async function dispatchWebhook(event, channel, options = {}) {
507
832
  clearTimeout(timeout);
508
833
  }
509
834
  }
835
+ function isRedirectStatus(status) {
836
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
837
+ }
838
+ function redirectKeepsBody(status) {
839
+ return status === 307 || status === 308;
840
+ }
841
+ async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
842
+ const isHttps = target.protocol === "https:";
843
+ if (!isHttps && target.protocol !== "http:") {
844
+ throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
845
+ }
846
+ const defaultPort = isHttps ? 443 : 80;
847
+ const port = target.port ? Number(target.port) : defaultPort;
848
+ const requestOptions = {
849
+ hostname: target.hostname,
850
+ port,
851
+ path: `${target.pathname}${target.search}`,
852
+ method,
853
+ headers,
854
+ ...tls?.ca ? { ca: tls.ca } : {},
855
+ lookup: (hostname, _options, callback) => {
856
+ const entries = addresses.map((address) => ({
857
+ address,
858
+ family: address.includes(":") ? 6 : 4
859
+ }));
860
+ callback(null, entries);
861
+ }
862
+ };
863
+ return new Promise((resolve2, reject) => {
864
+ const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
865
+ const onAbort = () => {
866
+ const error = new Error("The operation was aborted.");
867
+ error.name = "AbortError";
868
+ request.destroy(error);
869
+ };
870
+ if (signal.aborted)
871
+ onAbort();
872
+ else
873
+ signal.addEventListener("abort", onAbort, { once: true });
874
+ request.on("error", reject);
875
+ if (body !== undefined)
876
+ request.write(body);
877
+ request.end();
878
+ function onResponse(response) {
879
+ const chunks = [];
880
+ response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
881
+ response.on("error", reject);
882
+ response.on("end", () => {
883
+ const headersRecord = {};
884
+ for (const [name, value] of Object.entries(response.headers)) {
885
+ if (typeof value === "string")
886
+ headersRecord[name] = value;
887
+ else if (Array.isArray(value))
888
+ headersRecord[name] = value.join(", ");
889
+ }
890
+ resolve2(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
891
+ });
892
+ }
893
+ });
894
+ }
895
+ async function dispatchValidatedWebhook(event, channel, input) {
896
+ const { body, headers, startedAt, options } = input;
897
+ const webhook = channel.webhook;
898
+ if (!webhook)
899
+ throw new Error(`Channel ${channel.id} has no webhook config`);
900
+ const policy = options.webhookTargetPolicy ?? {};
901
+ const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
902
+ const controller = new AbortController;
903
+ const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
904
+ try {
905
+ let target = new URL(normalizeWebhookUrl(webhook.url));
906
+ let requestHeaders = headers;
907
+ let method = "POST";
908
+ let requestBody = body;
909
+ let redirectsFollowed = 0;
910
+ for (;; ) {
911
+ const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
912
+ throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
913
+ });
914
+ const response = options.fetchImpl ? await options.fetchImpl(target, {
915
+ method,
916
+ headers: requestHeaders,
917
+ body: requestBody,
918
+ signal: controller.signal,
919
+ redirect: "manual"
920
+ }) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
921
+ const location = response.headers.get("location");
922
+ if (isRedirectStatus(response.status) && location) {
923
+ if (redirectsFollowed >= maxRedirects) {
924
+ return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
925
+ }
926
+ redirectsFollowed += 1;
927
+ const next = new URL(location, target);
928
+ target = next;
929
+ if (!redirectKeepsBody(response.status)) {
930
+ method = "GET";
931
+ requestBody = undefined;
932
+ requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
933
+ }
934
+ continue;
935
+ }
936
+ const responseBody = truncate(await response.text());
937
+ return {
938
+ attempt: 1,
939
+ status: response.ok ? "success" : "failed",
940
+ startedAt,
941
+ completedAt: now(),
942
+ responseStatus: response.status,
943
+ responseBody,
944
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
945
+ };
946
+ }
947
+ } catch (error) {
948
+ return {
949
+ attempt: 1,
950
+ status: "failed",
951
+ startedAt,
952
+ completedAt: now(),
953
+ error: error instanceof Error ? error.message : String(error)
954
+ };
955
+ } finally {
956
+ clearTimeout(timeout);
957
+ }
958
+ }
510
959
  function failedAttempt(startedAt, error) {
511
960
  return {
512
961
  attempt: 1,
@@ -535,7 +984,7 @@ async function dispatchCommand(event, channel) {
535
984
  HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
536
985
  HASNA_EVENT_JSON: eventJson
537
986
  };
538
- return new Promise((resolve) => {
987
+ return new Promise((resolve2) => {
539
988
  const child = spawn(channel.command.command, channel.command.args ?? [], {
540
989
  cwd: channel.command.cwd,
541
990
  env,
@@ -553,7 +1002,7 @@ async function dispatchCommand(event, channel) {
553
1002
  });
554
1003
  child.on("error", (error) => {
555
1004
  clearTimeout(timeout);
556
- resolve({
1005
+ resolve2({
557
1006
  attempt: 1,
558
1007
  status: "failed",
559
1008
  startedAt,
@@ -566,7 +1015,7 @@ async function dispatchCommand(event, channel) {
566
1015
  child.on("close", (code, signal) => {
567
1016
  clearTimeout(timeout);
568
1017
  const success = code === 0;
569
- resolve({
1018
+ resolve2({
570
1019
  attempt: 1,
571
1020
  status: success ? "success" : "failed",
572
1021
  startedAt,
@@ -722,7 +1171,9 @@ class EventsClient {
722
1171
  this.transportOptions = {
723
1172
  fetchImpl: options.fetchImpl,
724
1173
  secretResolver: options.secretResolver,
725
- now: options.now
1174
+ now: options.now,
1175
+ tls: options.tls,
1176
+ webhookTargetPolicy: options.webhookTargetPolicy
726
1177
  };
727
1178
  this.catalog = options.catalog ?? defaultEventTypeCatalog;
728
1179
  this.validateCatalogTypes = options.validateCatalogTypes ?? false;
@@ -946,15 +1397,17 @@ import { createHash, randomUUID as randomUUID3 } from "crypto";
946
1397
  import {
947
1398
  chmodSync,
948
1399
  closeSync,
949
- existsSync as existsSync2,
1400
+ existsSync as existsSync3,
950
1401
  fsyncSync,
951
1402
  mkdirSync,
952
1403
  openSync,
953
1404
  readdirSync,
954
1405
  readFileSync,
955
- unlinkSync
1406
+ renameSync,
1407
+ unlinkSync,
1408
+ writeFileSync
956
1409
  } from "fs";
957
- import { join as join2 } from "path";
1410
+ import { basename, join as join3 } from "path";
958
1411
  var DURABLE_SCHEMA_VERSION = 1;
959
1412
  var MAX_RETRY_ATTEMPTS = 1000;
960
1413
  var MAX_RETRY_DELAY_MS = 365 * 24 * 60 * 60 * 1000;
@@ -1097,12 +1550,14 @@ class DurableEventsBroker {
1097
1550
  if (!options.dataDir)
1098
1551
  throw new Error("DurableEventsBroker requires dataDir");
1099
1552
  this.dataDir = options.dataDir;
1100
- this.databasePath = join2(options.dataDir, options.databaseName ?? "events.sqlite");
1553
+ this.databasePath = join3(options.dataDir, options.databaseName ?? "events.sqlite");
1101
1554
  this.now = options.now ?? (() => new Date);
1102
1555
  this.transportOptions = {
1103
1556
  fetchImpl: options.fetchImpl,
1104
1557
  secretResolver: options.secretResolver ?? defaultWebhookSecretResolver,
1105
- now: this.now
1558
+ now: this.now,
1559
+ tls: options.tls,
1560
+ webhookTargetPolicy: options.webhookTargetPolicy
1106
1561
  };
1107
1562
  mkdirSync(this.dataDir, { recursive: true, mode: 448 });
1108
1563
  chmodSync(this.dataDir, 448);
@@ -1239,24 +1694,29 @@ class DurableEventsBroker {
1239
1694
  return summary;
1240
1695
  }
1241
1696
  importSpool(options = {}) {
1242
- const inboxDir = join2(this.dataDir, "spool", "inbox");
1243
- if (!existsSync2(inboxDir))
1244
- return { scanned: 0, imported: 0, deduped: 0, queued: 0 };
1697
+ const inboxDir = join3(this.dataDir, "spool", "inbox");
1698
+ if (!existsSync3(inboxDir))
1699
+ return { scanned: 0, imported: 0, deduped: 0, queued: 0, quarantined: 0 };
1245
1700
  const limit = normalizePositiveInteger(options.limit, 100, "limit");
1246
1701
  const names = readdirSync(inboxDir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, limit);
1247
- const result = { scanned: names.length, imported: 0, deduped: 0, queued: 0 };
1702
+ const result = { scanned: names.length, imported: 0, deduped: 0, queued: 0, quarantined: 0 };
1248
1703
  for (const name of names) {
1249
- const path = join2(inboxDir, name);
1704
+ const path = join3(inboxDir, name);
1250
1705
  let event;
1251
1706
  try {
1252
1707
  event = parseSpoolEnvelope(readFileSync(path, "utf8"));
1253
1708
  } catch (error) {
1254
1709
  if (isNodeError(error, "ENOENT"))
1255
1710
  continue;
1256
- throw error;
1711
+ quarantineSpoolRecord(this.dataDir, path, "malformed");
1712
+ result.quarantined += 1;
1713
+ continue;
1714
+ }
1715
+ if (spoolFileName(event) !== name) {
1716
+ quarantineSpoolRecord(this.dataDir, path, "identity-mismatch");
1717
+ result.quarantined += 1;
1718
+ continue;
1257
1719
  }
1258
- if (spoolFileName(event) !== name)
1259
- throw new Error("Durable event spool filename does not match its identity");
1260
1720
  const enqueued = this.enqueue(event);
1261
1721
  if (enqueued.deduped)
1262
1722
  result.deduped += 1;
@@ -1581,7 +2041,7 @@ class DurableEventsBroker {
1581
2041
  }
1582
2042
  secureDatabaseFiles() {
1583
2043
  for (const path of [this.databasePath, `${this.databasePath}-wal`, `${this.databasePath}-shm`]) {
1584
- if (!existsSync2(path))
2044
+ if (!existsSync3(path))
1585
2045
  continue;
1586
2046
  chmodSync(path, 384);
1587
2047
  }
@@ -1712,6 +2172,26 @@ function syncDirectory(path) {
1712
2172
  closeSync(descriptor);
1713
2173
  }
1714
2174
  }
2175
+ function quarantineSpoolRecord(dataDir2, path, reason) {
2176
+ const spoolDir = join3(dataDir2, "spool");
2177
+ const quarantineDir = join3(spoolDir, "quarantine");
2178
+ mkdirSync(quarantineDir, { recursive: true, mode: 448 });
2179
+ chmodSync(spoolDir, 448);
2180
+ chmodSync(quarantineDir, 448);
2181
+ const name = basename(path);
2182
+ const base = name.replace(/\.json$/, "");
2183
+ const suffix = `${Date.now()}-${randomUUID3().slice(0, 8)}`;
2184
+ const destination = join3(quarantineDir, `${base}.${suffix}.json`);
2185
+ renameSync(path, destination);
2186
+ const metadata = {
2187
+ quarantinedAt: new Date().toISOString(),
2188
+ originalName: name,
2189
+ reason
2190
+ };
2191
+ writeFileSync(join3(quarantineDir, `${base}.${suffix}.meta.json`), `${JSON.stringify(metadata, null, 2)}
2192
+ `, { mode: 384 });
2193
+ syncDirectory(quarantineDir);
2194
+ }
1715
2195
  function isNodeError(error, code) {
1716
2196
  return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
1717
2197
  }
@@ -1723,7 +2203,7 @@ function spoolFileName(event) {
1723
2203
  // src/durable-worker.ts
1724
2204
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, watch } from "fs";
1725
2205
  import { randomUUID as randomUUID5 } from "crypto";
1726
- import { join as join4 } from "path";
2206
+ import { join as join5 } from "path";
1727
2207
 
1728
2208
  // src/durable-spool.ts
1729
2209
  import { createHash as createHash2, randomUUID as randomUUID4 } from "crypto";
@@ -1737,7 +2217,7 @@ import {
1737
2217
  stat,
1738
2218
  unlink
1739
2219
  } from "fs/promises";
1740
- import { join as join3 } from "path";
2220
+ import { join as join4 } from "path";
1741
2221
  class DurableEventSpool {
1742
2222
  dataDir;
1743
2223
  inboxDir;
@@ -1745,13 +2225,13 @@ class DurableEventSpool {
1745
2225
  if (!options.dataDir)
1746
2226
  throw new Error("DurableEventSpool requires dataDir");
1747
2227
  this.dataDir = options.dataDir;
1748
- this.inboxDir = join3(options.dataDir, "spool", "inbox");
2228
+ this.inboxDir = join4(options.dataDir, "spool", "inbox");
1749
2229
  }
1750
2230
  async enqueue(input) {
1751
2231
  const event = redactSensitiveKeys(createSpoolEvent(input));
1752
2232
  await this.ensureInbox();
1753
2233
  const finalPath = this.pathFor(event);
1754
- const tempPath = join3(this.inboxDir, `.tmp-${process.pid}-${randomUUID4()}`);
2234
+ const tempPath = join4(this.inboxDir, `.tmp-${process.pid}-${randomUUID4()}`);
1755
2235
  const handle = await open(tempPath, "wx", 384);
1756
2236
  try {
1757
2237
  await handle.writeFile(`${JSON.stringify(event)}
@@ -1784,7 +2264,7 @@ class DurableEventSpool {
1784
2264
  const result = { recovered: 0, deduped: 0, cleaned: 0 };
1785
2265
  const names = (await readdir(this.inboxDir)).filter((name) => name.startsWith(".tmp-")).sort();
1786
2266
  for (const name of names) {
1787
- const tempPath = join3(this.inboxDir, name);
2267
+ const tempPath = join4(this.inboxDir, name);
1788
2268
  const details = await stat(tempPath).catch(() => {
1789
2269
  return;
1790
2270
  });
@@ -1822,7 +2302,7 @@ class DurableEventSpool {
1822
2302
  pathFor(event) {
1823
2303
  const identity = event.dedupeKey ?? event.id;
1824
2304
  const digest = createHash2("sha256").update(identity, "utf8").digest("hex");
1825
- return join3(this.inboxDir, `${digest}.json`);
2305
+ return join4(this.inboxDir, `${digest}.json`);
1826
2306
  }
1827
2307
  async assertSameIdentity(path, event) {
1828
2308
  const existing = parseEnvelope(await readFile2(path, "utf8"));
@@ -1831,7 +2311,7 @@ class DurableEventSpool {
1831
2311
  throw new Error("Durable spool identity collision");
1832
2312
  }
1833
2313
  async ensureInbox() {
1834
- const spoolDir = join3(this.dataDir, "spool");
2314
+ const spoolDir = join4(this.dataDir, "spool");
1835
2315
  await mkdir2(this.inboxDir, { recursive: true, mode: 448 });
1836
2316
  await chmod2(this.dataDir, 448);
1837
2317
  await chmod2(spoolDir, 448);
@@ -1899,7 +2379,7 @@ async function runDurableWorker(options) {
1899
2379
  const spool = new DurableEventSpool({ dataDir: options.broker.dataDir });
1900
2380
  const inboxDir = spool.inboxDir;
1901
2381
  mkdirSync2(inboxDir, { recursive: true, mode: 448 });
1902
- chmodSync2(join4(options.broker.dataDir, "spool"), 448);
2382
+ chmodSync2(join5(options.broker.dataDir, "spool"), 448);
1903
2383
  chmodSync2(inboxDir, 448);
1904
2384
  const totals = {
1905
2385
  workerId,
@@ -1911,7 +2391,7 @@ async function runDurableWorker(options) {
1911
2391
  dead: 0,
1912
2392
  lost: 0
1913
2393
  };
1914
- return new Promise((resolve, reject) => {
2394
+ return new Promise((resolve2, reject) => {
1915
2395
  let watcher;
1916
2396
  let debounceTimer;
1917
2397
  let retryTimer;
@@ -1939,7 +2419,7 @@ async function runDurableWorker(options) {
1939
2419
  clearTimeout(restartTimer);
1940
2420
  options.signal.removeEventListener("abort", stop);
1941
2421
  if (!running)
1942
- resolve(totals);
2422
+ resolve2(totals);
1943
2423
  };
1944
2424
  const scheduleRetryWake = () => {
1945
2425
  clearRetryTimer();
@@ -1986,7 +2466,7 @@ async function runDurableWorker(options) {
1986
2466
  running = false;
1987
2467
  }
1988
2468
  if (stopped) {
1989
- resolve(totals);
2469
+ resolve2(totals);
1990
2470
  } else if (rerun) {
1991
2471
  rerun = false;
1992
2472
  queueMicrotask(() => {
@@ -2125,10 +2605,19 @@ function parseMatcherExpression(value, label) {
2125
2605
  };
2126
2606
  }
2127
2607
 
2608
+ // src/cli-webhook-policy.ts
2609
+ function webhookTargetPolicyFromEnv() {
2610
+ const value = process.env.HASNA_EVENTS_ALLOW_PRIVATE_WEBHOOK_TARGETS;
2611
+ if (!value)
2612
+ return;
2613
+ const hosts = value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
2614
+ return hosts.length > 0 ? { allowPrivateHosts: hosts } : undefined;
2615
+ }
2616
+
2128
2617
  // src/cli/index.ts
2129
2618
  function version() {
2130
2619
  try {
2131
- const packagePath = join5(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
2620
+ const packagePath = join6(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
2132
2621
  return JSON.parse(readFileSync2(packagePath, "utf-8")).version ?? "0.0.0";
2133
2622
  } catch {
2134
2623
  return "0.0.0";
@@ -2267,9 +2756,13 @@ Global options (must precede the command group):
2267
2756
  -v, --version Show version
2268
2757
 
2269
2758
  Environment:
2270
- HASNA_EVENTS_DIR Primary data-directory override
2271
- HASNA_EVENTS_HOME Legacy data-directory fallback
2272
- Default directory ${getEventsDataDir()}`);
2759
+ HASNA_EVENTS_DIR Primary data-directory override
2760
+ HASNA_EVENTS_HOME Legacy data-directory fallback
2761
+ HASNA_EVENTS_ALLOW_PRIVATE_WEBHOOK_TARGETS Admin allowlist for intentional private webhook
2762
+ ingress (comma-separated hostnames or IPs).
2763
+ Webhook targets default-deny private/special-use
2764
+ addresses.
2765
+ Default directory ${getEventsDataDir()}`);
2273
2766
  }
2274
2767
  function printChannelsHelp(options = {}) {
2275
2768
  const name = commandName(options);
@@ -2394,7 +2887,10 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
2394
2887
  printDurableHelp(options);
2395
2888
  return;
2396
2889
  }
2397
- const broker = new DurableEventsBroker({ dataDir: parsed.dir ?? getEventsDataDir() });
2890
+ const broker = new DurableEventsBroker({
2891
+ dataDir: parsed.dir ?? getEventsDataDir(),
2892
+ webhookTargetPolicy: webhookTargetPolicyFromEnv()
2893
+ });
2398
2894
  try {
2399
2895
  await handleDurable(broker, command, tail, parsed);
2400
2896
  } finally {
@@ -2403,7 +2899,7 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
2403
2899
  return;
2404
2900
  }
2405
2901
  const store = new JsonEventsStore(parsed.dir);
2406
- const client = new EventsClient({ store });
2902
+ const client = new EventsClient({ store, webhookTargetPolicy: webhookTargetPolicyFromEnv() });
2407
2903
  if (group === "channels") {
2408
2904
  if (!command || command === "--help" || command === "-h") {
2409
2905
  printChannelsHelp(options);
@@ -2521,7 +3017,7 @@ async function handleDurable(broker, command, tail, parsed) {
2521
3017
  if (command === "import") {
2522
3018
  const args = [...tail];
2523
3019
  const result = broker.importSpool({ limit: numberOption(takeOption(args, "--limit")) });
2524
- output(parsed, result, () => console.log(`Imported ${result.imported}, deduped ${result.deduped}, queued ${result.queued}`));
3020
+ output(parsed, result, () => console.log(`Imported ${result.imported}, deduped ${result.deduped}, queued ${result.queued}, quarantined ${result.quarantined}`));
2525
3021
  return;
2526
3022
  }
2527
3023
  if (command === "drain") {
@@ -2664,6 +3160,8 @@ async function handleChannels(client, command, tail, parsed, options) {
2664
3160
  metadata: parseJsonOption(takeOption(args, "--metadata"), {})
2665
3161
  }, { honorFilters });
2666
3162
  output(parsed, result, () => console.log(`${result.status}: ${result.channelId}`));
3163
+ if (result.status === "failed")
3164
+ process.exitCode = 1;
2667
3165
  return;
2668
3166
  }
2669
3167
  if (command === "match") {