@patchstack/connect 0.3.26 → 0.3.28

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);
@@ -2084,6 +2130,26 @@ function warnUnsupportedMatchType(type) {
2084
2130
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
2085
2131
  );
2086
2132
  }
2133
+ function hostFromValue(value) {
2134
+ const raw = String(value ?? "").trim();
2135
+ if (raw === "") return "";
2136
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("//")) {
2137
+ try {
2138
+ return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
2139
+ } catch {
2140
+ return raw;
2141
+ }
2142
+ }
2143
+ if (raw.startsWith("[")) {
2144
+ const end = raw.indexOf("]");
2145
+ if (end > 0) return raw.slice(1, end);
2146
+ }
2147
+ const colon = raw.indexOf(":");
2148
+ if (colon > 0 && raw.indexOf(":", colon + 1) === -1 && /^\d+$/.test(raw.slice(colon + 1))) {
2149
+ return raw.slice(0, colon);
2150
+ }
2151
+ return raw;
2152
+ }
2087
2153
  function isInternalHost(hostname) {
2088
2154
  if (!hostname) return false;
2089
2155
  let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
@@ -2173,8 +2239,20 @@ function expandIPv6(host) {
2173
2239
  }
2174
2240
  return out;
2175
2241
  }
2242
+ var warnedScopes = /* @__PURE__ */ new Set();
2243
+ function warnUnrecognisedScope(when) {
2244
+ const key = Object.keys(when).sort().join(",");
2245
+ if (warnedScopes.has(key)) return;
2246
+ warnedScopes.add(key);
2247
+ console.warn(
2248
+ `[patchstack] Rule scope \`when: { ${key} }\` names no supported key \u2014 the engine understands \`method\` and \`path\`. The scope is IGNORED and the rule applies to every request.`
2249
+ );
2250
+ }
2176
2251
  function ruleAppliesTo(when, resolver) {
2177
2252
  try {
2253
+ if (when.method === void 0 && when.path === void 0 && Object.keys(when).length > 0) {
2254
+ warnUnrecognisedScope(when);
2255
+ }
2178
2256
  if (when.method) {
2179
2257
  const methods = (Array.isArray(when.method) ? when.method : [when.method]).map((m) => String(m).toUpperCase());
2180
2258
  const actual = String(resolver.resolve("server.REQUEST_METHOD")[0] ?? "GET").toUpperCase();
@@ -2340,7 +2418,7 @@ function matchValue(type, value, matchVal, matchObj) {
2340
2418
  }
2341
2419
  }
2342
2420
  case "internal_host":
2343
- return isInternalHost(strValue);
2421
+ return isInternalHost(hostFromValue(strValue));
2344
2422
  case "quotes":
2345
2423
  // engine-php exposes `inline_js_xss` as an alias of `quotes`.
2346
2424
  case "inline_js_xss":
@@ -2379,21 +2457,20 @@ var RuleEngine = class {
2379
2457
  this.#whitelistKeys = whitelist_keys;
2380
2458
  this.#onError = onError;
2381
2459
  if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
2382
- warnOnce2(
2460
+ warnOnce3(
2383
2461
  "whitelist-no-rule-id",
2384
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."
2385
2463
  );
2386
2464
  }
2387
2465
  if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
2388
- 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.");
2389
2467
  }
2390
2468
  }
2391
2469
  // A mitigation engine must never take down the app it protects: any error while
2392
2470
  // evaluating a rule is reported and the request is allowed through (fail open). A
2393
2471
  // malformed rule is skipped without aborting the rest of the ruleset.
2394
2472
  #reportError(err) {
2395
- if (this.#onError) {
2396
- this.#onError(err);
2473
+ if (notify(this.#onError, err, "onError")) {
2397
2474
  return;
2398
2475
  }
2399
2476
  const key = err && err.message ? err.message : String(err);
@@ -2539,13 +2616,15 @@ var PulseRuleClient = class {
2539
2616
  #ttlEffective = 0;
2540
2617
  #etag;
2541
2618
  #pulseAuth;
2542
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
2619
+ #reportsDetections;
2620
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
2543
2621
  this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
2544
2622
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
2545
2623
  this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
2546
2624
  this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
2547
2625
  this.#etag = etag ?? null;
2548
2626
  this.#pulseAuth = pulseAuth ?? null;
2627
+ this.#reportsDetections = reportsDetections === true;
2549
2628
  if (!this.#siteUuid) {
2550
2629
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
2551
2630
  }
@@ -2557,13 +2636,14 @@ var PulseRuleClient = class {
2557
2636
  }
2558
2637
  const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
2559
2638
  try {
2560
- const headers = {
2561
- Accept: "application/json",
2562
- ...await pulseAuthHeader(
2563
- { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
2564
- fetch
2565
- )
2566
- };
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
+ }
2567
2647
  if (this.#etag) headers["If-None-Match"] = this.#etag;
2568
2648
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
2569
2649
  if (response.status === 304) {
@@ -2845,7 +2925,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2845
2925
  const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
2846
2926
  const block = (url, host, method) => {
2847
2927
  if (!shouldBlock(url, host, method)) return false;
2848
- onBlock?.({ url, host, method });
2928
+ notify(onBlock, { url, host, method }, "onEgressBlock");
2849
2929
  return true;
2850
2930
  };
2851
2931
  let screen = null;
@@ -2860,12 +2940,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2860
2940
  screen = null;
2861
2941
  }
2862
2942
  }
2863
- const skip = (reason, detail) => {
2864
- try {
2865
- onSkip?.({ phase: "egress", reason, detail });
2866
- } catch {
2867
- }
2868
- };
2943
+ const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
2869
2944
  const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
2870
2945
  if (!screen) {
2871
2946
  if (host && dnsScreen) skip("resolver-unavailable", { host });
@@ -3451,22 +3526,19 @@ function reportRejections(rejected, options, label) {
3451
3526
  const report = options.onRuleRejected;
3452
3527
  for (const r of rejected) {
3453
3528
  if (typeof report === "function") {
3454
- try {
3455
- report({ ...r, accepted: false });
3456
- } catch {
3457
- }
3529
+ notify(report, { ...r, accepted: false }, "onRuleRejected");
3458
3530
  }
3459
3531
  }
3460
3532
  const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
3461
- options.onError?.(new Error(
3533
+ notify(options.onError, new Error(
3462
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" : ""}`
3463
- ));
3535
+ ), "onError");
3464
3536
  }
3465
3537
  async function resolveRules(options, store, ctx = {}) {
3466
3538
  const timeoutMs = ctx.timeoutMs;
3467
3539
  if (options.siteUuid) {
3468
3540
  const prior = await store.read();
3469
- 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 });
3470
3542
  const res = await client.getRules();
3471
3543
  if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
3472
3544
  if (res.success && !res.notModified) {
@@ -3482,14 +3554,14 @@ async function resolveRules(options, store, ctx = {}) {
3482
3554
  return bundle;
3483
3555
  }
3484
3556
  if (prior?.bundle) {
3485
- 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");
3486
3558
  return normalizeBundle(prior.bundle, options);
3487
3559
  }
3488
3560
  if (options.rules) {
3489
- 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");
3490
3562
  return normalizeBundle(options.rules, options);
3491
3563
  }
3492
- 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");
3493
3565
  return emptyBundle();
3494
3566
  }
3495
3567
  if (options.token) {
@@ -3510,10 +3582,10 @@ async function resolveRules(options, store, ctx = {}) {
3510
3582
  return bundle;
3511
3583
  }
3512
3584
  if (prior?.bundle) {
3513
- 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");
3514
3586
  return normalizeBundle(prior.bundle, options);
3515
3587
  }
3516
- 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");
3517
3589
  return emptyBundle();
3518
3590
  }
3519
3591
  if (options.rules) {
@@ -3534,10 +3606,7 @@ function normalizeBundle(b, options = {}) {
3534
3606
  const report = options.onRuleRejected;
3535
3607
  if (typeof report === "function") {
3536
3608
  for (const r of rejected) {
3537
- try {
3538
- report(r);
3539
- } catch {
3540
- }
3609
+ notify(report, r, "onRuleRejected");
3541
3610
  }
3542
3611
  } else {
3543
3612
  const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
@@ -3579,7 +3648,7 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
3579
3648
  failures = 0;
3580
3649
  } catch (err) {
3581
3650
  failures++;
3582
- onError?.(err);
3651
+ notify(onError, err, "onError");
3583
3652
  }
3584
3653
  schedule();
3585
3654
  };
@@ -3611,10 +3680,125 @@ function makeRefreshHandler(tick, secret) {
3611
3680
  };
3612
3681
  }
3613
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
+
3614
3798
  // src/protect/firewall-log.js
3615
3799
  var DEFAULT_API_BASE = "https://api.patchstack.com";
3616
- var DEFAULT_FLUSH_MS = 1e3;
3617
- var MAX_BATCH = 50;
3800
+ var DEFAULT_FLUSH_MS2 = 1e3;
3801
+ var MAX_BATCH2 = 50;
3618
3802
  var TOKEN_SKEW_MS2 = 6e4;
3619
3803
  function parseApiKey(apiKey) {
3620
3804
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
@@ -3659,7 +3843,7 @@ function createFirewallLogReporter(opts) {
3659
3843
  }
3660
3844
  const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
3661
3845
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
3662
- const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
3846
+ const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
3663
3847
  const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
3664
3848
  let queue = [];
3665
3849
  let timer = null;
@@ -3708,7 +3892,7 @@ function createFirewallLogReporter(opts) {
3708
3892
  timer = null;
3709
3893
  }
3710
3894
  if (queue.length === 0 || typeof fetchImpl !== "function") return;
3711
- const batch = queue.splice(0, MAX_BATCH);
3895
+ const batch = queue.splice(0, MAX_BATCH2);
3712
3896
  void (async () => {
3713
3897
  const token = await fetchAccessToken();
3714
3898
  if (!token) return;
@@ -3755,7 +3939,7 @@ function createFirewallLogReporter(opts) {
3755
3939
  user_agent: event.userAgent ?? null,
3756
3940
  log_date: (/* @__PURE__ */ new Date()).toISOString()
3757
3941
  });
3758
- if (queue.length >= MAX_BATCH) {
3942
+ if (queue.length >= MAX_BATCH2) {
3759
3943
  flush();
3760
3944
  return;
3761
3945
  }
@@ -3854,8 +4038,10 @@ async function createProtection(options = {}) {
3854
4038
  sourceHost: options.sourceHost,
3855
4039
  fetchImpl: options.fetchImpl
3856
4040
  }) : null;
4041
+ let detections = null;
3857
4042
  const onDetect = (detection) => {
3858
- userOnDetect(detection);
4043
+ notify(userOnDetect, detection, "onDetect");
4044
+ if (detections) detections.record(detection);
3859
4045
  if (firewallLog && detection?.mode === "block") {
3860
4046
  firewallLog.record({
3861
4047
  rule: detection.rule,
@@ -3869,7 +4055,24 @@ async function createProtection(options = {}) {
3869
4055
  const store = makeStore(options);
3870
4056
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
3871
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
+ }
3872
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
+ }
3873
4076
  let mode = resolveMode(options, bundle);
3874
4077
  let requestRules;
3875
4078
  let responseRules;
@@ -3912,12 +4115,7 @@ async function createProtection(options = {}) {
3912
4115
  const recordSkip = (phase, reason, detail) => {
3913
4116
  const key = `${phase}:${reason}`;
3914
4117
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
3915
- if (onSkip) {
3916
- try {
3917
- onSkip({ phase, reason, detail, count: skipCounts[key] });
3918
- } catch {
3919
- }
3920
- }
4118
+ notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
3921
4119
  };
3922
4120
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
3923
4121
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
@@ -3953,7 +4151,7 @@ async function createProtection(options = {}) {
3953
4151
  try {
3954
4152
  result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
3955
4153
  } catch (err) {
3956
- onError?.(err);
4154
+ notify(onError, err, "onError");
3957
4155
  continue;
3958
4156
  }
3959
4157
  if (!result.blocked) continue;
@@ -4075,7 +4273,7 @@ async function createProtection(options = {}) {
4075
4273
  try {
4076
4274
  r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
4077
4275
  } catch (err) {
4078
- onError?.(err);
4276
+ notify(onError, err, "onError");
4079
4277
  for (const c of chunks) origWrite(c);
4080
4278
  return origEnd(cb);
4081
4279
  }
@@ -4127,7 +4325,7 @@ async function createProtection(options = {}) {
4127
4325
  try {
4128
4326
  result = egressEngine.evaluate({ _egress: { url, host, method } });
4129
4327
  } catch (err) {
4130
- onError?.(err);
4328
+ notify(onError, err, "onError");
4131
4329
  return false;
4132
4330
  }
4133
4331
  if (!result.blocked) return false;
@@ -4161,7 +4359,7 @@ async function createProtection(options = {}) {
4161
4359
  try {
4162
4360
  result = engine.evaluate(await fromFetchRequest(request));
4163
4361
  } catch (err) {
4164
- onError?.(err);
4362
+ notify(onError, err, "onError");
4165
4363
  return null;
4166
4364
  }
4167
4365
  return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
@@ -4185,7 +4383,7 @@ async function createProtection(options = {}) {
4185
4383
  try {
4186
4384
  result = engine.evaluate(req);
4187
4385
  } catch (err) {
4188
- onError?.(err);
4386
+ notify(onError, err, "onError");
4189
4387
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
4190
4388
  return next();
4191
4389
  }
@@ -4224,7 +4422,7 @@ async function createProtection(options = {}) {
4224
4422
  chunks.push(chunk);
4225
4423
  });
4226
4424
  req.on("error", (err) => {
4227
- onError?.(err);
4425
+ notify(onError, err, "onError");
4228
4426
  next();
4229
4427
  });
4230
4428
  req.on("end", () => {
@@ -4236,7 +4434,7 @@ async function createProtection(options = {}) {
4236
4434
  shaped = fromNodeRequest(req, rawBody);
4237
4435
  result = engine.evaluate(shaped);
4238
4436
  } catch (err) {
4239
- onError?.(err);
4437
+ notify(onError, err, "onError");
4240
4438
  return next();
4241
4439
  }
4242
4440
  decide(
@@ -4283,7 +4481,7 @@ async function createProtection(options = {}) {
4283
4481
  try {
4284
4482
  ({ reportManifest: reporter } = await Promise.resolve().then(() => (init_refresh_manifest(), refresh_manifest_exports)));
4285
4483
  } catch (err) {
4286
- onError?.(err);
4484
+ notify(onError, err, "onError");
4287
4485
  }
4288
4486
  }
4289
4487
  const runRefreshTick = async () => {
@@ -4291,7 +4489,7 @@ async function createProtection(options = {}) {
4291
4489
  try {
4292
4490
  await reporter(cwd);
4293
4491
  } catch (err) {
4294
- onError?.(err);
4492
+ notify(onError, err, "onError");
4295
4493
  }
4296
4494
  }
4297
4495
  const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
@@ -4307,9 +4505,13 @@ async function createProtection(options = {}) {
4307
4505
  protection.stopRefresh = () => {
4308
4506
  loop.stop();
4309
4507
  firewallLog?.stop();
4508
+ detections?.stop();
4310
4509
  };
4311
4510
  } else if (firewallLog) {
4312
- protection.stopRefresh = () => firewallLog.stop();
4511
+ protection.stopRefresh = () => {
4512
+ firewallLog.stop();
4513
+ detections?.stop();
4514
+ };
4313
4515
  }
4314
4516
  return protection;
4315
4517
  }