@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.
@@ -1,6 +1,6 @@
1
1
  // Patchstack runtime protection — GENERIC guard (CommonJS). Managed by `patchstack-connect protect`.
2
2
  // Wire whichever helper fits your server into your request path, then run `protect --check`.
3
- const { createProtection } = require("@patchstack/connect/protect");
3
+ const { createProtection, sentinelAnswer, VERIFY_HEADER } = require("@patchstack/connect/protect");
4
4
  // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
5
5
  // throw here is the app failing to boot rather than protection failing open — and a rules file can be
6
6
  // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
@@ -66,6 +66,12 @@ function psStepAside(err) {
66
66
  }
67
67
  function protectFetch(handler) {
68
68
  return async (request, ...rest) => {
69
+ // `protect --check --runtime` asks whether a request actually reaches this seam. The answer is
70
+ // derived from a challenge the verifying process mints per run, which rules out an app matching it
71
+ // by accident, and there is nothing to answer unless that process started this one. Without a
72
+ // challenge in the environment this is a header read.
73
+ const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER));
74
+ if (answered) return new Response(answered, { status: 200, headers: { "content-type": "text/plain" } });
69
75
  const active = await getProtection().catch(psStepAside);
70
76
  // The handler, and nothing around it: an exception the app throws is the app's, not a protection
71
77
  // failure, and must not be read as one or answered twice.
@@ -92,19 +98,37 @@ function patchstackMiddleware(req, res, next) {
92
98
  passedOn = true;
93
99
  next(err);
94
100
  };
95
- getProtection().then(
96
- (active) => active.node()(req, res, carryOn),
97
- (err) => {
101
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
102
+ // before the protection is asked for and before the request is passed on, so no handler of the app's
103
+ // ever sees a verification request. The answer is derived from a challenge the verifying process mints
104
+ // per run, which rules out an app matching it by accident; without a challenge in the environment
105
+ // there is nothing to answer and the request is screened as normal.
106
+ const screen = () => {
107
+ getProtection().then(
108
+ (active) => active.node()(req, res, carryOn),
109
+ (err) => {
110
+ psStepAside(err);
111
+ carryOn();
112
+ },
113
+ ).catch((err) => {
114
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
115
+ // request is carried on only if it never was — an error here must not take the process down and
116
+ // must not answer twice.
98
117
  psStepAside(err);
99
118
  carryOn();
100
- },
101
- ).catch((err) => {
102
- // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
103
- // request is carried on only if it never was — an error here must not take the process down and
104
- // must not answer twice.
105
- psStepAside(err);
106
- carryOn();
107
- });
119
+ });
120
+ };
121
+
122
+ sentinelAnswer(req.headers?.[VERIFY_HEADER]).then((answered) => {
123
+ if (answered) {
124
+ res.statusCode = 200;
125
+ res.setHeader("content-type", "text/plain");
126
+ res.end(answered);
127
+
128
+ return;
129
+ }
130
+ screen();
131
+ }, screen);
108
132
  }
109
133
 
110
134
  module.exports = { getProtection, protectFetch, patchstackMiddleware };
@@ -1,7 +1,7 @@
1
1
  // Patchstack runtime protection — GENERIC guard (ESM). Managed by `patchstack-connect protect`.
2
2
  // Wire whichever helper fits your server into your request path, then run `protect --check`.
3
3
  import { readFileSync } from "node:fs";
4
- import { createProtection } from "@patchstack/connect/protect";
4
+ import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
5
5
 
6
6
  // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
7
7
  // throw here is the app failing to boot rather than protection failing open — and a rules file can be
@@ -68,6 +68,12 @@ function psStepAside(err) {
68
68
  // Web-Fetch: export default { fetch: protectFetch(originalFetch) }
69
69
  export function protectFetch(handler) {
70
70
  return async (request, ...rest) => {
71
+ // `protect --check --runtime` asks whether a request actually reaches this seam. The answer is
72
+ // derived from a challenge the verifying process mints per run, which rules out an app matching it
73
+ // by accident, and there is nothing to answer unless that process started this one. Without a
74
+ // challenge in the environment this is a header read.
75
+ const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER));
76
+ if (answered) return new Response(answered, { status: 200, headers: { "content-type": "text/plain" } });
71
77
  const active = await getProtection().catch(psStepAside);
72
78
  // The handler, and nothing around it: an exception the app throws is the app's, not a protection
73
79
  // failure, and must not be read as one or answered twice.
@@ -94,17 +100,35 @@ export function patchstackMiddleware(req, res, next) {
94
100
  passedOn = true;
95
101
  next(err);
96
102
  };
97
- getProtection().then(
98
- (active) => active.node()(req, res, carryOn),
99
- (err) => {
103
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
104
+ // before the protection is asked for and before the request is passed on, so no handler of the app's
105
+ // ever sees a verification request. The answer is derived from a challenge the verifying process mints
106
+ // per run, which rules out an app matching it by accident; without a challenge in the environment
107
+ // there is nothing to answer and the request is screened as normal.
108
+ const screen = () => {
109
+ getProtection().then(
110
+ (active) => active.node()(req, res, carryOn),
111
+ (err) => {
112
+ psStepAside(err);
113
+ carryOn();
114
+ },
115
+ ).catch((err) => {
116
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
117
+ // request is carried on only if it never was — an error here must not take the process down and
118
+ // must not answer twice.
100
119
  psStepAside(err);
101
120
  carryOn();
102
- },
103
- ).catch((err) => {
104
- // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
105
- // request is carried on only if it never was — an error here must not take the process down and
106
- // must not answer twice.
107
- psStepAside(err);
108
- carryOn();
109
- });
121
+ });
122
+ };
123
+
124
+ sentinelAnswer(req.headers?.[VERIFY_HEADER]).then((answered) => {
125
+ if (answered) {
126
+ res.statusCode = 200;
127
+ res.setHeader("content-type", "text/plain");
128
+ res.end(answered);
129
+
130
+ return;
131
+ }
132
+ screen();
133
+ }, screen);
110
134
  }
@@ -4,7 +4,7 @@
4
4
  // whichever helper below fits your server into your request path (see the plan the CLI printed),
5
5
  // then run `patchstack-connect protect --check` to confirm it's hooked up. The engine ships inside
6
6
  // @patchstack/connect — nothing else to install.
7
- import { createProtection } from "@patchstack/connect/protect";
7
+ import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
8
8
  import fallbackRules from "./rules.json";
9
9
 
10
10
  // Baked by `patchstack-connect protect` from .patchstackrc.json when available.
@@ -76,6 +76,12 @@ function psStepAside(err: unknown) {
76
76
  // Wrap your fetch handler: export default { fetch: protectFetch(originalFetch) }
77
77
  export function protectFetch<H extends (request: Request, ...rest: unknown[]) => unknown>(handler: H): H {
78
78
  return (async (request: Request, ...rest: unknown[]) => {
79
+ // `protect --check --runtime` asks whether a request actually reaches this seam. The answer is
80
+ // derived from a challenge the verifying process mints per run, which rules out an app matching it
81
+ // by accident, and there is nothing to answer unless that process started this one. Without a
82
+ // challenge in the environment this is a header read.
83
+ const answered = await sentinelAnswer(request.headers.get(VERIFY_HEADER));
84
+ if (answered) return new Response(answered, { status: 200, headers: { "content-type": "text/plain" } });
79
85
  const protection = await getProtection().catch(psStepAside);
80
86
  // The handler, and nothing around it: an exception the app throws is the app's, not a protection
81
87
  // failure, and must not be read as one or answered twice.
@@ -103,17 +109,40 @@ export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: un
103
109
  passedOn = true;
104
110
  next(err);
105
111
  };
106
- getProtection().then(
107
- (protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
108
- (err) => {
112
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
113
+ // before the protection is asked for and before the request is passed on, so no handler of the app's
114
+ // ever sees a verification request. The answer is derived from a challenge the verifying process mints
115
+ // per run, which rules out an app matching it by accident; without a challenge in the environment
116
+ // there is nothing to answer and the request is screened as normal.
117
+ const screen = () => {
118
+ getProtection().then(
119
+ (protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
120
+ (err) => {
121
+ psStepAside(err);
122
+ carryOn();
123
+ },
124
+ ).catch((err) => {
125
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
126
+ // request is carried on only if it never was — an error here must not take the process down and
127
+ // must not answer twice.
109
128
  psStepAside(err);
110
129
  carryOn();
111
- },
112
- ).catch((err) => {
113
- // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
114
- // request is carried on only if it never was — an error here must not take the process down and
115
- // must not answer twice.
116
- psStepAside(err);
117
- carryOn();
118
- });
130
+ });
131
+ };
132
+
133
+ // The two shapes this seam touches, named rather than asserted wholesale: a request whose headers it
134
+ // reads, and a response it answers on.
135
+ const inbound = req as { headers?: Record<string, unknown> };
136
+ const outbound = res as { statusCode: number; setHeader(name: string, value: string): void; end(body?: string): void };
137
+
138
+ sentinelAnswer(inbound.headers?.[VERIFY_HEADER]).then((answered) => {
139
+ if (answered) {
140
+ outbound.statusCode = 200;
141
+ outbound.setHeader("content-type", "text/plain");
142
+ outbound.end(answered);
143
+
144
+ return;
145
+ }
146
+ screen();
147
+ }, screen);
119
148
  }
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";
@@ -1845,9 +1867,11 @@ var runtime_exports = {};
1845
1867
  __export(runtime_exports, {
1846
1868
  GUARD_PATH: () => GUARD_PATH,
1847
1869
  PulseRuleClient: () => PulseRuleClient,
1870
+ VERIFY_HEADER: () => VERIFY_HEADER,
1848
1871
  createProtection: () => createProtection,
1849
1872
  createServerFnGuard: () => createServerFnGuard,
1850
- createSupabaseGuard: () => createSupabaseGuard
1873
+ createSupabaseGuard: () => createSupabaseGuard,
1874
+ sentinelAnswer: () => sentinelAnswer
1851
1875
  });
1852
1876
  module.exports = __toCommonJS(runtime_exports);
1853
1877
 
@@ -3683,6 +3707,18 @@ var RULE_PROPERTY_SHAPES = Object.freeze({
3683
3707
  });
3684
3708
  var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
3685
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
+ });
3686
3722
  var RULE_PROPERTIES = Object.freeze([
3687
3723
  "id",
3688
3724
  "rule_id",
@@ -3702,12 +3738,13 @@ var RULE_PROPERTIES = Object.freeze([
3702
3738
  "remove_headers",
3703
3739
  "cookie_flags",
3704
3740
  "ensure",
3705
- "capture"
3741
+ "capture",
3742
+ BUILD_SCOPE_PROPERTY
3706
3743
  ]);
3707
3744
  var CAPTURE_VERSION = 1;
3708
3745
  var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
3709
3746
  var CAPTURE_RAW_CHARS_MAX = 512;
3710
- var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
3747
+ var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture", BUILD_SCOPE_PROPERTY]);
3711
3748
  function captureProblem(capture) {
3712
3749
  if (capture === void 0 || capture === null) return null;
3713
3750
  if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
@@ -3948,6 +3985,10 @@ function validateBundle(bundle, opts = {}) {
3948
3985
  rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
3949
3986
  continue;
3950
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
+ }
3951
3992
  const reason = conditionsProblem(wl?.rule_v2);
3952
3993
  if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
3953
3994
  else whitelists.push(wl);
@@ -4218,8 +4259,11 @@ function asText(value) {
4218
4259
 
4219
4260
  // src/protect/engine/pulse-client.js
4220
4261
  init_pulse_token();
4262
+ init_build_id();
4221
4263
  var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
4222
4264
  var DEFAULT_CACHE_TTL2 = 3e5;
4265
+ var BUILD_VERDICT_HEADER = "X-Patchstack-Build-Match";
4266
+ var BUILD_IDENTITY_HEADER = "X-Patchstack-Build-ID";
4223
4267
  var JITTER_FRACTION2 = 0.1;
4224
4268
  var PulseRuleClient = class {
4225
4269
  #siteUuid;
@@ -4232,7 +4276,8 @@ var PulseRuleClient = class {
4232
4276
  #etag;
4233
4277
  #pulseAuth;
4234
4278
  #detectionState;
4235
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) {
4279
+ #buildId;
4280
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState, buildId } = {}) {
4236
4281
  this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
4237
4282
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
4238
4283
  this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
@@ -4240,6 +4285,7 @@ var PulseRuleClient = class {
4240
4285
  this.#etag = etag ?? null;
4241
4286
  this.#pulseAuth = pulseAuth ?? null;
4242
4287
  this.#detectionState = typeof detectionState === "string" ? detectionState : null;
4288
+ this.#buildId = canonicalBuildId(buildId);
4243
4289
  if (!this.#siteUuid) {
4244
4290
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
4245
4291
  }
@@ -4259,11 +4305,23 @@ var PulseRuleClient = class {
4259
4305
  if (this.#detectionState !== null && typeof auth.Authorization === "string") {
4260
4306
  headers["X-Patchstack-Detections"] = this.#detectionState;
4261
4307
  }
4308
+ if (this.#buildId !== null && typeof auth.Authorization === "string") {
4309
+ headers["X-Patchstack-Build"] = this.#buildId;
4310
+ }
4262
4311
  if (this.#etag) headers["If-None-Match"] = this.#etag;
4263
4312
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
4264
4313
  if (response.status === 304) {
4265
4314
  this.#touch(now);
4266
- 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;
4267
4325
  }
4268
4326
  if (!response.ok) {
4269
4327
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
@@ -4279,7 +4337,10 @@ var PulseRuleClient = class {
4279
4337
  firewall: data.firewall,
4280
4338
  whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
4281
4339
  whitelist_keys: data.whitelist_keys ?? {},
4282
- ...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)
4283
4344
  };
4284
4345
  this.#cache = result;
4285
4346
  this.#etag = result.etag;
@@ -4305,6 +4366,13 @@ var PulseRuleClient = class {
4305
4366
  this.#etag = null;
4306
4367
  }
4307
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
+ }
4308
4376
  function enforcementField(data) {
4309
4377
  if (!data || typeof data !== "object") return {};
4310
4378
  const v = data.enforcement ?? data.mode;
@@ -5067,6 +5135,7 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
5067
5135
  }
5068
5136
 
5069
5137
  // src/protect/rules/store.js
5138
+ init_build_id();
5070
5139
  var fsMod;
5071
5140
  async function loadFs() {
5072
5141
  if (fsMod !== void 0) return fsMod;
@@ -5147,15 +5216,25 @@ async function cacheRead(dir) {
5147
5216
  function toEnvelope(value) {
5148
5217
  if (!value || typeof value !== "object") return null;
5149
5218
  if (value.bundle && typeof value.bundle === "object") {
5150
- 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
+ };
5151
5229
  }
5152
5230
  if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
5153
- return { bundle: value, etag: null };
5231
+ return { bundle: value, etag: null, buildId: null, matchedBuildId: null };
5154
5232
  }
5155
5233
  return null;
5156
5234
  }
5157
5235
 
5158
5236
  // src/protect/rules/source.js
5237
+ init_build_id();
5159
5238
  function liveUpdateRejections(res, options) {
5160
5239
  if (options.acceptPartialBundle) return [];
5161
5240
  const { rejected } = validateBundle(
@@ -5182,62 +5261,146 @@ function fromSource(bundle, origin, reason) {
5182
5261
  source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
5183
5262
  };
5184
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
+ }
5185
5308
  async function resolveRules(options, store, ctx = {}) {
5186
5309
  const timeoutMs = ctx.timeoutMs;
5187
5310
  if (options.siteUuid) {
5188
5311
  const prior = await store.read();
5189
- 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
+ });
5190
5327
  const res = await client.getRules();
5191
- 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
+ }
5192
5338
  if (res.success && !res.notModified) {
5339
+ const confirmed = confirmedBy(res, identity.id);
5193
5340
  const rejected = liveUpdateRejections(res, options);
5194
5341
  if (rejected.length > 0) {
5195
5342
  reportRejections(rejected, options, "rule update rejected");
5196
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
5197
- 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");
5198
5345
  return fromSource(emptyBundle(), "empty", "update rejected");
5199
5346
  }
5200
5347
  const bundle = normalizeBundle(res, options);
5201
- await store.write({ bundle, etag: res.etag ?? null });
5202
- 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");
5203
5350
  }
5204
5351
  if (prior?.bundle) {
5205
5352
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
5206
- 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");
5207
5354
  }
5208
5355
  if (options.rules) {
5209
5356
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
5210
- 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");
5211
5358
  }
5212
5359
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
5213
5360
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
5214
5361
  }
5215
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);
5216
5371
  const prior = await store.read();
5217
5372
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
5218
5373
  const res = await client.getRules();
5219
- 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
+ }
5220
5377
  if (res.success && !res.notModified) {
5221
5378
  const rejected = liveUpdateRejections(res, options);
5222
5379
  if (rejected.length > 0) {
5223
5380
  reportRejections(rejected, options, "rule update rejected");
5224
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
5225
- 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");
5226
5383
  return fromSource(emptyBundle(), "empty", "update rejected");
5227
5384
  }
5228
5385
  const bundle = normalizeBundle(res, options);
5229
5386
  await store.write({ bundle, etag: res.etag ?? null });
5230
- return fromSource(bundle, "api");
5387
+ return fromSource(remote(bundle), "api");
5231
5388
  }
5232
5389
  if (prior?.bundle) {
5233
5390
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
5234
- 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");
5235
5392
  }
5236
5393
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
5237
5394
  return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
5238
5395
  }
5239
5396
  if (options.rules) {
5240
- 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
+ );
5241
5404
  }
5242
5405
  return fromSource(emptyBundle(), "empty");
5243
5406
  }
@@ -6330,6 +6493,33 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
6330
6493
  };
6331
6494
  }
6332
6495
 
6496
+ // src/protect/verify-sentinel.js
6497
+ var VERIFY_HEADER = "x-patchstack-verify";
6498
+ var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
6499
+ function configuredChallenge() {
6500
+ const value = typeof process !== "undefined" ? process.env?.[CHALLENGE] : void 0;
6501
+ return typeof value === "string" && value.length >= 32 ? value : null;
6502
+ }
6503
+ function sameValue(a, b) {
6504
+ if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
6505
+ let differs = 0;
6506
+ for (let i = 0; i < a.length; i += 1) differs |= a.charCodeAt(i) ^ b.charCodeAt(i);
6507
+ return differs === 0;
6508
+ }
6509
+ async function sentinelAnswer(offered) {
6510
+ const challenge = configuredChallenge();
6511
+ if (challenge === null || !sameValue(challenge, typeof offered === "string" ? offered : null)) return null;
6512
+ const subtle = globalThis.crypto?.subtle;
6513
+ if (!subtle || typeof subtle.digest !== "function" || typeof TextEncoder === "undefined") return null;
6514
+ try {
6515
+ const bytes = new TextEncoder().encode(`patchstack-verify:${challenge}`);
6516
+ const digest = await subtle.digest("SHA-256", bytes);
6517
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
6518
+ } catch {
6519
+ return null;
6520
+ }
6521
+ }
6522
+
6333
6523
  // src/protect/runtime.js
6334
6524
  function createServerFnGuard({ protection }) {
6335
6525
  const guard = protection.fetchGuard();
@@ -7667,8 +7857,10 @@ function nodeRequestMeta(req, client) {
7667
7857
  0 && (module.exports = {
7668
7858
  GUARD_PATH,
7669
7859
  PulseRuleClient,
7860
+ VERIFY_HEADER,
7670
7861
  createProtection,
7671
7862
  createServerFnGuard,
7672
- createSupabaseGuard
7863
+ createSupabaseGuard,
7864
+ sentinelAnswer
7673
7865
  });
7674
7866
  //# sourceMappingURL=protect.cjs.map