@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.
package/dist/protect.cjs CHANGED
@@ -123,6 +123,27 @@ var init_pulse_token = __esm({
123
123
  }
124
124
  });
125
125
 
126
+ // src/build-id.ts
127
+ function canonicalBuildId(value) {
128
+ if (typeof value !== "string") return null;
129
+ const trimmed = value.trim();
130
+ return BUILD_ID.test(trimmed) ? trimmed.toLowerCase() : null;
131
+ }
132
+ function readBuildStamp(bundle) {
133
+ if (bundle === null || typeof bundle !== "object") return null;
134
+ const namespace = bundle[BUILD_STAMP_KEY];
135
+ if (namespace === null || typeof namespace !== "object") return null;
136
+ return canonicalBuildId(namespace.build_id);
137
+ }
138
+ var BUILD_ID, BUILD_STAMP_KEY;
139
+ var init_build_id = __esm({
140
+ "src/build-id.ts"() {
141
+ "use strict";
142
+ BUILD_ID = /^[0-9a-f]{64}$/i;
143
+ BUILD_STAMP_KEY = "_patchstack";
144
+ }
145
+ });
146
+
126
147
  // src/types.ts
127
148
  var PatchstackError;
128
149
  var init_types = __esm({
@@ -137,6 +158,46 @@ var init_types = __esm({
137
158
  }
138
159
  code;
139
160
  cause;
161
+ /**
162
+ * For a `VALIDATION_ERROR`, the request fields the server refused, as it named them. Read from the
163
+ * structured `errors` object a validation response carries, so a caller deciding what to do about a
164
+ * refusal keys on the field rather than on the wording of a sentence.
165
+ */
166
+ fields = [];
167
+ };
168
+ }
169
+ });
170
+
171
+ // src/hosting.ts
172
+ function detectHostingPlatform(env = process.env) {
173
+ for (const rule of RULES) {
174
+ const evidence = rule.any.filter((name) => present(env, name));
175
+ if (evidence.length > 0) {
176
+ return { platform: rule.platform, evidence };
177
+ }
178
+ }
179
+ return { platform: null, evidence: [] };
180
+ }
181
+ var RULES, present;
182
+ var init_hosting = __esm({
183
+ "src/hosting.ts"() {
184
+ "use strict";
185
+ RULES = [
186
+ { platform: "netlify", any: ["NETLIFY", "NETLIFY_BUILD_BASE", "DEPLOY_PRIME_URL"] },
187
+ { platform: "vercel", any: ["VERCEL", "VERCEL_ENV", "VERCEL_URL"] },
188
+ { platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "CLOUDFLARE_ACCOUNT_ID"] },
189
+ { platform: "aws", any: ["AWS_APP_ID", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
190
+ { platform: "render", any: ["RENDER", "RENDER_SERVICE_ID", "RENDER_EXTERNAL_URL"] },
191
+ { platform: "railway", any: ["RAILWAY_ENVIRONMENT", "RAILWAY_ENVIRONMENT_NAME", "RAILWAY_PROJECT_ID"] },
192
+ { platform: "fly", any: ["FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID"] },
193
+ { platform: "heroku", any: ["DYNO", "HEROKU_APP_NAME"] },
194
+ { platform: "google-cloud", any: ["K_SERVICE", "GAE_APPLICATION", "GAE_SERVICE"] },
195
+ { platform: "deno-deploy", any: ["DENO_DEPLOYMENT_ID"] },
196
+ { platform: "azure", any: ["WEBSITE_SITE_NAME", "WEBSITE_INSTANCE_ID"] }
197
+ ];
198
+ present = (env, name) => {
199
+ const value = env[name];
200
+ return value !== void 0 && value !== "";
140
201
  };
141
202
  }
142
203
  });
@@ -162,14 +223,22 @@ function authFailureMessage(status, config) {
162
223
  }
163
224
  return null;
164
225
  }
165
- function buildManifestBody(config, payload) {
226
+ function buildManifestBody(config, payload, env = process.env) {
227
+ const hosting = config.environment === "local" ? null : detectHostingPlatform(env);
166
228
  return {
167
229
  ...payload,
168
230
  environment: config.environment,
169
231
  ...typeof config.siteUrl === "string" && config.siteUrl !== "" ? { url: config.siteUrl } : {},
170
- ...typeof config.siteName === "string" && config.siteName !== "" ? { name: config.siteName } : {}
232
+ ...typeof config.siteName === "string" && config.siteName !== "" ? { name: config.siteName } : {},
233
+ ...hosting !== null && hosting.platform !== null ? { hosting } : {}
171
234
  };
172
235
  }
236
+ function validationFields(body) {
237
+ if (typeof body !== "object" || body === null) return [];
238
+ const errors = body.errors;
239
+ if (typeof errors !== "object" || errors === null || Array.isArray(errors)) return [];
240
+ return Object.keys(errors);
241
+ }
173
242
  async function postManifest(config, payload) {
174
243
  const url = buildEndpointUrl(config.endpoint, config.siteUuid);
175
244
  const timeoutMs = config.timeoutMs;
@@ -214,10 +283,12 @@ async function postManifest(config, payload) {
214
283
  );
215
284
  }
216
285
  if (response.status === 422) {
217
- throw new PatchstackError(
286
+ const refused2 = new PatchstackError(
218
287
  body?.message ?? "Patchstack rejected the manifest payload (validation failed).",
219
288
  "VALIDATION_ERROR"
220
289
  );
290
+ refused2.fields = validationFields(body);
291
+ throw refused2;
221
292
  }
222
293
  const refused = authFailureMessage(response.status, config);
223
294
  if (refused !== null) {
@@ -245,13 +316,63 @@ var init_client = __esm({
245
316
  "src/client.ts"() {
246
317
  "use strict";
247
318
  init_types();
319
+ init_hosting();
248
320
  init_pulse_token();
321
+ init_build_id();
249
322
  DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
250
323
  DEFAULT_TIMEOUT_MS = 3e4;
251
324
  CLAIM_TOKEN_HEADER = "X-Patchstack-Claim-Token";
252
325
  }
253
326
  });
254
327
 
328
+ // src/environment.ts
329
+ function inferEnvironment(env = process.env) {
330
+ for (const discriminator of DISCRIMINATORS) {
331
+ const verdict = discriminator.read(env);
332
+ if (verdict !== null) {
333
+ return { environment: verdict.environment, evidence: [`${discriminator.platform}: ${verdict.evidence}`] };
334
+ }
335
+ }
336
+ return { environment: "local", evidence: [] };
337
+ }
338
+ var set, DISCRIMINATORS;
339
+ var init_environment = __esm({
340
+ "src/environment.ts"() {
341
+ "use strict";
342
+ set = (value) => value !== void 0 && value !== "";
343
+ DISCRIMINATORS = [
344
+ {
345
+ platform: "vercel",
346
+ read: (env) => {
347
+ if (!set(env.VERCEL) || !set(env.VERCEL_ENV)) return null;
348
+ return env.VERCEL_ENV === "production" ? { environment: "production", evidence: "VERCEL_ENV=production" } : { environment: "sandbox", evidence: `VERCEL_ENV=${env.VERCEL_ENV}` };
349
+ }
350
+ },
351
+ {
352
+ platform: "netlify",
353
+ read: (env) => {
354
+ if (env.NETLIFY !== "true" || !set(env.CONTEXT)) return null;
355
+ return env.CONTEXT === "production" ? { environment: "production", evidence: "CONTEXT=production" } : { environment: "sandbox", evidence: `CONTEXT=${env.CONTEXT}` };
356
+ }
357
+ },
358
+ {
359
+ platform: "render",
360
+ read: (env) => {
361
+ if (env.RENDER !== "true") return null;
362
+ return env.IS_PULL_REQUEST === "true" ? { environment: "sandbox", evidence: "IS_PULL_REQUEST=true" } : { environment: "production", evidence: "RENDER=true, not a pull request" };
363
+ }
364
+ },
365
+ {
366
+ platform: "railway",
367
+ read: (env) => {
368
+ if (!set(env.RAILWAY_ENVIRONMENT_NAME)) return null;
369
+ return env.RAILWAY_ENVIRONMENT_NAME === "production" ? { environment: "production", evidence: "RAILWAY_ENVIRONMENT_NAME=production" } : { environment: "sandbox", evidence: `RAILWAY_ENVIRONMENT_NAME=${env.RAILWAY_ENVIRONMENT_NAME}` };
370
+ }
371
+ }
372
+ ];
373
+ }
374
+ });
375
+
255
376
  // src/site-url.ts
256
377
  function isUnpublishableHost(hostname) {
257
378
  const host = hostname.toLowerCase().replace(/\.+$/, "");
@@ -463,11 +584,13 @@ async function resolveConfig(options) {
463
584
  const environmentRaw = fromEnv.environment ?? fromFile.environment;
464
585
  if (environmentRaw !== void 0 && !isEnvironment(environmentRaw)) {
465
586
  throw new PatchstackError(
466
- `Environment must be "production" or "sandbox"; got "${environmentRaw}".`,
587
+ `Environment must be "production", "sandbox" or "local"; got "${environmentRaw}".`,
467
588
  "CONFIG_INVALID"
468
589
  );
469
590
  }
470
- const environment = environmentRaw ?? DEFAULT_ENVIRONMENT;
591
+ const inferred = environmentRaw === void 0 ? inferEnvironment(process.env) : null;
592
+ const environment = environmentRaw ?? inferred.environment;
593
+ const environmentEvidence = inferred?.evidence ?? [];
471
594
  if (siteUuid !== null && siteUuid.length > 0 && !isUuid(siteUuid)) {
472
595
  throw new PatchstackError(
473
596
  `Site UUID "${siteUuid}" does not look like a valid UUID.`,
@@ -493,6 +616,7 @@ async function resolveConfig(options) {
493
616
  endpoint,
494
617
  timeoutMs,
495
618
  environment,
619
+ environmentEvidence,
496
620
  widget: fromFile.widget !== false,
497
621
  claimToken
498
622
  };
@@ -558,9 +682,9 @@ function isUuid(value) {
558
682
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
559
683
  }
560
684
  function isEnvironment(value) {
561
- return value === "production" || value === "sandbox";
685
+ return value === "production" || value === "sandbox" || value === "local";
562
686
  }
563
- var import_promises2, import_node_path2, CONFIG_FILENAME, SECRET_FILENAME, DEFAULT_ENVIRONMENT;
687
+ var import_promises2, import_node_path2, CONFIG_FILENAME, SECRET_FILENAME;
564
688
  var init_config = __esm({
565
689
  "src/config.ts"() {
566
690
  "use strict";
@@ -568,11 +692,11 @@ var init_config = __esm({
568
692
  import_node_path2 = __toESM(require("path"), 1);
569
693
  init_types();
570
694
  init_client();
695
+ init_environment();
571
696
  init_site_url();
572
697
  init_site_name();
573
698
  CONFIG_FILENAME = ".patchstackrc.json";
574
699
  SECRET_FILENAME = ".patchstackrc.local.json";
575
- DEFAULT_ENVIRONMENT = "production";
576
700
  }
577
701
  });
578
702
 
@@ -609,8 +733,8 @@ async function readDeclaredDependencyNames(cwd) {
609
733
  return [...names];
610
734
  }
611
735
  function missingDependencies(declared, packages) {
612
- const present = new Set(packages.map((entry) => entry.name));
613
- return declared.filter((name) => !present.has(name));
736
+ const present2 = new Set(packages.map((entry) => entry.name));
737
+ return declared.filter((name) => !present2.has(name));
614
738
  }
615
739
  function disagreements(packages, other) {
616
740
  const otherVersions = /* @__PURE__ */ new Map();
@@ -3685,6 +3809,18 @@ var RULE_PROPERTY_SHAPES = Object.freeze({
3685
3809
  });
3686
3810
  var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
3687
3811
  var WHEN_KEYS = Object.freeze(["path", "method"]);
3812
+ var BUILD_SCOPE_PROPERTY = "build_scope";
3813
+ var BUILD_SCOPE = Object.freeze({
3814
+ applies_to: Object.freeze(["firewall"]),
3815
+ usable: Object.freeze({
3816
+ type: "string",
3817
+ trim: true,
3818
+ pattern: "^[0-9a-fA-F]{64}$",
3819
+ canonical: "lowercase"
3820
+ }),
3821
+ unreadable: "the rule remains accepted and detects only by default",
3822
+ local_override: "trustLocalRuleScope may explicitly enforce a caller-supplied rule"
3823
+ });
3688
3824
  var RULE_PROPERTIES = Object.freeze([
3689
3825
  "id",
3690
3826
  "rule_id",
@@ -3704,12 +3840,13 @@ var RULE_PROPERTIES = Object.freeze([
3704
3840
  "remove_headers",
3705
3841
  "cookie_flags",
3706
3842
  "ensure",
3707
- "capture"
3843
+ "capture",
3844
+ BUILD_SCOPE_PROPERTY
3708
3845
  ]);
3709
3846
  var CAPTURE_VERSION = 1;
3710
3847
  var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
3711
3848
  var CAPTURE_RAW_CHARS_MAX = 512;
3712
- var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
3849
+ var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture", BUILD_SCOPE_PROPERTY]);
3713
3850
  function captureProblem(capture) {
3714
3851
  if (capture === void 0 || capture === null) return null;
3715
3852
  if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
@@ -3950,6 +4087,10 @@ function validateBundle(bundle, opts = {}) {
3950
4087
  rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
3951
4088
  continue;
3952
4089
  }
4090
+ if (wl && typeof wl === "object" && BUILD_SCOPE_PROPERTY in wl) {
4091
+ rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
4092
+ continue;
4093
+ }
3953
4094
  const reason = conditionsProblem(wl?.rule_v2);
3954
4095
  if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
3955
4096
  else whitelists.push(wl);
@@ -4220,8 +4361,11 @@ function asText(value) {
4220
4361
 
4221
4362
  // src/protect/engine/pulse-client.js
4222
4363
  init_pulse_token();
4364
+ init_build_id();
4223
4365
  var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
4224
4366
  var DEFAULT_CACHE_TTL2 = 3e5;
4367
+ var BUILD_VERDICT_HEADER = "X-Patchstack-Build-Match";
4368
+ var BUILD_IDENTITY_HEADER = "X-Patchstack-Build-ID";
4225
4369
  var JITTER_FRACTION2 = 0.1;
4226
4370
  var PulseRuleClient = class {
4227
4371
  #siteUuid;
@@ -4234,7 +4378,8 @@ var PulseRuleClient = class {
4234
4378
  #etag;
4235
4379
  #pulseAuth;
4236
4380
  #detectionState;
4237
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) {
4381
+ #buildId;
4382
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState, buildId } = {}) {
4238
4383
  this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
4239
4384
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
4240
4385
  this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
@@ -4242,6 +4387,7 @@ var PulseRuleClient = class {
4242
4387
  this.#etag = etag ?? null;
4243
4388
  this.#pulseAuth = pulseAuth ?? null;
4244
4389
  this.#detectionState = typeof detectionState === "string" ? detectionState : null;
4390
+ this.#buildId = canonicalBuildId(buildId);
4245
4391
  if (!this.#siteUuid) {
4246
4392
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
4247
4393
  }
@@ -4261,11 +4407,23 @@ var PulseRuleClient = class {
4261
4407
  if (this.#detectionState !== null && typeof auth.Authorization === "string") {
4262
4408
  headers["X-Patchstack-Detections"] = this.#detectionState;
4263
4409
  }
4410
+ if (this.#buildId !== null && typeof auth.Authorization === "string") {
4411
+ headers["X-Patchstack-Build"] = this.#buildId;
4412
+ }
4264
4413
  if (this.#etag) headers["If-None-Match"] = this.#etag;
4265
4414
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
4266
4415
  if (response.status === 304) {
4267
4416
  this.#touch(now);
4268
- return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
4417
+ const result2 = {
4418
+ ...this.#cache ?? { firewall: [], whitelists: [], whitelist_keys: {} },
4419
+ success: true,
4420
+ notModified: true,
4421
+ etag: response.headers?.get?.("etag") ?? this.#etag,
4422
+ build: buildVerdictOf(response.headers)
4423
+ };
4424
+ this.#cache = result2;
4425
+ this.#etag = result2.etag;
4426
+ return result2;
4269
4427
  }
4270
4428
  if (!response.ok) {
4271
4429
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
@@ -4281,7 +4439,10 @@ var PulseRuleClient = class {
4281
4439
  firewall: data.firewall,
4282
4440
  whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
4283
4441
  whitelist_keys: data.whitelist_keys ?? {},
4284
- ...enforcementField(data)
4442
+ ...enforcementField(data),
4443
+ // Carried through so the caller can decide what a build-scoped rule may do. Response metadata,
4444
+ // rather than bundle content, because it varies with the build asking and is also present on 304.
4445
+ build: buildVerdictOf(response.headers)
4285
4446
  };
4286
4447
  this.#cache = result;
4287
4448
  this.#etag = result.etag;
@@ -4307,6 +4468,13 @@ var PulseRuleClient = class {
4307
4468
  this.#etag = null;
4308
4469
  }
4309
4470
  };
4471
+ function buildVerdictOf(headers) {
4472
+ const get = headers !== null && typeof headers === "object" && typeof headers.get === "function" ? (name) => headers.get(name) : () => null;
4473
+ const stated2 = get(BUILD_VERDICT_HEADER);
4474
+ const verdict = stated2 === "match" || stated2 === "missing" || stated2 === "mismatch" ? stated2 : "unknown";
4475
+ const matched = get(BUILD_IDENTITY_HEADER);
4476
+ return { verdict, matchedBuildId: typeof matched === "string" ? matched : null };
4477
+ }
4310
4478
  function enforcementField(data) {
4311
4479
  if (!data || typeof data !== "object") return {};
4312
4480
  const v = data.enforcement ?? data.mode;
@@ -5069,6 +5237,7 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
5069
5237
  }
5070
5238
 
5071
5239
  // src/protect/rules/store.js
5240
+ init_build_id();
5072
5241
  var fsMod;
5073
5242
  async function loadFs() {
5074
5243
  if (fsMod !== void 0) return fsMod;
@@ -5149,15 +5318,25 @@ async function cacheRead(dir) {
5149
5318
  function toEnvelope(value) {
5150
5319
  if (!value || typeof value !== "object") return null;
5151
5320
  if (value.bundle && typeof value.bundle === "object") {
5152
- return { bundle: value.bundle, etag: value.etag ?? null };
5321
+ const buildId = canonicalBuildId(value.buildId);
5322
+ const matched = canonicalBuildId(value.matchedBuildId);
5323
+ return {
5324
+ bundle: value.bundle,
5325
+ etag: value.etag ?? null,
5326
+ buildId,
5327
+ // A confirmation belongs to the presentation stored beside it. A crossed or partially written
5328
+ // envelope confirms nothing, even when one of its fields happens to name the current map.
5329
+ matchedBuildId: buildId !== null && matched === buildId ? matched : null
5330
+ };
5153
5331
  }
5154
5332
  if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
5155
- return { bundle: value, etag: null };
5333
+ return { bundle: value, etag: null, buildId: null, matchedBuildId: null };
5156
5334
  }
5157
5335
  return null;
5158
5336
  }
5159
5337
 
5160
5338
  // src/protect/rules/source.js
5339
+ init_build_id();
5161
5340
  function liveUpdateRejections(res, options) {
5162
5341
  if (options.acceptPartialBundle) return [];
5163
5342
  const { rejected } = validateBundle(
@@ -5184,62 +5363,146 @@ function fromSource(bundle, origin, reason) {
5184
5363
  source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
5185
5364
  };
5186
5365
  }
5366
+ function buildIdentity(options) {
5367
+ if (options.buildId !== void 0 && options.buildId !== null) {
5368
+ const explicit = canonicalBuildId(options.buildId);
5369
+ return explicit === null ? { id: null, reason: "the buildId option is not a complete 64-hex SHA-256 identity" } : { id: explicit };
5370
+ }
5371
+ const stamped = readBuildStamp(options.rules);
5372
+ if (stamped !== null) return { id: stamped };
5373
+ return {
5374
+ id: null,
5375
+ 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"
5376
+ };
5377
+ }
5378
+ function withScopedDryRun(bundle, confirmed, reason, options = {}) {
5379
+ let held = 0;
5380
+ let unusable = 0;
5381
+ const firewall = bundle.firewall.map((rule) => {
5382
+ if (rule === null || typeof rule !== "object") return rule;
5383
+ if (!(BUILD_SCOPE_PROPERTY in rule)) return rule;
5384
+ const scope = rule[BUILD_SCOPE_PROPERTY];
5385
+ const target = canonicalBuildId(scope);
5386
+ if (target !== null && confirmed !== null && target === confirmed) return rule;
5387
+ if (rule.enforcement === "dry-run") return rule;
5388
+ held += 1;
5389
+ if (target === null) unusable += 1;
5390
+ return { ...rule, enforcement: "dry-run" };
5391
+ });
5392
+ if (held === 0) return bundle;
5393
+ const explanations = [
5394
+ ...unusable > 0 ? [`${unusable} rule(s) carry an unusable ${BUILD_SCOPE_PROPERTY}`] : [],
5395
+ ...held > unusable ? [reason] : []
5396
+ ];
5397
+ notify(
5398
+ options.onError,
5399
+ new Error(`${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`),
5400
+ "onError"
5401
+ );
5402
+ return { ...bundle, firewall };
5403
+ }
5404
+ function confirmedBy(res, presented) {
5405
+ if (presented === null) return null;
5406
+ const verdict = res?.build;
5407
+ if (verdict?.verdict !== "match") return null;
5408
+ return canonicalBuildId(verdict.matchedBuildId) === presented ? presented : null;
5409
+ }
5187
5410
  async function resolveRules(options, store, ctx = {}) {
5188
5411
  const timeoutMs = ctx.timeoutMs;
5189
5412
  if (options.siteUuid) {
5190
5413
  const prior = await store.read();
5191
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, detectionState: ctx.detectionState });
5414
+ const identity = buildIdentity(options);
5415
+ const sameBuild = prior?.buildId === identity.id;
5416
+ const cacheConfirmed = identity.id !== null && sameBuild && prior?.matchedBuildId === identity.id ? identity.id : null;
5417
+ const unconfirmed = identity.reason ?? "the platform did not confirm that these coordinates belong to this map";
5418
+ const served = (bundle, confirmed) => withScopedDryRun(bundle, confirmed, unconfirmed, options);
5419
+ const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
5420
+ const client = new PulseRuleClient({
5421
+ siteUuid: options.siteUuid,
5422
+ baseUrl: options.pulseRulesUrl,
5423
+ etag: sameBuild ? prior?.etag : null,
5424
+ timeoutMs,
5425
+ pulseAuth: ctx.pulseAuth,
5426
+ detectionState: ctx.detectionState,
5427
+ buildId: identity.id
5428
+ });
5192
5429
  const res = await client.getRules();
5193
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
5430
+ if (res.success && res.notModified && prior?.bundle) {
5431
+ const confirmed = confirmedBy(res, identity.id);
5432
+ await store.write({
5433
+ bundle: prior.bundle,
5434
+ etag: res.etag ?? prior.etag ?? null,
5435
+ buildId: identity.id,
5436
+ matchedBuildId: confirmed
5437
+ });
5438
+ return fromSource(served(normalizeBundle(prior.bundle, options), confirmed), "cache");
5439
+ }
5194
5440
  if (res.success && !res.notModified) {
5441
+ const confirmed = confirmedBy(res, identity.id);
5195
5442
  const rejected = liveUpdateRejections(res, options);
5196
5443
  if (rejected.length > 0) {
5197
5444
  reportRejections(rejected, options, "rule update rejected");
5198
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
5199
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
5445
+ if (prior?.bundle) return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", "update rejected");
5446
+ if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
5200
5447
  return fromSource(emptyBundle(), "empty", "update rejected");
5201
5448
  }
5202
5449
  const bundle = normalizeBundle(res, options);
5203
- await store.write({ bundle, etag: res.etag ?? null });
5204
- return fromSource(bundle, "api");
5450
+ await store.write({ bundle, etag: res.etag ?? null, buildId: identity.id, matchedBuildId: confirmed });
5451
+ return fromSource(served(bundle, confirmed), "api");
5205
5452
  }
5206
5453
  if (prior?.bundle) {
5207
5454
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
5208
- return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
5455
+ return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", res.error ?? "no usable response");
5209
5456
  }
5210
5457
  if (options.rules) {
5211
5458
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
5212
- return fromSource(normalizeBundle(options.rules, options), "bundled", res.error ?? "no usable response");
5459
+ return fromSource(local(normalizeBundle(options.rules, options)), "bundled", res.error ?? "no usable response");
5213
5460
  }
5214
5461
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
5215
5462
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
5216
5463
  }
5217
5464
  if (options.token) {
5465
+ const identity = buildIdentity(options);
5466
+ const remote = (bundle) => withScopedDryRun(
5467
+ bundle,
5468
+ null,
5469
+ "the token-authenticated rules service did not corroborate this map",
5470
+ options
5471
+ );
5472
+ const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
5218
5473
  const prior = await store.read();
5219
5474
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
5220
5475
  const res = await client.getRules();
5221
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
5476
+ if (res.success && res.notModified && prior?.bundle) {
5477
+ return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache");
5478
+ }
5222
5479
  if (res.success && !res.notModified) {
5223
5480
  const rejected = liveUpdateRejections(res, options);
5224
5481
  if (rejected.length > 0) {
5225
5482
  reportRejections(rejected, options, "rule update rejected");
5226
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
5227
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
5483
+ if (prior?.bundle) return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", "update rejected");
5484
+ if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
5228
5485
  return fromSource(emptyBundle(), "empty", "update rejected");
5229
5486
  }
5230
5487
  const bundle = normalizeBundle(res, options);
5231
5488
  await store.write({ bundle, etag: res.etag ?? null });
5232
- return fromSource(bundle, "api");
5489
+ return fromSource(remote(bundle), "api");
5233
5490
  }
5234
5491
  if (prior?.bundle) {
5235
5492
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
5236
- return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
5493
+ return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", res.error ?? "no usable response");
5237
5494
  }
5238
5495
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
5239
5496
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
5240
5497
  }
5241
5498
  if (options.rules) {
5242
- return fromSource(normalizeBundle(options.rules, options), "bundled");
5499
+ const identity = buildIdentity(options);
5500
+ const bundle = normalizeBundle(options.rules, options);
5501
+ if (options.trustLocalRuleScope === true) return fromSource(bundle, "bundled");
5502
+ return fromSource(
5503
+ withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options),
5504
+ "bundled"
5505
+ );
5243
5506
  }
5244
5507
  return fromSource(emptyBundle(), "empty");
5245
5508
  }
@@ -6824,8 +7087,8 @@ async function createProtection(options = {}) {
6824
7087
  hardened = true;
6825
7088
  try {
6826
7089
  if (res.headersSent || typeof res.setHeader !== "function") return null;
6827
- const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
6828
- const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
7090
+ const set2 = typeof res.getHeaders === "function" ? res.getHeaders() : {};
7091
+ const headers = effective && effective.headers ? { ...set2, ...effective.headers } : set2;
6829
7092
  const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
6830
7093
  const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
6831
7094
  answeredWithoutBody = true;
@@ -7537,8 +7800,8 @@ function applyHeaderMutation(headers, rule) {
7537
7800
  const ensure = rule.ensure === true;
7538
7801
  for (const [name, value] of Object.entries(rule.set_headers ?? {})) {
7539
7802
  const key = String(name).toLowerCase();
7540
- const present = headers[key] != null && headers[key] !== "";
7541
- if (ensure && present) continue;
7803
+ const present2 = headers[key] != null && headers[key] !== "";
7804
+ if (ensure && present2) continue;
7542
7805
  headers[key] = String(value);
7543
7806
  }
7544
7807
  return;