@patchstack/connect 0.3.20 → 0.3.21

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
@@ -23,7 +23,12 @@ export interface Protection {
23
23
  node(options?: { maxBodyBytes?: number; screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void;
24
24
  /** Present when `egress: true` — restores the original global fetch. */
25
25
  uninstallEgress?: () => void;
26
- /** Present when `refreshMs > 0` — stops the live rule-refresh interval. */
26
+ /** Present with a live source — re-fetch + hot-swap the rules once (used by the loop + push). */
27
+ refresh?: () => Promise<void>;
28
+ /** Present with a live source — a fetch handler that runs `refresh()` when the request carries
29
+ * the configured refresh secret (a push/zero-day trigger). No secret set → the handler 404s. */
30
+ refreshHandler?: () => (request: Request) => Promise<Response>;
31
+ /** Present when `refreshMs > 0` — stops the live rule-refresh loop. */
27
32
  stopRefresh?: () => void;
28
33
  }
29
34
 
@@ -46,6 +51,12 @@ export interface CreateProtectionOptions {
46
51
  * meaningful with a live source (`siteUuid`/`token`).
47
52
  */
48
53
  refreshMs?: number;
54
+ /**
55
+ * Shared secret gating the push refresh endpoint (`refreshHandler()`): the platform/SaaS hits the
56
+ * endpoint with this secret to trigger an immediate refresh. Falls back to `PATCHSTACK_REFRESH_SECRET`.
57
+ * Unset → the endpoint 404s (never an open refresh trigger).
58
+ */
59
+ refreshSecret?: string;
49
60
  /**
50
61
  * During a refresh, also re-post the dependency manifest (the runtime counterpart to `scan`) so a
51
62
  * dependency added after boot — e.g. via `npm install <pkg>`, which fires no npm lifecycle hook —
package/dist/protect.js CHANGED
@@ -1665,9 +1665,189 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
1665
1665
  </html>`;
1666
1666
  }
1667
1667
 
1668
- // src/protect/runtime.js
1668
+ // src/protect/rules/store.js
1669
1669
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
1670
1670
  import { join } from "path";
1671
+ function makeStore(options = {}) {
1672
+ let mem = null;
1673
+ const durable = durableTier(options);
1674
+ return {
1675
+ async read() {
1676
+ if (mem) return mem;
1677
+ const env = await durable.read();
1678
+ if (env) mem = env;
1679
+ return env;
1680
+ },
1681
+ async write(env) {
1682
+ if (env) mem = env;
1683
+ await durable.write(env);
1684
+ }
1685
+ };
1686
+ }
1687
+ function durableTier(options) {
1688
+ const adapter = options.ruleCache;
1689
+ if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
1690
+ return {
1691
+ read: async () => {
1692
+ try {
1693
+ return toEnvelope(await adapter.read());
1694
+ } catch {
1695
+ return null;
1696
+ }
1697
+ },
1698
+ write: async (env) => {
1699
+ try {
1700
+ await adapter.write(env);
1701
+ } catch {
1702
+ }
1703
+ }
1704
+ };
1705
+ }
1706
+ const dir = options.cacheDir;
1707
+ return {
1708
+ read: async () => cacheRead(dir),
1709
+ write: async (env) => cacheWrite(dir, env)
1710
+ };
1711
+ }
1712
+ function cachePath(dir) {
1713
+ return join(dir, "patchstack-rules.json");
1714
+ }
1715
+ function cacheWrite(dir, env) {
1716
+ if (!dir) return;
1717
+ try {
1718
+ mkdirSync(dir, { recursive: true });
1719
+ writeFileSync(cachePath(dir), JSON.stringify(env));
1720
+ } catch {
1721
+ }
1722
+ }
1723
+ function cacheRead(dir) {
1724
+ if (!dir) return null;
1725
+ try {
1726
+ return toEnvelope(JSON.parse(readFileSync(cachePath(dir), "utf8")));
1727
+ } catch {
1728
+ return null;
1729
+ }
1730
+ }
1731
+ function toEnvelope(value) {
1732
+ if (!value || typeof value !== "object") return null;
1733
+ if (value.bundle && typeof value.bundle === "object") {
1734
+ return { bundle: value.bundle, etag: value.etag ?? null };
1735
+ }
1736
+ if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
1737
+ return { bundle: value, etag: null };
1738
+ }
1739
+ return null;
1740
+ }
1741
+
1742
+ // src/protect/rules/source.js
1743
+ async function resolveRules(options, store) {
1744
+ if (options.siteUuid) {
1745
+ const prior = await store.read();
1746
+ const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
1747
+ const res = await client.getRules();
1748
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
1749
+ if (res.success && !res.notModified) {
1750
+ const bundle = normalizeBundle(res);
1751
+ await store.write({ bundle, etag: res.etag ?? null });
1752
+ return bundle;
1753
+ }
1754
+ if (prior?.bundle) {
1755
+ options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
1756
+ return normalizeBundle(prior.bundle);
1757
+ }
1758
+ if (options.rules) {
1759
+ options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
1760
+ return normalizeBundle(options.rules);
1761
+ }
1762
+ options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
1763
+ return emptyBundle();
1764
+ }
1765
+ if (options.token) {
1766
+ const prior = await store.read();
1767
+ const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
1768
+ const res = await client.getRules();
1769
+ if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
1770
+ if (res.success && !res.notModified) {
1771
+ const bundle = normalizeBundle(res);
1772
+ await store.write({ bundle, etag: res.etag ?? null });
1773
+ return bundle;
1774
+ }
1775
+ if (prior?.bundle) {
1776
+ options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
1777
+ return normalizeBundle(prior.bundle);
1778
+ }
1779
+ options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
1780
+ return emptyBundle();
1781
+ }
1782
+ if (options.rules) {
1783
+ return normalizeBundle(options.rules);
1784
+ }
1785
+ return emptyBundle();
1786
+ }
1787
+ function normalizeBundle(b) {
1788
+ return {
1789
+ firewall: Array.isArray(b.firewall) ? b.firewall : [],
1790
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
1791
+ whitelist_keys: b.whitelist_keys ?? {}
1792
+ };
1793
+ }
1794
+ function emptyBundle() {
1795
+ return { firewall: [], whitelists: [], whitelist_keys: {} };
1796
+ }
1797
+
1798
+ // src/protect/rules/refresh.js
1799
+ var JITTER_FRACTION3 = 0.1;
1800
+ var MAX_BACKOFF_MULTIPLIER = 8;
1801
+ function startRefresh(tick, { refreshMs, onError } = {}) {
1802
+ let stopped = false;
1803
+ let failures = 0;
1804
+ let timer = null;
1805
+ const schedule = () => {
1806
+ if (stopped) return;
1807
+ const backoff = Math.min(2 ** failures, MAX_BACKOFF_MULTIPLIER);
1808
+ const jitter = 1 - Math.random() * JITTER_FRACTION3;
1809
+ const delay = refreshMs * backoff * jitter;
1810
+ timer = setTimeout(run, delay);
1811
+ timer?.unref?.();
1812
+ };
1813
+ const run = async () => {
1814
+ if (stopped) return;
1815
+ try {
1816
+ await tick();
1817
+ failures = 0;
1818
+ } catch (err) {
1819
+ failures++;
1820
+ onError?.(err);
1821
+ }
1822
+ schedule();
1823
+ };
1824
+ schedule();
1825
+ return {
1826
+ stop() {
1827
+ stopped = true;
1828
+ if (timer) clearTimeout(timer);
1829
+ }
1830
+ };
1831
+ }
1832
+ function makeRefreshHandler(tick, secret) {
1833
+ return async (request) => {
1834
+ if (!secret) return new Response("not found", { status: 404 });
1835
+ let provided = null;
1836
+ try {
1837
+ provided = request?.headers?.get?.("x-patchstack-refresh") ?? new URL(request.url).searchParams.get("token");
1838
+ } catch {
1839
+ provided = null;
1840
+ }
1841
+ if (provided !== secret) return new Response("forbidden", { status: 403 });
1842
+ let refreshed = true;
1843
+ try {
1844
+ await tick();
1845
+ } catch {
1846
+ refreshed = false;
1847
+ }
1848
+ return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
1849
+ };
1850
+ }
1671
1851
 
1672
1852
  // src/protect/supabase-guard.js
1673
1853
  var GUARD_PATH = "/_patchstack/guard";
@@ -1743,7 +1923,8 @@ async function createProtection(options = {}) {
1743
1923
  const mode = options.mode === "block" ? "block" : "dry-run";
1744
1924
  const onError = options.onError;
1745
1925
  const onDetect = options.onDetect ?? defaultOnDetect;
1746
- const bundle = await resolveRules(options);
1926
+ const store = makeStore(options);
1927
+ const bundle = await resolveRules(options, store);
1747
1928
  let requestRules;
1748
1929
  let responseRules;
1749
1930
  let egressRules;
@@ -2059,30 +2240,35 @@ async function createProtection(options = {}) {
2059
2240
  allowHosts: options.allowHosts
2060
2241
  });
2061
2242
  }
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) {
2243
+ const cwd = options.cwd ?? (typeof process !== "undefined" ? process.cwd() : void 0);
2244
+ const live = Boolean(options.siteUuid || options.token);
2245
+ const refreshSecret = options.refreshSecret ?? (typeof process !== "undefined" ? process.env.PATCHSTACK_REFRESH_SECRET : void 0);
2246
+ const refreshable = live && (options.refreshMs > 0 || Boolean(refreshSecret));
2247
+ let reporter = null;
2248
+ if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
2249
+ try {
2250
+ ({ reportManifest: reporter } = await import("./refresh-manifest-XBOGN446.js"));
2251
+ } catch (err) {
2252
+ onError?.(err);
2253
+ }
2254
+ }
2255
+ const runRefreshTick = async () => {
2256
+ if (reporter) {
2066
2257
  try {
2067
- ({ reportManifest } = await import("./refresh-manifest-XBOGN446.js"));
2258
+ await reporter(cwd);
2068
2259
  } catch (err) {
2069
2260
  onError?.(err);
2070
2261
  }
2071
2262
  }
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);
2263
+ applyBundle(await resolveRules(options, store));
2264
+ };
2265
+ if (live) {
2266
+ protection.refresh = () => runRefreshTick();
2267
+ protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
2268
+ }
2269
+ if (options.refreshMs > 0 && live) {
2270
+ const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
2271
+ protection.stopRefresh = loop.stop;
2086
2272
  }
2087
2273
  return protection;
2088
2274
  }
@@ -2324,115 +2510,6 @@ function leakResponse() {
2324
2510
  headers: { "content-type": "application/json" }
2325
2511
  });
2326
2512
  }
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
2513
  var BLOCK_MESSAGE = "This request has been blocked by Patchstack.";
2437
2514
  function blockBody(result) {
2438
2515
  return { error: BLOCK_MESSAGE, message: BLOCK_MESSAGE, rule: result?.rule?.id };