@patchstack/connect 0.5.2 → 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.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({
@@ -246,6 +267,7 @@ var init_client = __esm({
246
267
  "use strict";
247
268
  init_types();
248
269
  init_pulse_token();
270
+ init_build_id();
249
271
  DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
250
272
  DEFAULT_TIMEOUT_MS = 3e4;
251
273
  CLAIM_TOKEN_HEADER = "X-Patchstack-Claim-Token";
@@ -3685,6 +3707,18 @@ var RULE_PROPERTY_SHAPES = Object.freeze({
3685
3707
  });
3686
3708
  var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
3687
3709
  var WHEN_KEYS = Object.freeze(["path", "method"]);
3710
+ var BUILD_SCOPE_PROPERTY = "build_scope";
3711
+ var BUILD_SCOPE = Object.freeze({
3712
+ applies_to: Object.freeze(["firewall"]),
3713
+ usable: Object.freeze({
3714
+ type: "string",
3715
+ trim: true,
3716
+ pattern: "^[0-9a-fA-F]{64}$",
3717
+ canonical: "lowercase"
3718
+ }),
3719
+ unreadable: "the rule remains accepted and detects only by default",
3720
+ local_override: "trustLocalRuleScope may explicitly enforce a caller-supplied rule"
3721
+ });
3688
3722
  var RULE_PROPERTIES = Object.freeze([
3689
3723
  "id",
3690
3724
  "rule_id",
@@ -3704,12 +3738,13 @@ var RULE_PROPERTIES = Object.freeze([
3704
3738
  "remove_headers",
3705
3739
  "cookie_flags",
3706
3740
  "ensure",
3707
- "capture"
3741
+ "capture",
3742
+ BUILD_SCOPE_PROPERTY
3708
3743
  ]);
3709
3744
  var CAPTURE_VERSION = 1;
3710
3745
  var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
3711
3746
  var CAPTURE_RAW_CHARS_MAX = 512;
3712
- var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
3747
+ var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture", BUILD_SCOPE_PROPERTY]);
3713
3748
  function captureProblem(capture) {
3714
3749
  if (capture === void 0 || capture === null) return null;
3715
3750
  if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
@@ -3950,6 +3985,10 @@ function validateBundle(bundle, opts = {}) {
3950
3985
  rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
3951
3986
  continue;
3952
3987
  }
3988
+ if (wl && typeof wl === "object" && BUILD_SCOPE_PROPERTY in wl) {
3989
+ rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
3990
+ continue;
3991
+ }
3953
3992
  const reason = conditionsProblem(wl?.rule_v2);
3954
3993
  if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
3955
3994
  else whitelists.push(wl);
@@ -4220,8 +4259,11 @@ function asText(value) {
4220
4259
 
4221
4260
  // src/protect/engine/pulse-client.js
4222
4261
  init_pulse_token();
4262
+ init_build_id();
4223
4263
  var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
4224
4264
  var DEFAULT_CACHE_TTL2 = 3e5;
4265
+ var BUILD_VERDICT_HEADER = "X-Patchstack-Build-Match";
4266
+ var BUILD_IDENTITY_HEADER = "X-Patchstack-Build-ID";
4225
4267
  var JITTER_FRACTION2 = 0.1;
4226
4268
  var PulseRuleClient = class {
4227
4269
  #siteUuid;
@@ -4234,7 +4276,8 @@ var PulseRuleClient = class {
4234
4276
  #etag;
4235
4277
  #pulseAuth;
4236
4278
  #detectionState;
4237
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) {
4279
+ #buildId;
4280
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState, buildId } = {}) {
4238
4281
  this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
4239
4282
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
4240
4283
  this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
@@ -4242,6 +4285,7 @@ var PulseRuleClient = class {
4242
4285
  this.#etag = etag ?? null;
4243
4286
  this.#pulseAuth = pulseAuth ?? null;
4244
4287
  this.#detectionState = typeof detectionState === "string" ? detectionState : null;
4288
+ this.#buildId = canonicalBuildId(buildId);
4245
4289
  if (!this.#siteUuid) {
4246
4290
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
4247
4291
  }
@@ -4261,11 +4305,23 @@ var PulseRuleClient = class {
4261
4305
  if (this.#detectionState !== null && typeof auth.Authorization === "string") {
4262
4306
  headers["X-Patchstack-Detections"] = this.#detectionState;
4263
4307
  }
4308
+ if (this.#buildId !== null && typeof auth.Authorization === "string") {
4309
+ headers["X-Patchstack-Build"] = this.#buildId;
4310
+ }
4264
4311
  if (this.#etag) headers["If-None-Match"] = this.#etag;
4265
4312
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
4266
4313
  if (response.status === 304) {
4267
4314
  this.#touch(now);
4268
- return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
4315
+ const result2 = {
4316
+ ...this.#cache ?? { firewall: [], whitelists: [], whitelist_keys: {} },
4317
+ success: true,
4318
+ notModified: true,
4319
+ etag: response.headers?.get?.("etag") ?? this.#etag,
4320
+ build: buildVerdictOf(response.headers)
4321
+ };
4322
+ this.#cache = result2;
4323
+ this.#etag = result2.etag;
4324
+ return result2;
4269
4325
  }
4270
4326
  if (!response.ok) {
4271
4327
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
@@ -4281,7 +4337,10 @@ var PulseRuleClient = class {
4281
4337
  firewall: data.firewall,
4282
4338
  whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
4283
4339
  whitelist_keys: data.whitelist_keys ?? {},
4284
- ...enforcementField(data)
4340
+ ...enforcementField(data),
4341
+ // Carried through so the caller can decide what a build-scoped rule may do. Response metadata,
4342
+ // rather than bundle content, because it varies with the build asking and is also present on 304.
4343
+ build: buildVerdictOf(response.headers)
4285
4344
  };
4286
4345
  this.#cache = result;
4287
4346
  this.#etag = result.etag;
@@ -4307,6 +4366,13 @@ var PulseRuleClient = class {
4307
4366
  this.#etag = null;
4308
4367
  }
4309
4368
  };
4369
+ function buildVerdictOf(headers) {
4370
+ const get = headers !== null && typeof headers === "object" && typeof headers.get === "function" ? (name) => headers.get(name) : () => null;
4371
+ const stated2 = get(BUILD_VERDICT_HEADER);
4372
+ const verdict = stated2 === "match" || stated2 === "missing" || stated2 === "mismatch" ? stated2 : "unknown";
4373
+ const matched = get(BUILD_IDENTITY_HEADER);
4374
+ return { verdict, matchedBuildId: typeof matched === "string" ? matched : null };
4375
+ }
4310
4376
  function enforcementField(data) {
4311
4377
  if (!data || typeof data !== "object") return {};
4312
4378
  const v = data.enforcement ?? data.mode;
@@ -5069,6 +5135,7 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
5069
5135
  }
5070
5136
 
5071
5137
  // src/protect/rules/store.js
5138
+ init_build_id();
5072
5139
  var fsMod;
5073
5140
  async function loadFs() {
5074
5141
  if (fsMod !== void 0) return fsMod;
@@ -5149,15 +5216,25 @@ async function cacheRead(dir) {
5149
5216
  function toEnvelope(value) {
5150
5217
  if (!value || typeof value !== "object") return null;
5151
5218
  if (value.bundle && typeof value.bundle === "object") {
5152
- return { bundle: value.bundle, etag: value.etag ?? null };
5219
+ const buildId = canonicalBuildId(value.buildId);
5220
+ const matched = canonicalBuildId(value.matchedBuildId);
5221
+ return {
5222
+ bundle: value.bundle,
5223
+ etag: value.etag ?? null,
5224
+ buildId,
5225
+ // A confirmation belongs to the presentation stored beside it. A crossed or partially written
5226
+ // envelope confirms nothing, even when one of its fields happens to name the current map.
5227
+ matchedBuildId: buildId !== null && matched === buildId ? matched : null
5228
+ };
5153
5229
  }
5154
5230
  if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
5155
- return { bundle: value, etag: null };
5231
+ return { bundle: value, etag: null, buildId: null, matchedBuildId: null };
5156
5232
  }
5157
5233
  return null;
5158
5234
  }
5159
5235
 
5160
5236
  // src/protect/rules/source.js
5237
+ init_build_id();
5161
5238
  function liveUpdateRejections(res, options) {
5162
5239
  if (options.acceptPartialBundle) return [];
5163
5240
  const { rejected } = validateBundle(
@@ -5184,62 +5261,146 @@ function fromSource(bundle, origin, reason) {
5184
5261
  source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
5185
5262
  };
5186
5263
  }
5264
+ function buildIdentity(options) {
5265
+ if (options.buildId !== void 0 && options.buildId !== null) {
5266
+ const explicit = canonicalBuildId(options.buildId);
5267
+ return explicit === null ? { id: null, reason: "the buildId option is not a complete 64-hex SHA-256 identity" } : { id: explicit };
5268
+ }
5269
+ const stamped = readBuildStamp(options.rules);
5270
+ if (stamped !== null) return { id: stamped };
5271
+ return {
5272
+ id: null,
5273
+ 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"
5274
+ };
5275
+ }
5276
+ function withScopedDryRun(bundle, confirmed, reason, options = {}) {
5277
+ let held = 0;
5278
+ let unusable = 0;
5279
+ const firewall = bundle.firewall.map((rule) => {
5280
+ if (rule === null || typeof rule !== "object") return rule;
5281
+ if (!(BUILD_SCOPE_PROPERTY in rule)) return rule;
5282
+ const scope = rule[BUILD_SCOPE_PROPERTY];
5283
+ const target = canonicalBuildId(scope);
5284
+ if (target !== null && confirmed !== null && target === confirmed) return rule;
5285
+ if (rule.enforcement === "dry-run") return rule;
5286
+ held += 1;
5287
+ if (target === null) unusable += 1;
5288
+ return { ...rule, enforcement: "dry-run" };
5289
+ });
5290
+ if (held === 0) return bundle;
5291
+ const explanations = [
5292
+ ...unusable > 0 ? [`${unusable} rule(s) carry an unusable ${BUILD_SCOPE_PROPERTY}`] : [],
5293
+ ...held > unusable ? [reason] : []
5294
+ ];
5295
+ notify(
5296
+ options.onError,
5297
+ new Error(`${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`),
5298
+ "onError"
5299
+ );
5300
+ return { ...bundle, firewall };
5301
+ }
5302
+ function confirmedBy(res, presented) {
5303
+ if (presented === null) return null;
5304
+ const verdict = res?.build;
5305
+ if (verdict?.verdict !== "match") return null;
5306
+ return canonicalBuildId(verdict.matchedBuildId) === presented ? presented : null;
5307
+ }
5187
5308
  async function resolveRules(options, store, ctx = {}) {
5188
5309
  const timeoutMs = ctx.timeoutMs;
5189
5310
  if (options.siteUuid) {
5190
5311
  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 });
5312
+ const identity = buildIdentity(options);
5313
+ const sameBuild = prior?.buildId === identity.id;
5314
+ const cacheConfirmed = identity.id !== null && sameBuild && prior?.matchedBuildId === identity.id ? identity.id : null;
5315
+ const unconfirmed = identity.reason ?? "the platform did not confirm that these coordinates belong to this map";
5316
+ const served = (bundle, confirmed) => withScopedDryRun(bundle, confirmed, unconfirmed, options);
5317
+ const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
5318
+ const client = new PulseRuleClient({
5319
+ siteUuid: options.siteUuid,
5320
+ baseUrl: options.pulseRulesUrl,
5321
+ etag: sameBuild ? prior?.etag : null,
5322
+ timeoutMs,
5323
+ pulseAuth: ctx.pulseAuth,
5324
+ detectionState: ctx.detectionState,
5325
+ buildId: identity.id
5326
+ });
5192
5327
  const res = await client.getRules();
5193
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
5328
+ if (res.success && res.notModified && prior?.bundle) {
5329
+ const confirmed = confirmedBy(res, identity.id);
5330
+ await store.write({
5331
+ bundle: prior.bundle,
5332
+ etag: res.etag ?? prior.etag ?? null,
5333
+ buildId: identity.id,
5334
+ matchedBuildId: confirmed
5335
+ });
5336
+ return fromSource(served(normalizeBundle(prior.bundle, options), confirmed), "cache");
5337
+ }
5194
5338
  if (res.success && !res.notModified) {
5339
+ const confirmed = confirmedBy(res, identity.id);
5195
5340
  const rejected = liveUpdateRejections(res, options);
5196
5341
  if (rejected.length > 0) {
5197
5342
  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");
5343
+ if (prior?.bundle) return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", "update rejected");
5344
+ if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
5200
5345
  return fromSource(emptyBundle(), "empty", "update rejected");
5201
5346
  }
5202
5347
  const bundle = normalizeBundle(res, options);
5203
- await store.write({ bundle, etag: res.etag ?? null });
5204
- return fromSource(bundle, "api");
5348
+ await store.write({ bundle, etag: res.etag ?? null, buildId: identity.id, matchedBuildId: confirmed });
5349
+ return fromSource(served(bundle, confirmed), "api");
5205
5350
  }
5206
5351
  if (prior?.bundle) {
5207
5352
  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");
5353
+ return fromSource(served(normalizeBundle(prior.bundle, options), cacheConfirmed), "cache", res.error ?? "no usable response");
5209
5354
  }
5210
5355
  if (options.rules) {
5211
5356
  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");
5357
+ return fromSource(local(normalizeBundle(options.rules, options)), "bundled", res.error ?? "no usable response");
5213
5358
  }
5214
5359
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
5215
5360
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
5216
5361
  }
5217
5362
  if (options.token) {
5363
+ const identity = buildIdentity(options);
5364
+ const remote = (bundle) => withScopedDryRun(
5365
+ bundle,
5366
+ null,
5367
+ "the token-authenticated rules service did not corroborate this map",
5368
+ options
5369
+ );
5370
+ const local = (bundle) => options.trustLocalRuleScope === true ? bundle : withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options);
5218
5371
  const prior = await store.read();
5219
5372
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
5220
5373
  const res = await client.getRules();
5221
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
5374
+ if (res.success && res.notModified && prior?.bundle) {
5375
+ return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache");
5376
+ }
5222
5377
  if (res.success && !res.notModified) {
5223
5378
  const rejected = liveUpdateRejections(res, options);
5224
5379
  if (rejected.length > 0) {
5225
5380
  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");
5381
+ if (prior?.bundle) return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", "update rejected");
5382
+ if (options.rules) return fromSource(local(normalizeBundle(options.rules, options)), "bundled", "update rejected");
5228
5383
  return fromSource(emptyBundle(), "empty", "update rejected");
5229
5384
  }
5230
5385
  const bundle = normalizeBundle(res, options);
5231
5386
  await store.write({ bundle, etag: res.etag ?? null });
5232
- return fromSource(bundle, "api");
5387
+ return fromSource(remote(bundle), "api");
5233
5388
  }
5234
5389
  if (prior?.bundle) {
5235
5390
  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");
5391
+ return fromSource(remote(normalizeBundle(prior.bundle, options)), "cache", res.error ?? "no usable response");
5237
5392
  }
5238
5393
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
5239
5394
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
5240
5395
  }
5241
5396
  if (options.rules) {
5242
- return fromSource(normalizeBundle(options.rules, options), "bundled");
5397
+ const identity = buildIdentity(options);
5398
+ const bundle = normalizeBundle(options.rules, options);
5399
+ if (options.trustLocalRuleScope === true) return fromSource(bundle, "bundled");
5400
+ return fromSource(
5401
+ withScopedDryRun(bundle, identity.id, identity.reason ?? "these rules name a different map", options),
5402
+ "bundled"
5403
+ );
5243
5404
  }
5244
5405
  return fromSource(emptyBundle(), "empty");
5245
5406
  }