@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.
package/dist/index.js CHANGED
@@ -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)
@@ -186,20 +191,6 @@ var init_reconcile = __esm({
186
191
  });
187
192
 
188
193
  // src/generated/runtime-contract.ts
189
- var RUNTIME_BODY_HASH_CONTRACT = {
190
- algorithm: "SHA-256",
191
- encoding: "hex-lower",
192
- source: "raw-request-bytes",
193
- emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
194
- maxBodyBytes: 10485760,
195
- streamingExemptToken: "STREAM",
196
- streamingExemptContentTypes: [
197
- "text/event-stream",
198
- "application/octet-stream",
199
- "multipart/form-data"
200
- ],
201
- overMaxStatus: 413
202
- };
203
194
  var RUNTIME_ERROR_CODES = {
204
195
  missingSignature: "missing_signature",
205
196
  malformedSignature: "malformed_signature",
@@ -220,6 +211,20 @@ var RUNTIME_ERROR_CODES = {
220
211
  serviceSubjectRequired: "service_subject_required",
221
212
  surfaceNotAllowed: "surface_not_allowed"
222
213
  };
214
+ var RUNTIME_BODY_HASH_CONTRACT = {
215
+ algorithm: "SHA-256",
216
+ encoding: "hex-lower",
217
+ source: "raw-request-bytes",
218
+ emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
219
+ maxBodyBytes: 10485760,
220
+ streamingExemptToken: "STREAM",
221
+ streamingExemptContentTypes: [
222
+ "text/event-stream",
223
+ "application/octet-stream",
224
+ "multipart/form-data"
225
+ ],
226
+ overMaxStatus: 413
227
+ };
223
228
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
224
229
  headers: {
225
230
  payload: "x-fs-metering",
@@ -240,14 +245,18 @@ var RUNTIME_RESPONSE_METERING_CONTRACT = {
240
245
  payload: {
241
246
  method: "string",
242
247
  path: "string",
243
- rawDimsUnits: "Record<string, number>",
248
+ rawDimsUnits: "Record<string, number>?",
244
249
  measureContext: "Record<string, unknown>?",
245
- creditUnitsConsumed: "Record<string, number>?"
250
+ creditUnitsConsumed: "Record<string, number>?",
251
+ measurementsVersion: "1?",
252
+ measurements: "Array<{ meter: string; values: Record<string, number>; dims?: Record<string, string> }>?",
253
+ quote: "{ currency: string; amountNanos: string }?"
246
254
  },
247
255
  errors: {
248
256
  missingToken: "missing_token",
249
257
  invalidMeterKey: "invalid_meter_key",
250
- invalidMeterValue: "invalid_meter_value"
258
+ invalidMeterValue: "invalid_meter_value",
259
+ invalidQuote: "invalid_quote"
251
260
  },
252
261
  httpAdapter: {
253
262
  input: "Request",
@@ -465,9 +474,92 @@ function statusForCode(code) {
465
474
  return 401;
466
475
  }
467
476
 
477
+ // src/core/deadline.ts
478
+ var DEADLINE_MS = {
479
+ /** Boot-blocking; generous because it runs once and gates startup. */
480
+ bootstrap: 1e4,
481
+ /** On the inbound verification path — must not hold a request open. */
482
+ jwks: 5e3,
483
+ /** Background economic report, retried by the caller. */
484
+ metering: 1e4,
485
+ /** Background attested usage callback. */
486
+ postStreamUsage: 1e4,
487
+ /** Best-effort heartbeat; never blocks anything. */
488
+ health: 5e3,
489
+ /** Boot-time route drift report; fail-open at the caller. */
490
+ report: 1e4
491
+ };
492
+ var MAX_RESPONSE_BYTES = 1048576;
493
+ var ResponseTooLargeError = class extends Error {
494
+ constructor(limit) {
495
+ super(`response body exceeded ${limit} bytes and was cancelled`);
496
+ this.name = "ResponseTooLargeError";
497
+ }
498
+ };
499
+ var DeadlineExceededError = class extends Error {
500
+ operation;
501
+ constructor(operation, timeoutMs) {
502
+ super(`${operation} exceeded its ${timeoutMs}ms deadline`);
503
+ this.name = "TimeoutError";
504
+ this.operation = operation;
505
+ }
506
+ };
507
+ async function fetchWithDeadline(fetchImpl, input, init, operation, options = {}) {
508
+ const timeoutMs = options.timeoutMs ?? DEADLINE_MS[operation];
509
+ const timeout = AbortSignal.timeout(timeoutMs);
510
+ const signal = options.callerSignal ? AbortSignal.any([options.callerSignal, timeout]) : timeout;
511
+ try {
512
+ return await fetchImpl(input, { ...init, signal });
513
+ } catch (cause) {
514
+ if (options.callerSignal?.aborted) throw cause;
515
+ if (timeout.aborted) throw new DeadlineExceededError(operation, timeoutMs);
516
+ throw cause;
517
+ }
518
+ }
519
+ async function readBoundedText(response, limit = MAX_RESPONSE_BYTES) {
520
+ const body = response.body;
521
+ if (!body) {
522
+ const text = await response.text();
523
+ if (byteLength(text) > limit) throw new ResponseTooLargeError(limit);
524
+ return text;
525
+ }
526
+ const reader = body.getReader();
527
+ const chunks = [];
528
+ let total = 0;
529
+ try {
530
+ for (; ; ) {
531
+ const { done, value } = await reader.read();
532
+ if (done) break;
533
+ if (!value) continue;
534
+ total += value.byteLength;
535
+ if (total > limit) {
536
+ await reader.cancel();
537
+ throw new ResponseTooLargeError(limit);
538
+ }
539
+ chunks.push(value);
540
+ }
541
+ } finally {
542
+ reader.releaseLock();
543
+ }
544
+ const joined = new Uint8Array(total);
545
+ let offset = 0;
546
+ for (const chunk of chunks) {
547
+ joined.set(chunk, offset);
548
+ offset += chunk.byteLength;
549
+ }
550
+ return new TextDecoder().decode(joined);
551
+ }
552
+ async function readBoundedJson(response, limit = MAX_RESPONSE_BYTES) {
553
+ return JSON.parse(await readBoundedText(response, limit));
554
+ }
555
+ function byteLength(text) {
556
+ return new TextEncoder().encode(text).byteLength;
557
+ }
558
+
468
559
  // src/core/bootstrap.ts
469
560
  var BOOTSTRAP_PATH = "/v1/runtime/bootstrap";
470
561
  var DEFAULT_MIN_REFRESH_SECONDS = 30;
562
+ var DEFAULT_MAX_STALE_SECONDS = 300;
471
563
  var BootstrapClient = class {
472
564
  runtimeToken;
473
565
  endpoint;
@@ -475,6 +567,7 @@ var BootstrapClient = class {
475
567
  fetchImpl;
476
568
  now;
477
569
  minRefreshSeconds;
570
+ maxStaleMs;
478
571
  cached = null;
479
572
  fetchedAt = 0;
480
573
  refreshAfterMs = 0;
@@ -498,6 +591,7 @@ var BootstrapClient = class {
498
591
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
499
592
  this.now = options.now ?? (() => Date.now());
500
593
  this.minRefreshSeconds = options.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS;
594
+ this.maxStaleMs = (options.maxStaleSeconds ?? DEFAULT_MAX_STALE_SECONDS) * 1e3;
501
595
  }
502
596
  /** Cached config when fresh; otherwise refreshes. */
503
597
  async get() {
@@ -519,20 +613,37 @@ var BootstrapClient = class {
519
613
  isStale() {
520
614
  return this.now() - this.fetchedAt >= this.refreshAfterMs;
521
615
  }
616
+ isHardStale() {
617
+ return this.now() - this.fetchedAt >= this.maxStaleMs;
618
+ }
619
+ cachedOrThrowOnHardStale(reason) {
620
+ if (this.cached && !this.isHardStale()) return this.cached;
621
+ throw new FartherShoreError(
622
+ "jwks_unavailable",
623
+ `bootstrap refresh failed with stale cached authorization metadata: ${reason}`
624
+ );
625
+ }
522
626
  async doBootstrap() {
523
627
  let response;
524
628
  try {
525
- response = await this.fetchImpl(this.endpoint, {
526
- method: "POST",
527
- headers: {
528
- authorization: `Bearer ${this.runtimeToken}`,
529
- "content-type": "application/json",
530
- accept: "application/json"
629
+ response = await fetchWithDeadline(
630
+ this.fetchImpl,
631
+ this.endpoint,
632
+ {
633
+ method: "POST",
634
+ headers: {
635
+ authorization: `Bearer ${this.runtimeToken}`,
636
+ "content-type": "application/json",
637
+ accept: "application/json"
638
+ },
639
+ body: JSON.stringify(this.request)
531
640
  },
532
- body: JSON.stringify(this.request)
533
- });
641
+ "bootstrap"
642
+ );
534
643
  } catch (cause) {
535
- if (this.cached) return this.cached;
644
+ if (this.cached) {
645
+ return this.cachedOrThrowOnHardStale(stringify(cause));
646
+ }
536
647
  throw new FartherShoreError(
537
648
  "jwks_unavailable",
538
649
  `bootstrap request failed: ${stringify(cause)}`
@@ -545,13 +656,15 @@ var BootstrapClient = class {
545
656
  );
546
657
  }
547
658
  if (!response.ok) {
548
- if (this.cached) return this.cached;
659
+ if (this.cached) {
660
+ return this.cachedOrThrowOnHardStale(`HTTP ${response.status}`);
661
+ }
549
662
  throw new FartherShoreError(
550
663
  "jwks_unavailable",
551
664
  `bootstrap returned HTTP ${response.status}`
552
665
  );
553
666
  }
554
- const body = await response.json();
667
+ const body = await readBoundedJson(response);
555
668
  this.cached = body;
556
669
  this.fetchedAt = this.now();
557
670
  const refreshSeconds = Math.max(
@@ -584,17 +697,22 @@ async function reportHealth(options) {
584
697
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
585
698
  const endpoint = `${options.coreUrl.replace(/\/+$/, "")}${HEALTH_PATH}`;
586
699
  try {
587
- const response = await fetchImpl(endpoint, {
588
- method: "POST",
589
- headers: {
590
- authorization: `Bearer ${options.runtimeToken}`,
591
- "content-type": "application/json"
700
+ const response = await fetchWithDeadline(
701
+ fetchImpl,
702
+ endpoint,
703
+ {
704
+ method: "POST",
705
+ headers: {
706
+ authorization: `Bearer ${options.runtimeToken}`,
707
+ "content-type": "application/json"
708
+ },
709
+ body: JSON.stringify({
710
+ status: options.status,
711
+ ...options.instanceId ? { instanceId: options.instanceId } : {}
712
+ })
592
713
  },
593
- body: JSON.stringify({
594
- status: options.status,
595
- ...options.instanceId ? { instanceId: options.instanceId } : {}
596
- })
597
- });
714
+ "health"
715
+ );
598
716
  return response.ok;
599
717
  } catch {
600
718
  return false;
@@ -603,14 +721,22 @@ async function reportHealth(options) {
603
721
 
604
722
  // src/core/jwks.ts
605
723
  var DEFAULT_CACHE_TTL_MS = 5 * 6e4;
724
+ var DEFAULT_HARD_STALE_MS = 15 * 6e4;
606
725
  var DEFAULT_NEGATIVE_CACHE_MS = 3e4;
726
+ function finiteMsOr(value, fallback) {
727
+ if (value === void 0) return fallback;
728
+ if (!Number.isFinite(value) || value < 0) return fallback;
729
+ return value;
730
+ }
607
731
  var MAX_NEGATIVE_KIDS = 1e3;
608
732
  var JwksClient = class {
609
733
  jwksUrl;
610
734
  fetchImpl;
611
735
  cacheTtlMs;
736
+ hardStaleMs;
612
737
  negativeCacheMs;
613
738
  now;
739
+ onObservation;
614
740
  keysByKid = /* @__PURE__ */ new Map();
615
741
  fetchedAt = 0;
616
742
  hasFetchedOnce = false;
@@ -619,21 +745,34 @@ var JwksClient = class {
619
745
  constructor(options) {
620
746
  this.jwksUrl = options.jwksUrl;
621
747
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
622
- this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
748
+ this.cacheTtlMs = finiteMsOr(options.cacheTtlMs, DEFAULT_CACHE_TTL_MS);
749
+ this.hardStaleMs = Math.max(
750
+ finiteMsOr(options.hardStaleMs, DEFAULT_HARD_STALE_MS),
751
+ this.cacheTtlMs
752
+ );
623
753
  this.negativeCacheMs = options.negativeCacheMs ?? DEFAULT_NEGATIVE_CACHE_MS;
624
754
  this.now = options.now ?? (() => Date.now());
755
+ this.onObservation = options.onObservation;
625
756
  }
626
757
  /**
627
758
  * Resolve a public JWK for `kid`, fail-closed. Throws FartherShoreError with
628
- * `jwks_unavailable` (cold cache + fetch failed) or `unknown_key_id`.
759
+ * `jwks_unavailable` (cold cache, or a hard-stale cache whose refresh is
760
+ * failing) or `unknown_key_id`.
629
761
  */
630
762
  async getKey(kid) {
631
763
  const cached = this.keysByKid.get(kid);
632
- if (cached && !this.isStale()) return cached;
764
+ if (cached && !this.isStale()) {
765
+ this.observe("fresh", kid);
766
+ return cached;
767
+ }
633
768
  const negAt = this.negativeKids.get(kid);
634
769
  if (negAt !== void 0 && this.now() - negAt < this.negativeCacheMs) {
635
770
  const warm = this.keysByKid.get(kid);
636
- if (warm) return warm;
771
+ if (warm) {
772
+ this.assertWithinHardStale();
773
+ this.observe(this.cacheState(), kid);
774
+ return warm;
775
+ }
637
776
  throw new FartherShoreError(
638
777
  "unknown_key_id",
639
778
  `signing key '${kid}' is not present in the JWKS`
@@ -643,6 +782,7 @@ var JwksClient = class {
643
782
  const key2 = this.keysByKid.get(kid);
644
783
  if (key2) {
645
784
  this.negativeKids.delete(kid);
785
+ this.observe(this.cacheState(), kid);
646
786
  return key2;
647
787
  }
648
788
  this.rememberMissingKid(kid);
@@ -662,8 +802,37 @@ var JwksClient = class {
662
802
  }
663
803
  this.negativeKids.set(kid, this.now());
664
804
  }
805
+ ageMs() {
806
+ return this.now() - this.fetchedAt;
807
+ }
665
808
  isStale() {
666
- return this.now() - this.fetchedAt >= this.cacheTtlMs;
809
+ return this.ageMs() >= this.cacheTtlMs;
810
+ }
811
+ isHardStale() {
812
+ return this.ageMs() >= this.hardStaleMs;
813
+ }
814
+ /** Current freshness of the cached key set. */
815
+ cacheState() {
816
+ if (!this.hasFetchedOnce) return "cold";
817
+ if (this.isHardStale()) return "hard_stale";
818
+ if (this.isStale()) return "soft_stale";
819
+ return "fresh";
820
+ }
821
+ observe(state, kid) {
822
+ this.onObservation?.({
823
+ state,
824
+ ageMs: this.hasFetchedOnce ? this.ageMs() : 0,
825
+ ...kid !== void 0 ? { kid } : {}
826
+ });
827
+ }
828
+ /** Fail closed when the cached key set is past the hard-stale ceiling. */
829
+ assertWithinHardStale() {
830
+ if (!this.isHardStale()) return;
831
+ this.observe("hard_stale");
832
+ throw new FartherShoreError(
833
+ "jwks_unavailable",
834
+ `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`
835
+ );
667
836
  }
668
837
  /** Single-flight refresh: concurrent callers share one fetch. */
669
838
  async refresh() {
@@ -676,24 +845,27 @@ var JwksClient = class {
676
845
  async doFetch() {
677
846
  let response;
678
847
  try {
679
- response = await this.fetchImpl(this.jwksUrl, {
680
- headers: { accept: "application/json" }
681
- });
848
+ response = await fetchWithDeadline(
849
+ this.fetchImpl,
850
+ this.jwksUrl,
851
+ { headers: { accept: "application/json" } },
852
+ "jwks"
853
+ );
682
854
  } catch (cause) {
683
- this.failOnColdCache(cause);
855
+ this.handleRefreshFailure(cause);
684
856
  return;
685
857
  }
686
858
  if (!response.ok) {
687
- this.failOnColdCache(
859
+ this.handleRefreshFailure(
688
860
  new Error(`JWKS endpoint returned HTTP ${response.status}`)
689
861
  );
690
862
  return;
691
863
  }
692
864
  let doc;
693
865
  try {
694
- doc = await response.json();
866
+ doc = await readBoundedJson(response);
695
867
  } catch (cause) {
696
- this.failOnColdCache(cause);
868
+ this.handleRefreshFailure(cause);
697
869
  return;
698
870
  }
699
871
  const next = /* @__PURE__ */ new Map();
@@ -706,194 +878,32 @@ var JwksClient = class {
706
878
  this.negativeKids.clear();
707
879
  }
708
880
  /**
709
- * Stale-while-revalidate: with a warm cache, swallow the refresh failure and
710
- * keep serving the last-known keys. With a COLD cache, fail closed.
711
- */
712
- failOnColdCache(cause) {
713
- if (this.hasFetchedOnce) return;
714
- throw new FartherShoreError(
715
- "jwks_unavailable",
716
- `JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
717
- );
718
- }
719
- };
720
- function stringifyCause(cause) {
721
- if (cause instanceof Error) return cause.message;
722
- return String(cause);
723
- }
724
-
725
- // src/core/backoff.ts
726
- function computeBackoff(attempt, options) {
727
- const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
728
- const exponent = Math.max(0, attempt - 1);
729
- const cap = Math.min(baseMs * 2 ** exponent, maxMs);
730
- switch (jitter) {
731
- case "none":
732
- return cap;
733
- case "full":
734
- return random() * cap;
735
- case "equal":
736
- default:
737
- return cap / 2 + random() * (cap / 2);
738
- }
739
- }
740
-
741
- // src/core/metering.ts
742
- var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
743
- var DEFAULT_BASE_DELAY_MS = 200;
744
- var DEFAULT_MAX_DELAY_MS = 1e4;
745
- function isTransientStatus(status) {
746
- return status === 429 || status >= 500;
747
- }
748
- function retryAfterMs(headers) {
749
- const raw = headers.get("retry-after");
750
- if (raw === null) return null;
751
- const trimmed = raw.trim();
752
- if (!/^\d+$/.test(trimmed)) return null;
753
- const secs = Number(trimmed);
754
- return Number.isFinite(secs) ? secs * 1e3 : null;
755
- }
756
- var DEFAULT_MAX_RETRIES = 3;
757
- var MeteringClient = class {
758
- config;
759
- endpoint;
760
- businessId;
761
- backendId;
762
- fetchImpl;
763
- maxRetries;
764
- baseDelayMs;
765
- maxDelayMs;
766
- sleep;
767
- random;
768
- newId;
769
- now;
770
- buffer = [];
771
- constructor(options) {
772
- this.config = options.config;
773
- this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
774
- this.businessId = options.businessId;
775
- this.backendId = options.backendId;
776
- this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
777
- this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
778
- this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
779
- this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
780
- this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
781
- this.random = options.random ?? Math.random;
782
- this.newId = options.newId ?? (() => crypto.randomUUID());
783
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
784
- }
785
- /**
786
- * Record `qty` of `meter`. Enforces meter-key shape, non-negative finite qty,
787
- * the bootstrap allowedMeters/allowedRoutes scope, and the per-event sanity
788
- * max, then enqueues and flushes (best-effort; failures stay buffered).
881
+ * BOUNDED stale-while-revalidate. A COLD cache fails closed. A warm cache
882
+ * inside the soft window swallows the failure and keeps serving. Past the
883
+ * hard-stale ceiling it fails closed too — availability is worth a bounded
884
+ * window of degraded trust, not an unbounded one.
789
885
  */
790
- async meter(meter, qty, options = {}) {
791
- if (!this.config.enabled) {
792
- throw new FartherShoreError(
793
- "invalid_token",
794
- "metering is not enabled for this runtime token"
795
- );
796
- }
797
- if (!METER_KEY_RE.test(meter)) {
798
- throw new FartherShoreError(
799
- "invalid_token",
800
- `meter key '${meter}' must be lowercase alphanumeric with underscores`
801
- );
802
- }
803
- if (!Number.isFinite(qty) || qty < 0) {
804
- throw new FartherShoreError(
805
- "invalid_token",
806
- `meter '${meter}' qty must be a non-negative finite number`
807
- );
808
- }
809
- if (this.config.allowedMeters.length > 0 && !this.config.allowedMeters.includes(meter)) {
886
+ handleRefreshFailure(cause) {
887
+ if (!this.hasFetchedOnce) {
888
+ this.observe("cold");
810
889
  throw new FartherShoreError(
811
- "invalid_token",
812
- `meter '${meter}' is not in the token's allowedMeters`
890
+ "jwks_unavailable",
891
+ `JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
813
892
  );
814
893
  }
815
- if (this.config.allowedRoutes.length > 0) {
816
- if (!options.routeId) {
817
- throw new FartherShoreError(
818
- "invalid_token",
819
- "routeId is required because this runtime token is route-scoped"
820
- );
821
- }
822
- if (!this.config.allowedRoutes.includes(options.routeId)) {
823
- throw new FartherShoreError(
824
- "invalid_token",
825
- `route '${options.routeId}' is not in the token's allowedRoutes`
826
- );
827
- }
828
- }
829
- if (this.config.perEventMax > 0 && qty > this.config.perEventMax) {
894
+ if (this.isHardStale()) {
895
+ this.observe("hard_stale");
830
896
  throw new FartherShoreError(
831
- "invalid_token",
832
- `meter '${meter}' qty ${qty} exceeds the per-event max ${this.config.perEventMax}`
897
+ "jwks_unavailable",
898
+ `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)}`
833
899
  );
834
900
  }
835
- const event = {
836
- event_id: options.eventId ?? this.newId(),
837
- business_id: this.businessId,
838
- backend_id: this.backendId,
839
- meter,
840
- qty,
841
- timestamp: options.timestamp ?? this.now().toISOString(),
842
- ...options.routeId ? { route_id: options.routeId } : {},
843
- ...options.requestId ? { request_id: options.requestId } : {},
844
- ...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
845
- };
846
- this.buffer.push(event);
847
- await this.flush();
848
- }
849
- /** Drain the buffer. Events that fail all retries stay buffered (at-least-once). */
850
- async flush() {
851
- const pending = this.buffer.splice(0, this.buffer.length);
852
- const stillPending = [];
853
- for (const event of pending) {
854
- const sent = await this.sendWithRetry(event);
855
- if (!sent) stillPending.push(event);
856
- }
857
- if (stillPending.length > 0) this.buffer.unshift(...stillPending);
858
- }
859
- /** Buffered-but-unsent count (observability/tests). */
860
- get pending() {
861
- return this.buffer.length;
862
- }
863
- async sendWithRetry(event) {
864
- for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
865
- let retryAfter = null;
866
- try {
867
- const response = await this.fetchImpl(this.endpoint, {
868
- method: "POST",
869
- headers: {
870
- authorization: `Bearer ${this.config.credential}`,
871
- "content-type": "application/json",
872
- accept: "application/json"
873
- },
874
- body: JSON.stringify(event)
875
- });
876
- if (response.ok) return true;
877
- if (!isTransientStatus(response.status)) return false;
878
- retryAfter = retryAfterMs(response.headers);
879
- } catch {
880
- }
881
- const isLast = attempt === this.maxRetries - 1;
882
- if (isLast) break;
883
- const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
884
- baseMs: this.baseDelayMs,
885
- maxMs: this.maxDelayMs,
886
- random: this.random
887
- });
888
- await this.sleep(delay);
889
- }
890
- return false;
901
+ this.observe("soft_stale");
891
902
  }
892
903
  };
893
- function resolveEndpoint(endpoint, coreUrl) {
894
- if (/^https?:\/\//.test(endpoint)) return endpoint;
895
- if (!coreUrl) return endpoint;
896
- return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
904
+ function stringifyCause(cause) {
905
+ if (cause instanceof Error) return cause.message;
906
+ return String(cause);
897
907
  }
898
908
 
899
909
  // src/response-metering.ts
@@ -915,50 +925,6 @@ var MeteringError = class extends Error {
915
925
  this.code = code;
916
926
  }
917
927
  };
918
- function createUsage(request, options = {}) {
919
- const usage = {};
920
- const reporter = {
921
- report(meter, value) {
922
- usage[assertMeterKey(meter)] = assertMeterValue(meter, value);
923
- return reporter;
924
- },
925
- async wrap(response, wrapOptions = {}) {
926
- return signResponse(request, response, usage, options, wrapOptions);
927
- }
928
- };
929
- return reporter;
930
- }
931
- async function withUsage(request, response, usage, options = {}) {
932
- const reporter = createUsage(request, options);
933
- for (const [meter, value] of Object.entries(usage)) {
934
- reporter.report(meter, value);
935
- }
936
- return reporter.wrap(response);
937
- }
938
- async function signResponse(request, response, usage, options, wrapOptions) {
939
- const payload = buildPayload(request, usage, options, wrapOptions);
940
- const requestId = options.requestId ?? request.headers.get("x-fs-request-id") ?? void 0;
941
- const headers = await computeMeteringHeaders(payload, {
942
- ...options.token !== void 0 ? { token: options.token } : {},
943
- ...options.env !== void 0 ? { env: options.env } : {},
944
- ...requestId ? { requestId } : {},
945
- onSkip: () => {
946
- }
947
- });
948
- if (Object.keys(headers).length === 0) {
949
- throw new MeteringError(
950
- RESPONSE_METERING_ERROR_CODES.missingToken,
951
- `${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
952
- );
953
- }
954
- const merged = new Headers(response.headers);
955
- for (const [name, value] of Object.entries(headers)) merged.set(name, value);
956
- return new Response(response.body, {
957
- status: response.status,
958
- statusText: response.statusText,
959
- headers: merged
960
- });
961
- }
962
928
  async function computeMeteringHeaders(payload, options = {}) {
963
929
  try {
964
930
  const token = resolveTokenSoft(options);
@@ -990,67 +956,6 @@ function skip(reason, options) {
990
956
  function resolveTokenSoft(options) {
991
957
  return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
992
958
  }
993
- function buildPayload(request, usage, options, wrapOptions) {
994
- const url = new URL(request.url);
995
- const measureContext = wrapOptions.measureContext ?? options.measureContext;
996
- const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
997
- const operationKey = wrapOptions.operationKey ?? options.operationKey;
998
- const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
999
- const payload = {
1000
- method: request.method.toUpperCase(),
1001
- path: url.pathname,
1002
- rawDimsUnits: sortUsage(usage),
1003
- ...measureContext ? { measureContext } : {},
1004
- ...creditUnitsConsumed ? {
1005
- creditUnitsConsumed: sortUsage(
1006
- validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
1007
- )
1008
- } : {},
1009
- ...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
1010
- ...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
1011
- };
1012
- return payload;
1013
- }
1014
- function sortUsage(usage) {
1015
- return Object.fromEntries(
1016
- Object.entries(usage).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
1017
- );
1018
- }
1019
- function validateUsageMap(usage, label) {
1020
- return Object.fromEntries(
1021
- Object.entries(usage).map(([meter, value]) => [
1022
- assertMeterKey(meter),
1023
- assertMeterValue(`${label}.${meter}`, value)
1024
- ])
1025
- );
1026
- }
1027
- function assertMeterKey(meter) {
1028
- if (!/^[a-z0-9_]{1,64}$/.test(meter)) {
1029
- throw new MeteringError(
1030
- RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1031
- `meter key "${meter}" must be lowercase alphanumeric with underscores`
1032
- );
1033
- }
1034
- return meter;
1035
- }
1036
- function assertMeterValue(meter, value) {
1037
- if (!Number.isFinite(value) || value < 0) {
1038
- throw new MeteringError(
1039
- RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1040
- `meter "${meter}" value must be a non-negative finite number`
1041
- );
1042
- }
1043
- return value;
1044
- }
1045
- function assertIdentifier(value) {
1046
- if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
1047
- throw new MeteringError(
1048
- RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1049
- `operation and usage policy identifiers must be 1-128 URL-safe characters`
1050
- );
1051
- }
1052
- return value;
1053
- }
1054
959
  function processEnv(key2) {
1055
960
  const maybeProcess = globalThis.process;
1056
961
  return maybeProcess?.env?.[key2];
@@ -1079,7 +984,7 @@ function base64url(bytes) {
1079
984
  }
1080
985
 
1081
986
  // src/core/post-stream-usage.ts
1082
- var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
987
+ var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
1083
988
  var PostStreamUsageClient = class {
1084
989
  config;
1085
990
  endpoint;
@@ -1088,14 +993,16 @@ var PostStreamUsageClient = class {
1088
993
  logger;
1089
994
  sleep;
1090
995
  retryDelaysMs;
996
+ maxRetryDelayMs;
1091
997
  constructor(options) {
1092
998
  this.config = options.config;
1093
- this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
999
+ this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
1094
1000
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1095
1001
  this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
1096
1002
  this.logger = options.logger ?? ((message) => console.warn(message));
1097
1003
  this.sleep = options.sleep ?? sleep;
1098
1004
  this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
1005
+ this.maxRetryDelayMs = options.maxRetryDelayMs ?? 1e4;
1099
1006
  }
1100
1007
  async reportUsage(input) {
1101
1008
  try {
@@ -1106,6 +1013,11 @@ var PostStreamUsageClient = class {
1106
1013
  requestId: input.requestId,
1107
1014
  subscriptionId: input.subscriptionId,
1108
1015
  nonce: this.newNonce(),
1016
+ // The token's `allowedMeters` scope is enforced on BOTH lanes
1017
+ // independently (P0-1): `meters` is the flat METER-keyed projection
1018
+ // (the billed lane), so its keys must be in scope regardless of
1019
+ // whether the measurement lane is also present; `measurements[].meter`
1020
+ // is scoped in validateMeasurements below.
1109
1021
  meters: validateAndSortUsage(input.meters, "meters", this.config, true),
1110
1022
  ...input.creditUnitsConsumed ? {
1111
1023
  creditUnitsConsumed: validateAndSortUsage(
@@ -1115,7 +1027,13 @@ var PostStreamUsageClient = class {
1115
1027
  false
1116
1028
  )
1117
1029
  } : {},
1118
- ...input.measureContext ? { measureContext: input.measureContext } : {}
1030
+ ...input.measureContext ? { measureContext: input.measureContext } : {},
1031
+ // Key ORDER is load-bearing: core recomputes the HMAC over
1032
+ // JSON.stringify(unsigned) rebuilt in its zod schema's field order, so
1033
+ // these additive fields must sit in the same position on both sides.
1034
+ ...input.measurementsVersion !== void 0 ? { measurementsVersion: input.measurementsVersion } : {},
1035
+ ...input.measurements ? { measurements: this.validateMeasurements(input.measurements) } : {},
1036
+ ...input.quote ? { quote: input.quote } : {}
1119
1037
  };
1120
1038
  const signature = await signPayload(
1121
1039
  JSON.stringify(unsigned),
@@ -1123,20 +1041,39 @@ var PostStreamUsageClient = class {
1123
1041
  );
1124
1042
  const event = { ...unsigned, signature };
1125
1043
  const body = JSON.stringify(event);
1044
+ const headerSignature = await signPayload(body, this.config.credential);
1126
1045
  for (let attempt = 0; ; attempt += 1) {
1127
- const response = await this.fetchImpl(this.endpoint, {
1128
- method: "POST",
1129
- headers: {
1130
- authorization: `Bearer ${this.config.credential}`,
1131
- "content-type": "application/json",
1132
- accept: "application/json"
1133
- },
1134
- body
1135
- });
1046
+ let response;
1047
+ try {
1048
+ response = await fetchWithDeadline(
1049
+ this.fetchImpl,
1050
+ this.endpoint,
1051
+ {
1052
+ method: "POST",
1053
+ headers: {
1054
+ authorization: `Bearer ${this.config.credential}`,
1055
+ "content-type": "application/json",
1056
+ accept: "application/json",
1057
+ [RUNTIME_RESPONSE_METERING_CONTRACT.headers.signature]: headerSignature
1058
+ },
1059
+ body
1060
+ },
1061
+ "postStreamUsage"
1062
+ );
1063
+ } catch (cause) {
1064
+ const delayMs2 = this.retryDelayForAttempt(attempt, null);
1065
+ if (delayMs2 === null) throw cause;
1066
+ await this.sleep(delayMs2);
1067
+ continue;
1068
+ }
1136
1069
  if (response.ok) return { ok: true };
1137
1070
  const requestNotFound = await isPostStreamRequestNotFound(response);
1138
- const delayMs = this.retryDelaysMs[attempt];
1139
- if (!requestNotFound || delayMs === void 0) {
1071
+ const retryable = requestNotFound || isRetryableStatus(response.status);
1072
+ const delayMs = this.retryDelayForAttempt(
1073
+ attempt,
1074
+ retryAfterMs(response.headers)
1075
+ );
1076
+ if (!retryable || delayMs === null) {
1140
1077
  throw new Error(`metering endpoint returned ${response.status}`);
1141
1078
  }
1142
1079
  await this.sleep(delayMs);
@@ -1147,6 +1084,31 @@ var PostStreamUsageClient = class {
1147
1084
  return { ok: false, reason };
1148
1085
  }
1149
1086
  }
1087
+ /** Enforce the token's meter scope + per-event bounds on the measurement lane. */
1088
+ validateMeasurements(measurements) {
1089
+ const allowed = this.config.allowedMeters;
1090
+ for (const measurement of measurements) {
1091
+ if (allowed.length > 0 && !allowed.includes(measurement.meter)) {
1092
+ throw new Error(
1093
+ `meter '${measurement.meter}' is not in the token's allowedMeters`
1094
+ );
1095
+ }
1096
+ for (const [measure, value] of Object.entries(measurement.values)) {
1097
+ if (this.config.perEventMax > 0 && value > this.config.perEventMax) {
1098
+ throw new Error(
1099
+ `measure '${measure}' value ${value} exceeds the per-event max ${this.config.perEventMax}`
1100
+ );
1101
+ }
1102
+ }
1103
+ }
1104
+ return measurements;
1105
+ }
1106
+ retryDelayForAttempt(attempt, retryAfterMs2) {
1107
+ const fallback = this.retryDelaysMs[attempt];
1108
+ if (fallback === void 0) return null;
1109
+ if (retryAfterMs2 === null) return fallback;
1110
+ return Math.min(retryAfterMs2, this.maxRetryDelayMs);
1111
+ }
1150
1112
  };
1151
1113
  async function isPostStreamRequestNotFound(response) {
1152
1114
  if (response.status !== 422) return false;
@@ -1157,6 +1119,18 @@ async function isPostStreamRequestNotFound(response) {
1157
1119
  return false;
1158
1120
  }
1159
1121
  }
1122
+ function isRetryableStatus(status) {
1123
+ return status === 429 || status >= 500 && status <= 599;
1124
+ }
1125
+ function retryAfterMs(headers) {
1126
+ const raw = headers.get("retry-after");
1127
+ if (!raw) return null;
1128
+ const seconds = Number(raw);
1129
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
1130
+ const dateMs = Date.parse(raw);
1131
+ if (!Number.isFinite(dateMs)) return null;
1132
+ return Math.max(0, dateMs - Date.now());
1133
+ }
1160
1134
  function sleep(delayMs) {
1161
1135
  return new Promise((resolve) => setTimeout(resolve, delayMs));
1162
1136
  }
@@ -1165,7 +1139,7 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1165
1139
  ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1166
1140
  );
1167
1141
  for (const [meter, qty] of entries) {
1168
- if (!METER_KEY_RE2.test(meter)) {
1142
+ if (!METER_KEY_RE.test(meter)) {
1169
1143
  throw new Error(
1170
1144
  `${label} key '${meter}' must be lowercase alphanumeric with underscores`
1171
1145
  );
@@ -1184,12 +1158,303 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1184
1158
  }
1185
1159
  return Object.fromEntries(entries);
1186
1160
  }
1187
- function resolveEndpoint2(endpoint, coreUrl) {
1161
+ function resolveEndpoint(endpoint, coreUrl) {
1188
1162
  if (/^https?:\/\//.test(endpoint)) return endpoint;
1189
1163
  if (!coreUrl) return endpoint;
1190
1164
  return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1191
1165
  }
1192
1166
 
1167
+ // src/core/report.ts
1168
+ var MEASUREMENTS_VERSION = 1;
1169
+ var KEY_RE = /^[a-z0-9_]{1,64}$/;
1170
+ var DIMENSION_VALUE_RE = /^[\w.:-]{1,128}$/;
1171
+ var CURRENCY_RE = /^[A-Za-z]{3}$/;
1172
+ var DECIMAL_INTEGER_RE = /^\d{1,30}$/;
1173
+ function createReportFn(channels) {
1174
+ let stampedMeasurements = [];
1175
+ let stampedQuote;
1176
+ let inBandTail = Promise.resolve();
1177
+ let postStreamFinalized = false;
1178
+ let pendingPostStreamBatch = null;
1179
+ const deliverPostStream = async (reported, quote) => {
1180
+ if (pendingPostStreamBatch) {
1181
+ if (!quotesEqual(pendingPostStreamBatch.quote, quote)) {
1182
+ return {
1183
+ ok: false,
1184
+ transport: "post_stream",
1185
+ reason: "quote conflicts with this request's pending post-stream batch: one served request carries one quote across all measurements"
1186
+ };
1187
+ }
1188
+ if (reported.some(
1189
+ (measurement) => !dimsEqual(
1190
+ pendingPostStreamBatch.measurements[0]?.dims,
1191
+ measurement.dims
1192
+ )
1193
+ )) {
1194
+ return {
1195
+ ok: false,
1196
+ transport: "post_stream",
1197
+ reason: "dims conflict with this request's pending post-stream batch: the request receipt rates under ONE dims tuple"
1198
+ };
1199
+ }
1200
+ pendingPostStreamBatch.measurements.push(...reported);
1201
+ return pendingPostStreamBatch.flush;
1202
+ }
1203
+ if (postStreamFinalized) {
1204
+ return {
1205
+ ok: false,
1206
+ transport: "post_stream",
1207
+ 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"
1208
+ };
1209
+ }
1210
+ if (stampedMeasurements.length > 0) {
1211
+ return {
1212
+ ok: false,
1213
+ transport: "post_stream",
1214
+ reason: "this request already reported in-band; every report on one request must share the stamped aggregate (same quote, before the response is sent)"
1215
+ };
1216
+ }
1217
+ postStreamFinalized = true;
1218
+ const batch = {
1219
+ measurements: [...reported],
1220
+ quote,
1221
+ flush: void 0
1222
+ };
1223
+ batch.flush = new Promise((resolve) => setTimeout(resolve, 0)).then(
1224
+ async () => {
1225
+ pendingPostStreamBatch = null;
1226
+ const result = await channels.postStream({
1227
+ measurements: batch.measurements,
1228
+ ...batch.quote ? { quote: batch.quote } : {}
1229
+ });
1230
+ return result.ok ? { ok: true, transport: "post_stream" } : {
1231
+ ok: false,
1232
+ transport: "post_stream",
1233
+ reason: result.reason ?? "post-stream delivery failed"
1234
+ };
1235
+ }
1236
+ );
1237
+ pendingPostStreamBatch = batch;
1238
+ return batch.flush;
1239
+ };
1240
+ const tryInBand = (reported, quote) => {
1241
+ const run = inBandTail.then(async () => {
1242
+ const sink = channels.responseSink;
1243
+ if (!sink || !channels.request || !sink.canStampHeaders()) return null;
1244
+ if (postStreamFinalized) return null;
1245
+ if (!quotesEqual(stampedQuote, quote) && stampedMeasurements.length > 0) {
1246
+ return null;
1247
+ }
1248
+ if (stampedMeasurements.length > 0 && reported.some(
1249
+ (measurement) => !dimsEqual(stampedMeasurements[0].dims, measurement.dims)
1250
+ )) {
1251
+ return null;
1252
+ }
1253
+ const measurements = [...stampedMeasurements, ...reported];
1254
+ const payload = buildInBandPayload(channels.request, measurements, quote);
1255
+ const headers = await channels.computeHeaders(payload);
1256
+ if (Object.keys(headers).length === 0 || !sink.canStampHeaders()) {
1257
+ return null;
1258
+ }
1259
+ sink.stampHeaders(headers);
1260
+ stampedMeasurements = measurements;
1261
+ stampedQuote = quote;
1262
+ return { ok: true, transport: "in_band" };
1263
+ });
1264
+ inBandTail = run.then(
1265
+ () => void 0,
1266
+ () => void 0
1267
+ );
1268
+ return run;
1269
+ };
1270
+ return async (input) => {
1271
+ const inputs = Array.isArray(input) ? input : [input];
1272
+ if (inputs.length === 0) {
1273
+ throw new MeteringError(
1274
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1275
+ "report([]) is empty: a batched report needs at least one measurement"
1276
+ );
1277
+ }
1278
+ const measurements = inputs.map((entry) => validateMeasurement(entry));
1279
+ for (const measurement of measurements) {
1280
+ if (!dimsEqual(measurements[0].dims, measurement.dims)) {
1281
+ throw new MeteringError(
1282
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1283
+ "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"
1284
+ );
1285
+ }
1286
+ }
1287
+ let quote;
1288
+ for (const entry of inputs) {
1289
+ if (entry.quote === void 0) continue;
1290
+ const validated = validateQuote(entry.quote);
1291
+ if (quote === void 0) {
1292
+ quote = validated;
1293
+ } else if (!quotesEqual(quote, validated)) {
1294
+ throw new MeteringError(
1295
+ RESPONSE_METERING_ERROR_CODES.invalidQuote,
1296
+ "a batched report carries ONE quote: two entries supplied different quotes"
1297
+ );
1298
+ }
1299
+ }
1300
+ const inBand = await tryInBand(measurements, quote);
1301
+ if (inBand) return inBand;
1302
+ return deliverPostStream(measurements, quote);
1303
+ };
1304
+ }
1305
+ function unattachedReport() {
1306
+ return () => Promise.reject(
1307
+ new MeteringError(
1308
+ RESPONSE_METERING_ERROR_CODES.missingToken,
1309
+ "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"
1310
+ )
1311
+ );
1312
+ }
1313
+ function rawDimsUnitsOf(measurement) {
1314
+ let total = 0;
1315
+ for (const value of Object.values(measurement.values)) total += value;
1316
+ return { [measurement.meter]: total };
1317
+ }
1318
+ function buildInBandPayload(request, measurements, quote) {
1319
+ const rawDimsUnits = {};
1320
+ for (const measurement of measurements) {
1321
+ for (const [meter, units] of Object.entries(rawDimsUnitsOf(measurement))) {
1322
+ rawDimsUnits[meter] = (rawDimsUnits[meter] ?? 0) + units;
1323
+ }
1324
+ }
1325
+ return {
1326
+ method: request.method.toUpperCase(),
1327
+ path: request.path,
1328
+ rawDimsUnits,
1329
+ measurementsVersion: MEASUREMENTS_VERSION,
1330
+ measurements,
1331
+ ...quote ? { quote } : {}
1332
+ };
1333
+ }
1334
+ function dimsEqual(left, right) {
1335
+ const l = Object.entries(left ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
1336
+ const r = Object.entries(right ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
1337
+ if (l.length !== r.length) return false;
1338
+ return l.every(([k, v], i) => r[i][0] === k && r[i][1] === v);
1339
+ }
1340
+ function quotesEqual(left, right) {
1341
+ return left === right || left !== void 0 && right !== void 0 && left.currency === right.currency && left.amountNanos === right.amountNanos;
1342
+ }
1343
+ function validateMeasurement(input) {
1344
+ if (!input || typeof input !== "object") {
1345
+ throw invalidKey("report() requires a { meter, values } object");
1346
+ }
1347
+ const meter = assertKey(input.meter, "meter");
1348
+ const values = assertValues(input.values);
1349
+ const dims = input.dims === void 0 ? void 0 : assertDims(input.dims);
1350
+ return {
1351
+ meter,
1352
+ values,
1353
+ ...dims && Object.keys(dims).length > 0 ? { dims } : {}
1354
+ };
1355
+ }
1356
+ function validateQuote(quote) {
1357
+ if (!quote || typeof quote !== "object" || Array.isArray(quote)) {
1358
+ throw invalidQuote(
1359
+ "quote must be an object of the form { currency, amountNanos }"
1360
+ );
1361
+ }
1362
+ const { currency, amountNanos } = quote;
1363
+ if (typeof currency !== "string" || !CURRENCY_RE.test(currency)) {
1364
+ throw invalidQuote("quote.currency must be a 3-letter currency code");
1365
+ }
1366
+ return {
1367
+ currency: currency.toLowerCase(),
1368
+ amountNanos: assertAmountNanos(amountNanos)
1369
+ };
1370
+ }
1371
+ function assertAmountNanos(value) {
1372
+ if (typeof value === "bigint") {
1373
+ if (value < 0n) throw negativeAmountNanos();
1374
+ return value.toString();
1375
+ }
1376
+ if (typeof value === "number") {
1377
+ if (!Number.isSafeInteger(value)) {
1378
+ throw invalidQuote(
1379
+ "quote.amountNanos must be a safe integer number of nanodollars (pass a string or bigint for larger amounts)"
1380
+ );
1381
+ }
1382
+ if (value < 0) throw negativeAmountNanos();
1383
+ return String(value);
1384
+ }
1385
+ if (typeof value === "string") {
1386
+ if (/^-/.test(value)) throw negativeAmountNanos();
1387
+ if (DECIMAL_INTEGER_RE.test(value)) return value;
1388
+ }
1389
+ throw invalidQuote(
1390
+ "quote.amountNanos must be a non-negative integer number of nanodollars"
1391
+ );
1392
+ }
1393
+ function negativeAmountNanos() {
1394
+ return invalidQuote(
1395
+ "quote.amountNanos must be non-negative: a quote is a proposed rate, never a credit \u2014 refunds are platform operations"
1396
+ );
1397
+ }
1398
+ function assertValues(values) {
1399
+ if (!values || typeof values !== "object" || Array.isArray(values)) {
1400
+ throw invalidKey("report() requires a values object");
1401
+ }
1402
+ const entries = Object.entries(values).sort(
1403
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1404
+ );
1405
+ if (entries.length === 0) {
1406
+ throw invalidKey("report() requires at least one measure in values");
1407
+ }
1408
+ const out = {};
1409
+ for (const [measure, value] of entries) {
1410
+ assertKey(measure, "measure");
1411
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
1412
+ throw new MeteringError(
1413
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1414
+ `values.${measure} must be a non-negative safe integer`
1415
+ );
1416
+ }
1417
+ out[measure] = value;
1418
+ }
1419
+ return out;
1420
+ }
1421
+ function assertDims(dims) {
1422
+ if (!dims || typeof dims !== "object" || Array.isArray(dims)) {
1423
+ throw invalidKey("report() dims must be an object of dimension selectors");
1424
+ }
1425
+ const entries = Object.entries(dims).sort(
1426
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1427
+ );
1428
+ const out = {};
1429
+ for (const [dimension, value] of entries) {
1430
+ assertKey(dimension, "dimension");
1431
+ if (typeof value !== "string" || !DIMENSION_VALUE_RE.test(value)) {
1432
+ throw invalidKey(
1433
+ `dims.${dimension} must be a 1-128 character selector value`
1434
+ );
1435
+ }
1436
+ out[dimension] = value;
1437
+ }
1438
+ return out;
1439
+ }
1440
+ function assertKey(value, label) {
1441
+ if (typeof value !== "string" || !KEY_RE.test(value)) {
1442
+ throw invalidKey(
1443
+ `${label} key ${JSON.stringify(value)} must be 1-64 lowercase alphanumeric characters or underscores`
1444
+ );
1445
+ }
1446
+ return value;
1447
+ }
1448
+ function invalidKey(message) {
1449
+ return new MeteringError(
1450
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1451
+ message
1452
+ );
1453
+ }
1454
+ function invalidQuote(message) {
1455
+ return new MeteringError(RESPONSE_METERING_ERROR_CODES.invalidQuote, message);
1456
+ }
1457
+
1193
1458
  // src/core/nonceCache.ts
1194
1459
  var DEFAULT_MAX_ENTRIES = 25e4;
1195
1460
  var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
@@ -1232,6 +1497,37 @@ var NonceCache = class {
1232
1497
  }
1233
1498
  };
1234
1499
 
1500
+ // src/core/replay-protection.ts
1501
+ function resolveReplayProtection(input = {}) {
1502
+ if (input.nonceStore) {
1503
+ return {
1504
+ // An opted-in shared store that is DOWN must not degrade to "no replay
1505
+ // check" — that would make knocking it over a way to switch the
1506
+ // protection off entirely.
1507
+ store: failClosed(input.nonceStore),
1508
+ diagnostic: { mode: "shared", crossReplica: true }
1509
+ };
1510
+ }
1511
+ return {
1512
+ store: new NonceCache(),
1513
+ diagnostic: { mode: "single-instance", crossReplica: false }
1514
+ };
1515
+ }
1516
+ function failClosed(store) {
1517
+ return {
1518
+ async checkAndRemember(id) {
1519
+ try {
1520
+ return await store.checkAndRemember(id);
1521
+ } catch (cause) {
1522
+ throw new FartherShoreError(
1523
+ "replayed_nonce",
1524
+ `replay store is unavailable, refusing the request rather than skipping one-time-use enforcement: ${cause instanceof Error ? cause.message : String(cause)}`
1525
+ );
1526
+ }
1527
+ }
1528
+ };
1529
+ }
1530
+
1235
1531
  // src/core/shutdown.ts
1236
1532
  var ShutdownManager = class {
1237
1533
  hooks = [];
@@ -1752,22 +2048,7 @@ async function verifyRequest(input, deps) {
1752
2048
  "x-fs-timestamp is not an integer"
1753
2049
  );
1754
2050
  }
1755
- const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
1756
- const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
1757
- const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
1758
- const delta = now - timestamp;
1759
- if (Math.abs(delta) > window) {
1760
- if (Math.abs(delta) <= window + skew) {
1761
- throw new FartherShoreError(
1762
- "clock_skew",
1763
- "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
1764
- );
1765
- }
1766
- throw new FartherShoreError(
1767
- "expired_signature",
1768
- "x-fs-timestamp is outside the replay window"
1769
- );
1770
- }
2051
+ assertTimestampWithinWindow(timestamp, deps);
1771
2052
  const computedBodyHash = await computeBodyHash(input);
1772
2053
  if (signedBodyHash !== computedBodyHash) {
1773
2054
  throw new FartherShoreError(
@@ -1775,31 +2056,7 @@ async function verifyRequest(input, deps) {
1775
2056
  "recomputed body hash does not match the signed x-fs-body-hash"
1776
2057
  );
1777
2058
  }
1778
- if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
1779
- throw new FartherShoreError(
1780
- "route_mismatch",
1781
- "signed business-id does not match this backend's business"
1782
- );
1783
- }
1784
- if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
1785
- if (!deps.backendIds.has(signedBackendId)) {
1786
- throw new FartherShoreError(
1787
- "route_mismatch",
1788
- "signed backend-id is not one this deployment serves"
1789
- );
1790
- }
1791
- } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
1792
- throw new FartherShoreError(
1793
- "route_mismatch",
1794
- "signed backend-id does not match this backend"
1795
- );
1796
- }
1797
- if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
1798
- throw new FartherShoreError(
1799
- "route_mismatch",
1800
- "signed route-id is not served by this backend"
1801
- );
1802
- }
2059
+ assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId);
1803
2060
  const contextToken = h("x-fs-context") ?? null;
1804
2061
  const canonicalInput = {
1805
2062
  method: input.method,
@@ -1870,9 +2127,57 @@ async function verifyRequest(input, deps) {
1870
2127
  ...principal ? { principal } : {},
1871
2128
  ...permissions !== void 0 ? { permissions } : {},
1872
2129
  ...roles !== void 0 ? { roles } : {},
1873
- ...signedContext ? { signedContext } : {}
2130
+ ...signedContext ? { signedContext } : {},
2131
+ // The bare primitive has no metering channel; the runtime facade replaces
2132
+ // this with the real bound verb (see FartherShore.verifyRequest).
2133
+ report: unattachedReport()
1874
2134
  };
1875
2135
  }
2136
+ function assertTimestampWithinWindow(timestamp, deps) {
2137
+ const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
2138
+ const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
2139
+ const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
2140
+ const delta = now - timestamp;
2141
+ if (Math.abs(delta) > window) {
2142
+ if (Math.abs(delta) <= window + skew) {
2143
+ throw new FartherShoreError(
2144
+ "clock_skew",
2145
+ "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
2146
+ );
2147
+ }
2148
+ throw new FartherShoreError(
2149
+ "expired_signature",
2150
+ "x-fs-timestamp is outside the replay window"
2151
+ );
2152
+ }
2153
+ }
2154
+ function assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId) {
2155
+ if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
2156
+ throw new FartherShoreError(
2157
+ "route_mismatch",
2158
+ "signed business-id does not match this backend's business"
2159
+ );
2160
+ }
2161
+ if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
2162
+ if (!deps.backendIds.has(signedBackendId)) {
2163
+ throw new FartherShoreError(
2164
+ "route_mismatch",
2165
+ "signed backend-id is not one this deployment serves"
2166
+ );
2167
+ }
2168
+ } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
2169
+ throw new FartherShoreError(
2170
+ "route_mismatch",
2171
+ "signed backend-id does not match this backend"
2172
+ );
2173
+ }
2174
+ if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
2175
+ throw new FartherShoreError(
2176
+ "route_mismatch",
2177
+ "signed route-id is not served by this backend"
2178
+ );
2179
+ }
2180
+ }
1876
2181
  async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
1877
2182
  if (!contextToken) return null;
1878
2183
  const signedContext = decodeContextClaims(contextToken);
@@ -1925,7 +2230,7 @@ function headerGetter(headers) {
1925
2230
 
1926
2231
  // src/core/runtime.ts
1927
2232
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1928
- var SDK_VERSION = "0.19.0".length > 0 ? "0.19.0" : "0.0.0-dev";
2233
+ var SDK_VERSION = "0.21.0".length > 0 ? "0.21.0" : "0.0.0-dev";
1929
2234
  var FartherShore = class {
1930
2235
  bootstrapClient;
1931
2236
  fetchImpl;
@@ -1938,16 +2243,16 @@ var FartherShore = class {
1938
2243
  /** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
1939
2244
  contextSecrets;
1940
2245
  nonceCache;
2246
+ replayProtectionDiagnostic;
1941
2247
  shutdownManager = new ShutdownManager();
1942
2248
  jwks = null;
1943
- meteringClient = null;
1944
2249
  postStreamUsageClient = null;
1945
2250
  tunnel = null;
1946
2251
  bootstrapped = false;
1947
2252
  constructor(options = {}) {
1948
2253
  const env = options.env ?? readProcessEnv();
1949
2254
  const runtimeToken = options.runtimeToken ?? env[FS_RUNTIME_TOKEN_ENV] ?? "";
1950
- const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? env.FARTHERSHORE_CORE_URL ?? DEFAULT_CORE_URL;
2255
+ const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? DEFAULT_CORE_URL;
1951
2256
  this.runtimeToken = runtimeToken;
1952
2257
  this.coreUrl = coreUrl;
1953
2258
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
@@ -1955,7 +2260,9 @@ var FartherShore = class {
1955
2260
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
1956
2261
  this.tunnelOptions = options.tunnel ?? {};
1957
2262
  this.instanceId = options.instanceId;
1958
- this.nonceCache = options.nonceStore ?? new NonceCache();
2263
+ const replay = resolveReplayProtection({ nonceStore: options.nonceStore });
2264
+ this.nonceCache = replay.store;
2265
+ this.replayProtectionDiagnostic = replay.diagnostic;
1959
2266
  this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
1960
2267
  this.bootstrapClient = new BootstrapClient({
1961
2268
  runtimeToken,
@@ -1967,9 +2274,6 @@ var FartherShore = class {
1967
2274
  ...options.instanceId ? { instanceId: options.instanceId } : {}
1968
2275
  }
1969
2276
  });
1970
- this.shutdownManager.register(async () => {
1971
- await this.meteringClient?.flush();
1972
- });
1973
2277
  this.shutdownManager.register(async () => {
1974
2278
  await reportHealth({
1975
2279
  runtimeToken: this.runtimeToken,
@@ -1989,14 +2293,7 @@ var FartherShore = class {
1989
2293
  fetchImpl: this.fetchImpl
1990
2294
  });
1991
2295
  }
1992
- if (!this.meteringClient && config.metering.enabled) {
1993
- this.meteringClient = new MeteringClient({
1994
- config: config.metering,
1995
- businessId: config.business.id,
1996
- backendId: config.backend.id,
1997
- coreUrl: this.coreUrl,
1998
- fetchImpl: this.fetchImpl
1999
- });
2296
+ if (!this.postStreamUsageClient && config.metering.enabled) {
2000
2297
  this.postStreamUsageClient = new PostStreamUsageClient({
2001
2298
  config: config.metering,
2002
2299
  coreUrl: this.coreUrl,
@@ -2045,14 +2342,19 @@ var FartherShore = class {
2045
2342
  buildReportSink() {
2046
2343
  const post = async (path, body) => {
2047
2344
  const base = this.coreUrl.replace(/\/$/, "");
2048
- const res = await this.fetchImpl(`${base}${path}`, {
2049
- method: "POST",
2050
- headers: {
2051
- "content-type": "application/json",
2052
- authorization: `Bearer ${this.runtimeToken}`
2345
+ const res = await fetchWithDeadline(
2346
+ this.fetchImpl,
2347
+ `${base}${path}`,
2348
+ {
2349
+ method: "POST",
2350
+ headers: {
2351
+ "content-type": "application/json",
2352
+ authorization: `Bearer ${this.runtimeToken}`
2353
+ },
2354
+ body: JSON.stringify(body)
2053
2355
  },
2054
- body: JSON.stringify(body)
2055
- });
2356
+ "report"
2357
+ );
2056
2358
  if (!res.ok) throw new Error(`runtime report ${path} -> ${res.status}`);
2057
2359
  };
2058
2360
  return {
@@ -2064,7 +2366,7 @@ var FartherShore = class {
2064
2366
  * Framework-neutral verification primitive. Fail-closed: throws a typed
2065
2367
  * FartherShoreError on any verification failure. Returns the verified context.
2066
2368
  */
2067
- async verifyRequest(input) {
2369
+ async verifyRequest(input, options = {}) {
2068
2370
  const config = await this.ensureBootstrapped();
2069
2371
  if (!this.jwks) {
2070
2372
  throw new FartherShoreError(
@@ -2090,21 +2392,52 @@ var FartherShore = class {
2090
2392
  });
2091
2393
  return {
2092
2394
  ...context,
2093
- reportUsage: (report) => {
2094
- const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
2395
+ report: this.buildReportFn(context, input, options.responseSink)
2396
+ };
2397
+ }
2398
+ /**
2399
+ * Bind the ONE reporting verb to a verified context. Identity comes from the
2400
+ * context (`signedContext.subscriptionId` + `requestId`) — never from the
2401
+ * caller — so a handler cannot forget it, and a background job that is handed
2402
+ * this context keeps reporting against the SAME served identity.
2403
+ */
2404
+ buildReportFn(context, input, responseSink) {
2405
+ const channels = {
2406
+ request: { method: input.method, path: input.path },
2407
+ ...responseSink ? { responseSink } : {},
2408
+ computeHeaders: (payload) => computeMeteringHeaders(payload, {
2409
+ token: this.runtimeToken,
2410
+ requestId: context.requestId,
2411
+ onSkip: () => {
2412
+ }
2413
+ }),
2414
+ postStream: async ({ measurements, quote }) => {
2415
+ const subscriptionId = context.signedContext?.subscriptionId;
2095
2416
  if (!subscriptionId) {
2096
- return Promise.resolve({
2417
+ return {
2097
2418
  ok: false,
2098
- reason: "subscriptionId is required"
2099
- });
2419
+ 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"
2420
+ };
2100
2421
  }
2101
- return this.reportUsage({
2102
- ...report,
2103
- requestId: report.requestId ?? context.requestId,
2104
- subscriptionId
2422
+ const meters = {};
2423
+ for (const measurement of measurements) {
2424
+ for (const [meter, qty] of Object.entries(
2425
+ rawDimsUnitsOf(measurement)
2426
+ )) {
2427
+ meters[meter] = (meters[meter] ?? 0) + qty;
2428
+ }
2429
+ }
2430
+ return this.reportPostStreamUsage({
2431
+ requestId: context.requestId,
2432
+ subscriptionId,
2433
+ meters,
2434
+ measurementsVersion: MEASUREMENTS_VERSION,
2435
+ measurements,
2436
+ ...quote ? { quote } : {}
2105
2437
  });
2106
2438
  }
2107
2439
  };
2440
+ return createReportFn(channels);
2108
2441
  }
2109
2442
  /** Whether verification is required (bootstrap × opt-out). */
2110
2443
  async verificationRequired() {
@@ -2153,20 +2486,12 @@ var FartherShore = class {
2153
2486
  });
2154
2487
  await supervisor.start();
2155
2488
  }
2156
- /** Record metering usage (billing-only). */
2157
- async meter(meter, qty, options = {}) {
2158
- await this.ensureBootstrapped();
2159
- if (!this.meteringEnabledOverride) return;
2160
- if (!this.meteringClient) {
2161
- throw new FartherShoreError(
2162
- "invalid_token",
2163
- "metering is not enabled for this runtime token"
2164
- );
2165
- }
2166
- await this.meteringClient.meter(meter, qty, options);
2167
- }
2168
- /** Best-effort attested post-stream usage callback. Never rejects. */
2169
- async reportUsage(input) {
2489
+ /**
2490
+ * PRIVATE transport for the post-stream lane of `ctx.report()`. Never rejects
2491
+ * — a metering hiccup must not break a builder's endpoint. This is machinery,
2492
+ * not surface: the ONE public reporting verb is `ctx.report()`.
2493
+ */
2494
+ async reportPostStreamUsage(input) {
2170
2495
  try {
2171
2496
  await this.ensureBootstrapped();
2172
2497
  if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
@@ -2179,6 +2504,15 @@ var FartherShore = class {
2179
2504
  return { ok: false, reason };
2180
2505
  }
2181
2506
  }
2507
+ /**
2508
+ * How far replay protection actually reaches — `"shared"` (enforced across
2509
+ * every replica) or `"single-instance"` (this process only). Deployment
2510
+ * diagnostic: log it at boot, or assert on it in a smoke test, so the mode is
2511
+ * never a surprise. See {@link FartherShoreInitOptions.nonceStore}.
2512
+ */
2513
+ replayProtection() {
2514
+ return this.replayProtectionDiagnostic;
2515
+ }
2182
2516
  /** Current local health report. */
2183
2517
  health() {
2184
2518
  const config = this.bootstrapClient.peek();
@@ -2189,7 +2523,7 @@ var FartherShore = class {
2189
2523
  // fs.start() launches an embedded tunnel; otherwise the supervisor state.
2190
2524
  tunnel: this.tunnel ? this.tunnel.healthString() : null,
2191
2525
  verification: this.verificationEnabled && config !== null,
2192
- metering: this.meteringClient !== null
2526
+ metering: this.postStreamUsageClient !== null
2193
2527
  });
2194
2528
  }
2195
2529
  /** Graceful shutdown: flush metering + send a stopping heartbeat. */
@@ -2226,10 +2560,13 @@ var FartherShorePermissionError = class extends Error {
2226
2560
  this.requiredPermission = requiredPermission;
2227
2561
  }
2228
2562
  };
2229
- function permissionGrants(permissions, key2) {
2230
- if (permissions === void 0) return true;
2231
- if (permissions.includes(WILDCARD)) return true;
2232
- return permissions.includes(key2);
2563
+ var READ_METHODS = /* @__PURE__ */ new Set([
2564
+ "GET",
2565
+ "HEAD",
2566
+ "OPTIONS"
2567
+ ]);
2568
+ function routePermission(subject, method) {
2569
+ return READ_METHODS.has(method.toUpperCase()) ? `${subject}:read` : `${subject}:write`;
2233
2570
  }
2234
2571
  function permissionSatisfies(required, granted) {
2235
2572
  if (granted === void 0) return true;
@@ -2238,7 +2575,12 @@ function permissionSatisfies(required, granted) {
2238
2575
  const idx = required.indexOf(":");
2239
2576
  if (idx > 0 && idx < required.length - 1) {
2240
2577
  const subject = required.slice(0, idx);
2241
- if (granted.includes(`${subject}:${WILDCARD}`)) return true;
2578
+ if (subject !== WILDCARD && granted.includes(`${subject}:${WILDCARD}`))
2579
+ return true;
2580
+ if (required.slice(idx + 1) === WILDCARD && subject !== WILDCARD) {
2581
+ const prefix = `${subject}:`;
2582
+ return granted.some((permission) => permission.startsWith(prefix));
2583
+ }
2242
2584
  }
2243
2585
  return false;
2244
2586
  }
@@ -2273,14 +2615,17 @@ async function runMiddleware(fs, options, req, res, next) {
2273
2615
  const contentType = headerValue(req.headers, "content-type");
2274
2616
  const streamingExempt = isStreamingExempt(contentType);
2275
2617
  const body = streamingExempt ? null : extractRawBody(req);
2276
- const ctx = await fs.verifyRequest({
2277
- method: req.method,
2278
- path,
2279
- query,
2280
- headers: req.headers,
2281
- body,
2282
- streamingExempt
2283
- });
2618
+ const ctx = await fs.verifyRequest(
2619
+ {
2620
+ method: req.method,
2621
+ path,
2622
+ query,
2623
+ headers: req.headers,
2624
+ body,
2625
+ streamingExempt
2626
+ },
2627
+ { responseSink: expressResponseSink(res) }
2628
+ );
2284
2629
  req.fartherShore = ctx;
2285
2630
  stripFartherShoreHeaders(req);
2286
2631
  next();
@@ -2288,6 +2633,16 @@ async function runMiddleware(fs, options, req, res, next) {
2288
2633
  fail(res, error, options, req);
2289
2634
  }
2290
2635
  }
2636
+ function expressResponseSink(res) {
2637
+ return {
2638
+ canStampHeaders: () => res.headersSent !== true,
2639
+ stampHeaders: (headers) => {
2640
+ for (const [name, value] of Object.entries(headers)) {
2641
+ res.setHeader(name, value);
2642
+ }
2643
+ }
2644
+ };
2645
+ }
2291
2646
  function fail(res, error, options, req) {
2292
2647
  const code = error instanceof FartherShoreError ? error.code : "bad_signature";
2293
2648
  const status = error instanceof FartherShoreError ? error.status : 401;
@@ -2328,7 +2683,12 @@ function stripFartherShoreHeaders(req) {
2328
2683
  withRaw.rawHeaders = cleaned;
2329
2684
  }
2330
2685
  }
2331
- function createExpressHandler(handler) {
2686
+ function createExpressHandler(optionsOrHandler, maybeHandler) {
2687
+ const options = typeof optionsOrHandler === "function" ? {} : optionsOrHandler;
2688
+ const handler = typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler;
2689
+ if (typeof handler !== "function") {
2690
+ throw new TypeError("fs.handler(options, cb) requires a handler callback");
2691
+ }
2332
2692
  return (req, res, next) => {
2333
2693
  const ctx = req.fartherShore;
2334
2694
  if (!ctx) {
@@ -2339,10 +2699,22 @@ function createExpressHandler(handler) {
2339
2699
  res.status(401).json({ error: "principal_required" });
2340
2700
  return;
2341
2701
  }
2702
+ if (!ctx.signedContext) {
2703
+ res.status(401).json({ error: "context_unverified" });
2704
+ return;
2705
+ }
2342
2706
  const verified = ctx;
2343
- void Promise.resolve().then(
2344
- () => handler(verified, req, res, next)
2345
- ).catch((error) => failHandler(res, next, error));
2707
+ void Promise.resolve().then(() => {
2708
+ if (options.permission !== void 0) {
2709
+ requirePermission(verified, options.permission);
2710
+ }
2711
+ return handler(
2712
+ verified,
2713
+ req,
2714
+ res,
2715
+ next
2716
+ );
2717
+ }).catch((error) => failHandler(res, next, error));
2346
2718
  };
2347
2719
  }
2348
2720
  function failHandler(res, next, error) {
@@ -2804,7 +3176,7 @@ function readProcessEnv2() {
2804
3176
  // src/testing/usageSink.ts
2805
3177
  var DevUsageSink = class {
2806
3178
  events = [];
2807
- /** Record a signed response-metering payload (withUsage / computeMeteringHeaders). */
3179
+ /** Record a signed response-metering payload (report() in-band / computeMeteringHeaders). */
2808
3180
  recordResponse(payload, requestId) {
2809
3181
  const raw = payload.rawDimsUnits;
2810
3182
  const meters = raw && typeof raw === "object" ? raw : {};
@@ -3062,6 +3434,7 @@ function createDevRuntime(options) {
3062
3434
  }
3063
3435
  fs.middleware = middleware;
3064
3436
  fs.handler = createExpressHandler;
3437
+ fs.authz = tracedAuthz;
3065
3438
  const devRuntime = {
3066
3439
  fs,
3067
3440
  asPersona: (name) => personaClient.asPersona(name),
@@ -3190,6 +3563,7 @@ var fartherShore = {
3190
3563
  const fs = initFromEnv(options);
3191
3564
  fs.middleware = (mwOptions) => createExpressMiddleware(fs, mwOptions);
3192
3565
  fs.handler = createExpressHandler;
3566
+ fs.authz = { hasPermission, requirePermission };
3193
3567
  return fs;
3194
3568
  }
3195
3569
  };
@@ -3207,13 +3581,13 @@ export {
3207
3581
  FartherShorePermissionError,
3208
3582
  JwksClient,
3209
3583
  MAX_BODY_BYTES,
3584
+ MEASUREMENTS_VERSION,
3210
3585
  METERING_PAYLOAD_HEADER,
3211
3586
  METERING_SIGNATURE_HEADER,
3212
3587
  METERING_TOKEN_HEADER,
3213
- MeteringClient,
3214
3588
  MeteringError,
3215
3589
  NonceCache,
3216
- PostStreamUsageClient,
3590
+ READ_METHODS,
3217
3591
  REDACTED_TOKEN,
3218
3592
  RUNTIME_CLOCK_SKEW_SECONDS,
3219
3593
  RUNTIME_ERROR_CODES,
@@ -3230,7 +3604,6 @@ export {
3230
3604
  computeMeteringHeaders,
3231
3605
  createExpressHandler,
3232
3606
  createExpressMiddleware,
3233
- createUsage,
3234
3607
  credentialKind,
3235
3608
  decodeContextClaims,
3236
3609
  fartherShore,
@@ -3239,19 +3612,18 @@ export {
3239
3612
  initFromEnv2 as initFromEnv,
3240
3613
  isPortalSession,
3241
3614
  nodeSpawn,
3242
- permissionGrants,
3243
3615
  permissionSatisfies,
3244
3616
  principalFromContextClaims2 as principalFromContextClaims,
3245
3617
  reportHealth,
3246
3618
  requireMember,
3247
3619
  requirePermission,
3248
3620
  requireService,
3621
+ routePermission,
3249
3622
  runtimeErrorToErrorCode,
3250
3623
  runtimeTokenKind2 as runtimeTokenKind,
3251
3624
  signCanonicalString2 as signCanonicalString,
3252
3625
  statusForCode,
3253
3626
  verifyCanonicalSignature2 as verifyCanonicalSignature,
3254
3627
  verifyContext,
3255
- verifyRequest,
3256
- withUsage
3628
+ verifyRequest
3257
3629
  };