@patchstack/connect 0.3.21 → 0.3.23

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.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface RuleBundle {
5
5
  firewall: unknown[];
6
6
  whitelists: unknown[];
7
7
  whitelist_keys: Record<string, unknown>;
8
+ /** From the Pulse rules API when present (`block` = enforce, `dry-run` = detect only). */
9
+ enforcement?: "block" | "dry-run";
8
10
  }
9
11
 
10
12
  export type Phase = "request" | "response" | "egress";
@@ -33,7 +35,11 @@ export interface Protection {
33
35
  }
34
36
 
35
37
  export interface CreateProtectionOptions {
36
- /** Default "dry-run". The scaffolded guard sets "block". */
38
+ /**
39
+ * Fallback when the Pulse rules API does not send `enforcement`.
40
+ * Overridden by `PATCHSTACK_MODE` when set, otherwise by API `enforcement`.
41
+ * Default "dry-run". Scaffolded guards pass "block" when env is unset.
42
+ */
37
43
  mode?: "block" | "dry-run";
38
44
  /** Explicit rule bundle (used as the token-less fallback). */
39
45
  rules?: unknown;
@@ -42,8 +48,23 @@ export interface CreateProtectionOptions {
42
48
  baseUrl?: string;
43
49
  /** Pulse site UUID — pull live per-site rules from the Pulse rules API (cached). */
44
50
  siteUuid?: string;
51
+ /**
52
+ * WP-format site API key (`{secret}-{oauth.id}`) for authenticated block logs
53
+ * via connector `POST /api/logs/log`. Falls back to `PATCHSTACK_API_KEY`, then
54
+ * `.patchstackrc.json` `apiKey`. Never put this in the public widget.
55
+ */
56
+ apiKey?: string;
45
57
  /** Override the Pulse rules API base URL. */
46
58
  pulseRulesUrl?: string;
59
+ /**
60
+ * When false, skip posting block events to connector `/api/logs/log`.
61
+ * Also disabled when `PATCHSTACK_TELEMETRY=off` or when no apiKey is available.
62
+ */
63
+ reportFirewallLog?: boolean;
64
+ /** Optional Source-Host header for connector hostname checks. */
65
+ sourceHost?: string;
66
+ /** Optional fetch override (tests). */
67
+ fetchImpl?: typeof fetch;
47
68
  /**
48
69
  * Re-fetch and hot-swap the live rules every N ms. For long-lived runtimes that aren't restarted
49
70
  * on change (an AI builder's sandbox/preview) so a rule that becomes relevant after boot still
@@ -98,8 +119,12 @@ export interface CreateProtectionOptions {
98
119
  phase?: Phase;
99
120
  mode: string;
100
121
  category?: string;
101
- rule?: { id?: string; category?: string };
122
+ rule?: { id?: string | number; category?: string };
102
123
  message?: string;
124
+ method?: string | null;
125
+ path?: string | null;
126
+ ip?: string | null;
127
+ userAgent?: string | null;
103
128
  }) => void;
104
129
  }
105
130
 
package/dist/protect.js CHANGED
@@ -1032,7 +1032,8 @@ var PulseRuleClient = class {
1032
1032
  etag: response.headers?.get?.("etag") ?? null,
1033
1033
  firewall: data.firewall,
1034
1034
  whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
1035
- whitelist_keys: data.whitelist_keys ?? {}
1035
+ whitelist_keys: data.whitelist_keys ?? {},
1036
+ ...enforcementField(data)
1036
1037
  };
1037
1038
  this.#cache = result;
1038
1039
  this.#etag = result.etag;
@@ -1058,6 +1059,11 @@ var PulseRuleClient = class {
1058
1059
  this.#etag = null;
1059
1060
  }
1060
1061
  };
1062
+ function enforcementField(data) {
1063
+ if (!data || typeof data !== "object") return {};
1064
+ const v = data.enforcement ?? data.mode;
1065
+ return v === "block" || v === "dry-run" ? { enforcement: v } : {};
1066
+ }
1061
1067
 
1062
1068
  // src/protect/engine/fetch.js
1063
1069
  var MAX_BODY_BYTES = 1024 * 1024;
@@ -1785,10 +1791,12 @@ async function resolveRules(options, store) {
1785
1791
  return emptyBundle();
1786
1792
  }
1787
1793
  function normalizeBundle(b) {
1794
+ const enforcement = b?.enforcement ?? b?.mode;
1788
1795
  return {
1789
1796
  firewall: Array.isArray(b.firewall) ? b.firewall : [],
1790
1797
  whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
1791
- whitelist_keys: b.whitelist_keys ?? {}
1798
+ whitelist_keys: b.whitelist_keys ?? {},
1799
+ ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
1792
1800
  };
1793
1801
  }
1794
1802
  function emptyBundle() {
@@ -1849,6 +1857,169 @@ function makeRefreshHandler(tick, secret) {
1849
1857
  };
1850
1858
  }
1851
1859
 
1860
+ // src/protect/firewall-log.js
1861
+ var DEFAULT_API_BASE = "https://api.patchstack.com";
1862
+ var DEFAULT_FLUSH_MS = 1e3;
1863
+ var MAX_BATCH = 50;
1864
+ var TOKEN_SKEW_MS = 6e4;
1865
+ function parseApiKey(apiKey) {
1866
+ if (typeof apiKey !== "string" || apiKey.length === 0) return null;
1867
+ const idx = apiKey.lastIndexOf("-");
1868
+ if (idx <= 0 || idx === apiKey.length - 1) return null;
1869
+ const clientSecret = apiKey.slice(0, idx);
1870
+ const clientId = apiKey.slice(idx + 1);
1871
+ if (!/^\d+$/.test(clientId) || clientSecret.length === 0) return null;
1872
+ return { clientId, clientSecret };
1873
+ }
1874
+ function resolveApiBase(pulseOrManifestUrl) {
1875
+ const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
1876
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
1877
+ return fromEnv.replace(/\/$/, "");
1878
+ }
1879
+ if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
1880
+ try {
1881
+ return new URL(pulseOrManifestUrl).origin;
1882
+ } catch {
1883
+ }
1884
+ }
1885
+ const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
1886
+ if (typeof endpoint === "string" && endpoint.length > 0) {
1887
+ try {
1888
+ return new URL(endpoint).origin;
1889
+ } catch {
1890
+ }
1891
+ }
1892
+ return DEFAULT_API_BASE;
1893
+ }
1894
+ function createFirewallLogReporter(opts) {
1895
+ const creds = parseApiKey(opts.apiKey);
1896
+ if (!creds) {
1897
+ return { record() {
1898
+ }, flush() {
1899
+ }, stop() {
1900
+ } };
1901
+ }
1902
+ const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
1903
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
1904
+ const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
1905
+ const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
1906
+ let queue = [];
1907
+ let timer = null;
1908
+ let stopped = false;
1909
+ let cachedToken = null;
1910
+ let tokenInflight = null;
1911
+ const fetchAccessToken = async () => {
1912
+ if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS) {
1913
+ return cachedToken.token;
1914
+ }
1915
+ if (tokenInflight) return tokenInflight;
1916
+ tokenInflight = (async () => {
1917
+ try {
1918
+ const res = await fetchImpl(`${apiBase}/oauth/token`, {
1919
+ method: "POST",
1920
+ headers: {
1921
+ "Content-Type": "application/json",
1922
+ Accept: "application/json",
1923
+ "User-Agent": "@patchstack/connect"
1924
+ },
1925
+ body: JSON.stringify({
1926
+ grant_type: "client_credentials",
1927
+ client_id: creds.clientId,
1928
+ client_secret: creds.clientSecret
1929
+ })
1930
+ });
1931
+ if (!res || !res.ok) return null;
1932
+ const body = await res.json();
1933
+ const token = body?.access_token;
1934
+ if (typeof token !== "string" || token.length === 0) return null;
1935
+ const expiresIn = Number(body?.expires_in);
1936
+ const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
1937
+ cachedToken = { token, expiresAt: Date.now() + ttlMs };
1938
+ return token;
1939
+ } catch {
1940
+ return null;
1941
+ } finally {
1942
+ tokenInflight = null;
1943
+ }
1944
+ })();
1945
+ return tokenInflight;
1946
+ };
1947
+ const flush = () => {
1948
+ if (timer) {
1949
+ clearTimeout(timer);
1950
+ timer = null;
1951
+ }
1952
+ if (queue.length === 0 || typeof fetchImpl !== "function") return;
1953
+ const batch = queue.splice(0, MAX_BATCH);
1954
+ void (async () => {
1955
+ const token = await fetchAccessToken();
1956
+ if (!token) return;
1957
+ const body = new URLSearchParams();
1958
+ body.set("type", "firewall");
1959
+ body.set("logs", JSON.stringify(batch));
1960
+ try {
1961
+ const p = fetchImpl(`${apiBase}/api/logs/log`, {
1962
+ method: "POST",
1963
+ headers: {
1964
+ Authorization: `Bearer ${token}`,
1965
+ Accept: "application/json",
1966
+ "Content-Type": "application/x-www-form-urlencoded",
1967
+ "User-Agent": "@patchstack/connect",
1968
+ ...sourceHost ? { "Source-Host": sourceHost } : {}
1969
+ },
1970
+ body
1971
+ });
1972
+ if (p && typeof p.then === "function") p.catch(() => {
1973
+ });
1974
+ } catch {
1975
+ }
1976
+ })();
1977
+ };
1978
+ return {
1979
+ /**
1980
+ * @param {{
1981
+ * rule?: { id?: string | number },
1982
+ * method?: string | null,
1983
+ * path?: string | null,
1984
+ * ip?: string | null,
1985
+ * userAgent?: string | null,
1986
+ * }} event
1987
+ */
1988
+ record(event) {
1989
+ if (stopped) return;
1990
+ const fid = event?.rule?.id;
1991
+ if (fid === void 0 || fid === null || fid === "") return;
1992
+ queue.push({
1993
+ fid,
1994
+ method: event.method ?? null,
1995
+ request_uri: event.path ?? null,
1996
+ ip: event.ip ?? null,
1997
+ user_agent: event.userAgent ?? null,
1998
+ log_date: (/* @__PURE__ */ new Date()).toISOString()
1999
+ });
2000
+ if (queue.length >= MAX_BATCH) {
2001
+ flush();
2002
+ return;
2003
+ }
2004
+ if (!timer) timer = setTimeout(flush, flushMs);
2005
+ },
2006
+ flush,
2007
+ stop() {
2008
+ stopped = true;
2009
+ flush();
2010
+ }
2011
+ };
2012
+ }
2013
+ function telemetryEnabled() {
2014
+ const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
2015
+ if (v === void 0 || v === "") return true;
2016
+ return !/^(0|false|off|no)$/i.test(String(v));
2017
+ }
2018
+
2019
+ // src/protect/runtime.js
2020
+ import { readFileSync as readFileSync2 } from "fs";
2021
+ import { join as join2 } from "path";
2022
+
1852
2023
  // src/protect/supabase-guard.js
1853
2024
  var GUARD_PATH = "/_patchstack/guard";
1854
2025
  var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
@@ -1920,11 +2091,30 @@ function createServerFnGuard({ protection }) {
1920
2091
  };
1921
2092
  }
1922
2093
  async function createProtection(options = {}) {
1923
- const mode = options.mode === "block" ? "block" : "dry-run";
1924
2094
  const onError = options.onError;
1925
- const onDetect = options.onDetect ?? defaultOnDetect;
2095
+ const userOnDetect = options.onDetect ?? defaultOnDetect;
2096
+ const apiKey = resolveApiKey(options);
2097
+ const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
2098
+ apiKey,
2099
+ apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
2100
+ sourceHost: options.sourceHost,
2101
+ fetchImpl: options.fetchImpl
2102
+ }) : null;
2103
+ const onDetect = (detection) => {
2104
+ userOnDetect(detection);
2105
+ if (firewallLog && detection?.mode === "block") {
2106
+ firewallLog.record({
2107
+ rule: detection.rule,
2108
+ method: detection.method,
2109
+ path: detection.path,
2110
+ ip: detection.ip,
2111
+ userAgent: detection.userAgent
2112
+ });
2113
+ }
2114
+ };
1926
2115
  const store = makeStore(options);
1927
2116
  const bundle = await resolveRules(options, store);
2117
+ let mode = resolveMode(options, bundle);
1928
2118
  let requestRules;
1929
2119
  let responseRules;
1930
2120
  let egressRules;
@@ -1953,9 +2143,19 @@ async function createProtection(options = {}) {
1953
2143
  };
1954
2144
  applyBundle(bundle);
1955
2145
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
1956
- const decide = (phase, result, block, allow2) => {
2146
+ const decide = (phase, result, block, allow2, ctx = {}) => {
1957
2147
  if (!result || !result.blocked) return allow2();
1958
- onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message });
2148
+ onDetect({
2149
+ phase,
2150
+ mode,
2151
+ category: result.rule?.category,
2152
+ rule: result.rule,
2153
+ message: result.message,
2154
+ method: ctx.method,
2155
+ path: ctx.path,
2156
+ ip: ctx.ip,
2157
+ userAgent: ctx.userAgent
2158
+ });
1959
2159
  return mode === "block" ? block() : allow2();
1960
2160
  };
1961
2161
  const isTextCT = (ct) => {
@@ -2119,7 +2319,9 @@ async function createProtection(options = {}) {
2119
2319
  return mode === "block";
2120
2320
  };
2121
2321
  const protection = {
2122
- mode,
2322
+ get mode() {
2323
+ return mode;
2324
+ },
2123
2325
  get rules() {
2124
2326
  return { request: requestRules, response: responseRules, egress: egressRules };
2125
2327
  },
@@ -2136,7 +2338,7 @@ async function createProtection(options = {}) {
2136
2338
  onError?.(err);
2137
2339
  return null;
2138
2340
  }
2139
- return decide("request", result, () => blockResponse(result, request), () => null);
2341
+ return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
2140
2342
  };
2141
2343
  },
2142
2344
  // Wrap a fetch handler: screens the request, then the response (redact/block).
@@ -2174,7 +2376,8 @@ async function createProtection(options = {}) {
2174
2376
  () => {
2175
2377
  if (exprOptions.screenResponses) wrapNodeResponse(res);
2176
2378
  next();
2177
- }
2379
+ },
2380
+ nodeRequestMeta(req)
2178
2381
  );
2179
2382
  };
2180
2383
  },
@@ -2226,7 +2429,8 @@ async function createProtection(options = {}) {
2226
2429
  if (req.body === void 0) req.body = shaped.body;
2227
2430
  if (nodeOptions.screenResponses) wrapNodeResponse(res);
2228
2431
  next();
2229
- }
2432
+ },
2433
+ nodeRequestMeta(req)
2230
2434
  );
2231
2435
  });
2232
2436
  };
@@ -2247,7 +2451,7 @@ async function createProtection(options = {}) {
2247
2451
  let reporter = null;
2248
2452
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
2249
2453
  try {
2250
- ({ reportManifest: reporter } = await import("./refresh-manifest-XBOGN446.js"));
2454
+ ({ reportManifest: reporter } = await import("./refresh-manifest-ZIFRZF25.js"));
2251
2455
  } catch (err) {
2252
2456
  onError?.(err);
2253
2457
  }
@@ -2260,7 +2464,9 @@ async function createProtection(options = {}) {
2260
2464
  onError?.(err);
2261
2465
  }
2262
2466
  }
2263
- applyBundle(await resolveRules(options, store));
2467
+ const next = await resolveRules(options, store);
2468
+ mode = resolveMode(options, next);
2469
+ applyBundle(next);
2264
2470
  };
2265
2471
  if (live) {
2266
2472
  protection.refresh = () => runRefreshTick();
@@ -2268,10 +2474,39 @@ async function createProtection(options = {}) {
2268
2474
  }
2269
2475
  if (options.refreshMs > 0 && live) {
2270
2476
  const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
2271
- protection.stopRefresh = loop.stop;
2477
+ protection.stopRefresh = () => {
2478
+ loop.stop();
2479
+ firewallLog?.stop();
2480
+ };
2481
+ } else if (firewallLog) {
2482
+ protection.stopRefresh = () => firewallLog.stop();
2272
2483
  }
2273
2484
  return protection;
2274
2485
  }
2486
+ function resolveMode(options, bundle) {
2487
+ const env = typeof process !== "undefined" ? process.env?.PATCHSTACK_MODE : void 0;
2488
+ if (env === "block" || env === "dry-run") return env;
2489
+ if (bundle?.enforcement === "block" || bundle?.enforcement === "dry-run") return bundle.enforcement;
2490
+ if (options?.mode === "block") return "block";
2491
+ if (options?.mode === "dry-run") return "dry-run";
2492
+ return "dry-run";
2493
+ }
2494
+ function resolveApiKey(options) {
2495
+ if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
2496
+ if (typeof process !== "undefined") {
2497
+ const fromEnv = process.env?.PATCHSTACK_API_KEY;
2498
+ if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
2499
+ }
2500
+ try {
2501
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
2502
+ const cwd = options?.cwd ?? process.cwd();
2503
+ const raw = readFileSync2(join2(cwd, ".patchstackrc.json"), "utf8");
2504
+ const key = JSON.parse(raw)?.apiKey;
2505
+ if (typeof key === "string" && key.length > 0) return key;
2506
+ } catch {
2507
+ }
2508
+ return void 0;
2509
+ }
2275
2510
  function byPhase(rules, phase) {
2276
2511
  return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
2277
2512
  }
@@ -2535,6 +2770,33 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
2535
2770
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
2536
2771
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
2537
2772
  }
2773
+ function fetchRequestMeta(request) {
2774
+ if (!request) return {};
2775
+ let path = null;
2776
+ try {
2777
+ path = new URL(request.url).pathname;
2778
+ } catch {
2779
+ path = typeof request.url === "string" ? request.url : null;
2780
+ }
2781
+ return {
2782
+ method: request.method ?? null,
2783
+ path,
2784
+ ip: request.headers?.get?.("x-forwarded-for") ?? null,
2785
+ userAgent: request.headers?.get?.("user-agent") ?? null
2786
+ };
2787
+ }
2788
+ function nodeRequestMeta(req) {
2789
+ if (!req) return {};
2790
+ const headers = req.headers ?? {};
2791
+ const ua = headers["user-agent"] ?? headers["User-Agent"];
2792
+ const fwd = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"];
2793
+ return {
2794
+ method: req.method ?? null,
2795
+ path: req.originalUrl || req.url || null,
2796
+ ip: req.ip ?? (typeof fwd === "string" ? fwd : null),
2797
+ userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
2798
+ };
2799
+ }
2538
2800
  export {
2539
2801
  GUARD_PATH,
2540
2802
  PulseRuleClient,