@hasna/events 0.1.15 → 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/README.md +16 -5
- package/dist/cli/index.js +554 -53
- package/dist/commander.js +481 -20
- package/dist/durable.js +524 -34
- package/dist/index.js +486 -20
- package/dist/ssrf.js +222 -0
- package/dist/storage.js +120 -15
- package/dist/transports.js +355 -1
- package/fixtures/hasna.app_event.v1.json +2 -2
- package/package.json +7 -2
- package/types/app-home.d.ts +33 -0
- package/types/durable.d.ts +2 -0
- package/types/index.d.ts +1 -0
- package/types/ssrf.d.ts +59 -0
- package/types/storage.d.ts +2 -2
- package/types/transports.d.ts +23 -0
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
|
|
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 ||
|
|
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(
|
|
138
|
-
this.dataDir =
|
|
139
|
-
this.runtime = localJsonRuntime(
|
|
140
|
-
this.channelsPath =
|
|
141
|
-
this.eventsPath =
|
|
142
|
-
this.deliveriesPath =
|
|
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 (!
|
|
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(
|
|
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 ${
|
|
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(
|
|
367
|
-
const store = new JsonEventsStore(
|
|
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(
|
|
410
|
-
const path =
|
|
411
|
-
return { path, exists:
|
|
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)(
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1407
|
+
existsSync as existsSync3,
|
|
950
1408
|
fsyncSync,
|
|
951
1409
|
mkdirSync,
|
|
952
1410
|
openSync,
|
|
953
1411
|
readdirSync,
|
|
954
1412
|
readFileSync,
|
|
955
|
-
|
|
1413
|
+
renameSync,
|
|
1414
|
+
unlinkSync,
|
|
1415
|
+
writeFileSync
|
|
956
1416
|
} from "fs";
|
|
957
|
-
import { join as
|
|
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 =
|
|
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 =
|
|
1243
|
-
if (!
|
|
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 =
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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(
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
2271
|
-
HASNA_EVENTS_HOME
|
|
2272
|
-
|
|
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({
|
|
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));
|