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