@farthershore/backend 0.19.0 → 0.21.0

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,8 +1,13 @@
1
1
  import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
- var __esm = (fn, res) => function __init() {
5
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
4
+ var __esm = (fn, res, err) => function __init() {
5
+ if (err) throw err[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err = [e], e;
10
+ }
6
11
  };
7
12
  var __export = (target, all) => {
8
13
  for (var name in all)
@@ -189,20 +194,6 @@ var init_reconcile = __esm({
189
194
  import { generateKeyPairSync, randomBytes } from "node:crypto";
190
195
 
191
196
  // src/generated/runtime-contract.ts
192
- var RUNTIME_BODY_HASH_CONTRACT = {
193
- algorithm: "SHA-256",
194
- encoding: "hex-lower",
195
- source: "raw-request-bytes",
196
- emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
197
- maxBodyBytes: 10485760,
198
- streamingExemptToken: "STREAM",
199
- streamingExemptContentTypes: [
200
- "text/event-stream",
201
- "application/octet-stream",
202
- "multipart/form-data"
203
- ],
204
- overMaxStatus: 413
205
- };
206
197
  var RUNTIME_ERROR_CODES = {
207
198
  missingSignature: "missing_signature",
208
199
  malformedSignature: "malformed_signature",
@@ -223,6 +214,20 @@ var RUNTIME_ERROR_CODES = {
223
214
  serviceSubjectRequired: "service_subject_required",
224
215
  surfaceNotAllowed: "surface_not_allowed"
225
216
  };
217
+ var RUNTIME_BODY_HASH_CONTRACT = {
218
+ algorithm: "SHA-256",
219
+ encoding: "hex-lower",
220
+ source: "raw-request-bytes",
221
+ emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
222
+ maxBodyBytes: 10485760,
223
+ streamingExemptToken: "STREAM",
224
+ streamingExemptContentTypes: [
225
+ "text/event-stream",
226
+ "application/octet-stream",
227
+ "multipart/form-data"
228
+ ],
229
+ overMaxStatus: 413
230
+ };
226
231
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
227
232
  headers: {
228
233
  payload: "x-fs-metering",
@@ -243,14 +248,18 @@ var RUNTIME_RESPONSE_METERING_CONTRACT = {
243
248
  payload: {
244
249
  method: "string",
245
250
  path: "string",
246
- rawDimsUnits: "Record<string, number>",
251
+ rawDimsUnits: "Record<string, number>?",
247
252
  measureContext: "Record<string, unknown>?",
248
- creditUnitsConsumed: "Record<string, number>?"
253
+ creditUnitsConsumed: "Record<string, number>?",
254
+ measurementsVersion: "1?",
255
+ measurements: "Array<{ meter: string; values: Record<string, number>; dims?: Record<string, string> }>?",
256
+ quote: "{ currency: string; amountNanos: string }?"
249
257
  },
250
258
  errors: {
251
259
  missingToken: "missing_token",
252
260
  invalidMeterKey: "invalid_meter_key",
253
- invalidMeterValue: "invalid_meter_value"
261
+ invalidMeterValue: "invalid_meter_value",
262
+ invalidQuote: "invalid_quote"
254
263
  },
255
264
  httpAdapter: {
256
265
  input: "Request",
@@ -452,16 +461,106 @@ function statusForCode(code) {
452
461
  return 401;
453
462
  }
454
463
 
464
+ // src/core/deadline.ts
465
+ var DEADLINE_MS = {
466
+ /** Boot-blocking; generous because it runs once and gates startup. */
467
+ bootstrap: 1e4,
468
+ /** On the inbound verification path — must not hold a request open. */
469
+ jwks: 5e3,
470
+ /** Background economic report, retried by the caller. */
471
+ metering: 1e4,
472
+ /** Background attested usage callback. */
473
+ postStreamUsage: 1e4,
474
+ /** Best-effort heartbeat; never blocks anything. */
475
+ health: 5e3,
476
+ /** Boot-time route drift report; fail-open at the caller. */
477
+ report: 1e4
478
+ };
479
+ var MAX_RESPONSE_BYTES = 1048576;
480
+ var ResponseTooLargeError = class extends Error {
481
+ constructor(limit) {
482
+ super(`response body exceeded ${limit} bytes and was cancelled`);
483
+ this.name = "ResponseTooLargeError";
484
+ }
485
+ };
486
+ var DeadlineExceededError = class extends Error {
487
+ operation;
488
+ constructor(operation, timeoutMs) {
489
+ super(`${operation} exceeded its ${timeoutMs}ms deadline`);
490
+ this.name = "TimeoutError";
491
+ this.operation = operation;
492
+ }
493
+ };
494
+ async function fetchWithDeadline(fetchImpl, input, init, operation, options = {}) {
495
+ const timeoutMs = options.timeoutMs ?? DEADLINE_MS[operation];
496
+ const timeout = AbortSignal.timeout(timeoutMs);
497
+ const signal = options.callerSignal ? AbortSignal.any([options.callerSignal, timeout]) : timeout;
498
+ try {
499
+ return await fetchImpl(input, { ...init, signal });
500
+ } catch (cause) {
501
+ if (options.callerSignal?.aborted) throw cause;
502
+ if (timeout.aborted) throw new DeadlineExceededError(operation, timeoutMs);
503
+ throw cause;
504
+ }
505
+ }
506
+ async function readBoundedText(response, limit = MAX_RESPONSE_BYTES) {
507
+ const body = response.body;
508
+ if (!body) {
509
+ const text = await response.text();
510
+ if (byteLength(text) > limit) throw new ResponseTooLargeError(limit);
511
+ return text;
512
+ }
513
+ const reader = body.getReader();
514
+ const chunks = [];
515
+ let total = 0;
516
+ try {
517
+ for (; ; ) {
518
+ const { done, value } = await reader.read();
519
+ if (done) break;
520
+ if (!value) continue;
521
+ total += value.byteLength;
522
+ if (total > limit) {
523
+ await reader.cancel();
524
+ throw new ResponseTooLargeError(limit);
525
+ }
526
+ chunks.push(value);
527
+ }
528
+ } finally {
529
+ reader.releaseLock();
530
+ }
531
+ const joined = new Uint8Array(total);
532
+ let offset = 0;
533
+ for (const chunk of chunks) {
534
+ joined.set(chunk, offset);
535
+ offset += chunk.byteLength;
536
+ }
537
+ return new TextDecoder().decode(joined);
538
+ }
539
+ async function readBoundedJson(response, limit = MAX_RESPONSE_BYTES) {
540
+ return JSON.parse(await readBoundedText(response, limit));
541
+ }
542
+ function byteLength(text) {
543
+ return new TextEncoder().encode(text).byteLength;
544
+ }
545
+
455
546
  // src/core/jwks.ts
456
547
  var DEFAULT_CACHE_TTL_MS = 5 * 6e4;
548
+ var DEFAULT_HARD_STALE_MS = 15 * 6e4;
457
549
  var DEFAULT_NEGATIVE_CACHE_MS = 3e4;
550
+ function finiteMsOr(value, fallback) {
551
+ if (value === void 0) return fallback;
552
+ if (!Number.isFinite(value) || value < 0) return fallback;
553
+ return value;
554
+ }
458
555
  var MAX_NEGATIVE_KIDS = 1e3;
459
556
  var JwksClient = class {
460
557
  jwksUrl;
461
558
  fetchImpl;
462
559
  cacheTtlMs;
560
+ hardStaleMs;
463
561
  negativeCacheMs;
464
562
  now;
563
+ onObservation;
465
564
  keysByKid = /* @__PURE__ */ new Map();
466
565
  fetchedAt = 0;
467
566
  hasFetchedOnce = false;
@@ -470,21 +569,34 @@ var JwksClient = class {
470
569
  constructor(options) {
471
570
  this.jwksUrl = options.jwksUrl;
472
571
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
473
- this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
572
+ this.cacheTtlMs = finiteMsOr(options.cacheTtlMs, DEFAULT_CACHE_TTL_MS);
573
+ this.hardStaleMs = Math.max(
574
+ finiteMsOr(options.hardStaleMs, DEFAULT_HARD_STALE_MS),
575
+ this.cacheTtlMs
576
+ );
474
577
  this.negativeCacheMs = options.negativeCacheMs ?? DEFAULT_NEGATIVE_CACHE_MS;
475
578
  this.now = options.now ?? (() => Date.now());
579
+ this.onObservation = options.onObservation;
476
580
  }
477
581
  /**
478
582
  * Resolve a public JWK for `kid`, fail-closed. Throws FartherShoreError with
479
- * `jwks_unavailable` (cold cache + fetch failed) or `unknown_key_id`.
583
+ * `jwks_unavailable` (cold cache, or a hard-stale cache whose refresh is
584
+ * failing) or `unknown_key_id`.
480
585
  */
481
586
  async getKey(kid) {
482
587
  const cached = this.keysByKid.get(kid);
483
- if (cached && !this.isStale()) return cached;
588
+ if (cached && !this.isStale()) {
589
+ this.observe("fresh", kid);
590
+ return cached;
591
+ }
484
592
  const negAt = this.negativeKids.get(kid);
485
593
  if (negAt !== void 0 && this.now() - negAt < this.negativeCacheMs) {
486
594
  const warm = this.keysByKid.get(kid);
487
- if (warm) return warm;
595
+ if (warm) {
596
+ this.assertWithinHardStale();
597
+ this.observe(this.cacheState(), kid);
598
+ return warm;
599
+ }
488
600
  throw new FartherShoreError(
489
601
  "unknown_key_id",
490
602
  `signing key '${kid}' is not present in the JWKS`
@@ -494,6 +606,7 @@ var JwksClient = class {
494
606
  const key2 = this.keysByKid.get(kid);
495
607
  if (key2) {
496
608
  this.negativeKids.delete(kid);
609
+ this.observe(this.cacheState(), kid);
497
610
  return key2;
498
611
  }
499
612
  this.rememberMissingKid(kid);
@@ -513,8 +626,37 @@ var JwksClient = class {
513
626
  }
514
627
  this.negativeKids.set(kid, this.now());
515
628
  }
629
+ ageMs() {
630
+ return this.now() - this.fetchedAt;
631
+ }
516
632
  isStale() {
517
- return this.now() - this.fetchedAt >= this.cacheTtlMs;
633
+ return this.ageMs() >= this.cacheTtlMs;
634
+ }
635
+ isHardStale() {
636
+ return this.ageMs() >= this.hardStaleMs;
637
+ }
638
+ /** Current freshness of the cached key set. */
639
+ cacheState() {
640
+ if (!this.hasFetchedOnce) return "cold";
641
+ if (this.isHardStale()) return "hard_stale";
642
+ if (this.isStale()) return "soft_stale";
643
+ return "fresh";
644
+ }
645
+ observe(state, kid) {
646
+ this.onObservation?.({
647
+ state,
648
+ ageMs: this.hasFetchedOnce ? this.ageMs() : 0,
649
+ ...kid !== void 0 ? { kid } : {}
650
+ });
651
+ }
652
+ /** Fail closed when the cached key set is past the hard-stale ceiling. */
653
+ assertWithinHardStale() {
654
+ if (!this.isHardStale()) return;
655
+ this.observe("hard_stale");
656
+ throw new FartherShoreError(
657
+ "jwks_unavailable",
658
+ `JWKS key set is ${Math.round(this.ageMs() / 1e3)}s old, past the ${Math.round(this.hardStaleMs / 1e3)}s hard-stale limit, and cannot be refreshed; refusing to vouch for keys that may have been revoked`
659
+ );
518
660
  }
519
661
  /** Single-flight refresh: concurrent callers share one fetch. */
520
662
  async refresh() {
@@ -527,24 +669,27 @@ var JwksClient = class {
527
669
  async doFetch() {
528
670
  let response;
529
671
  try {
530
- response = await this.fetchImpl(this.jwksUrl, {
531
- headers: { accept: "application/json" }
532
- });
672
+ response = await fetchWithDeadline(
673
+ this.fetchImpl,
674
+ this.jwksUrl,
675
+ { headers: { accept: "application/json" } },
676
+ "jwks"
677
+ );
533
678
  } catch (cause) {
534
- this.failOnColdCache(cause);
679
+ this.handleRefreshFailure(cause);
535
680
  return;
536
681
  }
537
682
  if (!response.ok) {
538
- this.failOnColdCache(
683
+ this.handleRefreshFailure(
539
684
  new Error(`JWKS endpoint returned HTTP ${response.status}`)
540
685
  );
541
686
  return;
542
687
  }
543
688
  let doc;
544
689
  try {
545
- doc = await response.json();
690
+ doc = await readBoundedJson(response);
546
691
  } catch (cause) {
547
- this.failOnColdCache(cause);
692
+ this.handleRefreshFailure(cause);
548
693
  return;
549
694
  }
550
695
  const next = /* @__PURE__ */ new Map();
@@ -557,15 +702,27 @@ var JwksClient = class {
557
702
  this.negativeKids.clear();
558
703
  }
559
704
  /**
560
- * Stale-while-revalidate: with a warm cache, swallow the refresh failure and
561
- * keep serving the last-known keys. With a COLD cache, fail closed.
705
+ * BOUNDED stale-while-revalidate. A COLD cache fails closed. A warm cache
706
+ * inside the soft window swallows the failure and keeps serving. Past the
707
+ * hard-stale ceiling it fails closed too — availability is worth a bounded
708
+ * window of degraded trust, not an unbounded one.
562
709
  */
563
- failOnColdCache(cause) {
564
- if (this.hasFetchedOnce) return;
565
- throw new FartherShoreError(
566
- "jwks_unavailable",
567
- `JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
568
- );
710
+ handleRefreshFailure(cause) {
711
+ if (!this.hasFetchedOnce) {
712
+ this.observe("cold");
713
+ throw new FartherShoreError(
714
+ "jwks_unavailable",
715
+ `JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
716
+ );
717
+ }
718
+ if (this.isHardStale()) {
719
+ this.observe("hard_stale");
720
+ throw new FartherShoreError(
721
+ "jwks_unavailable",
722
+ `JWKS refresh failed and the cached key set is ${Math.round(this.ageMs() / 1e3)}s old, past the ${Math.round(this.hardStaleMs / 1e3)}s hard-stale limit: ${stringifyCause(cause)}`
723
+ );
724
+ }
725
+ this.observe("soft_stale");
569
726
  }
570
727
  };
571
728
  function stringifyCause(cause) {
@@ -968,6 +1125,7 @@ import { dirname as dirname2 } from "node:path";
968
1125
  // src/core/bootstrap.ts
969
1126
  var BOOTSTRAP_PATH = "/v1/runtime/bootstrap";
970
1127
  var DEFAULT_MIN_REFRESH_SECONDS = 30;
1128
+ var DEFAULT_MAX_STALE_SECONDS = 300;
971
1129
  var BootstrapClient = class {
972
1130
  runtimeToken;
973
1131
  endpoint;
@@ -975,6 +1133,7 @@ var BootstrapClient = class {
975
1133
  fetchImpl;
976
1134
  now;
977
1135
  minRefreshSeconds;
1136
+ maxStaleMs;
978
1137
  cached = null;
979
1138
  fetchedAt = 0;
980
1139
  refreshAfterMs = 0;
@@ -998,6 +1157,7 @@ var BootstrapClient = class {
998
1157
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
999
1158
  this.now = options.now ?? (() => Date.now());
1000
1159
  this.minRefreshSeconds = options.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS;
1160
+ this.maxStaleMs = (options.maxStaleSeconds ?? DEFAULT_MAX_STALE_SECONDS) * 1e3;
1001
1161
  }
1002
1162
  /** Cached config when fresh; otherwise refreshes. */
1003
1163
  async get() {
@@ -1019,20 +1179,37 @@ var BootstrapClient = class {
1019
1179
  isStale() {
1020
1180
  return this.now() - this.fetchedAt >= this.refreshAfterMs;
1021
1181
  }
1182
+ isHardStale() {
1183
+ return this.now() - this.fetchedAt >= this.maxStaleMs;
1184
+ }
1185
+ cachedOrThrowOnHardStale(reason) {
1186
+ if (this.cached && !this.isHardStale()) return this.cached;
1187
+ throw new FartherShoreError(
1188
+ "jwks_unavailable",
1189
+ `bootstrap refresh failed with stale cached authorization metadata: ${reason}`
1190
+ );
1191
+ }
1022
1192
  async doBootstrap() {
1023
1193
  let response;
1024
1194
  try {
1025
- response = await this.fetchImpl(this.endpoint, {
1026
- method: "POST",
1027
- headers: {
1028
- authorization: `Bearer ${this.runtimeToken}`,
1029
- "content-type": "application/json",
1030
- accept: "application/json"
1195
+ response = await fetchWithDeadline(
1196
+ this.fetchImpl,
1197
+ this.endpoint,
1198
+ {
1199
+ method: "POST",
1200
+ headers: {
1201
+ authorization: `Bearer ${this.runtimeToken}`,
1202
+ "content-type": "application/json",
1203
+ accept: "application/json"
1204
+ },
1205
+ body: JSON.stringify(this.request)
1031
1206
  },
1032
- body: JSON.stringify(this.request)
1033
- });
1207
+ "bootstrap"
1208
+ );
1034
1209
  } catch (cause) {
1035
- if (this.cached) return this.cached;
1210
+ if (this.cached) {
1211
+ return this.cachedOrThrowOnHardStale(stringify(cause));
1212
+ }
1036
1213
  throw new FartherShoreError(
1037
1214
  "jwks_unavailable",
1038
1215
  `bootstrap request failed: ${stringify(cause)}`
@@ -1045,13 +1222,15 @@ var BootstrapClient = class {
1045
1222
  );
1046
1223
  }
1047
1224
  if (!response.ok) {
1048
- if (this.cached) return this.cached;
1225
+ if (this.cached) {
1226
+ return this.cachedOrThrowOnHardStale(`HTTP ${response.status}`);
1227
+ }
1049
1228
  throw new FartherShoreError(
1050
1229
  "jwks_unavailable",
1051
1230
  `bootstrap returned HTTP ${response.status}`
1052
1231
  );
1053
1232
  }
1054
- const body = await response.json();
1233
+ const body = await readBoundedJson(response);
1055
1234
  this.cached = body;
1056
1235
  this.fetchedAt = this.now();
1057
1236
  const refreshSeconds = Math.max(
@@ -1084,197 +1263,28 @@ async function reportHealth(options) {
1084
1263
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1085
1264
  const endpoint = `${options.coreUrl.replace(/\/+$/, "")}${HEALTH_PATH}`;
1086
1265
  try {
1087
- const response = await fetchImpl(endpoint, {
1088
- method: "POST",
1089
- headers: {
1090
- authorization: `Bearer ${options.runtimeToken}`,
1091
- "content-type": "application/json"
1266
+ const response = await fetchWithDeadline(
1267
+ fetchImpl,
1268
+ endpoint,
1269
+ {
1270
+ method: "POST",
1271
+ headers: {
1272
+ authorization: `Bearer ${options.runtimeToken}`,
1273
+ "content-type": "application/json"
1274
+ },
1275
+ body: JSON.stringify({
1276
+ status: options.status,
1277
+ ...options.instanceId ? { instanceId: options.instanceId } : {}
1278
+ })
1092
1279
  },
1093
- body: JSON.stringify({
1094
- status: options.status,
1095
- ...options.instanceId ? { instanceId: options.instanceId } : {}
1096
- })
1097
- });
1280
+ "health"
1281
+ );
1098
1282
  return response.ok;
1099
1283
  } catch {
1100
1284
  return false;
1101
1285
  }
1102
1286
  }
1103
1287
 
1104
- // src/core/backoff.ts
1105
- function computeBackoff(attempt, options) {
1106
- const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
1107
- const exponent = Math.max(0, attempt - 1);
1108
- const cap = Math.min(baseMs * 2 ** exponent, maxMs);
1109
- switch (jitter) {
1110
- case "none":
1111
- return cap;
1112
- case "full":
1113
- return random() * cap;
1114
- case "equal":
1115
- default:
1116
- return cap / 2 + random() * (cap / 2);
1117
- }
1118
- }
1119
-
1120
- // src/core/metering.ts
1121
- var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
1122
- var DEFAULT_BASE_DELAY_MS = 200;
1123
- var DEFAULT_MAX_DELAY_MS = 1e4;
1124
- function isTransientStatus(status) {
1125
- return status === 429 || status >= 500;
1126
- }
1127
- function retryAfterMs(headers) {
1128
- const raw = headers.get("retry-after");
1129
- if (raw === null) return null;
1130
- const trimmed = raw.trim();
1131
- if (!/^\d+$/.test(trimmed)) return null;
1132
- const secs = Number(trimmed);
1133
- return Number.isFinite(secs) ? secs * 1e3 : null;
1134
- }
1135
- var DEFAULT_MAX_RETRIES = 3;
1136
- var MeteringClient = class {
1137
- config;
1138
- endpoint;
1139
- businessId;
1140
- backendId;
1141
- fetchImpl;
1142
- maxRetries;
1143
- baseDelayMs;
1144
- maxDelayMs;
1145
- sleep;
1146
- random;
1147
- newId;
1148
- now;
1149
- buffer = [];
1150
- constructor(options) {
1151
- this.config = options.config;
1152
- this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
1153
- this.businessId = options.businessId;
1154
- this.backendId = options.backendId;
1155
- this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1156
- this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
1157
- this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
1158
- this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
1159
- this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1160
- this.random = options.random ?? Math.random;
1161
- this.newId = options.newId ?? (() => crypto.randomUUID());
1162
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1163
- }
1164
- /**
1165
- * Record `qty` of `meter`. Enforces meter-key shape, non-negative finite qty,
1166
- * the bootstrap allowedMeters/allowedRoutes scope, and the per-event sanity
1167
- * max, then enqueues and flushes (best-effort; failures stay buffered).
1168
- */
1169
- async meter(meter, qty, options = {}) {
1170
- if (!this.config.enabled) {
1171
- throw new FartherShoreError(
1172
- "invalid_token",
1173
- "metering is not enabled for this runtime token"
1174
- );
1175
- }
1176
- if (!METER_KEY_RE.test(meter)) {
1177
- throw new FartherShoreError(
1178
- "invalid_token",
1179
- `meter key '${meter}' must be lowercase alphanumeric with underscores`
1180
- );
1181
- }
1182
- if (!Number.isFinite(qty) || qty < 0) {
1183
- throw new FartherShoreError(
1184
- "invalid_token",
1185
- `meter '${meter}' qty must be a non-negative finite number`
1186
- );
1187
- }
1188
- if (this.config.allowedMeters.length > 0 && !this.config.allowedMeters.includes(meter)) {
1189
- throw new FartherShoreError(
1190
- "invalid_token",
1191
- `meter '${meter}' is not in the token's allowedMeters`
1192
- );
1193
- }
1194
- if (this.config.allowedRoutes.length > 0) {
1195
- if (!options.routeId) {
1196
- throw new FartherShoreError(
1197
- "invalid_token",
1198
- "routeId is required because this runtime token is route-scoped"
1199
- );
1200
- }
1201
- if (!this.config.allowedRoutes.includes(options.routeId)) {
1202
- throw new FartherShoreError(
1203
- "invalid_token",
1204
- `route '${options.routeId}' is not in the token's allowedRoutes`
1205
- );
1206
- }
1207
- }
1208
- if (this.config.perEventMax > 0 && qty > this.config.perEventMax) {
1209
- throw new FartherShoreError(
1210
- "invalid_token",
1211
- `meter '${meter}' qty ${qty} exceeds the per-event max ${this.config.perEventMax}`
1212
- );
1213
- }
1214
- const event = {
1215
- event_id: options.eventId ?? this.newId(),
1216
- business_id: this.businessId,
1217
- backend_id: this.backendId,
1218
- meter,
1219
- qty,
1220
- timestamp: options.timestamp ?? this.now().toISOString(),
1221
- ...options.routeId ? { route_id: options.routeId } : {},
1222
- ...options.requestId ? { request_id: options.requestId } : {},
1223
- ...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
1224
- };
1225
- this.buffer.push(event);
1226
- await this.flush();
1227
- }
1228
- /** Drain the buffer. Events that fail all retries stay buffered (at-least-once). */
1229
- async flush() {
1230
- const pending = this.buffer.splice(0, this.buffer.length);
1231
- const stillPending = [];
1232
- for (const event of pending) {
1233
- const sent = await this.sendWithRetry(event);
1234
- if (!sent) stillPending.push(event);
1235
- }
1236
- if (stillPending.length > 0) this.buffer.unshift(...stillPending);
1237
- }
1238
- /** Buffered-but-unsent count (observability/tests). */
1239
- get pending() {
1240
- return this.buffer.length;
1241
- }
1242
- async sendWithRetry(event) {
1243
- for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
1244
- let retryAfter = null;
1245
- try {
1246
- const response = await this.fetchImpl(this.endpoint, {
1247
- method: "POST",
1248
- headers: {
1249
- authorization: `Bearer ${this.config.credential}`,
1250
- "content-type": "application/json",
1251
- accept: "application/json"
1252
- },
1253
- body: JSON.stringify(event)
1254
- });
1255
- if (response.ok) return true;
1256
- if (!isTransientStatus(response.status)) return false;
1257
- retryAfter = retryAfterMs(response.headers);
1258
- } catch {
1259
- }
1260
- const isLast = attempt === this.maxRetries - 1;
1261
- if (isLast) break;
1262
- const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
1263
- baseMs: this.baseDelayMs,
1264
- maxMs: this.maxDelayMs,
1265
- random: this.random
1266
- });
1267
- await this.sleep(delay);
1268
- }
1269
- return false;
1270
- }
1271
- };
1272
- function resolveEndpoint(endpoint, coreUrl) {
1273
- if (/^https?:\/\//.test(endpoint)) return endpoint;
1274
- if (!coreUrl) return endpoint;
1275
- return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1276
- }
1277
-
1278
1288
  // src/response-metering.ts
1279
1289
  var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
1280
1290
  var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
@@ -1286,6 +1296,49 @@ var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
1286
1296
  var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
1287
1297
  var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
1288
1298
  var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
1299
+ var MeteringError = class extends Error {
1300
+ code;
1301
+ constructor(code, message) {
1302
+ super(message);
1303
+ this.name = "MeteringError";
1304
+ this.code = code;
1305
+ }
1306
+ };
1307
+ async function computeMeteringHeaders(payload, options = {}) {
1308
+ try {
1309
+ const token = resolveTokenSoft(options);
1310
+ if (!token) {
1311
+ skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
1312
+ return {};
1313
+ }
1314
+ const json2 = JSON.stringify(payload);
1315
+ const signature = await signPayload(json2, token);
1316
+ devMeteringHooks?.record?.(payload, options.requestId);
1317
+ return {
1318
+ [METERING_PAYLOAD_HEADER]: json2,
1319
+ [METERING_SIGNATURE_HEADER]: signature,
1320
+ [METERING_TOKEN_HEADER]: token
1321
+ };
1322
+ } catch (error) {
1323
+ skip(error instanceof Error ? error.message : String(error), options);
1324
+ return {};
1325
+ }
1326
+ }
1327
+ function skip(reason, options) {
1328
+ if (options.onSkip) {
1329
+ options.onSkip(reason);
1330
+ } else {
1331
+ console.warn(`metering headers skipped: ${reason}`);
1332
+ }
1333
+ devMeteringHooks?.onSkip?.(reason, options.requestId);
1334
+ }
1335
+ function resolveTokenSoft(options) {
1336
+ return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
1337
+ }
1338
+ function processEnv(key2) {
1339
+ const maybeProcess = globalThis.process;
1340
+ return maybeProcess?.env?.[key2];
1341
+ }
1289
1342
  async function signPayload(payload, token) {
1290
1343
  const key2 = await crypto.subtle.importKey(
1291
1344
  "raw",
@@ -1310,7 +1363,7 @@ function base64url(bytes) {
1310
1363
  }
1311
1364
 
1312
1365
  // src/core/post-stream-usage.ts
1313
- var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
1366
+ var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
1314
1367
  var PostStreamUsageClient = class {
1315
1368
  config;
1316
1369
  endpoint;
@@ -1319,14 +1372,16 @@ var PostStreamUsageClient = class {
1319
1372
  logger;
1320
1373
  sleep;
1321
1374
  retryDelaysMs;
1375
+ maxRetryDelayMs;
1322
1376
  constructor(options) {
1323
1377
  this.config = options.config;
1324
- this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
1378
+ this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
1325
1379
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1326
1380
  this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
1327
1381
  this.logger = options.logger ?? ((message) => console.warn(message));
1328
1382
  this.sleep = options.sleep ?? sleep;
1329
1383
  this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
1384
+ this.maxRetryDelayMs = options.maxRetryDelayMs ?? 1e4;
1330
1385
  }
1331
1386
  async reportUsage(input) {
1332
1387
  try {
@@ -1337,6 +1392,11 @@ var PostStreamUsageClient = class {
1337
1392
  requestId: input.requestId,
1338
1393
  subscriptionId: input.subscriptionId,
1339
1394
  nonce: this.newNonce(),
1395
+ // The token's `allowedMeters` scope is enforced on BOTH lanes
1396
+ // independently (P0-1): `meters` is the flat METER-keyed projection
1397
+ // (the billed lane), so its keys must be in scope regardless of
1398
+ // whether the measurement lane is also present; `measurements[].meter`
1399
+ // is scoped in validateMeasurements below.
1340
1400
  meters: validateAndSortUsage(input.meters, "meters", this.config, true),
1341
1401
  ...input.creditUnitsConsumed ? {
1342
1402
  creditUnitsConsumed: validateAndSortUsage(
@@ -1346,7 +1406,13 @@ var PostStreamUsageClient = class {
1346
1406
  false
1347
1407
  )
1348
1408
  } : {},
1349
- ...input.measureContext ? { measureContext: input.measureContext } : {}
1409
+ ...input.measureContext ? { measureContext: input.measureContext } : {},
1410
+ // Key ORDER is load-bearing: core recomputes the HMAC over
1411
+ // JSON.stringify(unsigned) rebuilt in its zod schema's field order, so
1412
+ // these additive fields must sit in the same position on both sides.
1413
+ ...input.measurementsVersion !== void 0 ? { measurementsVersion: input.measurementsVersion } : {},
1414
+ ...input.measurements ? { measurements: this.validateMeasurements(input.measurements) } : {},
1415
+ ...input.quote ? { quote: input.quote } : {}
1350
1416
  };
1351
1417
  const signature = await signPayload(
1352
1418
  JSON.stringify(unsigned),
@@ -1354,20 +1420,39 @@ var PostStreamUsageClient = class {
1354
1420
  );
1355
1421
  const event = { ...unsigned, signature };
1356
1422
  const body = JSON.stringify(event);
1423
+ const headerSignature = await signPayload(body, this.config.credential);
1357
1424
  for (let attempt = 0; ; attempt += 1) {
1358
- const response = await this.fetchImpl(this.endpoint, {
1359
- method: "POST",
1360
- headers: {
1361
- authorization: `Bearer ${this.config.credential}`,
1362
- "content-type": "application/json",
1363
- accept: "application/json"
1364
- },
1365
- body
1366
- });
1425
+ let response;
1426
+ try {
1427
+ response = await fetchWithDeadline(
1428
+ this.fetchImpl,
1429
+ this.endpoint,
1430
+ {
1431
+ method: "POST",
1432
+ headers: {
1433
+ authorization: `Bearer ${this.config.credential}`,
1434
+ "content-type": "application/json",
1435
+ accept: "application/json",
1436
+ [RUNTIME_RESPONSE_METERING_CONTRACT.headers.signature]: headerSignature
1437
+ },
1438
+ body
1439
+ },
1440
+ "postStreamUsage"
1441
+ );
1442
+ } catch (cause) {
1443
+ const delayMs2 = this.retryDelayForAttempt(attempt, null);
1444
+ if (delayMs2 === null) throw cause;
1445
+ await this.sleep(delayMs2);
1446
+ continue;
1447
+ }
1367
1448
  if (response.ok) return { ok: true };
1368
1449
  const requestNotFound = await isPostStreamRequestNotFound(response);
1369
- const delayMs = this.retryDelaysMs[attempt];
1370
- if (!requestNotFound || delayMs === void 0) {
1450
+ const retryable = requestNotFound || isRetryableStatus(response.status);
1451
+ const delayMs = this.retryDelayForAttempt(
1452
+ attempt,
1453
+ retryAfterMs(response.headers)
1454
+ );
1455
+ if (!retryable || delayMs === null) {
1371
1456
  throw new Error(`metering endpoint returned ${response.status}`);
1372
1457
  }
1373
1458
  await this.sleep(delayMs);
@@ -1378,6 +1463,31 @@ var PostStreamUsageClient = class {
1378
1463
  return { ok: false, reason };
1379
1464
  }
1380
1465
  }
1466
+ /** Enforce the token's meter scope + per-event bounds on the measurement lane. */
1467
+ validateMeasurements(measurements) {
1468
+ const allowed = this.config.allowedMeters;
1469
+ for (const measurement of measurements) {
1470
+ if (allowed.length > 0 && !allowed.includes(measurement.meter)) {
1471
+ throw new Error(
1472
+ `meter '${measurement.meter}' is not in the token's allowedMeters`
1473
+ );
1474
+ }
1475
+ for (const [measure, value] of Object.entries(measurement.values)) {
1476
+ if (this.config.perEventMax > 0 && value > this.config.perEventMax) {
1477
+ throw new Error(
1478
+ `measure '${measure}' value ${value} exceeds the per-event max ${this.config.perEventMax}`
1479
+ );
1480
+ }
1481
+ }
1482
+ }
1483
+ return measurements;
1484
+ }
1485
+ retryDelayForAttempt(attempt, retryAfterMs2) {
1486
+ const fallback = this.retryDelaysMs[attempt];
1487
+ if (fallback === void 0) return null;
1488
+ if (retryAfterMs2 === null) return fallback;
1489
+ return Math.min(retryAfterMs2, this.maxRetryDelayMs);
1490
+ }
1381
1491
  };
1382
1492
  async function isPostStreamRequestNotFound(response) {
1383
1493
  if (response.status !== 422) return false;
@@ -1388,6 +1498,18 @@ async function isPostStreamRequestNotFound(response) {
1388
1498
  return false;
1389
1499
  }
1390
1500
  }
1501
+ function isRetryableStatus(status) {
1502
+ return status === 429 || status >= 500 && status <= 599;
1503
+ }
1504
+ function retryAfterMs(headers) {
1505
+ const raw = headers.get("retry-after");
1506
+ if (!raw) return null;
1507
+ const seconds = Number(raw);
1508
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
1509
+ const dateMs = Date.parse(raw);
1510
+ if (!Number.isFinite(dateMs)) return null;
1511
+ return Math.max(0, dateMs - Date.now());
1512
+ }
1391
1513
  function sleep(delayMs) {
1392
1514
  return new Promise((resolve) => setTimeout(resolve, delayMs));
1393
1515
  }
@@ -1396,7 +1518,7 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1396
1518
  ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1397
1519
  );
1398
1520
  for (const [meter, qty] of entries) {
1399
- if (!METER_KEY_RE2.test(meter)) {
1521
+ if (!METER_KEY_RE.test(meter)) {
1400
1522
  throw new Error(
1401
1523
  `${label} key '${meter}' must be lowercase alphanumeric with underscores`
1402
1524
  );
@@ -1415,12 +1537,303 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1415
1537
  }
1416
1538
  return Object.fromEntries(entries);
1417
1539
  }
1418
- function resolveEndpoint2(endpoint, coreUrl) {
1540
+ function resolveEndpoint(endpoint, coreUrl) {
1419
1541
  if (/^https?:\/\//.test(endpoint)) return endpoint;
1420
1542
  if (!coreUrl) return endpoint;
1421
1543
  return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1422
1544
  }
1423
1545
 
1546
+ // src/core/report.ts
1547
+ var MEASUREMENTS_VERSION = 1;
1548
+ var KEY_RE = /^[a-z0-9_]{1,64}$/;
1549
+ var DIMENSION_VALUE_RE = /^[\w.:-]{1,128}$/;
1550
+ var CURRENCY_RE = /^[A-Za-z]{3}$/;
1551
+ var DECIMAL_INTEGER_RE = /^\d{1,30}$/;
1552
+ function createReportFn(channels) {
1553
+ let stampedMeasurements = [];
1554
+ let stampedQuote;
1555
+ let inBandTail = Promise.resolve();
1556
+ let postStreamFinalized = false;
1557
+ let pendingPostStreamBatch = null;
1558
+ const deliverPostStream = async (reported, quote) => {
1559
+ if (pendingPostStreamBatch) {
1560
+ if (!quotesEqual(pendingPostStreamBatch.quote, quote)) {
1561
+ return {
1562
+ ok: false,
1563
+ transport: "post_stream",
1564
+ reason: "quote conflicts with this request's pending post-stream batch: one served request carries one quote across all measurements"
1565
+ };
1566
+ }
1567
+ if (reported.some(
1568
+ (measurement) => !dimsEqual(
1569
+ pendingPostStreamBatch.measurements[0]?.dims,
1570
+ measurement.dims
1571
+ )
1572
+ )) {
1573
+ return {
1574
+ ok: false,
1575
+ transport: "post_stream",
1576
+ reason: "dims conflict with this request's pending post-stream batch: the request receipt rates under ONE dims tuple"
1577
+ };
1578
+ }
1579
+ pendingPostStreamBatch.measurements.push(...reported);
1580
+ return pendingPostStreamBatch.flush;
1581
+ }
1582
+ if (postStreamFinalized) {
1583
+ return {
1584
+ ok: false,
1585
+ transport: "post_stream",
1586
+ reason: "the served request already used its post-stream callback; report multiple meters in ONE call \u2014 ctx.report([a, b]) \u2014 or before the flush"
1587
+ };
1588
+ }
1589
+ if (stampedMeasurements.length > 0) {
1590
+ return {
1591
+ ok: false,
1592
+ transport: "post_stream",
1593
+ reason: "this request already reported in-band; every report on one request must share the stamped aggregate (same quote, before the response is sent)"
1594
+ };
1595
+ }
1596
+ postStreamFinalized = true;
1597
+ const batch = {
1598
+ measurements: [...reported],
1599
+ quote,
1600
+ flush: void 0
1601
+ };
1602
+ batch.flush = new Promise((resolve) => setTimeout(resolve, 0)).then(
1603
+ async () => {
1604
+ pendingPostStreamBatch = null;
1605
+ const result = await channels.postStream({
1606
+ measurements: batch.measurements,
1607
+ ...batch.quote ? { quote: batch.quote } : {}
1608
+ });
1609
+ return result.ok ? { ok: true, transport: "post_stream" } : {
1610
+ ok: false,
1611
+ transport: "post_stream",
1612
+ reason: result.reason ?? "post-stream delivery failed"
1613
+ };
1614
+ }
1615
+ );
1616
+ pendingPostStreamBatch = batch;
1617
+ return batch.flush;
1618
+ };
1619
+ const tryInBand = (reported, quote) => {
1620
+ const run = inBandTail.then(async () => {
1621
+ const sink = channels.responseSink;
1622
+ if (!sink || !channels.request || !sink.canStampHeaders()) return null;
1623
+ if (postStreamFinalized) return null;
1624
+ if (!quotesEqual(stampedQuote, quote) && stampedMeasurements.length > 0) {
1625
+ return null;
1626
+ }
1627
+ if (stampedMeasurements.length > 0 && reported.some(
1628
+ (measurement) => !dimsEqual(stampedMeasurements[0].dims, measurement.dims)
1629
+ )) {
1630
+ return null;
1631
+ }
1632
+ const measurements = [...stampedMeasurements, ...reported];
1633
+ const payload = buildInBandPayload(channels.request, measurements, quote);
1634
+ const headers = await channels.computeHeaders(payload);
1635
+ if (Object.keys(headers).length === 0 || !sink.canStampHeaders()) {
1636
+ return null;
1637
+ }
1638
+ sink.stampHeaders(headers);
1639
+ stampedMeasurements = measurements;
1640
+ stampedQuote = quote;
1641
+ return { ok: true, transport: "in_band" };
1642
+ });
1643
+ inBandTail = run.then(
1644
+ () => void 0,
1645
+ () => void 0
1646
+ );
1647
+ return run;
1648
+ };
1649
+ return async (input) => {
1650
+ const inputs = Array.isArray(input) ? input : [input];
1651
+ if (inputs.length === 0) {
1652
+ throw new MeteringError(
1653
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1654
+ "report([]) is empty: a batched report needs at least one measurement"
1655
+ );
1656
+ }
1657
+ const measurements = inputs.map((entry) => validateMeasurement(entry));
1658
+ for (const measurement of measurements) {
1659
+ if (!dimsEqual(measurements[0].dims, measurement.dims)) {
1660
+ throw new MeteringError(
1661
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1662
+ "a batched report carries ONE dims tuple: the request receipt rates under (route, dims), so mixed dims are unratable \u2014 report each dims tuple on its own request"
1663
+ );
1664
+ }
1665
+ }
1666
+ let quote;
1667
+ for (const entry of inputs) {
1668
+ if (entry.quote === void 0) continue;
1669
+ const validated = validateQuote(entry.quote);
1670
+ if (quote === void 0) {
1671
+ quote = validated;
1672
+ } else if (!quotesEqual(quote, validated)) {
1673
+ throw new MeteringError(
1674
+ RESPONSE_METERING_ERROR_CODES.invalidQuote,
1675
+ "a batched report carries ONE quote: two entries supplied different quotes"
1676
+ );
1677
+ }
1678
+ }
1679
+ const inBand = await tryInBand(measurements, quote);
1680
+ if (inBand) return inBand;
1681
+ return deliverPostStream(measurements, quote);
1682
+ };
1683
+ }
1684
+ function unattachedReport() {
1685
+ return () => Promise.reject(
1686
+ new MeteringError(
1687
+ RESPONSE_METERING_ERROR_CODES.missingToken,
1688
+ "report() has no metering channel on this context: verify through the runtime (fs.middleware() / fs.verifyRequest()) instead of the bare verifyRequest() primitive, and pass that context to background jobs"
1689
+ )
1690
+ );
1691
+ }
1692
+ function rawDimsUnitsOf(measurement) {
1693
+ let total = 0;
1694
+ for (const value of Object.values(measurement.values)) total += value;
1695
+ return { [measurement.meter]: total };
1696
+ }
1697
+ function buildInBandPayload(request, measurements, quote) {
1698
+ const rawDimsUnits = {};
1699
+ for (const measurement of measurements) {
1700
+ for (const [meter, units] of Object.entries(rawDimsUnitsOf(measurement))) {
1701
+ rawDimsUnits[meter] = (rawDimsUnits[meter] ?? 0) + units;
1702
+ }
1703
+ }
1704
+ return {
1705
+ method: request.method.toUpperCase(),
1706
+ path: request.path,
1707
+ rawDimsUnits,
1708
+ measurementsVersion: MEASUREMENTS_VERSION,
1709
+ measurements,
1710
+ ...quote ? { quote } : {}
1711
+ };
1712
+ }
1713
+ function dimsEqual(left, right) {
1714
+ const l = Object.entries(left ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
1715
+ const r = Object.entries(right ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
1716
+ if (l.length !== r.length) return false;
1717
+ return l.every(([k, v], i) => r[i][0] === k && r[i][1] === v);
1718
+ }
1719
+ function quotesEqual(left, right) {
1720
+ return left === right || left !== void 0 && right !== void 0 && left.currency === right.currency && left.amountNanos === right.amountNanos;
1721
+ }
1722
+ function validateMeasurement(input) {
1723
+ if (!input || typeof input !== "object") {
1724
+ throw invalidKey("report() requires a { meter, values } object");
1725
+ }
1726
+ const meter = assertKey(input.meter, "meter");
1727
+ const values = assertValues(input.values);
1728
+ const dims = input.dims === void 0 ? void 0 : assertDims(input.dims);
1729
+ return {
1730
+ meter,
1731
+ values,
1732
+ ...dims && Object.keys(dims).length > 0 ? { dims } : {}
1733
+ };
1734
+ }
1735
+ function validateQuote(quote) {
1736
+ if (!quote || typeof quote !== "object" || Array.isArray(quote)) {
1737
+ throw invalidQuote(
1738
+ "quote must be an object of the form { currency, amountNanos }"
1739
+ );
1740
+ }
1741
+ const { currency, amountNanos } = quote;
1742
+ if (typeof currency !== "string" || !CURRENCY_RE.test(currency)) {
1743
+ throw invalidQuote("quote.currency must be a 3-letter currency code");
1744
+ }
1745
+ return {
1746
+ currency: currency.toLowerCase(),
1747
+ amountNanos: assertAmountNanos(amountNanos)
1748
+ };
1749
+ }
1750
+ function assertAmountNanos(value) {
1751
+ if (typeof value === "bigint") {
1752
+ if (value < 0n) throw negativeAmountNanos();
1753
+ return value.toString();
1754
+ }
1755
+ if (typeof value === "number") {
1756
+ if (!Number.isSafeInteger(value)) {
1757
+ throw invalidQuote(
1758
+ "quote.amountNanos must be a safe integer number of nanodollars (pass a string or bigint for larger amounts)"
1759
+ );
1760
+ }
1761
+ if (value < 0) throw negativeAmountNanos();
1762
+ return String(value);
1763
+ }
1764
+ if (typeof value === "string") {
1765
+ if (/^-/.test(value)) throw negativeAmountNanos();
1766
+ if (DECIMAL_INTEGER_RE.test(value)) return value;
1767
+ }
1768
+ throw invalidQuote(
1769
+ "quote.amountNanos must be a non-negative integer number of nanodollars"
1770
+ );
1771
+ }
1772
+ function negativeAmountNanos() {
1773
+ return invalidQuote(
1774
+ "quote.amountNanos must be non-negative: a quote is a proposed rate, never a credit \u2014 refunds are platform operations"
1775
+ );
1776
+ }
1777
+ function assertValues(values) {
1778
+ if (!values || typeof values !== "object" || Array.isArray(values)) {
1779
+ throw invalidKey("report() requires a values object");
1780
+ }
1781
+ const entries = Object.entries(values).sort(
1782
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1783
+ );
1784
+ if (entries.length === 0) {
1785
+ throw invalidKey("report() requires at least one measure in values");
1786
+ }
1787
+ const out = {};
1788
+ for (const [measure, value] of entries) {
1789
+ assertKey(measure, "measure");
1790
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
1791
+ throw new MeteringError(
1792
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1793
+ `values.${measure} must be a non-negative safe integer`
1794
+ );
1795
+ }
1796
+ out[measure] = value;
1797
+ }
1798
+ return out;
1799
+ }
1800
+ function assertDims(dims) {
1801
+ if (!dims || typeof dims !== "object" || Array.isArray(dims)) {
1802
+ throw invalidKey("report() dims must be an object of dimension selectors");
1803
+ }
1804
+ const entries = Object.entries(dims).sort(
1805
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1806
+ );
1807
+ const out = {};
1808
+ for (const [dimension, value] of entries) {
1809
+ assertKey(dimension, "dimension");
1810
+ if (typeof value !== "string" || !DIMENSION_VALUE_RE.test(value)) {
1811
+ throw invalidKey(
1812
+ `dims.${dimension} must be a 1-128 character selector value`
1813
+ );
1814
+ }
1815
+ out[dimension] = value;
1816
+ }
1817
+ return out;
1818
+ }
1819
+ function assertKey(value, label) {
1820
+ if (typeof value !== "string" || !KEY_RE.test(value)) {
1821
+ throw invalidKey(
1822
+ `${label} key ${JSON.stringify(value)} must be 1-64 lowercase alphanumeric characters or underscores`
1823
+ );
1824
+ }
1825
+ return value;
1826
+ }
1827
+ function invalidKey(message) {
1828
+ return new MeteringError(
1829
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1830
+ message
1831
+ );
1832
+ }
1833
+ function invalidQuote(message) {
1834
+ return new MeteringError(RESPONSE_METERING_ERROR_CODES.invalidQuote, message);
1835
+ }
1836
+
1424
1837
  // src/core/nonceCache.ts
1425
1838
  var DEFAULT_MAX_ENTRIES = 25e4;
1426
1839
  var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
@@ -1463,6 +1876,37 @@ var NonceCache = class {
1463
1876
  }
1464
1877
  };
1465
1878
 
1879
+ // src/core/replay-protection.ts
1880
+ function resolveReplayProtection(input = {}) {
1881
+ if (input.nonceStore) {
1882
+ return {
1883
+ // An opted-in shared store that is DOWN must not degrade to "no replay
1884
+ // check" — that would make knocking it over a way to switch the
1885
+ // protection off entirely.
1886
+ store: failClosed(input.nonceStore),
1887
+ diagnostic: { mode: "shared", crossReplica: true }
1888
+ };
1889
+ }
1890
+ return {
1891
+ store: new NonceCache(),
1892
+ diagnostic: { mode: "single-instance", crossReplica: false }
1893
+ };
1894
+ }
1895
+ function failClosed(store) {
1896
+ return {
1897
+ async checkAndRemember(id) {
1898
+ try {
1899
+ return await store.checkAndRemember(id);
1900
+ } catch (cause) {
1901
+ throw new FartherShoreError(
1902
+ "replayed_nonce",
1903
+ `replay store is unavailable, refusing the request rather than skipping one-time-use enforcement: ${cause instanceof Error ? cause.message : String(cause)}`
1904
+ );
1905
+ }
1906
+ }
1907
+ };
1908
+ }
1909
+
1466
1910
  // src/core/shutdown.ts
1467
1911
  var ShutdownManager = class {
1468
1912
  hooks = [];
@@ -1983,22 +2427,7 @@ async function verifyRequest(input, deps) {
1983
2427
  "x-fs-timestamp is not an integer"
1984
2428
  );
1985
2429
  }
1986
- const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
1987
- const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
1988
- const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
1989
- const delta = now - timestamp;
1990
- if (Math.abs(delta) > window) {
1991
- if (Math.abs(delta) <= window + skew) {
1992
- throw new FartherShoreError(
1993
- "clock_skew",
1994
- "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
1995
- );
1996
- }
1997
- throw new FartherShoreError(
1998
- "expired_signature",
1999
- "x-fs-timestamp is outside the replay window"
2000
- );
2001
- }
2430
+ assertTimestampWithinWindow(timestamp, deps);
2002
2431
  const computedBodyHash = await computeBodyHash(input);
2003
2432
  if (signedBodyHash !== computedBodyHash) {
2004
2433
  throw new FartherShoreError(
@@ -2006,31 +2435,7 @@ async function verifyRequest(input, deps) {
2006
2435
  "recomputed body hash does not match the signed x-fs-body-hash"
2007
2436
  );
2008
2437
  }
2009
- if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
2010
- throw new FartherShoreError(
2011
- "route_mismatch",
2012
- "signed business-id does not match this backend's business"
2013
- );
2014
- }
2015
- if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
2016
- if (!deps.backendIds.has(signedBackendId)) {
2017
- throw new FartherShoreError(
2018
- "route_mismatch",
2019
- "signed backend-id is not one this deployment serves"
2020
- );
2021
- }
2022
- } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
2023
- throw new FartherShoreError(
2024
- "route_mismatch",
2025
- "signed backend-id does not match this backend"
2026
- );
2027
- }
2028
- if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
2029
- throw new FartherShoreError(
2030
- "route_mismatch",
2031
- "signed route-id is not served by this backend"
2032
- );
2033
- }
2438
+ assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId);
2034
2439
  const contextToken = h("x-fs-context") ?? null;
2035
2440
  const canonicalInput = {
2036
2441
  method: input.method,
@@ -2101,9 +2506,57 @@ async function verifyRequest(input, deps) {
2101
2506
  ...principal ? { principal } : {},
2102
2507
  ...permissions !== void 0 ? { permissions } : {},
2103
2508
  ...roles !== void 0 ? { roles } : {},
2104
- ...signedContext ? { signedContext } : {}
2509
+ ...signedContext ? { signedContext } : {},
2510
+ // The bare primitive has no metering channel; the runtime facade replaces
2511
+ // this with the real bound verb (see FartherShore.verifyRequest).
2512
+ report: unattachedReport()
2105
2513
  };
2106
2514
  }
2515
+ function assertTimestampWithinWindow(timestamp, deps) {
2516
+ const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
2517
+ const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
2518
+ const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
2519
+ const delta = now - timestamp;
2520
+ if (Math.abs(delta) > window) {
2521
+ if (Math.abs(delta) <= window + skew) {
2522
+ throw new FartherShoreError(
2523
+ "clock_skew",
2524
+ "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
2525
+ );
2526
+ }
2527
+ throw new FartherShoreError(
2528
+ "expired_signature",
2529
+ "x-fs-timestamp is outside the replay window"
2530
+ );
2531
+ }
2532
+ }
2533
+ function assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId) {
2534
+ if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
2535
+ throw new FartherShoreError(
2536
+ "route_mismatch",
2537
+ "signed business-id does not match this backend's business"
2538
+ );
2539
+ }
2540
+ if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
2541
+ if (!deps.backendIds.has(signedBackendId)) {
2542
+ throw new FartherShoreError(
2543
+ "route_mismatch",
2544
+ "signed backend-id is not one this deployment serves"
2545
+ );
2546
+ }
2547
+ } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
2548
+ throw new FartherShoreError(
2549
+ "route_mismatch",
2550
+ "signed backend-id does not match this backend"
2551
+ );
2552
+ }
2553
+ if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
2554
+ throw new FartherShoreError(
2555
+ "route_mismatch",
2556
+ "signed route-id is not served by this backend"
2557
+ );
2558
+ }
2559
+ }
2107
2560
  async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
2108
2561
  if (!contextToken) return null;
2109
2562
  const signedContext = decodeContextClaims(contextToken);
@@ -2156,7 +2609,7 @@ function headerGetter(headers) {
2156
2609
 
2157
2610
  // src/core/runtime.ts
2158
2611
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
2159
- var SDK_VERSION = "0.19.0".length > 0 ? "0.19.0" : "0.0.0-dev";
2612
+ var SDK_VERSION = "0.21.0".length > 0 ? "0.21.0" : "0.0.0-dev";
2160
2613
  var FartherShore = class {
2161
2614
  bootstrapClient;
2162
2615
  fetchImpl;
@@ -2169,16 +2622,16 @@ var FartherShore = class {
2169
2622
  /** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
2170
2623
  contextSecrets;
2171
2624
  nonceCache;
2625
+ replayProtectionDiagnostic;
2172
2626
  shutdownManager = new ShutdownManager();
2173
2627
  jwks = null;
2174
- meteringClient = null;
2175
2628
  postStreamUsageClient = null;
2176
2629
  tunnel = null;
2177
2630
  bootstrapped = false;
2178
2631
  constructor(options = {}) {
2179
2632
  const env = options.env ?? readProcessEnv();
2180
2633
  const runtimeToken = options.runtimeToken ?? env[FS_RUNTIME_TOKEN_ENV] ?? "";
2181
- const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? env.FARTHERSHORE_CORE_URL ?? DEFAULT_CORE_URL;
2634
+ const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? DEFAULT_CORE_URL;
2182
2635
  this.runtimeToken = runtimeToken;
2183
2636
  this.coreUrl = coreUrl;
2184
2637
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
@@ -2186,7 +2639,9 @@ var FartherShore = class {
2186
2639
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
2187
2640
  this.tunnelOptions = options.tunnel ?? {};
2188
2641
  this.instanceId = options.instanceId;
2189
- this.nonceCache = options.nonceStore ?? new NonceCache();
2642
+ const replay = resolveReplayProtection({ nonceStore: options.nonceStore });
2643
+ this.nonceCache = replay.store;
2644
+ this.replayProtectionDiagnostic = replay.diagnostic;
2190
2645
  this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
2191
2646
  this.bootstrapClient = new BootstrapClient({
2192
2647
  runtimeToken,
@@ -2198,9 +2653,6 @@ var FartherShore = class {
2198
2653
  ...options.instanceId ? { instanceId: options.instanceId } : {}
2199
2654
  }
2200
2655
  });
2201
- this.shutdownManager.register(async () => {
2202
- await this.meteringClient?.flush();
2203
- });
2204
2656
  this.shutdownManager.register(async () => {
2205
2657
  await reportHealth({
2206
2658
  runtimeToken: this.runtimeToken,
@@ -2220,14 +2672,7 @@ var FartherShore = class {
2220
2672
  fetchImpl: this.fetchImpl
2221
2673
  });
2222
2674
  }
2223
- if (!this.meteringClient && config.metering.enabled) {
2224
- this.meteringClient = new MeteringClient({
2225
- config: config.metering,
2226
- businessId: config.business.id,
2227
- backendId: config.backend.id,
2228
- coreUrl: this.coreUrl,
2229
- fetchImpl: this.fetchImpl
2230
- });
2675
+ if (!this.postStreamUsageClient && config.metering.enabled) {
2231
2676
  this.postStreamUsageClient = new PostStreamUsageClient({
2232
2677
  config: config.metering,
2233
2678
  coreUrl: this.coreUrl,
@@ -2276,14 +2721,19 @@ var FartherShore = class {
2276
2721
  buildReportSink() {
2277
2722
  const post = async (path, body) => {
2278
2723
  const base = this.coreUrl.replace(/\/$/, "");
2279
- const res = await this.fetchImpl(`${base}${path}`, {
2280
- method: "POST",
2281
- headers: {
2282
- "content-type": "application/json",
2283
- authorization: `Bearer ${this.runtimeToken}`
2724
+ const res = await fetchWithDeadline(
2725
+ this.fetchImpl,
2726
+ `${base}${path}`,
2727
+ {
2728
+ method: "POST",
2729
+ headers: {
2730
+ "content-type": "application/json",
2731
+ authorization: `Bearer ${this.runtimeToken}`
2732
+ },
2733
+ body: JSON.stringify(body)
2284
2734
  },
2285
- body: JSON.stringify(body)
2286
- });
2735
+ "report"
2736
+ );
2287
2737
  if (!res.ok) throw new Error(`runtime report ${path} -> ${res.status}`);
2288
2738
  };
2289
2739
  return {
@@ -2295,7 +2745,7 @@ var FartherShore = class {
2295
2745
  * Framework-neutral verification primitive. Fail-closed: throws a typed
2296
2746
  * FartherShoreError on any verification failure. Returns the verified context.
2297
2747
  */
2298
- async verifyRequest(input) {
2748
+ async verifyRequest(input, options = {}) {
2299
2749
  const config = await this.ensureBootstrapped();
2300
2750
  if (!this.jwks) {
2301
2751
  throw new FartherShoreError(
@@ -2321,21 +2771,52 @@ var FartherShore = class {
2321
2771
  });
2322
2772
  return {
2323
2773
  ...context,
2324
- reportUsage: (report) => {
2325
- const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
2774
+ report: this.buildReportFn(context, input, options.responseSink)
2775
+ };
2776
+ }
2777
+ /**
2778
+ * Bind the ONE reporting verb to a verified context. Identity comes from the
2779
+ * context (`signedContext.subscriptionId` + `requestId`) — never from the
2780
+ * caller — so a handler cannot forget it, and a background job that is handed
2781
+ * this context keeps reporting against the SAME served identity.
2782
+ */
2783
+ buildReportFn(context, input, responseSink) {
2784
+ const channels = {
2785
+ request: { method: input.method, path: input.path },
2786
+ ...responseSink ? { responseSink } : {},
2787
+ computeHeaders: (payload) => computeMeteringHeaders(payload, {
2788
+ token: this.runtimeToken,
2789
+ requestId: context.requestId,
2790
+ onSkip: () => {
2791
+ }
2792
+ }),
2793
+ postStream: async ({ measurements, quote }) => {
2794
+ const subscriptionId = context.signedContext?.subscriptionId;
2326
2795
  if (!subscriptionId) {
2327
- return Promise.resolve({
2796
+ return {
2328
2797
  ok: false,
2329
- reason: "subscriptionId is required"
2330
- });
2798
+ reason: "this request carries no subscription identity, so late usage cannot be attributed \u2014 report before the response is sent, or serve the route through a subscribed surface"
2799
+ };
2800
+ }
2801
+ const meters = {};
2802
+ for (const measurement of measurements) {
2803
+ for (const [meter, qty] of Object.entries(
2804
+ rawDimsUnitsOf(measurement)
2805
+ )) {
2806
+ meters[meter] = (meters[meter] ?? 0) + qty;
2807
+ }
2331
2808
  }
2332
- return this.reportUsage({
2333
- ...report,
2334
- requestId: report.requestId ?? context.requestId,
2335
- subscriptionId
2809
+ return this.reportPostStreamUsage({
2810
+ requestId: context.requestId,
2811
+ subscriptionId,
2812
+ meters,
2813
+ measurementsVersion: MEASUREMENTS_VERSION,
2814
+ measurements,
2815
+ ...quote ? { quote } : {}
2336
2816
  });
2337
2817
  }
2338
2818
  };
2819
+ return createReportFn(channels);
2339
2820
  }
2340
2821
  /** Whether verification is required (bootstrap × opt-out). */
2341
2822
  async verificationRequired() {
@@ -2384,20 +2865,12 @@ var FartherShore = class {
2384
2865
  });
2385
2866
  await supervisor.start();
2386
2867
  }
2387
- /** Record metering usage (billing-only). */
2388
- async meter(meter, qty, options = {}) {
2389
- await this.ensureBootstrapped();
2390
- if (!this.meteringEnabledOverride) return;
2391
- if (!this.meteringClient) {
2392
- throw new FartherShoreError(
2393
- "invalid_token",
2394
- "metering is not enabled for this runtime token"
2395
- );
2396
- }
2397
- await this.meteringClient.meter(meter, qty, options);
2398
- }
2399
- /** Best-effort attested post-stream usage callback. Never rejects. */
2400
- async reportUsage(input) {
2868
+ /**
2869
+ * PRIVATE transport for the post-stream lane of `ctx.report()`. Never rejects
2870
+ * — a metering hiccup must not break a builder's endpoint. This is machinery,
2871
+ * not surface: the ONE public reporting verb is `ctx.report()`.
2872
+ */
2873
+ async reportPostStreamUsage(input) {
2401
2874
  try {
2402
2875
  await this.ensureBootstrapped();
2403
2876
  if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
@@ -2410,6 +2883,15 @@ var FartherShore = class {
2410
2883
  return { ok: false, reason };
2411
2884
  }
2412
2885
  }
2886
+ /**
2887
+ * How far replay protection actually reaches — `"shared"` (enforced across
2888
+ * every replica) or `"single-instance"` (this process only). Deployment
2889
+ * diagnostic: log it at boot, or assert on it in a smoke test, so the mode is
2890
+ * never a surprise. See {@link FartherShoreInitOptions.nonceStore}.
2891
+ */
2892
+ replayProtection() {
2893
+ return this.replayProtectionDiagnostic;
2894
+ }
2413
2895
  /** Current local health report. */
2414
2896
  health() {
2415
2897
  const config = this.bootstrapClient.peek();
@@ -2420,7 +2902,7 @@ var FartherShore = class {
2420
2902
  // fs.start() launches an embedded tunnel; otherwise the supervisor state.
2421
2903
  tunnel: this.tunnel ? this.tunnel.healthString() : null,
2422
2904
  verification: this.verificationEnabled && config !== null,
2423
- metering: this.meteringClient !== null
2905
+ metering: this.postStreamUsageClient !== null
2424
2906
  });
2425
2907
  }
2426
2908
  /** Graceful shutdown: flush metering + send a stopping heartbeat. */
@@ -2464,7 +2946,12 @@ function permissionSatisfies(required, granted) {
2464
2946
  const idx = required.indexOf(":");
2465
2947
  if (idx > 0 && idx < required.length - 1) {
2466
2948
  const subject = required.slice(0, idx);
2467
- if (granted.includes(`${subject}:${WILDCARD}`)) return true;
2949
+ if (subject !== WILDCARD && granted.includes(`${subject}:${WILDCARD}`))
2950
+ return true;
2951
+ if (required.slice(idx + 1) === WILDCARD && subject !== WILDCARD) {
2952
+ const prefix = `${subject}:`;
2953
+ return granted.some((permission) => permission.startsWith(prefix));
2954
+ }
2468
2955
  }
2469
2956
  return false;
2470
2957
  }
@@ -2499,14 +2986,17 @@ async function runMiddleware(fs, options, req, res, next) {
2499
2986
  const contentType = headerValue(req.headers, "content-type");
2500
2987
  const streamingExempt = isStreamingExempt(contentType);
2501
2988
  const body = streamingExempt ? null : extractRawBody(req);
2502
- const ctx = await fs.verifyRequest({
2503
- method: req.method,
2504
- path,
2505
- query,
2506
- headers: req.headers,
2507
- body,
2508
- streamingExempt
2509
- });
2989
+ const ctx = await fs.verifyRequest(
2990
+ {
2991
+ method: req.method,
2992
+ path,
2993
+ query,
2994
+ headers: req.headers,
2995
+ body,
2996
+ streamingExempt
2997
+ },
2998
+ { responseSink: expressResponseSink(res) }
2999
+ );
2510
3000
  req.fartherShore = ctx;
2511
3001
  stripFartherShoreHeaders(req);
2512
3002
  next();
@@ -2514,6 +3004,16 @@ async function runMiddleware(fs, options, req, res, next) {
2514
3004
  fail(res, error, options, req);
2515
3005
  }
2516
3006
  }
3007
+ function expressResponseSink(res) {
3008
+ return {
3009
+ canStampHeaders: () => res.headersSent !== true,
3010
+ stampHeaders: (headers) => {
3011
+ for (const [name, value] of Object.entries(headers)) {
3012
+ res.setHeader(name, value);
3013
+ }
3014
+ }
3015
+ };
3016
+ }
2517
3017
  function fail(res, error, options, req) {
2518
3018
  const code = error instanceof FartherShoreError ? error.code : "bad_signature";
2519
3019
  const status = error instanceof FartherShoreError ? error.status : 401;
@@ -2554,7 +3054,12 @@ function stripFartherShoreHeaders(req) {
2554
3054
  withRaw.rawHeaders = cleaned;
2555
3055
  }
2556
3056
  }
2557
- function createExpressHandler(handler) {
3057
+ function createExpressHandler(optionsOrHandler, maybeHandler) {
3058
+ const options = typeof optionsOrHandler === "function" ? {} : optionsOrHandler;
3059
+ const handler = typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler;
3060
+ if (typeof handler !== "function") {
3061
+ throw new TypeError("fs.handler(options, cb) requires a handler callback");
3062
+ }
2558
3063
  return (req, res, next) => {
2559
3064
  const ctx = req.fartherShore;
2560
3065
  if (!ctx) {
@@ -2565,10 +3070,22 @@ function createExpressHandler(handler) {
2565
3070
  res.status(401).json({ error: "principal_required" });
2566
3071
  return;
2567
3072
  }
3073
+ if (!ctx.signedContext) {
3074
+ res.status(401).json({ error: "context_unverified" });
3075
+ return;
3076
+ }
2568
3077
  const verified = ctx;
2569
- void Promise.resolve().then(
2570
- () => handler(verified, req, res, next)
2571
- ).catch((error) => failHandler(res, next, error));
3078
+ void Promise.resolve().then(() => {
3079
+ if (options.permission !== void 0) {
3080
+ requirePermission(verified, options.permission);
3081
+ }
3082
+ return handler(
3083
+ verified,
3084
+ req,
3085
+ res,
3086
+ next
3087
+ );
3088
+ }).catch((error) => failHandler(res, next, error));
2572
3089
  };
2573
3090
  }
2574
3091
  function failHandler(res, next, error) {
@@ -2629,7 +3146,7 @@ function readProcessEnv2() {
2629
3146
  // src/testing/usageSink.ts
2630
3147
  var DevUsageSink = class {
2631
3148
  events = [];
2632
- /** Record a signed response-metering payload (withUsage / computeMeteringHeaders). */
3149
+ /** Record a signed response-metering payload (report() in-band / computeMeteringHeaders). */
2633
3150
  recordResponse(payload, requestId) {
2634
3151
  const raw = payload.rawDimsUnits;
2635
3152
  const meters = raw && typeof raw === "object" ? raw : {};
@@ -2909,6 +3426,7 @@ function createDevRuntime(options) {
2909
3426
  }
2910
3427
  fs.middleware = middleware;
2911
3428
  fs.handler = createExpressHandler;
3429
+ fs.authz = tracedAuthz;
2912
3430
  const devRuntime = {
2913
3431
  fs,
2914
3432
  asPersona: (name) => personaClient.asPersona(name),
@@ -3021,6 +3539,69 @@ function splitUrl2(req) {
3021
3539
  if (qIndex === -1) return { path: raw, query: "" };
3022
3540
  return { path: raw.slice(0, qIndex), query: raw.slice(qIndex + 1) };
3023
3541
  }
3542
+
3543
+ // ../contracts/dist/webhooks/standard-webhooks.js
3544
+ import { createHmac, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
3545
+ var WEBHOOK_ID_HEADER = "webhook-id";
3546
+ var WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
3547
+ var WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
3548
+ var WEBHOOK_SIGNATURE_VERSION = "v1";
3549
+ var WEBHOOK_SECRET_PREFIX = "fswh_";
3550
+ function webhookSecretKeyBytes(secret) {
3551
+ const key2 = secret.startsWith(WEBHOOK_SECRET_PREFIX) ? Buffer.from(secret.slice(WEBHOOK_SECRET_PREFIX.length), "base64") : secret.startsWith("whsec_") ? Buffer.from(secret.slice("whsec_".length), "base64") : Buffer.from(secret, "utf8");
3552
+ if (key2.length === 0) {
3553
+ throw new Error("webhook secret resolves to an empty signing key \u2014 the configured secret must be a non-empty fswh_ value");
3554
+ }
3555
+ return key2;
3556
+ }
3557
+ function webhookSignedContent(id, timestamp, body) {
3558
+ return `${id}.${timestamp}.${body}`;
3559
+ }
3560
+ function signWebhookContent(secret, id, timestamp, body) {
3561
+ const mac = createHmac("sha256", webhookSecretKeyBytes(secret)).update(webhookSignedContent(id, timestamp, body)).digest("base64");
3562
+ return `${WEBHOOK_SIGNATURE_VERSION},${mac}`;
3563
+ }
3564
+ function signWebhook(input) {
3565
+ if (input.secrets.length === 0) {
3566
+ throw new Error("signWebhook: at least one secret is required");
3567
+ }
3568
+ const signature = input.secrets.map((secret) => signWebhookContent(secret, input.id, input.timestamp, input.body)).join(" ");
3569
+ return {
3570
+ [WEBHOOK_ID_HEADER]: input.id,
3571
+ [WEBHOOK_TIMESTAMP_HEADER]: String(input.timestamp),
3572
+ [WEBHOOK_SIGNATURE_HEADER]: signature
3573
+ };
3574
+ }
3575
+
3576
+ // src/testing/webhooks.ts
3577
+ function signWebhookForTesting(input) {
3578
+ const secrets = typeof input.secret === "string" ? [input.secret] : [...input.secret];
3579
+ const id = input.id ?? `test_${crypto.randomUUID()}`;
3580
+ const envelope = {
3581
+ id,
3582
+ type: input.type,
3583
+ createdAt: input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3584
+ businessId: input.businessId ?? "biz_test",
3585
+ environmentId: input.environmentId ?? null,
3586
+ data: input.data
3587
+ };
3588
+ const body = JSON.stringify(envelope);
3589
+ const timestamp = input.timestamp ?? Math.floor(Date.now() / 1e3);
3590
+ const signed = signWebhook({ id, timestamp, body, secrets });
3591
+ const headers = {
3592
+ "content-type": "application/json",
3593
+ ...signed,
3594
+ "x-fs-webhook-event": input.type
3595
+ };
3596
+ return {
3597
+ envelope,
3598
+ body,
3599
+ headers,
3600
+ request(url = "https://receiver.test/webhooks/farthershore") {
3601
+ return new Request(url, { method: "POST", headers, body });
3602
+ }
3603
+ };
3604
+ }
3024
3605
  export {
3025
3606
  CONTEXT_HEADER_NAME,
3026
3607
  DEFAULT_KEYS_FILE,
@@ -3053,6 +3634,7 @@ export {
3053
3634
  readDevKeysFile,
3054
3635
  redactValue,
3055
3636
  signContextToken,
3637
+ signWebhookForTesting,
3056
3638
  unreachableJwks,
3057
3639
  writeDevKeysFile
3058
3640
  };