@patchstack/connect 0.5.2 → 0.5.4

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.
@@ -290,6 +290,32 @@ export interface CreateProtectionOptions {
290
290
  responseRules?: unknown[];
291
291
  /** Override the default egress-phase (SSRF) rule set. */
292
292
  egressRules?: unknown[];
293
+ /**
294
+ * The mapped coordinate identity this guard carries, as a complete 64-hex SHA-256 value. It is
295
+ * trimmed and compared in lowercase.
296
+ *
297
+ * Only needed when wiring `createProtection` by hand. A scaffolded guard carries this in the rules
298
+ * file its build stamped, and passing it here overrides that.
299
+ *
300
+ * What it authorises is narrow: a rule carrying `build_scope` may block only when the platform
301
+ * confirms the coordinates it served belong to THIS mapped document. Presenting an id is not that
302
+ * confirmation — the server answers it through response metadata, including on a `304` — so a wrong
303
+ * or invented value withholds enforcement rather than granting it. Any other format is ignored, with
304
+ * the same effect as passing nothing.
305
+ */
306
+ buildId?: string;
307
+ /**
308
+ * Take responsibility for `rules` you supply yourself, including any `build_scope` they carry.
309
+ *
310
+ * Off by default, and the default is the safe one. A rule scoped to a build addresses a route and a
311
+ * field name read from one particular source; supplying it locally establishes that you intend it,
312
+ * not that it still describes the code now running. Left alone, such a rule detects without blocking
313
+ * unless it names the map identity this guard reports.
314
+ *
315
+ * Set this only where the caller genuinely knows the rules match the running source — a test that
316
+ * vendors a rule against a fixed fixture, or a build that generates both together.
317
+ */
318
+ trustLocalRuleScope?: boolean;
293
319
  /** Opt in to wrapping global fetch to screen the app's outbound calls (SSRF). */
294
320
  egress?: boolean;
295
321
  /** Hosts exempt from egress screening. */
package/dist/protect.d.ts CHANGED
@@ -290,6 +290,32 @@ export interface CreateProtectionOptions {
290
290
  responseRules?: unknown[];
291
291
  /** Override the default egress-phase (SSRF) rule set. */
292
292
  egressRules?: unknown[];
293
+ /**
294
+ * The mapped coordinate identity this guard carries, as a complete 64-hex SHA-256 value. It is
295
+ * trimmed and compared in lowercase.
296
+ *
297
+ * Only needed when wiring `createProtection` by hand. A scaffolded guard carries this in the rules
298
+ * file its build stamped, and passing it here overrides that.
299
+ *
300
+ * What it authorises is narrow: a rule carrying `build_scope` may block only when the platform
301
+ * confirms the coordinates it served belong to THIS mapped document. Presenting an id is not that
302
+ * confirmation — the server answers it through response metadata, including on a `304` — so a wrong
303
+ * or invented value withholds enforcement rather than granting it. Any other format is ignored, with
304
+ * the same effect as passing nothing.
305
+ */
306
+ buildId?: string;
307
+ /**
308
+ * Take responsibility for `rules` you supply yourself, including any `build_scope` they carry.
309
+ *
310
+ * Off by default, and the default is the safe one. A rule scoped to a build addresses a route and a
311
+ * field name read from one particular source; supplying it locally establishes that you intend it,
312
+ * not that it still describes the code now running. Left alone, such a rule detects without blocking
313
+ * unless it names the map identity this guard reports.
314
+ *
315
+ * Set this only where the caller genuinely knows the rules match the running source — a test that
316
+ * vendors a rule against a fixed fixture, or a build that generates both together.
317
+ */
318
+ trustLocalRuleScope?: boolean;
293
319
  /** Opt in to wrapping global fetch to screen the app's outbound calls (SSRF). */
294
320
  egress?: boolean;
295
321
  /** Hosts exempt from egress screening. */
@@ -1867,6 +1867,18 @@ var RULE_PROPERTY_SHAPES = Object.freeze({
1867
1867
  });
1868
1868
  var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
1869
1869
  var WHEN_KEYS = Object.freeze(["path", "method"]);
1870
+ var BUILD_SCOPE_PROPERTY = "build_scope";
1871
+ var BUILD_SCOPE = Object.freeze({
1872
+ applies_to: Object.freeze(["firewall"]),
1873
+ usable: Object.freeze({
1874
+ type: "string",
1875
+ trim: true,
1876
+ pattern: "^[0-9a-fA-F]{64}$",
1877
+ canonical: "lowercase"
1878
+ }),
1879
+ unreadable: "the rule remains accepted and detects only by default",
1880
+ local_override: "trustLocalRuleScope may explicitly enforce a caller-supplied rule"
1881
+ });
1870
1882
  var RULE_PROPERTIES = Object.freeze([
1871
1883
  "id",
1872
1884
  "rule_id",
@@ -1886,12 +1898,13 @@ var RULE_PROPERTIES = Object.freeze([
1886
1898
  "remove_headers",
1887
1899
  "cookie_flags",
1888
1900
  "ensure",
1889
- "capture"
1901
+ "capture",
1902
+ BUILD_SCOPE_PROPERTY
1890
1903
  ]);
1891
1904
  var CAPTURE_VERSION = 1;
1892
1905
  var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
1893
1906
  var CAPTURE_RAW_CHARS_MAX = 512;
1894
- var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
1907
+ var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture", BUILD_SCOPE_PROPERTY]);
1895
1908
  function captureProblem(capture) {
1896
1909
  if (capture === void 0 || capture === null) return null;
1897
1910
  if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
@@ -2132,6 +2145,10 @@ function validateBundle(bundle, opts = {}) {
2132
2145
  rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2133
2146
  continue;
2134
2147
  }
2148
+ if (wl && typeof wl === "object" && BUILD_SCOPE_PROPERTY in wl) {
2149
+ rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
2150
+ continue;
2151
+ }
2135
2152
  const reason = conditionsProblem(wl?.rule_v2);
2136
2153
  if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
2137
2154
  else whitelists.push(wl);
@@ -2482,9 +2499,26 @@ async function pulseFetch(config, url, init, fetchImpl = fetch) {
2482
2499
  return first.response;
2483
2500
  }
2484
2501
 
2502
+ // src/build-id.ts
2503
+ var BUILD_ID = /^[0-9a-f]{64}$/i;
2504
+ function canonicalBuildId(value) {
2505
+ if (typeof value !== "string") return null;
2506
+ const trimmed = value.trim();
2507
+ return BUILD_ID.test(trimmed) ? trimmed.toLowerCase() : null;
2508
+ }
2509
+ var BUILD_STAMP_KEY = "_patchstack";
2510
+ function readBuildStamp(bundle) {
2511
+ if (bundle === null || typeof bundle !== "object") return null;
2512
+ const namespace = bundle[BUILD_STAMP_KEY];
2513
+ if (namespace === null || typeof namespace !== "object") return null;
2514
+ return canonicalBuildId(namespace.build_id);
2515
+ }
2516
+
2485
2517
  // src/protect/engine/pulse-client.js
2486
2518
  var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
2487
2519
  var DEFAULT_CACHE_TTL2 = 3e5;
2520
+ var BUILD_VERDICT_HEADER = "X-Patchstack-Build-Match";
2521
+ var BUILD_IDENTITY_HEADER = "X-Patchstack-Build-ID";
2488
2522
  var JITTER_FRACTION2 = 0.1;
2489
2523
  var PulseRuleClient = class {
2490
2524
  #siteUuid;
@@ -2497,7 +2531,8 @@ var PulseRuleClient = class {
2497
2531
  #etag;
2498
2532
  #pulseAuth;
2499
2533
  #detectionState;
2500
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) {
2534
+ #buildId;
2535
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState, buildId } = {}) {
2501
2536
  this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
2502
2537
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
2503
2538
  this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
@@ -2505,6 +2540,7 @@ var PulseRuleClient = class {
2505
2540
  this.#etag = etag ?? null;
2506
2541
  this.#pulseAuth = pulseAuth ?? null;
2507
2542
  this.#detectionState = typeof detectionState === "string" ? detectionState : null;
2543
+ this.#buildId = canonicalBuildId(buildId);
2508
2544
  if (!this.#siteUuid) {
2509
2545
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
2510
2546
  }
@@ -2524,11 +2560,23 @@ var PulseRuleClient = class {
2524
2560
  if (this.#detectionState !== null && typeof auth.Authorization === "string") {
2525
2561
  headers["X-Patchstack-Detections"] = this.#detectionState;
2526
2562
  }
2563
+ if (this.#buildId !== null && typeof auth.Authorization === "string") {
2564
+ headers["X-Patchstack-Build"] = this.#buildId;
2565
+ }
2527
2566
  if (this.#etag) headers["If-None-Match"] = this.#etag;
2528
2567
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
2529
2568
  if (response.status === 304) {
2530
2569
  this.#touch(now);
2531
- return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
2570
+ const result2 = {
2571
+ ...this.#cache ?? { firewall: [], whitelists: [], whitelist_keys: {} },
2572
+ success: true,
2573
+ notModified: true,
2574
+ etag: response.headers?.get?.("etag") ?? this.#etag,
2575
+ build: buildVerdictOf(response.headers)
2576
+ };
2577
+ this.#cache = result2;
2578
+ this.#etag = result2.etag;
2579
+ return result2;
2532
2580
  }
2533
2581
  if (!response.ok) {
2534
2582
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
@@ -2544,7 +2592,10 @@ var PulseRuleClient = class {
2544
2592
  firewall: data.firewall,
2545
2593
  whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
2546
2594
  whitelist_keys: data.whitelist_keys ?? {},
2547
- ...enforcementField(data)
2595
+ ...enforcementField(data),
2596
+ // Carried through so the caller can decide what a build-scoped rule may do. Response metadata,
2597
+ // rather than bundle content, because it varies with the build asking and is also present on 304.
2598
+ build: buildVerdictOf(response.headers)
2548
2599
  };
2549
2600
  this.#cache = result;
2550
2601
  this.#etag = result.etag;
@@ -2570,6 +2621,13 @@ var PulseRuleClient = class {
2570
2621
  this.#etag = null;
2571
2622
  }
2572
2623
  };
2624
+ function buildVerdictOf(headers) {
2625
+ const get = headers !== null && typeof headers === "object" && typeof headers.get === "function" ? (name) => headers.get(name) : () => null;
2626
+ const stated = get(BUILD_VERDICT_HEADER);
2627
+ const verdict = stated === "match" || stated === "missing" || stated === "mismatch" ? stated : "unknown";
2628
+ const matched = get(BUILD_IDENTITY_HEADER);
2629
+ return { verdict, matchedBuildId: typeof matched === "string" ? matched : null };
2630
+ }
2573
2631
  function enforcementField(data) {
2574
2632
  if (!data || typeof data !== "object") return {};
2575
2633
  const v = data.enforcement ?? data.mode;
@@ -3412,10 +3470,19 @@ async function cacheRead(dir) {
3412
3470
  function toEnvelope(value) {
3413
3471
  if (!value || typeof value !== "object") return null;
3414
3472
  if (value.bundle && typeof value.bundle === "object") {
3415
- return { bundle: value.bundle, etag: value.etag ?? null };
3473
+ const buildId = canonicalBuildId(value.buildId);
3474
+ const matched = canonicalBuildId(value.matchedBuildId);
3475
+ return {
3476
+ bundle: value.bundle,
3477
+ etag: value.etag ?? null,
3478
+ buildId,
3479
+ // A confirmation belongs to the presentation stored beside it. A crossed or partially written
3480
+ // envelope confirms nothing, even when one of its fields happens to name the current map.
3481
+ matchedBuildId: buildId !== null && matched === buildId ? matched : null
3482
+ };
3416
3483
  }
3417
3484
  if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
3418
- return { bundle: value, etag: null };
3485
+ return { bundle: value, etag: null, buildId: null, matchedBuildId: null };
3419
3486
  }
3420
3487
  return null;
3421
3488
  }
@@ -3447,62 +3514,146 @@ function fromSource(bundle, origin, reason) {
3447
3514
  source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
3448
3515
  };
3449
3516
  }
3517
+ function buildIdentity(options) {
3518
+ if (options.buildId !== void 0 && options.buildId !== null) {
3519
+ const explicit = canonicalBuildId(options.buildId);
3520
+ return explicit === null ? { id: null, reason: "the buildId option is not a complete 64-hex SHA-256 identity" } : { id: explicit };
3521
+ }
3522
+ const stamped = readBuildStamp(options.rules);
3523
+ if (stamped !== null) return { id: stamped };
3524
+ return {
3525
+ id: null,
3526
+ reason: options.rules ? "the guard rules file carries no build identity \u2014 the build did not stamp one" : "this guard was configured without bundled rules, so it carries no build identity"
3527
+ };
3528
+ }
3529
+ function withScopedDryRun(bundle, confirmed, reason, options = {}) {
3530
+ let held = 0;
3531
+ let unusable = 0;
3532
+ const firewall = bundle.firewall.map((rule) => {
3533
+ if (rule === null || typeof rule !== "object") return rule;
3534
+ if (!(BUILD_SCOPE_PROPERTY in rule)) return rule;
3535
+ const scope = rule[BUILD_SCOPE_PROPERTY];
3536
+ const target = canonicalBuildId(scope);
3537
+ if (target !== null && confirmed !== null && target === confirmed) return rule;
3538
+ if (rule.enforcement === "dry-run") return rule;
3539
+ held += 1;
3540
+ if (target === null) unusable += 1;
3541
+ return { ...rule, enforcement: "dry-run" };
3542
+ });
3543
+ if (held === 0) return bundle;
3544
+ const explanations = [
3545
+ ...unusable > 0 ? [`${unusable} rule(s) carry an unusable ${BUILD_SCOPE_PROPERTY}`] : [],
3546
+ ...held > unusable ? [reason] : []
3547
+ ];
3548
+ notify(
3549
+ options.onError,
3550
+ new Error(`${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`),
3551
+ "onError"
3552
+ );
3553
+ return { ...bundle, firewall };
3554
+ }
3555
+ function confirmedBy(res, presented) {
3556
+ if (presented === null) return null;
3557
+ const verdict = res?.build;
3558
+ if (verdict?.verdict !== "match") return null;
3559
+ return canonicalBuildId(verdict.matchedBuildId) === presented ? presented : null;
3560
+ }
3450
3561
  async function resolveRules(options, store, ctx = {}) {
3451
3562
  const timeoutMs = ctx.timeoutMs;
3452
3563
  if (options.siteUuid) {
3453
3564
  const prior = await store.read();
3454
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, detectionState: ctx.detectionState });
3565
+ const identity = buildIdentity(options);
3566
+ const sameBuild = prior?.buildId === identity.id;
3567
+ const cacheConfirmed = identity.id !== null && sameBuild && prior?.matchedBuildId === identity.id ? identity.id : null;
3568
+ const unconfirmed = identity.reason ?? "the platform did not confirm that these coordinates belong to this map";
3569
+ const served = (bundle, confirmed) => withScopedDryRun(bundle, confirmed, unconfirmed, options);
3570
+ const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
3571
+ const client = new PulseRuleClient({
3572
+ siteUuid: options.siteUuid,
3573
+ baseUrl: options.pulseRulesUrl,
3574
+ etag: sameBuild ? prior?.etag : null,
3575
+ timeoutMs,
3576
+ pulseAuth: ctx.pulseAuth,
3577
+ detectionState: ctx.detectionState,
3578
+ buildId: identity.id
3579
+ });
3455
3580
  const res = await client.getRules();
3456
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
3581
+ if (res.success && res.notModified && prior?.bundle) {
3582
+ const confirmed = confirmedBy(res, identity.id);
3583
+ await store.write({
3584
+ bundle: prior.bundle,
3585
+ etag: res.etag ?? prior.etag ?? null,
3586
+ buildId: identity.id,
3587
+ matchedBuildId: confirmed
3588
+ });
3589
+ return fromSource(served(normalizeBundle(prior.bundle, options), confirmed), "cache");
3590
+ }
3457
3591
  if (res.success && !res.notModified) {
3592
+ const confirmed = confirmedBy(res, identity.id);
3458
3593
  const rejected = liveUpdateRejections(res, options);
3459
3594
  if (rejected.length > 0) {
3460
3595
  reportRejections(rejected, options, "rule update rejected");
3461
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
3462
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
3596
+ if (prior?.bundle) return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", "update rejected");
3597
+ if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
3463
3598
  return fromSource(emptyBundle(), "empty", "update rejected");
3464
3599
  }
3465
3600
  const bundle = normalizeBundle(res, options);
3466
- await store.write({ bundle, etag: res.etag ?? null });
3467
- return fromSource(bundle, "api");
3601
+ await store.write({ bundle, etag: res.etag ?? null, buildId: identity.id, matchedBuildId: confirmed });
3602
+ return fromSource(served(bundle, confirmed), "api");
3468
3603
  }
3469
3604
  if (prior?.bundle) {
3470
3605
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3471
- return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
3606
+ return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", res.error ?? "no usable response");
3472
3607
  }
3473
3608
  if (options.rules) {
3474
3609
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
3475
- return fromSource(normalizeBundle(options.rules, options), "bundled", res.error ?? "no usable response");
3610
+ return fromSource(local(normalizeBundle(options.rules, options)), "bundled", res.error ?? "no usable response");
3476
3611
  }
3477
3612
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
3478
3613
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
3479
3614
  }
3480
3615
  if (options.token) {
3616
+ const identity = buildIdentity(options);
3617
+ const remote = (bundle) => withScopedDryRun(
3618
+ bundle,
3619
+ null,
3620
+ "the token-authenticated rules service did not corroborate this map",
3621
+ options
3622
+ );
3623
+ const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
3481
3624
  const prior = await store.read();
3482
3625
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
3483
3626
  const res = await client.getRules();
3484
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
3627
+ if (res.success && res.notModified && prior?.bundle) {
3628
+ return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache");
3629
+ }
3485
3630
  if (res.success && !res.notModified) {
3486
3631
  const rejected = liveUpdateRejections(res, options);
3487
3632
  if (rejected.length > 0) {
3488
3633
  reportRejections(rejected, options, "rule update rejected");
3489
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
3490
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
3634
+ if (prior?.bundle) return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", "update rejected");
3635
+ if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
3491
3636
  return fromSource(emptyBundle(), "empty", "update rejected");
3492
3637
  }
3493
3638
  const bundle = normalizeBundle(res, options);
3494
3639
  await store.write({ bundle, etag: res.etag ?? null });
3495
- return fromSource(bundle, "api");
3640
+ return fromSource(remote(bundle), "api");
3496
3641
  }
3497
3642
  if (prior?.bundle) {
3498
3643
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3499
- return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
3644
+ return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", res.error ?? "no usable response");
3500
3645
  }
3501
3646
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
3502
3647
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
3503
3648
  }
3504
3649
  if (options.rules) {
3505
- return fromSource(normalizeBundle(options.rules, options), "bundled");
3650
+ const identity = buildIdentity(options);
3651
+ const bundle = normalizeBundle(options.rules, options);
3652
+ if (options.trustLocalRuleScope === true) return fromSource(bundle, "bundled");
3653
+ return fromSource(
3654
+ withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options),
3655
+ "bundled"
3656
+ );
3506
3657
  }
3507
3658
  return fromSource(emptyBundle(), "empty");
3508
3659
  }