@patchstack/connect 0.3.20 → 0.3.22

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";
@@ -23,12 +25,21 @@ export interface Protection {
23
25
  node(options?: { maxBodyBytes?: number; screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void;
24
26
  /** Present when `egress: true` — restores the original global fetch. */
25
27
  uninstallEgress?: () => void;
26
- /** Present when `refreshMs > 0` — stops the live rule-refresh interval. */
28
+ /** Present with a live source — re-fetch + hot-swap the rules once (used by the loop + push). */
29
+ refresh?: () => Promise<void>;
30
+ /** Present with a live source — a fetch handler that runs `refresh()` when the request carries
31
+ * the configured refresh secret (a push/zero-day trigger). No secret set → the handler 404s. */
32
+ refreshHandler?: () => (request: Request) => Promise<Response>;
33
+ /** Present when `refreshMs > 0` — stops the live rule-refresh loop. */
27
34
  stopRefresh?: () => void;
28
35
  }
29
36
 
30
37
  export interface CreateProtectionOptions {
31
- /** 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
+ */
32
43
  mode?: "block" | "dry-run";
33
44
  /** Explicit rule bundle (used as the token-less fallback). */
34
45
  rules?: unknown;
@@ -46,6 +57,12 @@ export interface CreateProtectionOptions {
46
57
  * meaningful with a live source (`siteUuid`/`token`).
47
58
  */
48
59
  refreshMs?: number;
60
+ /**
61
+ * Shared secret gating the push refresh endpoint (`refreshHandler()`): the platform/SaaS hits the
62
+ * endpoint with this secret to trigger an immediate refresh. Falls back to `PATCHSTACK_REFRESH_SECRET`.
63
+ * Unset → the endpoint 404s (never an open refresh trigger).
64
+ */
65
+ refreshSecret?: string;
49
66
  /**
50
67
  * During a refresh, also re-post the dependency manifest (the runtime counterpart to `scan`) so a
51
68
  * dependency added after boot — e.g. via `npm install <pkg>`, which fires no npm lifecycle hook —
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;
@@ -1665,9 +1671,191 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
1665
1671
  </html>`;
1666
1672
  }
1667
1673
 
1668
- // src/protect/runtime.js
1674
+ // src/protect/rules/store.js
1669
1675
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
1670
1676
  import { join } from "path";
1677
+ function makeStore(options = {}) {
1678
+ let mem = null;
1679
+ const durable = durableTier(options);
1680
+ return {
1681
+ async read() {
1682
+ if (mem) return mem;
1683
+ const env = await durable.read();
1684
+ if (env) mem = env;
1685
+ return env;
1686
+ },
1687
+ async write(env) {
1688
+ if (env) mem = env;
1689
+ await durable.write(env);
1690
+ }
1691
+ };
1692
+ }
1693
+ function durableTier(options) {
1694
+ const adapter = options.ruleCache;
1695
+ if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
1696
+ return {
1697
+ read: async () => {
1698
+ try {
1699
+ return toEnvelope(await adapter.read());
1700
+ } catch {
1701
+ return null;
1702
+ }
1703
+ },
1704
+ write: async (env) => {
1705
+ try {
1706
+ await adapter.write(env);
1707
+ } catch {
1708
+ }
1709
+ }
1710
+ };
1711
+ }
1712
+ const dir = options.cacheDir;
1713
+ return {
1714
+ read: async () => cacheRead(dir),
1715
+ write: async (env) => cacheWrite(dir, env)
1716
+ };
1717
+ }
1718
+ function cachePath(dir) {
1719
+ return join(dir, "patchstack-rules.json");
1720
+ }
1721
+ function cacheWrite(dir, env) {
1722
+ if (!dir) return;
1723
+ try {
1724
+ mkdirSync(dir, { recursive: true });
1725
+ writeFileSync(cachePath(dir), JSON.stringify(env));
1726
+ } catch {
1727
+ }
1728
+ }
1729
+ function cacheRead(dir) {
1730
+ if (!dir) return null;
1731
+ try {
1732
+ return toEnvelope(JSON.parse(readFileSync(cachePath(dir), "utf8")));
1733
+ } catch {
1734
+ return null;
1735
+ }
1736
+ }
1737
+ function toEnvelope(value) {
1738
+ if (!value || typeof value !== "object") return null;
1739
+ if (value.bundle && typeof value.bundle === "object") {
1740
+ return { bundle: value.bundle, etag: value.etag ?? null };
1741
+ }
1742
+ if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
1743
+ return { bundle: value, etag: null };
1744
+ }
1745
+ return null;
1746
+ }
1747
+
1748
+ // src/protect/rules/source.js
1749
+ async function resolveRules(options, store) {
1750
+ if (options.siteUuid) {
1751
+ const prior = await store.read();
1752
+ const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
1753
+ const res = await client.getRules();
1754
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
1755
+ if (res.success && !res.notModified) {
1756
+ const bundle = normalizeBundle(res);
1757
+ await store.write({ bundle, etag: res.etag ?? null });
1758
+ return bundle;
1759
+ }
1760
+ if (prior?.bundle) {
1761
+ options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
1762
+ return normalizeBundle(prior.bundle);
1763
+ }
1764
+ if (options.rules) {
1765
+ options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
1766
+ return normalizeBundle(options.rules);
1767
+ }
1768
+ options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
1769
+ return emptyBundle();
1770
+ }
1771
+ if (options.token) {
1772
+ const prior = await store.read();
1773
+ const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
1774
+ const res = await client.getRules();
1775
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
1776
+ if (res.success && !res.notModified) {
1777
+ const bundle = normalizeBundle(res);
1778
+ await store.write({ bundle, etag: res.etag ?? null });
1779
+ return bundle;
1780
+ }
1781
+ if (prior?.bundle) {
1782
+ options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
1783
+ return normalizeBundle(prior.bundle);
1784
+ }
1785
+ options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
1786
+ return emptyBundle();
1787
+ }
1788
+ if (options.rules) {
1789
+ return normalizeBundle(options.rules);
1790
+ }
1791
+ return emptyBundle();
1792
+ }
1793
+ function normalizeBundle(b) {
1794
+ const enforcement = b?.enforcement ?? b?.mode;
1795
+ return {
1796
+ firewall: Array.isArray(b.firewall) ? b.firewall : [],
1797
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
1798
+ whitelist_keys: b.whitelist_keys ?? {},
1799
+ ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
1800
+ };
1801
+ }
1802
+ function emptyBundle() {
1803
+ return { firewall: [], whitelists: [], whitelist_keys: {} };
1804
+ }
1805
+
1806
+ // src/protect/rules/refresh.js
1807
+ var JITTER_FRACTION3 = 0.1;
1808
+ var MAX_BACKOFF_MULTIPLIER = 8;
1809
+ function startRefresh(tick, { refreshMs, onError } = {}) {
1810
+ let stopped = false;
1811
+ let failures = 0;
1812
+ let timer = null;
1813
+ const schedule = () => {
1814
+ if (stopped) return;
1815
+ const backoff = Math.min(2 ** failures, MAX_BACKOFF_MULTIPLIER);
1816
+ const jitter = 1 - Math.random() * JITTER_FRACTION3;
1817
+ const delay = refreshMs * backoff * jitter;
1818
+ timer = setTimeout(run, delay);
1819
+ timer?.unref?.();
1820
+ };
1821
+ const run = async () => {
1822
+ if (stopped) return;
1823
+ try {
1824
+ await tick();
1825
+ failures = 0;
1826
+ } catch (err) {
1827
+ failures++;
1828
+ onError?.(err);
1829
+ }
1830
+ schedule();
1831
+ };
1832
+ schedule();
1833
+ return {
1834
+ stop() {
1835
+ stopped = true;
1836
+ if (timer) clearTimeout(timer);
1837
+ }
1838
+ };
1839
+ }
1840
+ function makeRefreshHandler(tick, secret) {
1841
+ return async (request) => {
1842
+ if (!secret) return new Response("not found", { status: 404 });
1843
+ let provided = null;
1844
+ try {
1845
+ provided = request?.headers?.get?.("x-patchstack-refresh") ?? new URL(request.url).searchParams.get("token");
1846
+ } catch {
1847
+ provided = null;
1848
+ }
1849
+ if (provided !== secret) return new Response("forbidden", { status: 403 });
1850
+ let refreshed = true;
1851
+ try {
1852
+ await tick();
1853
+ } catch {
1854
+ refreshed = false;
1855
+ }
1856
+ return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
1857
+ };
1858
+ }
1671
1859
 
1672
1860
  // src/protect/supabase-guard.js
1673
1861
  var GUARD_PATH = "/_patchstack/guard";
@@ -1740,10 +1928,11 @@ function createServerFnGuard({ protection }) {
1740
1928
  };
1741
1929
  }
1742
1930
  async function createProtection(options = {}) {
1743
- const mode = options.mode === "block" ? "block" : "dry-run";
1744
1931
  const onError = options.onError;
1745
1932
  const onDetect = options.onDetect ?? defaultOnDetect;
1746
- const bundle = await resolveRules(options);
1933
+ const store = makeStore(options);
1934
+ const bundle = await resolveRules(options, store);
1935
+ let mode = resolveMode(options, bundle);
1747
1936
  let requestRules;
1748
1937
  let responseRules;
1749
1938
  let egressRules;
@@ -1938,7 +2127,9 @@ async function createProtection(options = {}) {
1938
2127
  return mode === "block";
1939
2128
  };
1940
2129
  const protection = {
1941
- mode,
2130
+ get mode() {
2131
+ return mode;
2132
+ },
1942
2133
  get rules() {
1943
2134
  return { request: requestRules, response: responseRules, egress: egressRules };
1944
2135
  },
@@ -2059,33 +2250,48 @@ async function createProtection(options = {}) {
2059
2250
  allowHosts: options.allowHosts
2060
2251
  });
2061
2252
  }
2062
- if (options.refreshMs > 0 && (options.siteUuid || options.token)) {
2063
- const cwd = options.cwd ?? (typeof process !== "undefined" ? process.cwd() : void 0);
2064
- let reportManifest = null;
2065
- if (options.siteUuid && options.reportManifest !== false && cwd) {
2253
+ const cwd = options.cwd ?? (typeof process !== "undefined" ? process.cwd() : void 0);
2254
+ const live = Boolean(options.siteUuid || options.token);
2255
+ const refreshSecret = options.refreshSecret ?? (typeof process !== "undefined" ? process.env.PATCHSTACK_REFRESH_SECRET : void 0);
2256
+ const refreshable = live && (options.refreshMs > 0 || Boolean(refreshSecret));
2257
+ let reporter = null;
2258
+ if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
2259
+ try {
2260
+ ({ reportManifest: reporter } = await import("./refresh-manifest-OW7WT5A6.js"));
2261
+ } catch (err) {
2262
+ onError?.(err);
2263
+ }
2264
+ }
2265
+ const runRefreshTick = async () => {
2266
+ if (reporter) {
2066
2267
  try {
2067
- ({ reportManifest } = await import("./refresh-manifest-XBOGN446.js"));
2268
+ await reporter(cwd);
2068
2269
  } catch (err) {
2069
2270
  onError?.(err);
2070
2271
  }
2071
2272
  }
2072
- const timer = setInterval(() => {
2073
- (async () => {
2074
- if (reportManifest) {
2075
- try {
2076
- await reportManifest(cwd);
2077
- } catch (err) {
2078
- onError?.(err);
2079
- }
2080
- }
2081
- applyBundle(await resolveRules(options));
2082
- })().catch((err) => onError?.(err));
2083
- }, options.refreshMs);
2084
- timer.unref?.();
2085
- protection.stopRefresh = () => clearInterval(timer);
2273
+ const next = await resolveRules(options, store);
2274
+ mode = resolveMode(options, next);
2275
+ applyBundle(next);
2276
+ };
2277
+ if (live) {
2278
+ protection.refresh = () => runRefreshTick();
2279
+ protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
2280
+ }
2281
+ if (options.refreshMs > 0 && live) {
2282
+ const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
2283
+ protection.stopRefresh = loop.stop;
2086
2284
  }
2087
2285
  return protection;
2088
2286
  }
2287
+ function resolveMode(options, bundle) {
2288
+ const env = typeof process !== "undefined" ? process.env?.PATCHSTACK_MODE : void 0;
2289
+ if (env === "block" || env === "dry-run") return env;
2290
+ if (bundle?.enforcement === "block" || bundle?.enforcement === "dry-run") return bundle.enforcement;
2291
+ if (options?.mode === "block") return "block";
2292
+ if (options?.mode === "dry-run") return "dry-run";
2293
+ return "dry-run";
2294
+ }
2089
2295
  function byPhase(rules, phase) {
2090
2296
  return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
2091
2297
  }
@@ -2324,115 +2530,6 @@ function leakResponse() {
2324
2530
  headers: { "content-type": "application/json" }
2325
2531
  });
2326
2532
  }
2327
- async function resolveRules(options) {
2328
- const cache = makeCache(options);
2329
- if (options.siteUuid) {
2330
- const prior = await cache.read();
2331
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
2332
- const res = await client.getRules();
2333
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
2334
- if (res.success && !res.notModified) {
2335
- const bundle = normalizeBundle(res);
2336
- await cache.write({ bundle, etag: res.etag ?? null });
2337
- return bundle;
2338
- }
2339
- if (prior?.bundle) {
2340
- options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
2341
- return normalizeBundle(prior.bundle);
2342
- }
2343
- if (options.rules) {
2344
- options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
2345
- return normalizeBundle(options.rules);
2346
- }
2347
- options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
2348
- return emptyBundle();
2349
- }
2350
- if (options.token) {
2351
- const prior = await cache.read();
2352
- const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
2353
- const res = await client.getRules();
2354
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
2355
- if (res.success && !res.notModified) {
2356
- const bundle = normalizeBundle(res);
2357
- await cache.write({ bundle, etag: res.etag ?? null });
2358
- return bundle;
2359
- }
2360
- if (prior?.bundle) {
2361
- options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
2362
- return normalizeBundle(prior.bundle);
2363
- }
2364
- options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
2365
- return emptyBundle();
2366
- }
2367
- if (options.rules) {
2368
- return normalizeBundle(options.rules);
2369
- }
2370
- return emptyBundle();
2371
- }
2372
- function normalizeBundle(b) {
2373
- return {
2374
- firewall: Array.isArray(b.firewall) ? b.firewall : [],
2375
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
2376
- whitelist_keys: b.whitelist_keys ?? {}
2377
- };
2378
- }
2379
- function emptyBundle() {
2380
- return { firewall: [], whitelists: [], whitelist_keys: {} };
2381
- }
2382
- function makeCache(options) {
2383
- const adapter = options.ruleCache;
2384
- if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
2385
- return {
2386
- read: async () => {
2387
- try {
2388
- return toEnvelope(await adapter.read());
2389
- } catch {
2390
- return null;
2391
- }
2392
- },
2393
- write: async (env) => {
2394
- try {
2395
- await adapter.write(env);
2396
- } catch {
2397
- }
2398
- }
2399
- };
2400
- }
2401
- const dir = options.cacheDir;
2402
- return {
2403
- read: async () => cacheRead(dir),
2404
- write: async (env) => cacheWrite(dir, env)
2405
- };
2406
- }
2407
- function cachePath(dir) {
2408
- return join(dir, "patchstack-rules.json");
2409
- }
2410
- function cacheWrite(dir, env) {
2411
- if (!dir) return;
2412
- try {
2413
- mkdirSync(dir, { recursive: true });
2414
- writeFileSync(cachePath(dir), JSON.stringify(env));
2415
- } catch {
2416
- }
2417
- }
2418
- function cacheRead(dir) {
2419
- if (!dir) return null;
2420
- try {
2421
- return toEnvelope(JSON.parse(readFileSync(cachePath(dir), "utf8")));
2422
- } catch {
2423
- return null;
2424
- }
2425
- }
2426
- function toEnvelope(value) {
2427
- if (!value || typeof value !== "object") return null;
2428
- if (value.bundle && typeof value.bundle === "object") {
2429
- return { bundle: value.bundle, etag: value.etag ?? null };
2430
- }
2431
- if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
2432
- return { bundle: value, etag: null };
2433
- }
2434
- return null;
2435
- }
2436
2533
  var BLOCK_MESSAGE = "This request has been blocked by Patchstack.";
2437
2534
  function blockBody(result) {
2438
2535
  return { error: BLOCK_MESSAGE, message: BLOCK_MESSAGE, rule: result?.rule?.id };