@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/durable.js CHANGED
@@ -99,16 +99,121 @@ 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 join3 } from "path";
104
+
105
+ // src/app-home.ts
102
106
  import { existsSync } from "fs";
107
+ import { homedir as homedir2 } from "os";
108
+ import { join as join2, resolve } from "path";
109
+
110
+ // ../../node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
103
111
  import { homedir } from "os";
104
112
  import { join } from "path";
113
+ var KIND_ENV = {
114
+ config: "HASNA_CONFIG_HOME",
115
+ data: "HASNA_DATA_HOME",
116
+ state: "HASNA_STATE_HOME",
117
+ cache: "HASNA_CACHE_HOME"
118
+ };
119
+ var APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
120
+ function assertApp(app) {
121
+ if (typeof app !== "string" || app.length === 0) {
122
+ throw new TypeError("paths: app must be a non-empty string");
123
+ }
124
+ if (!APP_SLUG_RE.test(app)) {
125
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
126
+ }
127
+ }
128
+ function envOf(options) {
129
+ return options.env ?? process.env;
130
+ }
131
+ function envValue(options, kind) {
132
+ const value = envOf(options)[KIND_ENV[kind]];
133
+ return typeof value === "string" && value.length > 0 ? value : undefined;
134
+ }
135
+ function isMacOS(platform) {
136
+ return platform === "darwin";
137
+ }
138
+ function baseDir(kind, options) {
139
+ const override = envValue(options, kind);
140
+ if (override)
141
+ return override;
142
+ const home = options.home ?? homedir();
143
+ const platform = options.platform ?? process.platform;
144
+ if (isMacOS(platform)) {
145
+ switch (kind) {
146
+ case "config":
147
+ case "data":
148
+ return join(home, "Library", "Application Support", "Hasna");
149
+ case "cache":
150
+ return join(home, "Library", "Caches", "Hasna");
151
+ case "state":
152
+ return join(home, "Library", "Logs", "Hasna");
153
+ }
154
+ }
155
+ switch (kind) {
156
+ case "config":
157
+ return join(home, ".config", "hasna");
158
+ case "data":
159
+ return join(home, ".local", "share", "hasna");
160
+ case "state":
161
+ return join(home, ".local", "state", "hasna");
162
+ case "cache":
163
+ return join(home, ".cache", "hasna");
164
+ }
165
+ }
166
+ function resolvePath(kind, options) {
167
+ assertApp(options.app);
168
+ const appSegment = options.internal === true ? join("internal", options.app) : options.app;
169
+ return join(baseDir(kind, options), appSegment);
170
+ }
171
+ function dataDir(options) {
172
+ return resolvePath("data", options);
173
+ }
174
+
175
+ // src/app-home.ts
105
176
  var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
106
177
  var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
178
+ var EVENTS_STORE_SENTINEL_FILE = "events.json";
179
+ function effectiveHome() {
180
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
181
+ }
182
+ function legacyHomeDir() {
183
+ return join2(effectiveHome(), ".hasna", "events");
184
+ }
185
+ function resolverHome() {
186
+ return dataDir({ app: "events", home: effectiveHome() || undefined });
187
+ }
188
+ function adoptResolverHome(resolved, env = process.env) {
189
+ const dataOverride = env.HASNA_DATA_HOME;
190
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
191
+ return true;
192
+ return existsSync(join2(resolved, EVENTS_STORE_SENTINEL_FILE));
193
+ }
194
+ function exactEventsHome() {
195
+ const dir = process.env[HASNA_EVENTS_DIR_ENV];
196
+ if (dir && dir.trim())
197
+ return dir.trim();
198
+ const home = process.env[HASNA_EVENTS_HOME_ENV];
199
+ if (home && home.trim())
200
+ return home.trim();
201
+ return;
202
+ }
203
+ function getEventsHome() {
204
+ const exact = exactEventsHome();
205
+ if (exact)
206
+ return resolve(exact);
207
+ const resolved = resolverHome();
208
+ return adoptResolverHome(resolved) ? resolve(resolved) : resolve(legacyHomeDir());
209
+ }
210
+
211
+ // src/storage.ts
107
212
  var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
108
213
  var DEFAULT_EVENT_PAGE_LIMIT = 100;
109
214
  var MAX_EVENT_PAGE_LIMIT = 1000;
110
215
  function getEventsDataDir(override) {
111
- return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
216
+ return override || getEventsHome();
112
217
  }
113
218
  function getActiveEventsDirEnv() {
114
219
  if (process.env[HASNA_EVENTS_DIR_ENV])
@@ -124,12 +229,12 @@ class JsonEventsStore {
124
229
  channelsPath;
125
230
  eventsPath;
126
231
  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");
232
+ constructor(dataDir2 = getEventsDataDir()) {
233
+ this.dataDir = dataDir2;
234
+ this.runtime = localJsonRuntime(dataDir2);
235
+ this.channelsPath = join3(dataDir2, "channels.json");
236
+ this.eventsPath = join3(dataDir2, "events.json");
237
+ this.deliveriesPath = join3(dataDir2, "deliveries.json");
133
238
  }
134
239
  async init() {
135
240
  await mkdir(this.dataDir, { recursive: true, mode: 448 });
@@ -246,7 +351,7 @@ class JsonEventsStore {
246
351
  };
247
352
  }
248
353
  async ensureArrayFile(path) {
249
- if (!existsSync(path)) {
354
+ if (!existsSync2(path)) {
250
355
  await writeFile(path, `[]
251
356
  `, { encoding: "utf-8", mode: 384 });
252
357
  }
@@ -276,7 +381,7 @@ class JsonEventsStore {
276
381
  });
277
382
  }
278
383
  }
279
- function localJsonRuntime(dataDir = getEventsDataDir()) {
384
+ function localJsonRuntime(dataDir2 = getEventsDataDir()) {
280
385
  return {
281
386
  mode: "local-files",
282
387
  name: "json-events-store",
@@ -289,7 +394,7 @@ function localJsonRuntime(dataDir = getEventsDataDir()) {
289
394
  durable: true,
290
395
  idempotency: "best-effort-local",
291
396
  replayCursors: true,
292
- description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
397
+ description: `Local JSON files in ${dataDir2}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
293
398
  };
294
399
  }
295
400
  function encodeLocalJsonEventCursor(offset, options = {}) {
@@ -353,8 +458,8 @@ function assertCursorFilter(name, cursorValue, optionValue) {
353
458
  function findEventByIdentity(events, identity) {
354
459
  return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
355
460
  }
356
- async function getEventsStatus(dataDir) {
357
- const store = new JsonEventsStore(dataDir);
461
+ async function getEventsStatus(dataDir2) {
462
+ const store = new JsonEventsStore(dataDir2);
358
463
  await store.init();
359
464
  const [channels, events, deliveries] = await Promise.all([
360
465
  store.listChannels(),
@@ -396,9 +501,9 @@ async function getEventsStatus(dataDir) {
396
501
  }
397
502
  };
398
503
  }
399
- function statusFile(dataDir, fileName, records) {
400
- const path = join(dataDir, fileName);
401
- return { path, exists: existsSync(path), records };
504
+ function statusFile(dataDir2, fileName, records) {
505
+ const path = join3(dataDir2, fileName);
506
+ return { path, exists: existsSync2(path), records };
402
507
  }
403
508
 
404
509
  // src/signing.ts
@@ -435,9 +540,226 @@ function verifyWebhookSignature(secret, timestamp, body, signature, options = {}
435
540
  return verifyPayloadSignature(secret, timestamp, body, signature);
436
541
  }
437
542
 
543
+ // src/ssrf.ts
544
+ import { lookup as dnsLookup } from "dns/promises";
545
+ import { isIP } from "net";
546
+ var DEFAULT_MAX_REDIRECTS = 5;
547
+ var IPV4_PRIVATE_RANGES = [
548
+ [0, 16777215],
549
+ [167772160, 184549375],
550
+ [1681915904, 1686110207],
551
+ [2130706432, 2147483647],
552
+ [2851995648, 2852061183],
553
+ [2886729728, 2887778303],
554
+ [3221225472, 3221225727],
555
+ [3221225984, 3221226239],
556
+ [3227017984, 3227018239],
557
+ [3232235520, 3232301055],
558
+ [3323068416, 3323199487],
559
+ [3325256704, 3325256959],
560
+ [3405803776, 3405804031],
561
+ [3758096384, 4294967295]
562
+ ];
563
+ var IPV6_SPECIAL_PREFIXES = [
564
+ { groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
565
+ { groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
566
+ { groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
567
+ { groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
568
+ { groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
569
+ { groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
570
+ { groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
571
+ { groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
572
+ { groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
573
+ { groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
574
+ { groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
575
+ { groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
576
+ { groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
577
+ { groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
578
+ { groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
579
+ ];
580
+ function isPrivateAddress(address) {
581
+ const normalized = stripZoneId(address);
582
+ const version = isIP(normalized);
583
+ if (version === 4) {
584
+ const integer = ipv4ToInt(normalized);
585
+ if (integer === undefined)
586
+ return true;
587
+ return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
588
+ }
589
+ if (version === 6) {
590
+ const groups = ipv6Groups(normalized);
591
+ if (!groups)
592
+ return true;
593
+ for (const prefix of IPV6_SPECIAL_PREFIXES) {
594
+ if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
595
+ continue;
596
+ if (prefix.bits === 96 && groups[5] === 65535) {
597
+ return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
598
+ }
599
+ if (prefix.bits === 16 && groups[0] === 8194) {
600
+ return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
601
+ }
602
+ return true;
603
+ }
604
+ return false;
605
+ }
606
+ return true;
607
+ }
608
+ async function resolveWebhookTarget(url, policy = {}) {
609
+ const hostname = normalizeHostname(url.hostname);
610
+ const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
611
+ if (allowlist.includes(hostname)) {
612
+ const version2 = isIP(hostname);
613
+ if (version2 === 4 || version2 === 6) {
614
+ return { hostname, addresses: [hostname] };
615
+ }
616
+ const lookup2 = policy.lookup ?? defaultTargetLookup;
617
+ let resolved2;
618
+ try {
619
+ resolved2 = await lookup2(hostname);
620
+ } catch {
621
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
622
+ }
623
+ if (!Array.isArray(resolved2) || resolved2.length === 0) {
624
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
625
+ }
626
+ const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
627
+ return { hostname, addresses };
628
+ }
629
+ const version = isIP(hostname);
630
+ if (version === 4 || version === 6) {
631
+ if (isPrivateAddress(hostname)) {
632
+ throw new Error(`Webhook target ${hostname} is a private or special-use address`);
633
+ }
634
+ return { hostname, addresses: [hostname] };
635
+ }
636
+ const lookup = policy.lookup ?? defaultTargetLookup;
637
+ let resolved;
638
+ try {
639
+ resolved = await lookup(hostname);
640
+ } catch {
641
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
642
+ }
643
+ if (!Array.isArray(resolved) || resolved.length === 0) {
644
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
645
+ }
646
+ const allowed = [];
647
+ for (const entry of resolved) {
648
+ const address = normalizeHostname(entry.address);
649
+ if (isPrivateAddress(address)) {
650
+ if (allowlist.includes(address)) {
651
+ allowed.push(address);
652
+ continue;
653
+ }
654
+ throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
655
+ }
656
+ allowed.push(address);
657
+ }
658
+ if (allowed.length === 0) {
659
+ throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
660
+ }
661
+ return { hostname, addresses: allowed };
662
+ }
663
+ async function assertWebhookTargetAllowed(url, policy = {}) {
664
+ await resolveWebhookTarget(url, policy);
665
+ }
666
+ function normalizeMaxRedirects(value) {
667
+ if (value === undefined)
668
+ return DEFAULT_MAX_REDIRECTS;
669
+ if (!Number.isInteger(value) || value < 0)
670
+ throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
671
+ return value;
672
+ }
673
+ var defaultTargetLookup = async (hostname) => {
674
+ return dnsLookup(hostname, { all: true, verbatim: false });
675
+ };
676
+ function normalizeHostname(hostname) {
677
+ const lower = hostname.toLowerCase();
678
+ if (lower.startsWith("[") && lower.endsWith("]"))
679
+ return lower.slice(1, -1);
680
+ return lower;
681
+ }
682
+ function stripZoneId(address) {
683
+ const percent = address.indexOf("%");
684
+ return percent === -1 ? address : address.slice(0, percent);
685
+ }
686
+ function ipv4ToInt(address) {
687
+ const parts = address.split(".");
688
+ if (parts.length !== 4)
689
+ return;
690
+ let value = 0;
691
+ for (const part of parts) {
692
+ if (!/^\d{1,3}$/.test(part))
693
+ return;
694
+ const octet = Number(part);
695
+ if (octet > 255)
696
+ return;
697
+ value = value << 8 | octet;
698
+ }
699
+ return value >>> 0;
700
+ }
701
+ function ipv4IntToString(integer) {
702
+ return [
703
+ integer >>> 24 & 255,
704
+ integer >>> 16 & 255,
705
+ integer >>> 8 & 255,
706
+ integer & 255
707
+ ].join(".");
708
+ }
709
+ function ipv6Groups(address) {
710
+ const raw = stripZoneId(address);
711
+ const doubleColon = raw.indexOf("::");
712
+ const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
713
+ const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
714
+ const parseGroups = (text) => {
715
+ if (text === "")
716
+ return [];
717
+ const out = [];
718
+ for (const part of text.split(":")) {
719
+ if (part.includes(".")) {
720
+ const v4 = ipv4ToInt(part);
721
+ if (v4 === undefined)
722
+ return;
723
+ out.push(v4 >>> 16 & 65535, v4 & 65535);
724
+ } else {
725
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
726
+ return;
727
+ out.push(parseInt(part, 16));
728
+ }
729
+ }
730
+ return out;
731
+ };
732
+ const head = parseGroups(headText);
733
+ if (!head)
734
+ return;
735
+ const tail = parseGroups(tailText);
736
+ if (!tail)
737
+ return;
738
+ const total = head.length + tail.length;
739
+ if (doubleColon === -1) {
740
+ return total === 8 ? head : undefined;
741
+ }
742
+ if (total >= 8)
743
+ return;
744
+ return [...head, ...new Array(8 - total).fill(0), ...tail];
745
+ }
746
+ function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
747
+ let remaining = prefixBits;
748
+ for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
749
+ const take = Math.min(16, remaining);
750
+ const mask = 65535 << 16 - take & 65535;
751
+ if ((groups[index] & mask) !== (prefixGroups[index] & mask))
752
+ return false;
753
+ remaining -= take;
754
+ }
755
+ return true;
756
+ }
757
+
438
758
  // src/transports.ts
439
759
  import { randomUUID } from "crypto";
440
760
  import { spawn } from "child_process";
761
+ import { request as nodeHttpRequest } from "http";
762
+ import { request as nodeHttpsRequest } from "https";
441
763
  function now() {
442
764
  return new Date().toISOString();
443
765
  }
@@ -468,9 +790,18 @@ function buildWebhookRequest(event, channel, options = {}) {
468
790
  }
469
791
  return { body, headers };
470
792
  }
793
+ function normalizeWebhookUrl(raw) {
794
+ const url = new URL(raw);
795
+ if (url.username !== "" || url.password !== "") {
796
+ url.username = "";
797
+ url.password = "";
798
+ }
799
+ return url.toString();
800
+ }
471
801
  async function dispatchWebhook(event, channel, options = {}) {
472
802
  if (!channel.webhook)
473
803
  throw new Error(`Channel ${channel.id} has no webhook config`);
804
+ const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
474
805
  const startedAt = now();
475
806
  let secret = channel.webhook.secret;
476
807
  if (channel.webhook.secretRef) {
@@ -487,10 +818,14 @@ async function dispatchWebhook(event, channel, options = {}) {
487
818
  }
488
819
  const timestamp = (options.now?.() ?? new Date).toISOString();
489
820
  const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
821
+ const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
822
+ if (validateTargets) {
823
+ return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
824
+ }
490
825
  const controller = new AbortController;
491
826
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
492
827
  try {
493
- const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
828
+ const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
494
829
  method: "POST",
495
830
  headers,
496
831
  body,
@@ -518,6 +853,130 @@ async function dispatchWebhook(event, channel, options = {}) {
518
853
  clearTimeout(timeout);
519
854
  }
520
855
  }
856
+ function isRedirectStatus(status) {
857
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
858
+ }
859
+ function redirectKeepsBody(status) {
860
+ return status === 307 || status === 308;
861
+ }
862
+ async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
863
+ const isHttps = target.protocol === "https:";
864
+ if (!isHttps && target.protocol !== "http:") {
865
+ throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
866
+ }
867
+ const defaultPort = isHttps ? 443 : 80;
868
+ const port = target.port ? Number(target.port) : defaultPort;
869
+ const requestOptions = {
870
+ hostname: target.hostname,
871
+ port,
872
+ path: `${target.pathname}${target.search}`,
873
+ method,
874
+ headers,
875
+ ...tls?.ca ? { ca: tls.ca } : {},
876
+ lookup: (hostname, _options, callback) => {
877
+ const entries = addresses.map((address) => ({
878
+ address,
879
+ family: address.includes(":") ? 6 : 4
880
+ }));
881
+ callback(null, entries);
882
+ }
883
+ };
884
+ return new Promise((resolve2, reject) => {
885
+ const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
886
+ const onAbort = () => {
887
+ const error = new Error("The operation was aborted.");
888
+ error.name = "AbortError";
889
+ request.destroy(error);
890
+ };
891
+ if (signal.aborted)
892
+ onAbort();
893
+ else
894
+ signal.addEventListener("abort", onAbort, { once: true });
895
+ request.on("error", reject);
896
+ if (body !== undefined)
897
+ request.write(body);
898
+ request.end();
899
+ function onResponse(response) {
900
+ const chunks = [];
901
+ response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
902
+ response.on("error", reject);
903
+ response.on("end", () => {
904
+ const headersRecord = {};
905
+ for (const [name, value] of Object.entries(response.headers)) {
906
+ if (typeof value === "string")
907
+ headersRecord[name] = value;
908
+ else if (Array.isArray(value))
909
+ headersRecord[name] = value.join(", ");
910
+ }
911
+ resolve2(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
912
+ });
913
+ }
914
+ });
915
+ }
916
+ async function dispatchValidatedWebhook(event, channel, input) {
917
+ const { body, headers, startedAt, options } = input;
918
+ const webhook = channel.webhook;
919
+ if (!webhook)
920
+ throw new Error(`Channel ${channel.id} has no webhook config`);
921
+ const policy = options.webhookTargetPolicy ?? {};
922
+ const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
923
+ const controller = new AbortController;
924
+ const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
925
+ try {
926
+ let target = new URL(normalizeWebhookUrl(webhook.url));
927
+ let requestHeaders = headers;
928
+ let method = "POST";
929
+ let requestBody = body;
930
+ let redirectsFollowed = 0;
931
+ for (;; ) {
932
+ const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
933
+ throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
934
+ });
935
+ const response = options.fetchImpl ? await options.fetchImpl(target, {
936
+ method,
937
+ headers: requestHeaders,
938
+ body: requestBody,
939
+ signal: controller.signal,
940
+ redirect: "manual"
941
+ }) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
942
+ const location = response.headers.get("location");
943
+ if (isRedirectStatus(response.status) && location) {
944
+ if (redirectsFollowed >= maxRedirects) {
945
+ return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
946
+ }
947
+ redirectsFollowed += 1;
948
+ const next = new URL(location, target);
949
+ target = next;
950
+ if (!redirectKeepsBody(response.status)) {
951
+ method = "GET";
952
+ requestBody = undefined;
953
+ requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
954
+ }
955
+ continue;
956
+ }
957
+ const responseBody = truncate(await response.text());
958
+ return {
959
+ attempt: 1,
960
+ status: response.ok ? "success" : "failed",
961
+ startedAt,
962
+ completedAt: now(),
963
+ responseStatus: response.status,
964
+ responseBody,
965
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
966
+ };
967
+ }
968
+ } catch (error) {
969
+ return {
970
+ attempt: 1,
971
+ status: "failed",
972
+ startedAt,
973
+ completedAt: now(),
974
+ error: error instanceof Error ? error.message : String(error)
975
+ };
976
+ } finally {
977
+ clearTimeout(timeout);
978
+ }
979
+ }
521
980
  function failedAttempt(startedAt, error) {
522
981
  return {
523
982
  attempt: 1,
@@ -546,7 +1005,7 @@ async function dispatchCommand(event, channel) {
546
1005
  HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
547
1006
  HASNA_EVENT_JSON: eventJson
548
1007
  };
549
- return new Promise((resolve) => {
1008
+ return new Promise((resolve2) => {
550
1009
  const child = spawn(channel.command.command, channel.command.args ?? [], {
551
1010
  cwd: channel.command.cwd,
552
1011
  env,
@@ -564,7 +1023,7 @@ async function dispatchCommand(event, channel) {
564
1023
  });
565
1024
  child.on("error", (error) => {
566
1025
  clearTimeout(timeout);
567
- resolve({
1026
+ resolve2({
568
1027
  attempt: 1,
569
1028
  status: "failed",
570
1029
  startedAt,
@@ -577,7 +1036,7 @@ async function dispatchCommand(event, channel) {
577
1036
  child.on("close", (code, signal) => {
578
1037
  clearTimeout(timeout);
579
1038
  const success = code === 0;
580
- resolve({
1039
+ resolve2({
581
1040
  attempt: 1,
582
1041
  status: success ? "success" : "failed",
583
1042
  startedAt,
@@ -1229,7 +1688,9 @@ class EventsClient {
1229
1688
  this.transportOptions = {
1230
1689
  fetchImpl: options.fetchImpl,
1231
1690
  secretResolver: options.secretResolver,
1232
- now: options.now
1691
+ now: options.now,
1692
+ tls: options.tls,
1693
+ webhookTargetPolicy: options.webhookTargetPolicy
1233
1694
  };
1234
1695
  this.catalog = options.catalog ?? defaultEventTypeCatalog;
1235
1696
  this.validateCatalogTypes = options.validateCatalogTypes ?? false;
@@ -1453,15 +1914,17 @@ import { createHash, randomUUID as randomUUID3 } from "crypto";
1453
1914
  import {
1454
1915
  chmodSync,
1455
1916
  closeSync,
1456
- existsSync as existsSync2,
1917
+ existsSync as existsSync3,
1457
1918
  fsyncSync,
1458
1919
  mkdirSync,
1459
1920
  openSync,
1460
1921
  readdirSync,
1461
1922
  readFileSync,
1462
- unlinkSync
1923
+ renameSync,
1924
+ unlinkSync,
1925
+ writeFileSync
1463
1926
  } from "fs";
1464
- import { join as join2 } from "path";
1927
+ import { basename, join as join4 } from "path";
1465
1928
  var DURABLE_SCHEMA_VERSION = 1;
1466
1929
  var MAX_RETRY_ATTEMPTS = 1000;
1467
1930
  var MAX_RETRY_DELAY_MS = 365 * 24 * 60 * 60 * 1000;
@@ -1604,12 +2067,14 @@ class DurableEventsBroker {
1604
2067
  if (!options.dataDir)
1605
2068
  throw new Error("DurableEventsBroker requires dataDir");
1606
2069
  this.dataDir = options.dataDir;
1607
- this.databasePath = join2(options.dataDir, options.databaseName ?? "events.sqlite");
2070
+ this.databasePath = join4(options.dataDir, options.databaseName ?? "events.sqlite");
1608
2071
  this.now = options.now ?? (() => new Date);
1609
2072
  this.transportOptions = {
1610
2073
  fetchImpl: options.fetchImpl,
1611
2074
  secretResolver: options.secretResolver ?? defaultWebhookSecretResolver,
1612
- now: this.now
2075
+ now: this.now,
2076
+ tls: options.tls,
2077
+ webhookTargetPolicy: options.webhookTargetPolicy
1613
2078
  };
1614
2079
  mkdirSync(this.dataDir, { recursive: true, mode: 448 });
1615
2080
  chmodSync(this.dataDir, 448);
@@ -1746,24 +2211,29 @@ class DurableEventsBroker {
1746
2211
  return summary;
1747
2212
  }
1748
2213
  importSpool(options = {}) {
1749
- const inboxDir = join2(this.dataDir, "spool", "inbox");
1750
- if (!existsSync2(inboxDir))
1751
- return { scanned: 0, imported: 0, deduped: 0, queued: 0 };
2214
+ const inboxDir = join4(this.dataDir, "spool", "inbox");
2215
+ if (!existsSync3(inboxDir))
2216
+ return { scanned: 0, imported: 0, deduped: 0, queued: 0, quarantined: 0 };
1752
2217
  const limit = normalizePositiveInteger(options.limit, 100, "limit");
1753
2218
  const names = readdirSync(inboxDir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, limit);
1754
- const result = { scanned: names.length, imported: 0, deduped: 0, queued: 0 };
2219
+ const result = { scanned: names.length, imported: 0, deduped: 0, queued: 0, quarantined: 0 };
1755
2220
  for (const name of names) {
1756
- const path = join2(inboxDir, name);
2221
+ const path = join4(inboxDir, name);
1757
2222
  let event;
1758
2223
  try {
1759
2224
  event = parseSpoolEnvelope(readFileSync(path, "utf8"));
1760
2225
  } catch (error) {
1761
2226
  if (isNodeError(error, "ENOENT"))
1762
2227
  continue;
1763
- throw error;
2228
+ quarantineSpoolRecord(this.dataDir, path, "malformed");
2229
+ result.quarantined += 1;
2230
+ continue;
2231
+ }
2232
+ if (spoolFileName(event) !== name) {
2233
+ quarantineSpoolRecord(this.dataDir, path, "identity-mismatch");
2234
+ result.quarantined += 1;
2235
+ continue;
1764
2236
  }
1765
- if (spoolFileName(event) !== name)
1766
- throw new Error("Durable event spool filename does not match its identity");
1767
2237
  const enqueued = this.enqueue(event);
1768
2238
  if (enqueued.deduped)
1769
2239
  result.deduped += 1;
@@ -2088,7 +2558,7 @@ class DurableEventsBroker {
2088
2558
  }
2089
2559
  secureDatabaseFiles() {
2090
2560
  for (const path of [this.databasePath, `${this.databasePath}-wal`, `${this.databasePath}-shm`]) {
2091
- if (!existsSync2(path))
2561
+ if (!existsSync3(path))
2092
2562
  continue;
2093
2563
  chmodSync(path, 384);
2094
2564
  }
@@ -2219,6 +2689,26 @@ function syncDirectory(path) {
2219
2689
  closeSync(descriptor);
2220
2690
  }
2221
2691
  }
2692
+ function quarantineSpoolRecord(dataDir2, path, reason) {
2693
+ const spoolDir = join4(dataDir2, "spool");
2694
+ const quarantineDir = join4(spoolDir, "quarantine");
2695
+ mkdirSync(quarantineDir, { recursive: true, mode: 448 });
2696
+ chmodSync(spoolDir, 448);
2697
+ chmodSync(quarantineDir, 448);
2698
+ const name = basename(path);
2699
+ const base = name.replace(/\.json$/, "");
2700
+ const suffix = `${Date.now()}-${randomUUID3().slice(0, 8)}`;
2701
+ const destination = join4(quarantineDir, `${base}.${suffix}.json`);
2702
+ renameSync(path, destination);
2703
+ const metadata = {
2704
+ quarantinedAt: new Date().toISOString(),
2705
+ originalName: name,
2706
+ reason
2707
+ };
2708
+ writeFileSync(join4(quarantineDir, `${base}.${suffix}.meta.json`), `${JSON.stringify(metadata, null, 2)}
2709
+ `, { mode: 384 });
2710
+ syncDirectory(quarantineDir);
2711
+ }
2222
2712
  function isNodeError(error, code) {
2223
2713
  return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
2224
2714
  }
@@ -2227,6 +2717,6 @@ function spoolFileName(event) {
2227
2717
  return `${createHash("sha256").update(identity, "utf8").digest("hex")}.json`;
2228
2718
  }
2229
2719
  export {
2230
- DurableEventsBroker,
2231
- defaultWebhookSecretResolver
2720
+ defaultWebhookSecretResolver,
2721
+ DurableEventsBroker
2232
2722
  };