@hasna/events 0.1.16 → 0.1.17

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 join7 } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
 
9
9
  // src/index.ts
@@ -109,16 +109,121 @@ 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 join3 } from "path";
114
+
115
+ // src/app-home.ts
112
116
  import { existsSync } from "fs";
117
+ import { homedir as homedir2 } from "os";
118
+ import { join as join2, resolve } from "path";
119
+
120
+ // ../../node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
113
121
  import { homedir } from "os";
114
122
  import { join } from "path";
123
+ var KIND_ENV = {
124
+ config: "HASNA_CONFIG_HOME",
125
+ data: "HASNA_DATA_HOME",
126
+ state: "HASNA_STATE_HOME",
127
+ cache: "HASNA_CACHE_HOME"
128
+ };
129
+ var APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
130
+ function assertApp(app) {
131
+ if (typeof app !== "string" || app.length === 0) {
132
+ throw new TypeError("paths: app must be a non-empty string");
133
+ }
134
+ if (!APP_SLUG_RE.test(app)) {
135
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
136
+ }
137
+ }
138
+ function envOf(options) {
139
+ return options.env ?? process.env;
140
+ }
141
+ function envValue(options, kind) {
142
+ const value = envOf(options)[KIND_ENV[kind]];
143
+ return typeof value === "string" && value.length > 0 ? value : undefined;
144
+ }
145
+ function isMacOS(platform) {
146
+ return platform === "darwin";
147
+ }
148
+ function baseDir(kind, options) {
149
+ const override = envValue(options, kind);
150
+ if (override)
151
+ return override;
152
+ const home = options.home ?? homedir();
153
+ const platform = options.platform ?? process.platform;
154
+ if (isMacOS(platform)) {
155
+ switch (kind) {
156
+ case "config":
157
+ case "data":
158
+ return join(home, "Library", "Application Support", "Hasna");
159
+ case "cache":
160
+ return join(home, "Library", "Caches", "Hasna");
161
+ case "state":
162
+ return join(home, "Library", "Logs", "Hasna");
163
+ }
164
+ }
165
+ switch (kind) {
166
+ case "config":
167
+ return join(home, ".config", "hasna");
168
+ case "data":
169
+ return join(home, ".local", "share", "hasna");
170
+ case "state":
171
+ return join(home, ".local", "state", "hasna");
172
+ case "cache":
173
+ return join(home, ".cache", "hasna");
174
+ }
175
+ }
176
+ function resolvePath(kind, options) {
177
+ assertApp(options.app);
178
+ const appSegment = options.internal === true ? join("internal", options.app) : options.app;
179
+ return join(baseDir(kind, options), appSegment);
180
+ }
181
+ function dataDir(options) {
182
+ return resolvePath("data", options);
183
+ }
184
+
185
+ // src/app-home.ts
115
186
  var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
116
187
  var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
188
+ var EVENTS_STORE_SENTINEL_FILE = "events.json";
189
+ function effectiveHome() {
190
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
191
+ }
192
+ function legacyHomeDir() {
193
+ return join2(effectiveHome(), ".hasna", "events");
194
+ }
195
+ function resolverHome() {
196
+ return dataDir({ app: "events", home: effectiveHome() || undefined });
197
+ }
198
+ function adoptResolverHome(resolved, env = process.env) {
199
+ const dataOverride = env.HASNA_DATA_HOME;
200
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
201
+ return true;
202
+ return existsSync(join2(resolved, EVENTS_STORE_SENTINEL_FILE));
203
+ }
204
+ function exactEventsHome() {
205
+ const dir = process.env[HASNA_EVENTS_DIR_ENV];
206
+ if (dir && dir.trim())
207
+ return dir.trim();
208
+ const home = process.env[HASNA_EVENTS_HOME_ENV];
209
+ if (home && home.trim())
210
+ return home.trim();
211
+ return;
212
+ }
213
+ function getEventsHome() {
214
+ const exact = exactEventsHome();
215
+ if (exact)
216
+ return resolve(exact);
217
+ const resolved = resolverHome();
218
+ return adoptResolverHome(resolved) ? resolve(resolved) : resolve(legacyHomeDir());
219
+ }
220
+
221
+ // src/storage.ts
117
222
  var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
118
223
  var DEFAULT_EVENT_PAGE_LIMIT = 100;
119
224
  var MAX_EVENT_PAGE_LIMIT = 1000;
120
225
  function getEventsDataDir(override) {
121
- return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
226
+ return override || getEventsHome();
122
227
  }
123
228
  function getActiveEventsDirEnv() {
124
229
  if (process.env[HASNA_EVENTS_DIR_ENV])
@@ -134,12 +239,12 @@ class JsonEventsStore {
134
239
  channelsPath;
135
240
  eventsPath;
136
241
  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");
242
+ constructor(dataDir2 = getEventsDataDir()) {
243
+ this.dataDir = dataDir2;
244
+ this.runtime = localJsonRuntime(dataDir2);
245
+ this.channelsPath = join3(dataDir2, "channels.json");
246
+ this.eventsPath = join3(dataDir2, "events.json");
247
+ this.deliveriesPath = join3(dataDir2, "deliveries.json");
143
248
  }
144
249
  async init() {
145
250
  await mkdir(this.dataDir, { recursive: true, mode: 448 });
@@ -256,7 +361,7 @@ class JsonEventsStore {
256
361
  };
257
362
  }
258
363
  async ensureArrayFile(path) {
259
- if (!existsSync(path)) {
364
+ if (!existsSync2(path)) {
260
365
  await writeFile(path, `[]
261
366
  `, { encoding: "utf-8", mode: 384 });
262
367
  }
@@ -286,7 +391,7 @@ class JsonEventsStore {
286
391
  });
287
392
  }
288
393
  }
289
- function localJsonRuntime(dataDir = getEventsDataDir()) {
394
+ function localJsonRuntime(dataDir2 = getEventsDataDir()) {
290
395
  return {
291
396
  mode: "local-files",
292
397
  name: "json-events-store",
@@ -299,7 +404,7 @@ function localJsonRuntime(dataDir = getEventsDataDir()) {
299
404
  durable: true,
300
405
  idempotency: "best-effort-local",
301
406
  replayCursors: true,
302
- description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
407
+ description: `Local JSON files in ${dataDir2}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
303
408
  };
304
409
  }
305
410
  function encodeLocalJsonEventCursor(offset, options = {}) {
@@ -363,8 +468,8 @@ function assertCursorFilter(name, cursorValue, optionValue) {
363
468
  function findEventByIdentity(events, identity) {
364
469
  return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
365
470
  }
366
- async function getEventsStatus(dataDir) {
367
- const store = new JsonEventsStore(dataDir);
471
+ async function getEventsStatus(dataDir2) {
472
+ const store = new JsonEventsStore(dataDir2);
368
473
  await store.init();
369
474
  const [channels, events, deliveries] = await Promise.all([
370
475
  store.listChannels(),
@@ -406,14 +511,16 @@ async function getEventsStatus(dataDir) {
406
511
  }
407
512
  };
408
513
  }
409
- function statusFile(dataDir, fileName, records) {
410
- const path = join(dataDir, fileName);
411
- return { path, exists: existsSync(path), records };
514
+ function statusFile(dataDir2, fileName, records) {
515
+ const path = join3(dataDir2, fileName);
516
+ return { path, exists: existsSync2(path), records };
412
517
  }
413
518
 
414
519
  // src/transports.ts
415
520
  import { randomUUID } from "crypto";
416
521
  import { spawn } from "child_process";
522
+ import { request as nodeHttpRequest } from "http";
523
+ import { request as nodeHttpsRequest } from "https";
417
524
 
418
525
  // src/signing.ts
419
526
  import { createHmac, timingSafeEqual } from "crypto";
@@ -426,6 +533,218 @@ function signPayload(secret, timestamp, body) {
426
533
  return `sha256=${digest}`;
427
534
  }
428
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
+ function normalizeMaxRedirects(value) {
657
+ if (value === undefined)
658
+ return DEFAULT_MAX_REDIRECTS;
659
+ if (!Number.isInteger(value) || value < 0)
660
+ throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
661
+ return value;
662
+ }
663
+ var defaultTargetLookup = async (hostname) => {
664
+ return dnsLookup(hostname, { all: true, verbatim: false });
665
+ };
666
+ function normalizeHostname(hostname) {
667
+ const lower = hostname.toLowerCase();
668
+ if (lower.startsWith("[") && lower.endsWith("]"))
669
+ return lower.slice(1, -1);
670
+ return lower;
671
+ }
672
+ function stripZoneId(address) {
673
+ const percent = address.indexOf("%");
674
+ return percent === -1 ? address : address.slice(0, percent);
675
+ }
676
+ function ipv4ToInt(address) {
677
+ const parts = address.split(".");
678
+ if (parts.length !== 4)
679
+ return;
680
+ let value = 0;
681
+ for (const part of parts) {
682
+ if (!/^\d{1,3}$/.test(part))
683
+ return;
684
+ const octet = Number(part);
685
+ if (octet > 255)
686
+ return;
687
+ value = value << 8 | octet;
688
+ }
689
+ return value >>> 0;
690
+ }
691
+ function ipv4IntToString(integer) {
692
+ return [
693
+ integer >>> 24 & 255,
694
+ integer >>> 16 & 255,
695
+ integer >>> 8 & 255,
696
+ integer & 255
697
+ ].join(".");
698
+ }
699
+ function ipv6Groups(address) {
700
+ const raw = stripZoneId(address);
701
+ const doubleColon = raw.indexOf("::");
702
+ const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
703
+ const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
704
+ const parseGroups = (text) => {
705
+ if (text === "")
706
+ return [];
707
+ const out = [];
708
+ for (const part of text.split(":")) {
709
+ if (part.includes(".")) {
710
+ const v4 = ipv4ToInt(part);
711
+ if (v4 === undefined)
712
+ return;
713
+ out.push(v4 >>> 16 & 65535, v4 & 65535);
714
+ } else {
715
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
716
+ return;
717
+ out.push(parseInt(part, 16));
718
+ }
719
+ }
720
+ return out;
721
+ };
722
+ const head = parseGroups(headText);
723
+ if (!head)
724
+ return;
725
+ const tail = parseGroups(tailText);
726
+ if (!tail)
727
+ return;
728
+ const total = head.length + tail.length;
729
+ if (doubleColon === -1) {
730
+ return total === 8 ? head : undefined;
731
+ }
732
+ if (total >= 8)
733
+ return;
734
+ return [...head, ...new Array(8 - total).fill(0), ...tail];
735
+ }
736
+ function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
737
+ let remaining = prefixBits;
738
+ for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
739
+ const take = Math.min(16, remaining);
740
+ const mask = 65535 << 16 - take & 65535;
741
+ if ((groups[index] & mask) !== (prefixGroups[index] & mask))
742
+ return false;
743
+ remaining -= take;
744
+ }
745
+ return true;
746
+ }
747
+
429
748
  // src/transports.ts
430
749
  function now() {
431
750
  return new Date().toISOString();
@@ -457,9 +776,18 @@ function buildWebhookRequest(event, channel, options = {}) {
457
776
  }
458
777
  return { body, headers };
459
778
  }
779
+ function normalizeWebhookUrl(raw) {
780
+ const url = new URL(raw);
781
+ if (url.username !== "" || url.password !== "") {
782
+ url.username = "";
783
+ url.password = "";
784
+ }
785
+ return url.toString();
786
+ }
460
787
  async function dispatchWebhook(event, channel, options = {}) {
461
788
  if (!channel.webhook)
462
789
  throw new Error(`Channel ${channel.id} has no webhook config`);
790
+ const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
463
791
  const startedAt = now();
464
792
  let secret = channel.webhook.secret;
465
793
  if (channel.webhook.secretRef) {
@@ -476,10 +804,14 @@ async function dispatchWebhook(event, channel, options = {}) {
476
804
  }
477
805
  const timestamp = (options.now?.() ?? new Date).toISOString();
478
806
  const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
807
+ const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
808
+ if (validateTargets) {
809
+ return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
810
+ }
479
811
  const controller = new AbortController;
480
812
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
481
813
  try {
482
- const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
814
+ const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
483
815
  method: "POST",
484
816
  headers,
485
817
  body,
@@ -507,6 +839,130 @@ async function dispatchWebhook(event, channel, options = {}) {
507
839
  clearTimeout(timeout);
508
840
  }
509
841
  }
842
+ function isRedirectStatus(status) {
843
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
844
+ }
845
+ function redirectKeepsBody(status) {
846
+ return status === 307 || status === 308;
847
+ }
848
+ async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
849
+ const isHttps = target.protocol === "https:";
850
+ if (!isHttps && target.protocol !== "http:") {
851
+ throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
852
+ }
853
+ const defaultPort = isHttps ? 443 : 80;
854
+ const port = target.port ? Number(target.port) : defaultPort;
855
+ const requestOptions = {
856
+ hostname: target.hostname,
857
+ port,
858
+ path: `${target.pathname}${target.search}`,
859
+ method,
860
+ headers,
861
+ ...tls?.ca ? { ca: tls.ca } : {},
862
+ lookup: (hostname, _options, callback) => {
863
+ const entries = addresses.map((address) => ({
864
+ address,
865
+ family: address.includes(":") ? 6 : 4
866
+ }));
867
+ callback(null, entries);
868
+ }
869
+ };
870
+ return new Promise((resolve2, reject) => {
871
+ const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
872
+ const onAbort = () => {
873
+ const error = new Error("The operation was aborted.");
874
+ error.name = "AbortError";
875
+ request.destroy(error);
876
+ };
877
+ if (signal.aborted)
878
+ onAbort();
879
+ else
880
+ signal.addEventListener("abort", onAbort, { once: true });
881
+ request.on("error", reject);
882
+ if (body !== undefined)
883
+ request.write(body);
884
+ request.end();
885
+ function onResponse(response) {
886
+ const chunks = [];
887
+ response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
888
+ response.on("error", reject);
889
+ response.on("end", () => {
890
+ const headersRecord = {};
891
+ for (const [name, value] of Object.entries(response.headers)) {
892
+ if (typeof value === "string")
893
+ headersRecord[name] = value;
894
+ else if (Array.isArray(value))
895
+ headersRecord[name] = value.join(", ");
896
+ }
897
+ resolve2(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
898
+ });
899
+ }
900
+ });
901
+ }
902
+ async function dispatchValidatedWebhook(event, channel, input) {
903
+ const { body, headers, startedAt, options } = input;
904
+ const webhook = channel.webhook;
905
+ if (!webhook)
906
+ throw new Error(`Channel ${channel.id} has no webhook config`);
907
+ const policy = options.webhookTargetPolicy ?? {};
908
+ const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
909
+ const controller = new AbortController;
910
+ const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
911
+ try {
912
+ let target = new URL(normalizeWebhookUrl(webhook.url));
913
+ let requestHeaders = headers;
914
+ let method = "POST";
915
+ let requestBody = body;
916
+ let redirectsFollowed = 0;
917
+ for (;; ) {
918
+ const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
919
+ throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
920
+ });
921
+ const response = options.fetchImpl ? await options.fetchImpl(target, {
922
+ method,
923
+ headers: requestHeaders,
924
+ body: requestBody,
925
+ signal: controller.signal,
926
+ redirect: "manual"
927
+ }) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
928
+ const location = response.headers.get("location");
929
+ if (isRedirectStatus(response.status) && location) {
930
+ if (redirectsFollowed >= maxRedirects) {
931
+ return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
932
+ }
933
+ redirectsFollowed += 1;
934
+ const next = new URL(location, target);
935
+ target = next;
936
+ if (!redirectKeepsBody(response.status)) {
937
+ method = "GET";
938
+ requestBody = undefined;
939
+ requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
940
+ }
941
+ continue;
942
+ }
943
+ const responseBody = truncate(await response.text());
944
+ return {
945
+ attempt: 1,
946
+ status: response.ok ? "success" : "failed",
947
+ startedAt,
948
+ completedAt: now(),
949
+ responseStatus: response.status,
950
+ responseBody,
951
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
952
+ };
953
+ }
954
+ } catch (error) {
955
+ return {
956
+ attempt: 1,
957
+ status: "failed",
958
+ startedAt,
959
+ completedAt: now(),
960
+ error: error instanceof Error ? error.message : String(error)
961
+ };
962
+ } finally {
963
+ clearTimeout(timeout);
964
+ }
965
+ }
510
966
  function failedAttempt(startedAt, error) {
511
967
  return {
512
968
  attempt: 1,
@@ -535,7 +991,7 @@ async function dispatchCommand(event, channel) {
535
991
  HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
536
992
  HASNA_EVENT_JSON: eventJson
537
993
  };
538
- return new Promise((resolve) => {
994
+ return new Promise((resolve2) => {
539
995
  const child = spawn(channel.command.command, channel.command.args ?? [], {
540
996
  cwd: channel.command.cwd,
541
997
  env,
@@ -553,7 +1009,7 @@ async function dispatchCommand(event, channel) {
553
1009
  });
554
1010
  child.on("error", (error) => {
555
1011
  clearTimeout(timeout);
556
- resolve({
1012
+ resolve2({
557
1013
  attempt: 1,
558
1014
  status: "failed",
559
1015
  startedAt,
@@ -566,7 +1022,7 @@ async function dispatchCommand(event, channel) {
566
1022
  child.on("close", (code, signal) => {
567
1023
  clearTimeout(timeout);
568
1024
  const success = code === 0;
569
- resolve({
1025
+ resolve2({
570
1026
  attempt: 1,
571
1027
  status: success ? "success" : "failed",
572
1028
  startedAt,
@@ -722,7 +1178,9 @@ class EventsClient {
722
1178
  this.transportOptions = {
723
1179
  fetchImpl: options.fetchImpl,
724
1180
  secretResolver: options.secretResolver,
725
- now: options.now
1181
+ now: options.now,
1182
+ tls: options.tls,
1183
+ webhookTargetPolicy: options.webhookTargetPolicy
726
1184
  };
727
1185
  this.catalog = options.catalog ?? defaultEventTypeCatalog;
728
1186
  this.validateCatalogTypes = options.validateCatalogTypes ?? false;
@@ -946,15 +1404,17 @@ import { createHash, randomUUID as randomUUID3 } from "crypto";
946
1404
  import {
947
1405
  chmodSync,
948
1406
  closeSync,
949
- existsSync as existsSync2,
1407
+ existsSync as existsSync3,
950
1408
  fsyncSync,
951
1409
  mkdirSync,
952
1410
  openSync,
953
1411
  readdirSync,
954
1412
  readFileSync,
955
- unlinkSync
1413
+ renameSync,
1414
+ unlinkSync,
1415
+ writeFileSync
956
1416
  } from "fs";
957
- import { join as join2 } from "path";
1417
+ import { basename, join as join4 } from "path";
958
1418
  var DURABLE_SCHEMA_VERSION = 1;
959
1419
  var MAX_RETRY_ATTEMPTS = 1000;
960
1420
  var MAX_RETRY_DELAY_MS = 365 * 24 * 60 * 60 * 1000;
@@ -1097,12 +1557,14 @@ class DurableEventsBroker {
1097
1557
  if (!options.dataDir)
1098
1558
  throw new Error("DurableEventsBroker requires dataDir");
1099
1559
  this.dataDir = options.dataDir;
1100
- this.databasePath = join2(options.dataDir, options.databaseName ?? "events.sqlite");
1560
+ this.databasePath = join4(options.dataDir, options.databaseName ?? "events.sqlite");
1101
1561
  this.now = options.now ?? (() => new Date);
1102
1562
  this.transportOptions = {
1103
1563
  fetchImpl: options.fetchImpl,
1104
1564
  secretResolver: options.secretResolver ?? defaultWebhookSecretResolver,
1105
- now: this.now
1565
+ now: this.now,
1566
+ tls: options.tls,
1567
+ webhookTargetPolicy: options.webhookTargetPolicy
1106
1568
  };
1107
1569
  mkdirSync(this.dataDir, { recursive: true, mode: 448 });
1108
1570
  chmodSync(this.dataDir, 448);
@@ -1239,24 +1701,29 @@ class DurableEventsBroker {
1239
1701
  return summary;
1240
1702
  }
1241
1703
  importSpool(options = {}) {
1242
- const inboxDir = join2(this.dataDir, "spool", "inbox");
1243
- if (!existsSync2(inboxDir))
1244
- return { scanned: 0, imported: 0, deduped: 0, queued: 0 };
1704
+ const inboxDir = join4(this.dataDir, "spool", "inbox");
1705
+ if (!existsSync3(inboxDir))
1706
+ return { scanned: 0, imported: 0, deduped: 0, queued: 0, quarantined: 0 };
1245
1707
  const limit = normalizePositiveInteger(options.limit, 100, "limit");
1246
1708
  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 };
1709
+ const result = { scanned: names.length, imported: 0, deduped: 0, queued: 0, quarantined: 0 };
1248
1710
  for (const name of names) {
1249
- const path = join2(inboxDir, name);
1711
+ const path = join4(inboxDir, name);
1250
1712
  let event;
1251
1713
  try {
1252
1714
  event = parseSpoolEnvelope(readFileSync(path, "utf8"));
1253
1715
  } catch (error) {
1254
1716
  if (isNodeError(error, "ENOENT"))
1255
1717
  continue;
1256
- throw error;
1718
+ quarantineSpoolRecord(this.dataDir, path, "malformed");
1719
+ result.quarantined += 1;
1720
+ continue;
1721
+ }
1722
+ if (spoolFileName(event) !== name) {
1723
+ quarantineSpoolRecord(this.dataDir, path, "identity-mismatch");
1724
+ result.quarantined += 1;
1725
+ continue;
1257
1726
  }
1258
- if (spoolFileName(event) !== name)
1259
- throw new Error("Durable event spool filename does not match its identity");
1260
1727
  const enqueued = this.enqueue(event);
1261
1728
  if (enqueued.deduped)
1262
1729
  result.deduped += 1;
@@ -1581,7 +2048,7 @@ class DurableEventsBroker {
1581
2048
  }
1582
2049
  secureDatabaseFiles() {
1583
2050
  for (const path of [this.databasePath, `${this.databasePath}-wal`, `${this.databasePath}-shm`]) {
1584
- if (!existsSync2(path))
2051
+ if (!existsSync3(path))
1585
2052
  continue;
1586
2053
  chmodSync(path, 384);
1587
2054
  }
@@ -1712,6 +2179,26 @@ function syncDirectory(path) {
1712
2179
  closeSync(descriptor);
1713
2180
  }
1714
2181
  }
2182
+ function quarantineSpoolRecord(dataDir2, path, reason) {
2183
+ const spoolDir = join4(dataDir2, "spool");
2184
+ const quarantineDir = join4(spoolDir, "quarantine");
2185
+ mkdirSync(quarantineDir, { recursive: true, mode: 448 });
2186
+ chmodSync(spoolDir, 448);
2187
+ chmodSync(quarantineDir, 448);
2188
+ const name = basename(path);
2189
+ const base = name.replace(/\.json$/, "");
2190
+ const suffix = `${Date.now()}-${randomUUID3().slice(0, 8)}`;
2191
+ const destination = join4(quarantineDir, `${base}.${suffix}.json`);
2192
+ renameSync(path, destination);
2193
+ const metadata = {
2194
+ quarantinedAt: new Date().toISOString(),
2195
+ originalName: name,
2196
+ reason
2197
+ };
2198
+ writeFileSync(join4(quarantineDir, `${base}.${suffix}.meta.json`), `${JSON.stringify(metadata, null, 2)}
2199
+ `, { mode: 384 });
2200
+ syncDirectory(quarantineDir);
2201
+ }
1715
2202
  function isNodeError(error, code) {
1716
2203
  return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
1717
2204
  }
@@ -1723,7 +2210,7 @@ function spoolFileName(event) {
1723
2210
  // src/durable-worker.ts
1724
2211
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, watch } from "fs";
1725
2212
  import { randomUUID as randomUUID5 } from "crypto";
1726
- import { join as join4 } from "path";
2213
+ import { join as join6 } from "path";
1727
2214
 
1728
2215
  // src/durable-spool.ts
1729
2216
  import { createHash as createHash2, randomUUID as randomUUID4 } from "crypto";
@@ -1737,7 +2224,7 @@ import {
1737
2224
  stat,
1738
2225
  unlink
1739
2226
  } from "fs/promises";
1740
- import { join as join3 } from "path";
2227
+ import { join as join5 } from "path";
1741
2228
  class DurableEventSpool {
1742
2229
  dataDir;
1743
2230
  inboxDir;
@@ -1745,13 +2232,13 @@ class DurableEventSpool {
1745
2232
  if (!options.dataDir)
1746
2233
  throw new Error("DurableEventSpool requires dataDir");
1747
2234
  this.dataDir = options.dataDir;
1748
- this.inboxDir = join3(options.dataDir, "spool", "inbox");
2235
+ this.inboxDir = join5(options.dataDir, "spool", "inbox");
1749
2236
  }
1750
2237
  async enqueue(input) {
1751
2238
  const event = redactSensitiveKeys(createSpoolEvent(input));
1752
2239
  await this.ensureInbox();
1753
2240
  const finalPath = this.pathFor(event);
1754
- const tempPath = join3(this.inboxDir, `.tmp-${process.pid}-${randomUUID4()}`);
2241
+ const tempPath = join5(this.inboxDir, `.tmp-${process.pid}-${randomUUID4()}`);
1755
2242
  const handle = await open(tempPath, "wx", 384);
1756
2243
  try {
1757
2244
  await handle.writeFile(`${JSON.stringify(event)}
@@ -1784,7 +2271,7 @@ class DurableEventSpool {
1784
2271
  const result = { recovered: 0, deduped: 0, cleaned: 0 };
1785
2272
  const names = (await readdir(this.inboxDir)).filter((name) => name.startsWith(".tmp-")).sort();
1786
2273
  for (const name of names) {
1787
- const tempPath = join3(this.inboxDir, name);
2274
+ const tempPath = join5(this.inboxDir, name);
1788
2275
  const details = await stat(tempPath).catch(() => {
1789
2276
  return;
1790
2277
  });
@@ -1822,7 +2309,7 @@ class DurableEventSpool {
1822
2309
  pathFor(event) {
1823
2310
  const identity = event.dedupeKey ?? event.id;
1824
2311
  const digest = createHash2("sha256").update(identity, "utf8").digest("hex");
1825
- return join3(this.inboxDir, `${digest}.json`);
2312
+ return join5(this.inboxDir, `${digest}.json`);
1826
2313
  }
1827
2314
  async assertSameIdentity(path, event) {
1828
2315
  const existing = parseEnvelope(await readFile2(path, "utf8"));
@@ -1831,7 +2318,7 @@ class DurableEventSpool {
1831
2318
  throw new Error("Durable spool identity collision");
1832
2319
  }
1833
2320
  async ensureInbox() {
1834
- const spoolDir = join3(this.dataDir, "spool");
2321
+ const spoolDir = join5(this.dataDir, "spool");
1835
2322
  await mkdir2(this.inboxDir, { recursive: true, mode: 448 });
1836
2323
  await chmod2(this.dataDir, 448);
1837
2324
  await chmod2(spoolDir, 448);
@@ -1899,7 +2386,7 @@ async function runDurableWorker(options) {
1899
2386
  const spool = new DurableEventSpool({ dataDir: options.broker.dataDir });
1900
2387
  const inboxDir = spool.inboxDir;
1901
2388
  mkdirSync2(inboxDir, { recursive: true, mode: 448 });
1902
- chmodSync2(join4(options.broker.dataDir, "spool"), 448);
2389
+ chmodSync2(join6(options.broker.dataDir, "spool"), 448);
1903
2390
  chmodSync2(inboxDir, 448);
1904
2391
  const totals = {
1905
2392
  workerId,
@@ -1911,7 +2398,7 @@ async function runDurableWorker(options) {
1911
2398
  dead: 0,
1912
2399
  lost: 0
1913
2400
  };
1914
- return new Promise((resolve, reject) => {
2401
+ return new Promise((resolve2, reject) => {
1915
2402
  let watcher;
1916
2403
  let debounceTimer;
1917
2404
  let retryTimer;
@@ -1939,7 +2426,7 @@ async function runDurableWorker(options) {
1939
2426
  clearTimeout(restartTimer);
1940
2427
  options.signal.removeEventListener("abort", stop);
1941
2428
  if (!running)
1942
- resolve(totals);
2429
+ resolve2(totals);
1943
2430
  };
1944
2431
  const scheduleRetryWake = () => {
1945
2432
  clearRetryTimer();
@@ -1986,7 +2473,7 @@ async function runDurableWorker(options) {
1986
2473
  running = false;
1987
2474
  }
1988
2475
  if (stopped) {
1989
- resolve(totals);
2476
+ resolve2(totals);
1990
2477
  } else if (rerun) {
1991
2478
  rerun = false;
1992
2479
  queueMicrotask(() => {
@@ -2128,7 +2615,7 @@ function parseMatcherExpression(value, label) {
2128
2615
  // src/cli/index.ts
2129
2616
  function version() {
2130
2617
  try {
2131
- const packagePath = join5(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
2618
+ const packagePath = join7(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
2132
2619
  return JSON.parse(readFileSync2(packagePath, "utf-8")).version ?? "0.0.0";
2133
2620
  } catch {
2134
2621
  return "0.0.0";
@@ -2267,9 +2754,13 @@ Global options (must precede the command group):
2267
2754
  -v, --version Show version
2268
2755
 
2269
2756
  Environment:
2270
- HASNA_EVENTS_DIR Primary data-directory override
2271
- HASNA_EVENTS_HOME Legacy data-directory fallback
2272
- Default directory ${getEventsDataDir()}`);
2757
+ HASNA_EVENTS_DIR Primary data-directory override
2758
+ HASNA_EVENTS_HOME Legacy data-directory fallback
2759
+ HASNA_EVENTS_ALLOW_PRIVATE_WEBHOOK_TARGETS Admin allowlist for intentional private webhook
2760
+ ingress (comma-separated hostnames or IPs).
2761
+ Webhook targets default-deny private/special-use
2762
+ addresses.
2763
+ Default directory ${getEventsDataDir()}`);
2273
2764
  }
2274
2765
  function printChannelsHelp(options = {}) {
2275
2766
  const name = commandName(options);
@@ -2394,7 +2885,10 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
2394
2885
  printDurableHelp(options);
2395
2886
  return;
2396
2887
  }
2397
- const broker = new DurableEventsBroker({ dataDir: parsed.dir ?? getEventsDataDir() });
2888
+ const broker = new DurableEventsBroker({
2889
+ dataDir: parsed.dir ?? getEventsDataDir(),
2890
+ webhookTargetPolicy: webhookTargetPolicyFromEnv()
2891
+ });
2398
2892
  try {
2399
2893
  await handleDurable(broker, command, tail, parsed);
2400
2894
  } finally {
@@ -2403,7 +2897,7 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
2403
2897
  return;
2404
2898
  }
2405
2899
  const store = new JsonEventsStore(parsed.dir);
2406
- const client = new EventsClient({ store });
2900
+ const client = new EventsClient({ store, webhookTargetPolicy: webhookTargetPolicyFromEnv() });
2407
2901
  if (group === "channels") {
2408
2902
  if (!command || command === "--help" || command === "-h") {
2409
2903
  printChannelsHelp(options);
@@ -2521,7 +3015,7 @@ async function handleDurable(broker, command, tail, parsed) {
2521
3015
  if (command === "import") {
2522
3016
  const args = [...tail];
2523
3017
  const result = broker.importSpool({ limit: numberOption(takeOption(args, "--limit")) });
2524
- output(parsed, result, () => console.log(`Imported ${result.imported}, deduped ${result.deduped}, queued ${result.queued}`));
3018
+ output(parsed, result, () => console.log(`Imported ${result.imported}, deduped ${result.deduped}, queued ${result.queued}, quarantined ${result.quarantined}`));
2525
3019
  return;
2526
3020
  }
2527
3021
  if (command === "drain") {
@@ -2774,6 +3268,13 @@ function replaySummary(events, deliveries, nextCursor) {
2774
3268
  const suffix = nextCursor ? `, next cursor: ${nextCursor}` : "";
2775
3269
  return `Replayed ${events} event(s), ${deliveries} delivery result(s)${suffix}`;
2776
3270
  }
3271
+ function webhookTargetPolicyFromEnv() {
3272
+ const value = process.env.HASNA_EVENTS_ALLOW_PRIVATE_WEBHOOK_TARGETS;
3273
+ if (!value)
3274
+ return;
3275
+ const hosts = value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
3276
+ return hosts.length > 0 ? { allowPrivateHosts: hosts } : undefined;
3277
+ }
2777
3278
  if (import.meta.main) {
2778
3279
  runEventsCli().catch((error) => {
2779
3280
  const parsed = parseGlobalArgs(process.argv.slice(2));