@farthershore/backend 0.19.0 → 0.20.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)
@@ -465,9 +470,92 @@ function statusForCode(code) {
465
470
  return 401;
466
471
  }
467
472
 
473
+ // src/core/deadline.ts
474
+ var DEADLINE_MS = {
475
+ /** Boot-blocking; generous because it runs once and gates startup. */
476
+ bootstrap: 1e4,
477
+ /** On the inbound verification path — must not hold a request open. */
478
+ jwks: 5e3,
479
+ /** Background economic report, retried by the caller. */
480
+ metering: 1e4,
481
+ /** Background attested usage callback. */
482
+ postStreamUsage: 1e4,
483
+ /** Best-effort heartbeat; never blocks anything. */
484
+ health: 5e3,
485
+ /** Boot-time route drift report; fail-open at the caller. */
486
+ report: 1e4
487
+ };
488
+ var MAX_RESPONSE_BYTES = 1048576;
489
+ var ResponseTooLargeError = class extends Error {
490
+ constructor(limit) {
491
+ super(`response body exceeded ${limit} bytes and was cancelled`);
492
+ this.name = "ResponseTooLargeError";
493
+ }
494
+ };
495
+ var DeadlineExceededError = class extends Error {
496
+ operation;
497
+ constructor(operation, timeoutMs) {
498
+ super(`${operation} exceeded its ${timeoutMs}ms deadline`);
499
+ this.name = "TimeoutError";
500
+ this.operation = operation;
501
+ }
502
+ };
503
+ async function fetchWithDeadline(fetchImpl, input, init, operation, options = {}) {
504
+ const timeoutMs = options.timeoutMs ?? DEADLINE_MS[operation];
505
+ const timeout = AbortSignal.timeout(timeoutMs);
506
+ const signal = options.callerSignal ? AbortSignal.any([options.callerSignal, timeout]) : timeout;
507
+ try {
508
+ return await fetchImpl(input, { ...init, signal });
509
+ } catch (cause) {
510
+ if (options.callerSignal?.aborted) throw cause;
511
+ if (timeout.aborted) throw new DeadlineExceededError(operation, timeoutMs);
512
+ throw cause;
513
+ }
514
+ }
515
+ async function readBoundedText(response, limit = MAX_RESPONSE_BYTES) {
516
+ const body = response.body;
517
+ if (!body) {
518
+ const text = await response.text();
519
+ if (byteLength(text) > limit) throw new ResponseTooLargeError(limit);
520
+ return text;
521
+ }
522
+ const reader = body.getReader();
523
+ const chunks = [];
524
+ let total = 0;
525
+ try {
526
+ for (; ; ) {
527
+ const { done, value } = await reader.read();
528
+ if (done) break;
529
+ if (!value) continue;
530
+ total += value.byteLength;
531
+ if (total > limit) {
532
+ await reader.cancel();
533
+ throw new ResponseTooLargeError(limit);
534
+ }
535
+ chunks.push(value);
536
+ }
537
+ } finally {
538
+ reader.releaseLock();
539
+ }
540
+ const joined = new Uint8Array(total);
541
+ let offset = 0;
542
+ for (const chunk of chunks) {
543
+ joined.set(chunk, offset);
544
+ offset += chunk.byteLength;
545
+ }
546
+ return new TextDecoder().decode(joined);
547
+ }
548
+ async function readBoundedJson(response, limit = MAX_RESPONSE_BYTES) {
549
+ return JSON.parse(await readBoundedText(response, limit));
550
+ }
551
+ function byteLength(text) {
552
+ return new TextEncoder().encode(text).byteLength;
553
+ }
554
+
468
555
  // src/core/bootstrap.ts
469
556
  var BOOTSTRAP_PATH = "/v1/runtime/bootstrap";
470
557
  var DEFAULT_MIN_REFRESH_SECONDS = 30;
558
+ var DEFAULT_MAX_STALE_SECONDS = 300;
471
559
  var BootstrapClient = class {
472
560
  runtimeToken;
473
561
  endpoint;
@@ -475,6 +563,7 @@ var BootstrapClient = class {
475
563
  fetchImpl;
476
564
  now;
477
565
  minRefreshSeconds;
566
+ maxStaleMs;
478
567
  cached = null;
479
568
  fetchedAt = 0;
480
569
  refreshAfterMs = 0;
@@ -498,6 +587,7 @@ var BootstrapClient = class {
498
587
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
499
588
  this.now = options.now ?? (() => Date.now());
500
589
  this.minRefreshSeconds = options.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS;
590
+ this.maxStaleMs = (options.maxStaleSeconds ?? DEFAULT_MAX_STALE_SECONDS) * 1e3;
501
591
  }
502
592
  /** Cached config when fresh; otherwise refreshes. */
503
593
  async get() {
@@ -519,20 +609,37 @@ var BootstrapClient = class {
519
609
  isStale() {
520
610
  return this.now() - this.fetchedAt >= this.refreshAfterMs;
521
611
  }
612
+ isHardStale() {
613
+ return this.now() - this.fetchedAt >= this.maxStaleMs;
614
+ }
615
+ cachedOrThrowOnHardStale(reason) {
616
+ if (this.cached && !this.isHardStale()) return this.cached;
617
+ throw new FartherShoreError(
618
+ "jwks_unavailable",
619
+ `bootstrap refresh failed with stale cached authorization metadata: ${reason}`
620
+ );
621
+ }
522
622
  async doBootstrap() {
523
623
  let response;
524
624
  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"
625
+ response = await fetchWithDeadline(
626
+ this.fetchImpl,
627
+ this.endpoint,
628
+ {
629
+ method: "POST",
630
+ headers: {
631
+ authorization: `Bearer ${this.runtimeToken}`,
632
+ "content-type": "application/json",
633
+ accept: "application/json"
634
+ },
635
+ body: JSON.stringify(this.request)
531
636
  },
532
- body: JSON.stringify(this.request)
533
- });
637
+ "bootstrap"
638
+ );
534
639
  } catch (cause) {
535
- if (this.cached) return this.cached;
640
+ if (this.cached) {
641
+ return this.cachedOrThrowOnHardStale(stringify(cause));
642
+ }
536
643
  throw new FartherShoreError(
537
644
  "jwks_unavailable",
538
645
  `bootstrap request failed: ${stringify(cause)}`
@@ -545,13 +652,15 @@ var BootstrapClient = class {
545
652
  );
546
653
  }
547
654
  if (!response.ok) {
548
- if (this.cached) return this.cached;
655
+ if (this.cached) {
656
+ return this.cachedOrThrowOnHardStale(`HTTP ${response.status}`);
657
+ }
549
658
  throw new FartherShoreError(
550
659
  "jwks_unavailable",
551
660
  `bootstrap returned HTTP ${response.status}`
552
661
  );
553
662
  }
554
- const body = await response.json();
663
+ const body = await readBoundedJson(response);
555
664
  this.cached = body;
556
665
  this.fetchedAt = this.now();
557
666
  const refreshSeconds = Math.max(
@@ -584,17 +693,22 @@ async function reportHealth(options) {
584
693
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
585
694
  const endpoint = `${options.coreUrl.replace(/\/+$/, "")}${HEALTH_PATH}`;
586
695
  try {
587
- const response = await fetchImpl(endpoint, {
588
- method: "POST",
589
- headers: {
590
- authorization: `Bearer ${options.runtimeToken}`,
591
- "content-type": "application/json"
696
+ const response = await fetchWithDeadline(
697
+ fetchImpl,
698
+ endpoint,
699
+ {
700
+ method: "POST",
701
+ headers: {
702
+ authorization: `Bearer ${options.runtimeToken}`,
703
+ "content-type": "application/json"
704
+ },
705
+ body: JSON.stringify({
706
+ status: options.status,
707
+ ...options.instanceId ? { instanceId: options.instanceId } : {}
708
+ })
592
709
  },
593
- body: JSON.stringify({
594
- status: options.status,
595
- ...options.instanceId ? { instanceId: options.instanceId } : {}
596
- })
597
- });
710
+ "health"
711
+ );
598
712
  return response.ok;
599
713
  } catch {
600
714
  return false;
@@ -603,14 +717,22 @@ async function reportHealth(options) {
603
717
 
604
718
  // src/core/jwks.ts
605
719
  var DEFAULT_CACHE_TTL_MS = 5 * 6e4;
720
+ var DEFAULT_HARD_STALE_MS = 15 * 6e4;
606
721
  var DEFAULT_NEGATIVE_CACHE_MS = 3e4;
722
+ function finiteMsOr(value, fallback) {
723
+ if (value === void 0) return fallback;
724
+ if (!Number.isFinite(value) || value < 0) return fallback;
725
+ return value;
726
+ }
607
727
  var MAX_NEGATIVE_KIDS = 1e3;
608
728
  var JwksClient = class {
609
729
  jwksUrl;
610
730
  fetchImpl;
611
731
  cacheTtlMs;
732
+ hardStaleMs;
612
733
  negativeCacheMs;
613
734
  now;
735
+ onObservation;
614
736
  keysByKid = /* @__PURE__ */ new Map();
615
737
  fetchedAt = 0;
616
738
  hasFetchedOnce = false;
@@ -619,21 +741,34 @@ var JwksClient = class {
619
741
  constructor(options) {
620
742
  this.jwksUrl = options.jwksUrl;
621
743
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
622
- this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
744
+ this.cacheTtlMs = finiteMsOr(options.cacheTtlMs, DEFAULT_CACHE_TTL_MS);
745
+ this.hardStaleMs = Math.max(
746
+ finiteMsOr(options.hardStaleMs, DEFAULT_HARD_STALE_MS),
747
+ this.cacheTtlMs
748
+ );
623
749
  this.negativeCacheMs = options.negativeCacheMs ?? DEFAULT_NEGATIVE_CACHE_MS;
624
750
  this.now = options.now ?? (() => Date.now());
751
+ this.onObservation = options.onObservation;
625
752
  }
626
753
  /**
627
754
  * Resolve a public JWK for `kid`, fail-closed. Throws FartherShoreError with
628
- * `jwks_unavailable` (cold cache + fetch failed) or `unknown_key_id`.
755
+ * `jwks_unavailable` (cold cache, or a hard-stale cache whose refresh is
756
+ * failing) or `unknown_key_id`.
629
757
  */
630
758
  async getKey(kid) {
631
759
  const cached = this.keysByKid.get(kid);
632
- if (cached && !this.isStale()) return cached;
760
+ if (cached && !this.isStale()) {
761
+ this.observe("fresh", kid);
762
+ return cached;
763
+ }
633
764
  const negAt = this.negativeKids.get(kid);
634
765
  if (negAt !== void 0 && this.now() - negAt < this.negativeCacheMs) {
635
766
  const warm = this.keysByKid.get(kid);
636
- if (warm) return warm;
767
+ if (warm) {
768
+ this.assertWithinHardStale();
769
+ this.observe(this.cacheState(), kid);
770
+ return warm;
771
+ }
637
772
  throw new FartherShoreError(
638
773
  "unknown_key_id",
639
774
  `signing key '${kid}' is not present in the JWKS`
@@ -643,6 +778,7 @@ var JwksClient = class {
643
778
  const key2 = this.keysByKid.get(kid);
644
779
  if (key2) {
645
780
  this.negativeKids.delete(kid);
781
+ this.observe(this.cacheState(), kid);
646
782
  return key2;
647
783
  }
648
784
  this.rememberMissingKid(kid);
@@ -662,8 +798,37 @@ var JwksClient = class {
662
798
  }
663
799
  this.negativeKids.set(kid, this.now());
664
800
  }
801
+ ageMs() {
802
+ return this.now() - this.fetchedAt;
803
+ }
665
804
  isStale() {
666
- return this.now() - this.fetchedAt >= this.cacheTtlMs;
805
+ return this.ageMs() >= this.cacheTtlMs;
806
+ }
807
+ isHardStale() {
808
+ return this.ageMs() >= this.hardStaleMs;
809
+ }
810
+ /** Current freshness of the cached key set. */
811
+ cacheState() {
812
+ if (!this.hasFetchedOnce) return "cold";
813
+ if (this.isHardStale()) return "hard_stale";
814
+ if (this.isStale()) return "soft_stale";
815
+ return "fresh";
816
+ }
817
+ observe(state, kid) {
818
+ this.onObservation?.({
819
+ state,
820
+ ageMs: this.hasFetchedOnce ? this.ageMs() : 0,
821
+ ...kid !== void 0 ? { kid } : {}
822
+ });
823
+ }
824
+ /** Fail closed when the cached key set is past the hard-stale ceiling. */
825
+ assertWithinHardStale() {
826
+ if (!this.isHardStale()) return;
827
+ this.observe("hard_stale");
828
+ throw new FartherShoreError(
829
+ "jwks_unavailable",
830
+ `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`
831
+ );
667
832
  }
668
833
  /** Single-flight refresh: concurrent callers share one fetch. */
669
834
  async refresh() {
@@ -676,24 +841,27 @@ var JwksClient = class {
676
841
  async doFetch() {
677
842
  let response;
678
843
  try {
679
- response = await this.fetchImpl(this.jwksUrl, {
680
- headers: { accept: "application/json" }
681
- });
844
+ response = await fetchWithDeadline(
845
+ this.fetchImpl,
846
+ this.jwksUrl,
847
+ { headers: { accept: "application/json" } },
848
+ "jwks"
849
+ );
682
850
  } catch (cause) {
683
- this.failOnColdCache(cause);
851
+ this.handleRefreshFailure(cause);
684
852
  return;
685
853
  }
686
854
  if (!response.ok) {
687
- this.failOnColdCache(
855
+ this.handleRefreshFailure(
688
856
  new Error(`JWKS endpoint returned HTTP ${response.status}`)
689
857
  );
690
858
  return;
691
859
  }
692
860
  let doc;
693
861
  try {
694
- doc = await response.json();
862
+ doc = await readBoundedJson(response);
695
863
  } catch (cause) {
696
- this.failOnColdCache(cause);
864
+ this.handleRefreshFailure(cause);
697
865
  return;
698
866
  }
699
867
  const next = /* @__PURE__ */ new Map();
@@ -706,15 +874,27 @@ var JwksClient = class {
706
874
  this.negativeKids.clear();
707
875
  }
708
876
  /**
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.
877
+ * BOUNDED stale-while-revalidate. A COLD cache fails closed. A warm cache
878
+ * inside the soft window swallows the failure and keeps serving. Past the
879
+ * hard-stale ceiling it fails closed too — availability is worth a bounded
880
+ * window of degraded trust, not an unbounded one.
711
881
  */
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
- );
882
+ handleRefreshFailure(cause) {
883
+ if (!this.hasFetchedOnce) {
884
+ this.observe("cold");
885
+ throw new FartherShoreError(
886
+ "jwks_unavailable",
887
+ `JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
888
+ );
889
+ }
890
+ if (this.isHardStale()) {
891
+ this.observe("hard_stale");
892
+ throw new FartherShoreError(
893
+ "jwks_unavailable",
894
+ `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)}`
895
+ );
896
+ }
897
+ this.observe("soft_stale");
718
898
  }
719
899
  };
720
900
  function stringifyCause(cause) {
@@ -864,15 +1044,20 @@ var MeteringClient = class {
864
1044
  for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
865
1045
  let retryAfter = null;
866
1046
  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"
1047
+ const response = await fetchWithDeadline(
1048
+ this.fetchImpl,
1049
+ this.endpoint,
1050
+ {
1051
+ method: "POST",
1052
+ headers: {
1053
+ authorization: `Bearer ${this.config.credential}`,
1054
+ "content-type": "application/json",
1055
+ accept: "application/json"
1056
+ },
1057
+ body: JSON.stringify(event)
873
1058
  },
874
- body: JSON.stringify(event)
875
- });
1059
+ "metering"
1060
+ );
876
1061
  if (response.ok) return true;
877
1062
  if (!isTransientStatus(response.status)) return false;
878
1063
  retryAfter = retryAfterMs(response.headers);
@@ -1088,6 +1273,7 @@ var PostStreamUsageClient = class {
1088
1273
  logger;
1089
1274
  sleep;
1090
1275
  retryDelaysMs;
1276
+ maxRetryDelayMs;
1091
1277
  constructor(options) {
1092
1278
  this.config = options.config;
1093
1279
  this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
@@ -1096,6 +1282,7 @@ var PostStreamUsageClient = class {
1096
1282
  this.logger = options.logger ?? ((message) => console.warn(message));
1097
1283
  this.sleep = options.sleep ?? sleep;
1098
1284
  this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
1285
+ this.maxRetryDelayMs = options.maxRetryDelayMs ?? 1e4;
1099
1286
  }
1100
1287
  async reportUsage(input) {
1101
1288
  try {
@@ -1124,19 +1311,36 @@ var PostStreamUsageClient = class {
1124
1311
  const event = { ...unsigned, signature };
1125
1312
  const body = JSON.stringify(event);
1126
1313
  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
- });
1314
+ let response;
1315
+ try {
1316
+ response = await fetchWithDeadline(
1317
+ this.fetchImpl,
1318
+ this.endpoint,
1319
+ {
1320
+ method: "POST",
1321
+ headers: {
1322
+ authorization: `Bearer ${this.config.credential}`,
1323
+ "content-type": "application/json",
1324
+ accept: "application/json"
1325
+ },
1326
+ body
1327
+ },
1328
+ "postStreamUsage"
1329
+ );
1330
+ } catch (cause) {
1331
+ const delayMs2 = this.retryDelayForAttempt(attempt, null);
1332
+ if (delayMs2 === null) throw cause;
1333
+ await this.sleep(delayMs2);
1334
+ continue;
1335
+ }
1136
1336
  if (response.ok) return { ok: true };
1137
1337
  const requestNotFound = await isPostStreamRequestNotFound(response);
1138
- const delayMs = this.retryDelaysMs[attempt];
1139
- if (!requestNotFound || delayMs === void 0) {
1338
+ const retryable = requestNotFound || isRetryableStatus(response.status);
1339
+ const delayMs = this.retryDelayForAttempt(
1340
+ attempt,
1341
+ retryAfterMs2(response.headers)
1342
+ );
1343
+ if (!retryable || delayMs === null) {
1140
1344
  throw new Error(`metering endpoint returned ${response.status}`);
1141
1345
  }
1142
1346
  await this.sleep(delayMs);
@@ -1147,6 +1351,12 @@ var PostStreamUsageClient = class {
1147
1351
  return { ok: false, reason };
1148
1352
  }
1149
1353
  }
1354
+ retryDelayForAttempt(attempt, retryAfterMs3) {
1355
+ const fallback = this.retryDelaysMs[attempt];
1356
+ if (fallback === void 0) return null;
1357
+ if (retryAfterMs3 === null) return fallback;
1358
+ return Math.min(retryAfterMs3, this.maxRetryDelayMs);
1359
+ }
1150
1360
  };
1151
1361
  async function isPostStreamRequestNotFound(response) {
1152
1362
  if (response.status !== 422) return false;
@@ -1157,6 +1367,18 @@ async function isPostStreamRequestNotFound(response) {
1157
1367
  return false;
1158
1368
  }
1159
1369
  }
1370
+ function isRetryableStatus(status) {
1371
+ return status === 429 || status >= 500 && status <= 599;
1372
+ }
1373
+ function retryAfterMs2(headers) {
1374
+ const raw = headers.get("retry-after");
1375
+ if (!raw) return null;
1376
+ const seconds = Number(raw);
1377
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
1378
+ const dateMs = Date.parse(raw);
1379
+ if (!Number.isFinite(dateMs)) return null;
1380
+ return Math.max(0, dateMs - Date.now());
1381
+ }
1160
1382
  function sleep(delayMs) {
1161
1383
  return new Promise((resolve) => setTimeout(resolve, delayMs));
1162
1384
  }
@@ -1232,6 +1454,37 @@ var NonceCache = class {
1232
1454
  }
1233
1455
  };
1234
1456
 
1457
+ // src/core/replay-protection.ts
1458
+ function resolveReplayProtection(input = {}) {
1459
+ if (input.nonceStore) {
1460
+ return {
1461
+ // An opted-in shared store that is DOWN must not degrade to "no replay
1462
+ // check" — that would make knocking it over a way to switch the
1463
+ // protection off entirely.
1464
+ store: failClosed(input.nonceStore),
1465
+ diagnostic: { mode: "shared", crossReplica: true }
1466
+ };
1467
+ }
1468
+ return {
1469
+ store: new NonceCache(),
1470
+ diagnostic: { mode: "single-instance", crossReplica: false }
1471
+ };
1472
+ }
1473
+ function failClosed(store) {
1474
+ return {
1475
+ async checkAndRemember(id) {
1476
+ try {
1477
+ return await store.checkAndRemember(id);
1478
+ } catch (cause) {
1479
+ throw new FartherShoreError(
1480
+ "replayed_nonce",
1481
+ `replay store is unavailable, refusing the request rather than skipping one-time-use enforcement: ${cause instanceof Error ? cause.message : String(cause)}`
1482
+ );
1483
+ }
1484
+ }
1485
+ };
1486
+ }
1487
+
1235
1488
  // src/core/shutdown.ts
1236
1489
  var ShutdownManager = class {
1237
1490
  hooks = [];
@@ -1925,7 +2178,7 @@ function headerGetter(headers) {
1925
2178
 
1926
2179
  // src/core/runtime.ts
1927
2180
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1928
- var SDK_VERSION = "0.19.0".length > 0 ? "0.19.0" : "0.0.0-dev";
2181
+ var SDK_VERSION = "0.20.0".length > 0 ? "0.20.0" : "0.0.0-dev";
1929
2182
  var FartherShore = class {
1930
2183
  bootstrapClient;
1931
2184
  fetchImpl;
@@ -1938,6 +2191,7 @@ var FartherShore = class {
1938
2191
  /** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
1939
2192
  contextSecrets;
1940
2193
  nonceCache;
2194
+ replayProtectionDiagnostic;
1941
2195
  shutdownManager = new ShutdownManager();
1942
2196
  jwks = null;
1943
2197
  meteringClient = null;
@@ -1955,7 +2209,9 @@ var FartherShore = class {
1955
2209
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
1956
2210
  this.tunnelOptions = options.tunnel ?? {};
1957
2211
  this.instanceId = options.instanceId;
1958
- this.nonceCache = options.nonceStore ?? new NonceCache();
2212
+ const replay = resolveReplayProtection({ nonceStore: options.nonceStore });
2213
+ this.nonceCache = replay.store;
2214
+ this.replayProtectionDiagnostic = replay.diagnostic;
1959
2215
  this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
1960
2216
  this.bootstrapClient = new BootstrapClient({
1961
2217
  runtimeToken,
@@ -2045,14 +2301,19 @@ var FartherShore = class {
2045
2301
  buildReportSink() {
2046
2302
  const post = async (path, body) => {
2047
2303
  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}`
2304
+ const res = await fetchWithDeadline(
2305
+ this.fetchImpl,
2306
+ `${base}${path}`,
2307
+ {
2308
+ method: "POST",
2309
+ headers: {
2310
+ "content-type": "application/json",
2311
+ authorization: `Bearer ${this.runtimeToken}`
2312
+ },
2313
+ body: JSON.stringify(body)
2053
2314
  },
2054
- body: JSON.stringify(body)
2055
- });
2315
+ "report"
2316
+ );
2056
2317
  if (!res.ok) throw new Error(`runtime report ${path} -> ${res.status}`);
2057
2318
  };
2058
2319
  return {
@@ -2179,6 +2440,15 @@ var FartherShore = class {
2179
2440
  return { ok: false, reason };
2180
2441
  }
2181
2442
  }
2443
+ /**
2444
+ * How far replay protection actually reaches — `"shared"` (enforced across
2445
+ * every replica) or `"single-instance"` (this process only). Deployment
2446
+ * diagnostic: log it at boot, or assert on it in a smoke test, so the mode is
2447
+ * never a surprise. See {@link FartherShoreInitOptions.nonceStore}.
2448
+ */
2449
+ replayProtection() {
2450
+ return this.replayProtectionDiagnostic;
2451
+ }
2182
2452
  /** Current local health report. */
2183
2453
  health() {
2184
2454
  const config = this.bootstrapClient.peek();