@patchstack/connect 0.5.1 → 0.5.3

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.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import {
2
+ canonicalBuildId,
2
3
  pulseAuthHeader,
3
- pulseFetch
4
- } from "./chunk-3G2I6QL6.js";
4
+ pulseFetch,
5
+ readBuildStamp
6
+ } from "./chunk-IOODP4ZB.js";
5
7
 
6
8
  // src/protect/client-ip.js
7
9
  var ZONE = /^[A-Za-z0-9._-]{1,64}$/;
@@ -1835,6 +1837,18 @@ var RULE_PROPERTY_SHAPES = Object.freeze({
1835
1837
  });
1836
1838
  var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
1837
1839
  var WHEN_KEYS = Object.freeze(["path", "method"]);
1840
+ var BUILD_SCOPE_PROPERTY = "build_scope";
1841
+ var BUILD_SCOPE = Object.freeze({
1842
+ applies_to: Object.freeze(["firewall"]),
1843
+ usable: Object.freeze({
1844
+ type: "string",
1845
+ trim: true,
1846
+ pattern: "^[0-9a-fA-F]{64}$",
1847
+ canonical: "lowercase"
1848
+ }),
1849
+ unreadable: "the rule remains accepted and detects only by default",
1850
+ local_override: "trustLocalRuleScope may explicitly enforce a caller-supplied rule"
1851
+ });
1838
1852
  var RULE_PROPERTIES = Object.freeze([
1839
1853
  "id",
1840
1854
  "rule_id",
@@ -1854,12 +1868,13 @@ var RULE_PROPERTIES = Object.freeze([
1854
1868
  "remove_headers",
1855
1869
  "cookie_flags",
1856
1870
  "ensure",
1857
- "capture"
1871
+ "capture",
1872
+ BUILD_SCOPE_PROPERTY
1858
1873
  ]);
1859
1874
  var CAPTURE_VERSION = 1;
1860
1875
  var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
1861
1876
  var CAPTURE_RAW_CHARS_MAX = 512;
1862
- var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
1877
+ var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture", BUILD_SCOPE_PROPERTY]);
1863
1878
  function captureProblem(capture) {
1864
1879
  if (capture === void 0 || capture === null) return null;
1865
1880
  if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
@@ -2100,6 +2115,10 @@ function validateBundle(bundle, opts = {}) {
2100
2115
  rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2101
2116
  continue;
2102
2117
  }
2118
+ if (wl && typeof wl === "object" && BUILD_SCOPE_PROPERTY in wl) {
2119
+ rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
2120
+ continue;
2121
+ }
2103
2122
  const reason = conditionsProblem(wl?.rule_v2);
2104
2123
  if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
2105
2124
  else whitelists.push(wl);
@@ -2371,6 +2390,8 @@ function asText(value) {
2371
2390
  // src/protect/engine/pulse-client.js
2372
2391
  var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
2373
2392
  var DEFAULT_CACHE_TTL2 = 3e5;
2393
+ var BUILD_VERDICT_HEADER = "X-Patchstack-Build-Match";
2394
+ var BUILD_IDENTITY_HEADER = "X-Patchstack-Build-ID";
2374
2395
  var JITTER_FRACTION2 = 0.1;
2375
2396
  var PulseRuleClient = class {
2376
2397
  #siteUuid;
@@ -2383,7 +2404,8 @@ var PulseRuleClient = class {
2383
2404
  #etag;
2384
2405
  #pulseAuth;
2385
2406
  #detectionState;
2386
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) {
2407
+ #buildId;
2408
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState, buildId } = {}) {
2387
2409
  this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
2388
2410
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
2389
2411
  this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
@@ -2391,6 +2413,7 @@ var PulseRuleClient = class {
2391
2413
  this.#etag = etag ?? null;
2392
2414
  this.#pulseAuth = pulseAuth ?? null;
2393
2415
  this.#detectionState = typeof detectionState === "string" ? detectionState : null;
2416
+ this.#buildId = canonicalBuildId(buildId);
2394
2417
  if (!this.#siteUuid) {
2395
2418
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
2396
2419
  }
@@ -2410,11 +2433,23 @@ var PulseRuleClient = class {
2410
2433
  if (this.#detectionState !== null && typeof auth.Authorization === "string") {
2411
2434
  headers["X-Patchstack-Detections"] = this.#detectionState;
2412
2435
  }
2436
+ if (this.#buildId !== null && typeof auth.Authorization === "string") {
2437
+ headers["X-Patchstack-Build"] = this.#buildId;
2438
+ }
2413
2439
  if (this.#etag) headers["If-None-Match"] = this.#etag;
2414
2440
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
2415
2441
  if (response.status === 304) {
2416
2442
  this.#touch(now);
2417
- return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
2443
+ const result2 = {
2444
+ ...this.#cache ?? { firewall: [], whitelists: [], whitelist_keys: {} },
2445
+ success: true,
2446
+ notModified: true,
2447
+ etag: response.headers?.get?.("etag") ?? this.#etag,
2448
+ build: buildVerdictOf(response.headers)
2449
+ };
2450
+ this.#cache = result2;
2451
+ this.#etag = result2.etag;
2452
+ return result2;
2418
2453
  }
2419
2454
  if (!response.ok) {
2420
2455
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
@@ -2430,7 +2465,10 @@ var PulseRuleClient = class {
2430
2465
  firewall: data.firewall,
2431
2466
  whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
2432
2467
  whitelist_keys: data.whitelist_keys ?? {},
2433
- ...enforcementField(data)
2468
+ ...enforcementField(data),
2469
+ // Carried through so the caller can decide what a build-scoped rule may do. Response metadata,
2470
+ // rather than bundle content, because it varies with the build asking and is also present on 304.
2471
+ build: buildVerdictOf(response.headers)
2434
2472
  };
2435
2473
  this.#cache = result;
2436
2474
  this.#etag = result.etag;
@@ -2456,6 +2494,13 @@ var PulseRuleClient = class {
2456
2494
  this.#etag = null;
2457
2495
  }
2458
2496
  };
2497
+ function buildVerdictOf(headers) {
2498
+ const get = headers !== null && typeof headers === "object" && typeof headers.get === "function" ? (name) => headers.get(name) : () => null;
2499
+ const stated = get(BUILD_VERDICT_HEADER);
2500
+ const verdict = stated === "match" || stated === "missing" || stated === "mismatch" ? stated : "unknown";
2501
+ const matched = get(BUILD_IDENTITY_HEADER);
2502
+ return { verdict, matchedBuildId: typeof matched === "string" ? matched : null };
2503
+ }
2459
2504
  function enforcementField(data) {
2460
2505
  if (!data || typeof data !== "object") return {};
2461
2506
  const v = data.enforcement ?? data.mode;
@@ -3298,10 +3343,19 @@ async function cacheRead(dir) {
3298
3343
  function toEnvelope(value) {
3299
3344
  if (!value || typeof value !== "object") return null;
3300
3345
  if (value.bundle && typeof value.bundle === "object") {
3301
- return { bundle: value.bundle, etag: value.etag ?? null };
3346
+ const buildId = canonicalBuildId(value.buildId);
3347
+ const matched = canonicalBuildId(value.matchedBuildId);
3348
+ return {
3349
+ bundle: value.bundle,
3350
+ etag: value.etag ?? null,
3351
+ buildId,
3352
+ // A confirmation belongs to the presentation stored beside it. A crossed or partially written
3353
+ // envelope confirms nothing, even when one of its fields happens to name the current map.
3354
+ matchedBuildId: buildId !== null && matched === buildId ? matched : null
3355
+ };
3302
3356
  }
3303
3357
  if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
3304
- return { bundle: value, etag: null };
3358
+ return { bundle: value, etag: null, buildId: null, matchedBuildId: null };
3305
3359
  }
3306
3360
  return null;
3307
3361
  }
@@ -3333,62 +3387,146 @@ function fromSource(bundle, origin, reason) {
3333
3387
  source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
3334
3388
  };
3335
3389
  }
3390
+ function buildIdentity(options) {
3391
+ if (options.buildId !== void 0 && options.buildId !== null) {
3392
+ const explicit = canonicalBuildId(options.buildId);
3393
+ return explicit === null ? { id: null, reason: "the buildId option is not a complete 64-hex SHA-256 identity" } : { id: explicit };
3394
+ }
3395
+ const stamped = readBuildStamp(options.rules);
3396
+ if (stamped !== null) return { id: stamped };
3397
+ return {
3398
+ id: null,
3399
+ 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"
3400
+ };
3401
+ }
3402
+ function withScopedDryRun(bundle, confirmed, reason, options = {}) {
3403
+ let held = 0;
3404
+ let unusable = 0;
3405
+ const firewall = bundle.firewall.map((rule) => {
3406
+ if (rule === null || typeof rule !== "object") return rule;
3407
+ if (!(BUILD_SCOPE_PROPERTY in rule)) return rule;
3408
+ const scope = rule[BUILD_SCOPE_PROPERTY];
3409
+ const target = canonicalBuildId(scope);
3410
+ if (target !== null && confirmed !== null && target === confirmed) return rule;
3411
+ if (rule.enforcement === "dry-run") return rule;
3412
+ held += 1;
3413
+ if (target === null) unusable += 1;
3414
+ return { ...rule, enforcement: "dry-run" };
3415
+ });
3416
+ if (held === 0) return bundle;
3417
+ const explanations = [
3418
+ ...unusable > 0 ? [`${unusable} rule(s) carry an unusable ${BUILD_SCOPE_PROPERTY}`] : [],
3419
+ ...held > unusable ? [reason] : []
3420
+ ];
3421
+ notify(
3422
+ options.onError,
3423
+ new Error(`${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`),
3424
+ "onError"
3425
+ );
3426
+ return { ...bundle, firewall };
3427
+ }
3428
+ function confirmedBy(res, presented) {
3429
+ if (presented === null) return null;
3430
+ const verdict = res?.build;
3431
+ if (verdict?.verdict !== "match") return null;
3432
+ return canonicalBuildId(verdict.matchedBuildId) === presented ? presented : null;
3433
+ }
3336
3434
  async function resolveRules(options, store, ctx = {}) {
3337
3435
  const timeoutMs = ctx.timeoutMs;
3338
3436
  if (options.siteUuid) {
3339
3437
  const prior = await store.read();
3340
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, detectionState: ctx.detectionState });
3438
+ const identity = buildIdentity(options);
3439
+ const sameBuild = prior?.buildId === identity.id;
3440
+ const cacheConfirmed = identity.id !== null && sameBuild && prior?.matchedBuildId === identity.id ? identity.id : null;
3441
+ const unconfirmed = identity.reason ?? "the platform did not confirm that these coordinates belong to this map";
3442
+ const served = (bundle, confirmed) => withScopedDryRun(bundle, confirmed, unconfirmed, options);
3443
+ const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
3444
+ const client = new PulseRuleClient({
3445
+ siteUuid: options.siteUuid,
3446
+ baseUrl: options.pulseRulesUrl,
3447
+ etag: sameBuild ? prior?.etag : null,
3448
+ timeoutMs,
3449
+ pulseAuth: ctx.pulseAuth,
3450
+ detectionState: ctx.detectionState,
3451
+ buildId: identity.id
3452
+ });
3341
3453
  const res = await client.getRules();
3342
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
3454
+ if (res.success && res.notModified && prior?.bundle) {
3455
+ const confirmed = confirmedBy(res, identity.id);
3456
+ await store.write({
3457
+ bundle: prior.bundle,
3458
+ etag: res.etag ?? prior.etag ?? null,
3459
+ buildId: identity.id,
3460
+ matchedBuildId: confirmed
3461
+ });
3462
+ return fromSource(served(normalizeBundle(prior.bundle, options), confirmed), "cache");
3463
+ }
3343
3464
  if (res.success && !res.notModified) {
3465
+ const confirmed = confirmedBy(res, identity.id);
3344
3466
  const rejected = liveUpdateRejections(res, options);
3345
3467
  if (rejected.length > 0) {
3346
3468
  reportRejections(rejected, options, "rule update rejected");
3347
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
3348
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
3469
+ if (prior?.bundle) return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", "update rejected");
3470
+ if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
3349
3471
  return fromSource(emptyBundle(), "empty", "update rejected");
3350
3472
  }
3351
3473
  const bundle = normalizeBundle(res, options);
3352
- await store.write({ bundle, etag: res.etag ?? null });
3353
- return fromSource(bundle, "api");
3474
+ await store.write({ bundle, etag: res.etag ?? null, buildId: identity.id, matchedBuildId: confirmed });
3475
+ return fromSource(served(bundle, confirmed), "api");
3354
3476
  }
3355
3477
  if (prior?.bundle) {
3356
3478
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3357
- return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
3479
+ return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", res.error ?? "no usable response");
3358
3480
  }
3359
3481
  if (options.rules) {
3360
3482
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
3361
- return fromSource(normalizeBundle(options.rules, options), "bundled", res.error ?? "no usable response");
3483
+ return fromSource(local(normalizeBundle(options.rules, options)), "bundled", res.error ?? "no usable response");
3362
3484
  }
3363
3485
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
3364
3486
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
3365
3487
  }
3366
3488
  if (options.token) {
3489
+ const identity = buildIdentity(options);
3490
+ const remote = (bundle) => withScopedDryRun(
3491
+ bundle,
3492
+ null,
3493
+ "the token-authenticated rules service did not corroborate this map",
3494
+ options
3495
+ );
3496
+ const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
3367
3497
  const prior = await store.read();
3368
3498
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
3369
3499
  const res = await client.getRules();
3370
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
3500
+ if (res.success && res.notModified && prior?.bundle) {
3501
+ return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache");
3502
+ }
3371
3503
  if (res.success && !res.notModified) {
3372
3504
  const rejected = liveUpdateRejections(res, options);
3373
3505
  if (rejected.length > 0) {
3374
3506
  reportRejections(rejected, options, "rule update rejected");
3375
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
3376
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
3507
+ if (prior?.bundle) return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", "update rejected");
3508
+ if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
3377
3509
  return fromSource(emptyBundle(), "empty", "update rejected");
3378
3510
  }
3379
3511
  const bundle = normalizeBundle(res, options);
3380
3512
  await store.write({ bundle, etag: res.etag ?? null });
3381
- return fromSource(bundle, "api");
3513
+ return fromSource(remote(bundle), "api");
3382
3514
  }
3383
3515
  if (prior?.bundle) {
3384
3516
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
3385
- return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
3517
+ return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", res.error ?? "no usable response");
3386
3518
  }
3387
3519
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
3388
3520
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
3389
3521
  }
3390
3522
  if (options.rules) {
3391
- return fromSource(normalizeBundle(options.rules, options), "bundled");
3523
+ const identity = buildIdentity(options);
3524
+ const bundle = normalizeBundle(options.rules, options);
3525
+ if (options.trustLocalRuleScope === true) return fromSource(bundle, "bundled");
3526
+ return fromSource(
3527
+ withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options),
3528
+ "bundled"
3529
+ );
3392
3530
  }
3393
3531
  return fromSource(emptyBundle(), "empty");
3394
3532
  }
@@ -4480,6 +4618,33 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
4480
4618
  };
4481
4619
  }
4482
4620
 
4621
+ // src/protect/verify-sentinel.js
4622
+ var VERIFY_HEADER = "x-patchstack-verify";
4623
+ var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
4624
+ function configuredChallenge() {
4625
+ const value = typeof process !== "undefined" ? process.env?.[CHALLENGE] : void 0;
4626
+ return typeof value === "string" && value.length >= 32 ? value : null;
4627
+ }
4628
+ function sameValue(a, b) {
4629
+ if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
4630
+ let differs = 0;
4631
+ for (let i = 0; i < a.length; i += 1) differs |= a.charCodeAt(i) ^ b.charCodeAt(i);
4632
+ return differs === 0;
4633
+ }
4634
+ async function sentinelAnswer(offered) {
4635
+ const challenge = configuredChallenge();
4636
+ if (challenge === null || !sameValue(challenge, typeof offered === "string" ? offered : null)) return null;
4637
+ const subtle = globalThis.crypto?.subtle;
4638
+ if (!subtle || typeof subtle.digest !== "function" || typeof TextEncoder === "undefined") return null;
4639
+ try {
4640
+ const bytes = new TextEncoder().encode(`patchstack-verify:${challenge}`);
4641
+ const digest = await subtle.digest("SHA-256", bytes);
4642
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
4643
+ } catch {
4644
+ return null;
4645
+ }
4646
+ }
4647
+
4483
4648
  // src/protect/runtime.js
4484
4649
  function createServerFnGuard({ protection }) {
4485
4650
  const guard = protection.fetchGuard();
@@ -5290,7 +5455,7 @@ async function createProtection(options = {}) {
5290
5455
  let reporter = null;
5291
5456
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
5292
5457
  try {
5293
- ({ reportManifest: reporter } = await import("./refresh-manifest-2MMPQN2A.js"));
5458
+ ({ reportManifest: reporter } = await import("./refresh-manifest-3QGLYXV5.js"));
5294
5459
  } catch (err) {
5295
5460
  notify(onError, err, "onError");
5296
5461
  }
@@ -5816,8 +5981,10 @@ function nodeRequestMeta(req, client) {
5816
5981
  export {
5817
5982
  GUARD_PATH,
5818
5983
  PulseRuleClient,
5984
+ VERIFY_HEADER,
5819
5985
  createProtection,
5820
5986
  createServerFnGuard,
5821
- createSupabaseGuard
5987
+ createSupabaseGuard,
5988
+ sentinelAnswer
5822
5989
  };
5823
5990
  //# sourceMappingURL=protect.js.map