@patchstack/connect 0.3.27 → 0.3.29

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/protect.cjs CHANGED
@@ -141,6 +141,19 @@ function buildEndpointUrl(base, siteUuid) {
141
141
  const trimmed = base.replace(/\/$/, "");
142
142
  return siteUuid !== void 0 && siteUuid !== null && siteUuid.length > 0 ? `${trimmed}/${encodeURIComponent(siteUuid)}` : trimmed;
143
143
  }
144
+ function authFailureMessage(status, config) {
145
+ const hasCredential = typeof config.pulseAuth === "string" && config.pulseAuth.length > 0;
146
+ if (status === 401 && !hasCredential) {
147
+ return "Patchstack requires an API credential for this site and none is configured. Run `npx patchstack-connect login`, or set PATCHSTACK_API_KEY.";
148
+ }
149
+ if (status === 401) {
150
+ return "Patchstack rejected this API credential. It may have expired, been revoked, or the site may no longer exist. Run `npx patchstack-connect login` to issue a new one.";
151
+ }
152
+ if (status === 403) {
153
+ return "This API credential is not permitted to act on this site. Check that siteUuid in .patchstackrc.json matches the credential (a credential is issued for one site).";
154
+ }
155
+ return null;
156
+ }
144
157
  async function postManifest(config, payload) {
145
158
  const url = buildEndpointUrl(config.endpoint, config.siteUuid);
146
159
  const timeoutMs = config.timeoutMs;
@@ -189,6 +202,10 @@ async function postManifest(config, payload) {
189
202
  "VALIDATION_ERROR"
190
203
  );
191
204
  }
205
+ const refused = authFailureMessage(response.status, config);
206
+ if (refused !== null) {
207
+ throw new PatchstackError(refused, "UNAUTHORIZED");
208
+ }
192
209
  if (response.status < 200 || response.status >= 300) {
193
210
  throw new PatchstackError(
194
211
  `Patchstack returned ${response.status}: ${text.slice(0, 200)}`,
@@ -1785,6 +1802,35 @@ var RequestResolver = class {
1785
1802
  }
1786
1803
  };
1787
1804
 
1805
+ // src/protect/notify.js
1806
+ var reported = /* @__PURE__ */ new Set();
1807
+ function notify(fn, arg, label) {
1808
+ if (typeof fn !== "function") return false;
1809
+ try {
1810
+ const result = fn(arg);
1811
+ if (result !== null && typeof result === "object" && typeof result.then === "function") {
1812
+ try {
1813
+ result.then(void 0, (err) => warnOnce2(label, err));
1814
+ } catch {
1815
+ }
1816
+ }
1817
+ return true;
1818
+ } catch (err) {
1819
+ warnOnce2(label, err);
1820
+ return false;
1821
+ }
1822
+ }
1823
+ function warnOnce2(label, err) {
1824
+ if (reported.has(label)) return;
1825
+ reported.add(label);
1826
+ try {
1827
+ console.warn(
1828
+ `Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this is reported once per process. Cause: ${err && err.message ? err.message : String(err)}`
1829
+ );
1830
+ } catch {
1831
+ }
1832
+ }
1833
+
1788
1834
  // src/protect/engine/normalizer.js
1789
1835
  var HTML_ENTITIES = {
1790
1836
  "&amp;": "&",
@@ -2069,7 +2115,7 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
2069
2115
  return out;
2070
2116
  }
2071
2117
  var warnedKeys = /* @__PURE__ */ new Set();
2072
- function warnOnce2(key, message) {
2118
+ function warnOnce3(key, message) {
2073
2119
  if (warnedKeys.has(key)) return;
2074
2120
  warnedKeys.add(key);
2075
2121
  console.warn(message);
@@ -2411,21 +2457,20 @@ var RuleEngine = class {
2411
2457
  this.#whitelistKeys = whitelist_keys;
2412
2458
  this.#onError = onError;
2413
2459
  if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
2414
- warnOnce2(
2460
+ warnOnce3(
2415
2461
  "whitelist-no-rule-id",
2416
2462
  "[patchstack] a whitelist has no `rule_id` \u2014 it suppresses ALL rules when it matches. Scope each whitelist to a specific rule_id, and key it only on values an attacker cannot set."
2417
2463
  );
2418
2464
  }
2419
2465
  if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
2420
- warnOnce2("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
2466
+ warnOnce3("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
2421
2467
  }
2422
2468
  }
2423
2469
  // A mitigation engine must never take down the app it protects: any error while
2424
2470
  // evaluating a rule is reported and the request is allowed through (fail open). A
2425
2471
  // malformed rule is skipped without aborting the rest of the ruleset.
2426
2472
  #reportError(err) {
2427
- if (this.#onError) {
2428
- this.#onError(err);
2473
+ if (notify(this.#onError, err, "onError")) {
2429
2474
  return;
2430
2475
  }
2431
2476
  const key = err && err.message ? err.message : String(err);
@@ -2571,13 +2616,15 @@ var PulseRuleClient = class {
2571
2616
  #ttlEffective = 0;
2572
2617
  #etag;
2573
2618
  #pulseAuth;
2574
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
2619
+ #reportsDetections;
2620
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
2575
2621
  this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
2576
2622
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
2577
2623
  this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
2578
2624
  this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
2579
2625
  this.#etag = etag ?? null;
2580
2626
  this.#pulseAuth = pulseAuth ?? null;
2627
+ this.#reportsDetections = reportsDetections === true;
2581
2628
  if (!this.#siteUuid) {
2582
2629
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
2583
2630
  }
@@ -2589,13 +2636,14 @@ var PulseRuleClient = class {
2589
2636
  }
2590
2637
  const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
2591
2638
  try {
2592
- const headers = {
2593
- Accept: "application/json",
2594
- ...await pulseAuthHeader(
2595
- { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
2596
- fetch
2597
- )
2598
- };
2639
+ const auth = await pulseAuthHeader(
2640
+ { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
2641
+ fetch
2642
+ );
2643
+ const headers = { Accept: "application/json", ...auth };
2644
+ if (this.#reportsDetections && typeof auth.Authorization === "string") {
2645
+ headers["X-Patchstack-Detections"] = "enabled";
2646
+ }
2599
2647
  if (this.#etag) headers["If-None-Match"] = this.#etag;
2600
2648
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
2601
2649
  if (response.status === 304) {
@@ -2877,7 +2925,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2877
2925
  const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
2878
2926
  const block = (url, host, method) => {
2879
2927
  if (!shouldBlock(url, host, method)) return false;
2880
- onBlock?.({ url, host, method });
2928
+ notify(onBlock, { url, host, method }, "onEgressBlock");
2881
2929
  return true;
2882
2930
  };
2883
2931
  let screen = null;
@@ -2892,12 +2940,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2892
2940
  screen = null;
2893
2941
  }
2894
2942
  }
2895
- const skip = (reason, detail) => {
2896
- try {
2897
- onSkip?.({ phase: "egress", reason, detail });
2898
- } catch {
2899
- }
2900
- };
2943
+ const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
2901
2944
  const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
2902
2945
  if (!screen) {
2903
2946
  if (host && dnsScreen) skip("resolver-unavailable", { host });
@@ -3483,22 +3526,19 @@ function reportRejections(rejected, options, label) {
3483
3526
  const report = options.onRuleRejected;
3484
3527
  for (const r of rejected) {
3485
3528
  if (typeof report === "function") {
3486
- try {
3487
- report({ ...r, accepted: false });
3488
- } catch {
3489
- }
3529
+ notify(report, { ...r, accepted: false }, "onRuleRejected");
3490
3530
  }
3491
3531
  }
3492
3532
  const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
3493
- options.onError?.(new Error(
3533
+ notify(options.onError, new Error(
3494
3534
  `${label}: rejected the entire update because ${rejected.length} rule(s) failed validation \u2014 keeping the previous ruleset and NOT caching this response: ${sample}${rejected.length > 3 ? ", \u2026" : ""}`
3495
- ));
3535
+ ), "onError");
3496
3536
  }
3497
3537
  async function resolveRules(options, store, ctx = {}) {
3498
3538
  const timeoutMs = ctx.timeoutMs;
3499
3539
  if (options.siteUuid) {
3500
3540
  const prior = await store.read();
3501
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
3541
+ const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
3502
3542
  const res = await client.getRules();
3503
3543
  if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
3504
3544
  if (res.success && !res.notModified) {
@@ -3514,14 +3554,14 @@ async function resolveRules(options, store, ctx = {}) {
3514
3554
  return bundle;
3515
3555
  }
3516
3556
  if (prior?.bundle) {
3517
- options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
3557
+ notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3518
3558
  return normalizeBundle(prior.bundle, options);
3519
3559
  }
3520
3560
  if (options.rules) {
3521
- options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
3561
+ notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
3522
3562
  return normalizeBundle(options.rules, options);
3523
3563
  }
3524
- options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
3564
+ notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
3525
3565
  return emptyBundle();
3526
3566
  }
3527
3567
  if (options.token) {
@@ -3542,10 +3582,10 @@ async function resolveRules(options, store, ctx = {}) {
3542
3582
  return bundle;
3543
3583
  }
3544
3584
  if (prior?.bundle) {
3545
- options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
3585
+ notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3546
3586
  return normalizeBundle(prior.bundle, options);
3547
3587
  }
3548
- options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
3588
+ notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
3549
3589
  return emptyBundle();
3550
3590
  }
3551
3591
  if (options.rules) {
@@ -3566,10 +3606,7 @@ function normalizeBundle(b, options = {}) {
3566
3606
  const report = options.onRuleRejected;
3567
3607
  if (typeof report === "function") {
3568
3608
  for (const r of rejected) {
3569
- try {
3570
- report(r);
3571
- } catch {
3572
- }
3609
+ notify(report, r, "onRuleRejected");
3573
3610
  }
3574
3611
  } else {
3575
3612
  const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
@@ -3611,7 +3648,7 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
3611
3648
  failures = 0;
3612
3649
  } catch (err) {
3613
3650
  failures++;
3614
- onError?.(err);
3651
+ notify(onError, err, "onError");
3615
3652
  }
3616
3653
  schedule();
3617
3654
  };
@@ -3643,10 +3680,125 @@ function makeRefreshHandler(tick, secret) {
3643
3680
  };
3644
3681
  }
3645
3682
 
3683
+ // src/protect/detections.js
3684
+ init_pulse_token();
3685
+ var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
3686
+ var DEFAULT_FLUSH_MS = 5e3;
3687
+ var MAX_BATCH = 50;
3688
+ var MAX_QUEUE = 500;
3689
+ function ruleParameters(rule) {
3690
+ const out = /* @__PURE__ */ new Set();
3691
+ const walk2 = (conditions) => {
3692
+ if (!Array.isArray(conditions)) return;
3693
+ for (const condition of conditions) {
3694
+ if (!condition || typeof condition !== "object") continue;
3695
+ if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
3696
+ out.add(condition.parameter);
3697
+ }
3698
+ if (Array.isArray(condition.rules)) walk2(condition.rules);
3699
+ }
3700
+ };
3701
+ walk2(rule?.rule_v2);
3702
+ return [...out];
3703
+ }
3704
+ function routeOf(path7) {
3705
+ if (typeof path7 !== "string" || path7 === "") return null;
3706
+ const cut = path7.search(/[?#]/);
3707
+ return cut === -1 ? path7 : path7.slice(0, cut);
3708
+ }
3709
+ function createDetectionReporter(opts) {
3710
+ const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
3711
+ if (!siteUuid) {
3712
+ return { record() {
3713
+ }, flush() {
3714
+ }, stop() {
3715
+ }, dropped: () => 0 };
3716
+ }
3717
+ const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
3718
+ const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
3719
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
3720
+ const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
3721
+ const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
3722
+ let queue = [];
3723
+ let timer = null;
3724
+ let stopped = false;
3725
+ let dropped = 0;
3726
+ const flush = () => {
3727
+ if (timer) {
3728
+ clearTimeout(timer);
3729
+ timer = null;
3730
+ }
3731
+ if (queue.length === 0 || typeof fetchImpl !== "function") return;
3732
+ const batch = queue.splice(0, MAX_BATCH);
3733
+ const droppedWith = dropped;
3734
+ dropped = 0;
3735
+ void (async () => {
3736
+ try {
3737
+ const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
3738
+ method: "POST",
3739
+ headers: {
3740
+ "Content-Type": "application/json",
3741
+ Accept: "application/json",
3742
+ "User-Agent": "@patchstack/connect",
3743
+ // Same credential path as the rules fetch, and unauthenticated when none resolves: the
3744
+ // server accepts the UUID, and reporting must never hinge on getting a token.
3745
+ ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
3746
+ },
3747
+ body: JSON.stringify({ detections: batch, dropped: droppedWith })
3748
+ });
3749
+ if (res && typeof res.then === "function") res.catch(() => {
3750
+ });
3751
+ } catch {
3752
+ }
3753
+ })();
3754
+ };
3755
+ return {
3756
+ /**
3757
+ * @param {{
3758
+ * rule?: { id?: string, rule_v2?: unknown },
3759
+ * phase?: string,
3760
+ * mode?: string,
3761
+ * path?: string,
3762
+ * }} detection
3763
+ */
3764
+ record(detection) {
3765
+ if (stopped) return;
3766
+ const ruleId = detection?.rule?.id;
3767
+ if (ruleId === void 0 || ruleId === null || ruleId === "") return;
3768
+ if (queue.length >= maxQueue) {
3769
+ queue.shift();
3770
+ dropped++;
3771
+ }
3772
+ queue.push({
3773
+ rule_id: ruleId,
3774
+ route: routeOf(detection.path),
3775
+ parameters: ruleParameters(detection.rule),
3776
+ phase: detection.phase ?? null,
3777
+ // The state this detection was handled under, which is the whole point: `false` is a rule that
3778
+ // saw traffic it would have stopped.
3779
+ enforced: detection.mode === "block",
3780
+ rules_etag: opts.rulesEtag ?? null,
3781
+ detected_at: (/* @__PURE__ */ new Date()).toISOString()
3782
+ });
3783
+ if (queue.length >= MAX_BATCH) {
3784
+ flush();
3785
+ return;
3786
+ }
3787
+ if (!timer) timer = setTimeout(flush, flushMs);
3788
+ },
3789
+ flush,
3790
+ stop() {
3791
+ stopped = true;
3792
+ flush();
3793
+ },
3794
+ dropped: () => dropped
3795
+ };
3796
+ }
3797
+
3646
3798
  // src/protect/firewall-log.js
3647
3799
  var DEFAULT_API_BASE = "https://api.patchstack.com";
3648
- var DEFAULT_FLUSH_MS = 1e3;
3649
- var MAX_BATCH = 50;
3800
+ var DEFAULT_FLUSH_MS2 = 1e3;
3801
+ var MAX_BATCH2 = 50;
3650
3802
  var TOKEN_SKEW_MS2 = 6e4;
3651
3803
  function parseApiKey(apiKey) {
3652
3804
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
@@ -3691,7 +3843,7 @@ function createFirewallLogReporter(opts) {
3691
3843
  }
3692
3844
  const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
3693
3845
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
3694
- const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
3846
+ const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
3695
3847
  const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
3696
3848
  let queue = [];
3697
3849
  let timer = null;
@@ -3740,7 +3892,7 @@ function createFirewallLogReporter(opts) {
3740
3892
  timer = null;
3741
3893
  }
3742
3894
  if (queue.length === 0 || typeof fetchImpl !== "function") return;
3743
- const batch = queue.splice(0, MAX_BATCH);
3895
+ const batch = queue.splice(0, MAX_BATCH2);
3744
3896
  void (async () => {
3745
3897
  const token = await fetchAccessToken();
3746
3898
  if (!token) return;
@@ -3787,7 +3939,7 @@ function createFirewallLogReporter(opts) {
3787
3939
  user_agent: event.userAgent ?? null,
3788
3940
  log_date: (/* @__PURE__ */ new Date()).toISOString()
3789
3941
  });
3790
- if (queue.length >= MAX_BATCH) {
3942
+ if (queue.length >= MAX_BATCH2) {
3791
3943
  flush();
3792
3944
  return;
3793
3945
  }
@@ -3886,8 +4038,10 @@ async function createProtection(options = {}) {
3886
4038
  sourceHost: options.sourceHost,
3887
4039
  fetchImpl: options.fetchImpl
3888
4040
  }) : null;
4041
+ let detections = null;
3889
4042
  const onDetect = (detection) => {
3890
- userOnDetect(detection);
4043
+ notify(userOnDetect, detection, "onDetect");
4044
+ if (detections) detections.record(detection);
3891
4045
  if (firewallLog && detection?.mode === "block") {
3892
4046
  firewallLog.record({
3893
4047
  rule: detection.rule,
@@ -3901,7 +4055,24 @@ async function createProtection(options = {}) {
3901
4055
  const store = makeStore(options);
3902
4056
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
3903
4057
  const pulseAuth = await resolvePulseAuth(options);
4058
+ if (options.siteUuid && !pulseAuth) {
4059
+ const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates may be rejected and this guard would keep running on its cached rules. Set PATCHSTACK_API_KEY (or pass { pulseAuth }) \u2014 required on runtimes without a filesystem.";
4060
+ notify(onError, new Error(message), "onError");
4061
+ console.warn(message);
4062
+ }
3904
4063
  const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
4064
+ if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
4065
+ detections = createDetectionReporter({
4066
+ siteUuid: options.siteUuid,
4067
+ baseUrl: options.pulseRulesUrl,
4068
+ pulseAuth,
4069
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
4070
+ // it rather than to whatever is current when the report is read.
4071
+ rulesEtag: (await store.read())?.etag ?? null,
4072
+ fetchImpl: options.fetchImpl,
4073
+ flushMs: options.detectionFlushMs
4074
+ });
4075
+ }
3905
4076
  let mode = resolveMode(options, bundle);
3906
4077
  let requestRules;
3907
4078
  let responseRules;
@@ -3944,12 +4115,7 @@ async function createProtection(options = {}) {
3944
4115
  const recordSkip = (phase, reason, detail) => {
3945
4116
  const key = `${phase}:${reason}`;
3946
4117
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
3947
- if (onSkip) {
3948
- try {
3949
- onSkip({ phase, reason, detail, count: skipCounts[key] });
3950
- } catch {
3951
- }
3952
- }
4118
+ notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
3953
4119
  };
3954
4120
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
3955
4121
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
@@ -3985,7 +4151,7 @@ async function createProtection(options = {}) {
3985
4151
  try {
3986
4152
  result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
3987
4153
  } catch (err) {
3988
- onError?.(err);
4154
+ notify(onError, err, "onError");
3989
4155
  continue;
3990
4156
  }
3991
4157
  if (!result.blocked) continue;
@@ -4107,7 +4273,7 @@ async function createProtection(options = {}) {
4107
4273
  try {
4108
4274
  r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
4109
4275
  } catch (err) {
4110
- onError?.(err);
4276
+ notify(onError, err, "onError");
4111
4277
  for (const c of chunks) origWrite(c);
4112
4278
  return origEnd(cb);
4113
4279
  }
@@ -4159,7 +4325,7 @@ async function createProtection(options = {}) {
4159
4325
  try {
4160
4326
  result = egressEngine.evaluate({ _egress: { url, host, method } });
4161
4327
  } catch (err) {
4162
- onError?.(err);
4328
+ notify(onError, err, "onError");
4163
4329
  return false;
4164
4330
  }
4165
4331
  if (!result.blocked) return false;
@@ -4193,7 +4359,7 @@ async function createProtection(options = {}) {
4193
4359
  try {
4194
4360
  result = engine.evaluate(await fromFetchRequest(request));
4195
4361
  } catch (err) {
4196
- onError?.(err);
4362
+ notify(onError, err, "onError");
4197
4363
  return null;
4198
4364
  }
4199
4365
  return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
@@ -4217,7 +4383,7 @@ async function createProtection(options = {}) {
4217
4383
  try {
4218
4384
  result = engine.evaluate(req);
4219
4385
  } catch (err) {
4220
- onError?.(err);
4386
+ notify(onError, err, "onError");
4221
4387
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
4222
4388
  return next();
4223
4389
  }
@@ -4256,7 +4422,7 @@ async function createProtection(options = {}) {
4256
4422
  chunks.push(chunk);
4257
4423
  });
4258
4424
  req.on("error", (err) => {
4259
- onError?.(err);
4425
+ notify(onError, err, "onError");
4260
4426
  next();
4261
4427
  });
4262
4428
  req.on("end", () => {
@@ -4268,7 +4434,7 @@ async function createProtection(options = {}) {
4268
4434
  shaped = fromNodeRequest(req, rawBody);
4269
4435
  result = engine.evaluate(shaped);
4270
4436
  } catch (err) {
4271
- onError?.(err);
4437
+ notify(onError, err, "onError");
4272
4438
  return next();
4273
4439
  }
4274
4440
  decide(
@@ -4315,7 +4481,7 @@ async function createProtection(options = {}) {
4315
4481
  try {
4316
4482
  ({ reportManifest: reporter } = await Promise.resolve().then(() => (init_refresh_manifest(), refresh_manifest_exports)));
4317
4483
  } catch (err) {
4318
- onError?.(err);
4484
+ notify(onError, err, "onError");
4319
4485
  }
4320
4486
  }
4321
4487
  const runRefreshTick = async () => {
@@ -4323,7 +4489,7 @@ async function createProtection(options = {}) {
4323
4489
  try {
4324
4490
  await reporter(cwd);
4325
4491
  } catch (err) {
4326
- onError?.(err);
4492
+ notify(onError, err, "onError");
4327
4493
  }
4328
4494
  }
4329
4495
  const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
@@ -4339,9 +4505,13 @@ async function createProtection(options = {}) {
4339
4505
  protection.stopRefresh = () => {
4340
4506
  loop.stop();
4341
4507
  firewallLog?.stop();
4508
+ detections?.stop();
4342
4509
  };
4343
4510
  } else if (firewallLog) {
4344
- protection.stopRefresh = () => firewallLog.stop();
4511
+ protection.stopRefresh = () => {
4512
+ firewallLog.stop();
4513
+ detections?.stop();
4514
+ };
4345
4515
  }
4346
4516
  return protection;
4347
4517
  }