@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.
@@ -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)
@@ -452,16 +457,106 @@ function statusForCode(code) {
452
457
  return 401;
453
458
  }
454
459
 
460
+ // src/core/deadline.ts
461
+ var DEADLINE_MS = {
462
+ /** Boot-blocking; generous because it runs once and gates startup. */
463
+ bootstrap: 1e4,
464
+ /** On the inbound verification path — must not hold a request open. */
465
+ jwks: 5e3,
466
+ /** Background economic report, retried by the caller. */
467
+ metering: 1e4,
468
+ /** Background attested usage callback. */
469
+ postStreamUsage: 1e4,
470
+ /** Best-effort heartbeat; never blocks anything. */
471
+ health: 5e3,
472
+ /** Boot-time route drift report; fail-open at the caller. */
473
+ report: 1e4
474
+ };
475
+ var MAX_RESPONSE_BYTES = 1048576;
476
+ var ResponseTooLargeError = class extends Error {
477
+ constructor(limit) {
478
+ super(`response body exceeded ${limit} bytes and was cancelled`);
479
+ this.name = "ResponseTooLargeError";
480
+ }
481
+ };
482
+ var DeadlineExceededError = class extends Error {
483
+ operation;
484
+ constructor(operation, timeoutMs) {
485
+ super(`${operation} exceeded its ${timeoutMs}ms deadline`);
486
+ this.name = "TimeoutError";
487
+ this.operation = operation;
488
+ }
489
+ };
490
+ async function fetchWithDeadline(fetchImpl, input, init, operation, options = {}) {
491
+ const timeoutMs = options.timeoutMs ?? DEADLINE_MS[operation];
492
+ const timeout = AbortSignal.timeout(timeoutMs);
493
+ const signal = options.callerSignal ? AbortSignal.any([options.callerSignal, timeout]) : timeout;
494
+ try {
495
+ return await fetchImpl(input, { ...init, signal });
496
+ } catch (cause) {
497
+ if (options.callerSignal?.aborted) throw cause;
498
+ if (timeout.aborted) throw new DeadlineExceededError(operation, timeoutMs);
499
+ throw cause;
500
+ }
501
+ }
502
+ async function readBoundedText(response, limit = MAX_RESPONSE_BYTES) {
503
+ const body = response.body;
504
+ if (!body) {
505
+ const text = await response.text();
506
+ if (byteLength(text) > limit) throw new ResponseTooLargeError(limit);
507
+ return text;
508
+ }
509
+ const reader = body.getReader();
510
+ const chunks = [];
511
+ let total = 0;
512
+ try {
513
+ for (; ; ) {
514
+ const { done, value } = await reader.read();
515
+ if (done) break;
516
+ if (!value) continue;
517
+ total += value.byteLength;
518
+ if (total > limit) {
519
+ await reader.cancel();
520
+ throw new ResponseTooLargeError(limit);
521
+ }
522
+ chunks.push(value);
523
+ }
524
+ } finally {
525
+ reader.releaseLock();
526
+ }
527
+ const joined = new Uint8Array(total);
528
+ let offset = 0;
529
+ for (const chunk of chunks) {
530
+ joined.set(chunk, offset);
531
+ offset += chunk.byteLength;
532
+ }
533
+ return new TextDecoder().decode(joined);
534
+ }
535
+ async function readBoundedJson(response, limit = MAX_RESPONSE_BYTES) {
536
+ return JSON.parse(await readBoundedText(response, limit));
537
+ }
538
+ function byteLength(text) {
539
+ return new TextEncoder().encode(text).byteLength;
540
+ }
541
+
455
542
  // src/core/jwks.ts
456
543
  var DEFAULT_CACHE_TTL_MS = 5 * 6e4;
544
+ var DEFAULT_HARD_STALE_MS = 15 * 6e4;
457
545
  var DEFAULT_NEGATIVE_CACHE_MS = 3e4;
546
+ function finiteMsOr(value, fallback) {
547
+ if (value === void 0) return fallback;
548
+ if (!Number.isFinite(value) || value < 0) return fallback;
549
+ return value;
550
+ }
458
551
  var MAX_NEGATIVE_KIDS = 1e3;
459
552
  var JwksClient = class {
460
553
  jwksUrl;
461
554
  fetchImpl;
462
555
  cacheTtlMs;
556
+ hardStaleMs;
463
557
  negativeCacheMs;
464
558
  now;
559
+ onObservation;
465
560
  keysByKid = /* @__PURE__ */ new Map();
466
561
  fetchedAt = 0;
467
562
  hasFetchedOnce = false;
@@ -470,21 +565,34 @@ var JwksClient = class {
470
565
  constructor(options) {
471
566
  this.jwksUrl = options.jwksUrl;
472
567
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
473
- this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
568
+ this.cacheTtlMs = finiteMsOr(options.cacheTtlMs, DEFAULT_CACHE_TTL_MS);
569
+ this.hardStaleMs = Math.max(
570
+ finiteMsOr(options.hardStaleMs, DEFAULT_HARD_STALE_MS),
571
+ this.cacheTtlMs
572
+ );
474
573
  this.negativeCacheMs = options.negativeCacheMs ?? DEFAULT_NEGATIVE_CACHE_MS;
475
574
  this.now = options.now ?? (() => Date.now());
575
+ this.onObservation = options.onObservation;
476
576
  }
477
577
  /**
478
578
  * Resolve a public JWK for `kid`, fail-closed. Throws FartherShoreError with
479
- * `jwks_unavailable` (cold cache + fetch failed) or `unknown_key_id`.
579
+ * `jwks_unavailable` (cold cache, or a hard-stale cache whose refresh is
580
+ * failing) or `unknown_key_id`.
480
581
  */
481
582
  async getKey(kid) {
482
583
  const cached = this.keysByKid.get(kid);
483
- if (cached && !this.isStale()) return cached;
584
+ if (cached && !this.isStale()) {
585
+ this.observe("fresh", kid);
586
+ return cached;
587
+ }
484
588
  const negAt = this.negativeKids.get(kid);
485
589
  if (negAt !== void 0 && this.now() - negAt < this.negativeCacheMs) {
486
590
  const warm = this.keysByKid.get(kid);
487
- if (warm) return warm;
591
+ if (warm) {
592
+ this.assertWithinHardStale();
593
+ this.observe(this.cacheState(), kid);
594
+ return warm;
595
+ }
488
596
  throw new FartherShoreError(
489
597
  "unknown_key_id",
490
598
  `signing key '${kid}' is not present in the JWKS`
@@ -494,6 +602,7 @@ var JwksClient = class {
494
602
  const key2 = this.keysByKid.get(kid);
495
603
  if (key2) {
496
604
  this.negativeKids.delete(kid);
605
+ this.observe(this.cacheState(), kid);
497
606
  return key2;
498
607
  }
499
608
  this.rememberMissingKid(kid);
@@ -513,8 +622,37 @@ var JwksClient = class {
513
622
  }
514
623
  this.negativeKids.set(kid, this.now());
515
624
  }
625
+ ageMs() {
626
+ return this.now() - this.fetchedAt;
627
+ }
516
628
  isStale() {
517
- return this.now() - this.fetchedAt >= this.cacheTtlMs;
629
+ return this.ageMs() >= this.cacheTtlMs;
630
+ }
631
+ isHardStale() {
632
+ return this.ageMs() >= this.hardStaleMs;
633
+ }
634
+ /** Current freshness of the cached key set. */
635
+ cacheState() {
636
+ if (!this.hasFetchedOnce) return "cold";
637
+ if (this.isHardStale()) return "hard_stale";
638
+ if (this.isStale()) return "soft_stale";
639
+ return "fresh";
640
+ }
641
+ observe(state, kid) {
642
+ this.onObservation?.({
643
+ state,
644
+ ageMs: this.hasFetchedOnce ? this.ageMs() : 0,
645
+ ...kid !== void 0 ? { kid } : {}
646
+ });
647
+ }
648
+ /** Fail closed when the cached key set is past the hard-stale ceiling. */
649
+ assertWithinHardStale() {
650
+ if (!this.isHardStale()) return;
651
+ this.observe("hard_stale");
652
+ throw new FartherShoreError(
653
+ "jwks_unavailable",
654
+ `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`
655
+ );
518
656
  }
519
657
  /** Single-flight refresh: concurrent callers share one fetch. */
520
658
  async refresh() {
@@ -527,24 +665,27 @@ var JwksClient = class {
527
665
  async doFetch() {
528
666
  let response;
529
667
  try {
530
- response = await this.fetchImpl(this.jwksUrl, {
531
- headers: { accept: "application/json" }
532
- });
668
+ response = await fetchWithDeadline(
669
+ this.fetchImpl,
670
+ this.jwksUrl,
671
+ { headers: { accept: "application/json" } },
672
+ "jwks"
673
+ );
533
674
  } catch (cause) {
534
- this.failOnColdCache(cause);
675
+ this.handleRefreshFailure(cause);
535
676
  return;
536
677
  }
537
678
  if (!response.ok) {
538
- this.failOnColdCache(
679
+ this.handleRefreshFailure(
539
680
  new Error(`JWKS endpoint returned HTTP ${response.status}`)
540
681
  );
541
682
  return;
542
683
  }
543
684
  let doc;
544
685
  try {
545
- doc = await response.json();
686
+ doc = await readBoundedJson(response);
546
687
  } catch (cause) {
547
- this.failOnColdCache(cause);
688
+ this.handleRefreshFailure(cause);
548
689
  return;
549
690
  }
550
691
  const next = /* @__PURE__ */ new Map();
@@ -557,15 +698,27 @@ var JwksClient = class {
557
698
  this.negativeKids.clear();
558
699
  }
559
700
  /**
560
- * Stale-while-revalidate: with a warm cache, swallow the refresh failure and
561
- * keep serving the last-known keys. With a COLD cache, fail closed.
701
+ * BOUNDED stale-while-revalidate. A COLD cache fails closed. A warm cache
702
+ * inside the soft window swallows the failure and keeps serving. Past the
703
+ * hard-stale ceiling it fails closed too — availability is worth a bounded
704
+ * window of degraded trust, not an unbounded one.
562
705
  */
563
- failOnColdCache(cause) {
564
- if (this.hasFetchedOnce) return;
565
- throw new FartherShoreError(
566
- "jwks_unavailable",
567
- `JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
568
- );
706
+ handleRefreshFailure(cause) {
707
+ if (!this.hasFetchedOnce) {
708
+ this.observe("cold");
709
+ throw new FartherShoreError(
710
+ "jwks_unavailable",
711
+ `JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
712
+ );
713
+ }
714
+ if (this.isHardStale()) {
715
+ this.observe("hard_stale");
716
+ throw new FartherShoreError(
717
+ "jwks_unavailable",
718
+ `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)}`
719
+ );
720
+ }
721
+ this.observe("soft_stale");
569
722
  }
570
723
  };
571
724
  function stringifyCause(cause) {
@@ -968,6 +1121,7 @@ import { dirname as dirname2 } from "node:path";
968
1121
  // src/core/bootstrap.ts
969
1122
  var BOOTSTRAP_PATH = "/v1/runtime/bootstrap";
970
1123
  var DEFAULT_MIN_REFRESH_SECONDS = 30;
1124
+ var DEFAULT_MAX_STALE_SECONDS = 300;
971
1125
  var BootstrapClient = class {
972
1126
  runtimeToken;
973
1127
  endpoint;
@@ -975,6 +1129,7 @@ var BootstrapClient = class {
975
1129
  fetchImpl;
976
1130
  now;
977
1131
  minRefreshSeconds;
1132
+ maxStaleMs;
978
1133
  cached = null;
979
1134
  fetchedAt = 0;
980
1135
  refreshAfterMs = 0;
@@ -998,6 +1153,7 @@ var BootstrapClient = class {
998
1153
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
999
1154
  this.now = options.now ?? (() => Date.now());
1000
1155
  this.minRefreshSeconds = options.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS;
1156
+ this.maxStaleMs = (options.maxStaleSeconds ?? DEFAULT_MAX_STALE_SECONDS) * 1e3;
1001
1157
  }
1002
1158
  /** Cached config when fresh; otherwise refreshes. */
1003
1159
  async get() {
@@ -1019,20 +1175,37 @@ var BootstrapClient = class {
1019
1175
  isStale() {
1020
1176
  return this.now() - this.fetchedAt >= this.refreshAfterMs;
1021
1177
  }
1178
+ isHardStale() {
1179
+ return this.now() - this.fetchedAt >= this.maxStaleMs;
1180
+ }
1181
+ cachedOrThrowOnHardStale(reason) {
1182
+ if (this.cached && !this.isHardStale()) return this.cached;
1183
+ throw new FartherShoreError(
1184
+ "jwks_unavailable",
1185
+ `bootstrap refresh failed with stale cached authorization metadata: ${reason}`
1186
+ );
1187
+ }
1022
1188
  async doBootstrap() {
1023
1189
  let response;
1024
1190
  try {
1025
- response = await this.fetchImpl(this.endpoint, {
1026
- method: "POST",
1027
- headers: {
1028
- authorization: `Bearer ${this.runtimeToken}`,
1029
- "content-type": "application/json",
1030
- accept: "application/json"
1191
+ response = await fetchWithDeadline(
1192
+ this.fetchImpl,
1193
+ this.endpoint,
1194
+ {
1195
+ method: "POST",
1196
+ headers: {
1197
+ authorization: `Bearer ${this.runtimeToken}`,
1198
+ "content-type": "application/json",
1199
+ accept: "application/json"
1200
+ },
1201
+ body: JSON.stringify(this.request)
1031
1202
  },
1032
- body: JSON.stringify(this.request)
1033
- });
1203
+ "bootstrap"
1204
+ );
1034
1205
  } catch (cause) {
1035
- if (this.cached) return this.cached;
1206
+ if (this.cached) {
1207
+ return this.cachedOrThrowOnHardStale(stringify(cause));
1208
+ }
1036
1209
  throw new FartherShoreError(
1037
1210
  "jwks_unavailable",
1038
1211
  `bootstrap request failed: ${stringify(cause)}`
@@ -1045,13 +1218,15 @@ var BootstrapClient = class {
1045
1218
  );
1046
1219
  }
1047
1220
  if (!response.ok) {
1048
- if (this.cached) return this.cached;
1221
+ if (this.cached) {
1222
+ return this.cachedOrThrowOnHardStale(`HTTP ${response.status}`);
1223
+ }
1049
1224
  throw new FartherShoreError(
1050
1225
  "jwks_unavailable",
1051
1226
  `bootstrap returned HTTP ${response.status}`
1052
1227
  );
1053
1228
  }
1054
- const body = await response.json();
1229
+ const body = await readBoundedJson(response);
1055
1230
  this.cached = body;
1056
1231
  this.fetchedAt = this.now();
1057
1232
  const refreshSeconds = Math.max(
@@ -1084,17 +1259,22 @@ async function reportHealth(options) {
1084
1259
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1085
1260
  const endpoint = `${options.coreUrl.replace(/\/+$/, "")}${HEALTH_PATH}`;
1086
1261
  try {
1087
- const response = await fetchImpl(endpoint, {
1088
- method: "POST",
1089
- headers: {
1090
- authorization: `Bearer ${options.runtimeToken}`,
1091
- "content-type": "application/json"
1262
+ const response = await fetchWithDeadline(
1263
+ fetchImpl,
1264
+ endpoint,
1265
+ {
1266
+ method: "POST",
1267
+ headers: {
1268
+ authorization: `Bearer ${options.runtimeToken}`,
1269
+ "content-type": "application/json"
1270
+ },
1271
+ body: JSON.stringify({
1272
+ status: options.status,
1273
+ ...options.instanceId ? { instanceId: options.instanceId } : {}
1274
+ })
1092
1275
  },
1093
- body: JSON.stringify({
1094
- status: options.status,
1095
- ...options.instanceId ? { instanceId: options.instanceId } : {}
1096
- })
1097
- });
1276
+ "health"
1277
+ );
1098
1278
  return response.ok;
1099
1279
  } catch {
1100
1280
  return false;
@@ -1243,15 +1423,20 @@ var MeteringClient = class {
1243
1423
  for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
1244
1424
  let retryAfter = null;
1245
1425
  try {
1246
- const response = await this.fetchImpl(this.endpoint, {
1247
- method: "POST",
1248
- headers: {
1249
- authorization: `Bearer ${this.config.credential}`,
1250
- "content-type": "application/json",
1251
- accept: "application/json"
1426
+ const response = await fetchWithDeadline(
1427
+ this.fetchImpl,
1428
+ this.endpoint,
1429
+ {
1430
+ method: "POST",
1431
+ headers: {
1432
+ authorization: `Bearer ${this.config.credential}`,
1433
+ "content-type": "application/json",
1434
+ accept: "application/json"
1435
+ },
1436
+ body: JSON.stringify(event)
1252
1437
  },
1253
- body: JSON.stringify(event)
1254
- });
1438
+ "metering"
1439
+ );
1255
1440
  if (response.ok) return true;
1256
1441
  if (!isTransientStatus(response.status)) return false;
1257
1442
  retryAfter = retryAfterMs(response.headers);
@@ -1319,6 +1504,7 @@ var PostStreamUsageClient = class {
1319
1504
  logger;
1320
1505
  sleep;
1321
1506
  retryDelaysMs;
1507
+ maxRetryDelayMs;
1322
1508
  constructor(options) {
1323
1509
  this.config = options.config;
1324
1510
  this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
@@ -1327,6 +1513,7 @@ var PostStreamUsageClient = class {
1327
1513
  this.logger = options.logger ?? ((message) => console.warn(message));
1328
1514
  this.sleep = options.sleep ?? sleep;
1329
1515
  this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
1516
+ this.maxRetryDelayMs = options.maxRetryDelayMs ?? 1e4;
1330
1517
  }
1331
1518
  async reportUsage(input) {
1332
1519
  try {
@@ -1355,19 +1542,36 @@ var PostStreamUsageClient = class {
1355
1542
  const event = { ...unsigned, signature };
1356
1543
  const body = JSON.stringify(event);
1357
1544
  for (let attempt = 0; ; attempt += 1) {
1358
- const response = await this.fetchImpl(this.endpoint, {
1359
- method: "POST",
1360
- headers: {
1361
- authorization: `Bearer ${this.config.credential}`,
1362
- "content-type": "application/json",
1363
- accept: "application/json"
1364
- },
1365
- body
1366
- });
1545
+ let response;
1546
+ try {
1547
+ response = await fetchWithDeadline(
1548
+ this.fetchImpl,
1549
+ this.endpoint,
1550
+ {
1551
+ method: "POST",
1552
+ headers: {
1553
+ authorization: `Bearer ${this.config.credential}`,
1554
+ "content-type": "application/json",
1555
+ accept: "application/json"
1556
+ },
1557
+ body
1558
+ },
1559
+ "postStreamUsage"
1560
+ );
1561
+ } catch (cause) {
1562
+ const delayMs2 = this.retryDelayForAttempt(attempt, null);
1563
+ if (delayMs2 === null) throw cause;
1564
+ await this.sleep(delayMs2);
1565
+ continue;
1566
+ }
1367
1567
  if (response.ok) return { ok: true };
1368
1568
  const requestNotFound = await isPostStreamRequestNotFound(response);
1369
- const delayMs = this.retryDelaysMs[attempt];
1370
- if (!requestNotFound || delayMs === void 0) {
1569
+ const retryable = requestNotFound || isRetryableStatus(response.status);
1570
+ const delayMs = this.retryDelayForAttempt(
1571
+ attempt,
1572
+ retryAfterMs2(response.headers)
1573
+ );
1574
+ if (!retryable || delayMs === null) {
1371
1575
  throw new Error(`metering endpoint returned ${response.status}`);
1372
1576
  }
1373
1577
  await this.sleep(delayMs);
@@ -1378,6 +1582,12 @@ var PostStreamUsageClient = class {
1378
1582
  return { ok: false, reason };
1379
1583
  }
1380
1584
  }
1585
+ retryDelayForAttempt(attempt, retryAfterMs3) {
1586
+ const fallback = this.retryDelaysMs[attempt];
1587
+ if (fallback === void 0) return null;
1588
+ if (retryAfterMs3 === null) return fallback;
1589
+ return Math.min(retryAfterMs3, this.maxRetryDelayMs);
1590
+ }
1381
1591
  };
1382
1592
  async function isPostStreamRequestNotFound(response) {
1383
1593
  if (response.status !== 422) return false;
@@ -1388,6 +1598,18 @@ async function isPostStreamRequestNotFound(response) {
1388
1598
  return false;
1389
1599
  }
1390
1600
  }
1601
+ function isRetryableStatus(status) {
1602
+ return status === 429 || status >= 500 && status <= 599;
1603
+ }
1604
+ function retryAfterMs2(headers) {
1605
+ const raw = headers.get("retry-after");
1606
+ if (!raw) return null;
1607
+ const seconds = Number(raw);
1608
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
1609
+ const dateMs = Date.parse(raw);
1610
+ if (!Number.isFinite(dateMs)) return null;
1611
+ return Math.max(0, dateMs - Date.now());
1612
+ }
1391
1613
  function sleep(delayMs) {
1392
1614
  return new Promise((resolve) => setTimeout(resolve, delayMs));
1393
1615
  }
@@ -1463,6 +1685,37 @@ var NonceCache = class {
1463
1685
  }
1464
1686
  };
1465
1687
 
1688
+ // src/core/replay-protection.ts
1689
+ function resolveReplayProtection(input = {}) {
1690
+ if (input.nonceStore) {
1691
+ return {
1692
+ // An opted-in shared store that is DOWN must not degrade to "no replay
1693
+ // check" — that would make knocking it over a way to switch the
1694
+ // protection off entirely.
1695
+ store: failClosed(input.nonceStore),
1696
+ diagnostic: { mode: "shared", crossReplica: true }
1697
+ };
1698
+ }
1699
+ return {
1700
+ store: new NonceCache(),
1701
+ diagnostic: { mode: "single-instance", crossReplica: false }
1702
+ };
1703
+ }
1704
+ function failClosed(store) {
1705
+ return {
1706
+ async checkAndRemember(id) {
1707
+ try {
1708
+ return await store.checkAndRemember(id);
1709
+ } catch (cause) {
1710
+ throw new FartherShoreError(
1711
+ "replayed_nonce",
1712
+ `replay store is unavailable, refusing the request rather than skipping one-time-use enforcement: ${cause instanceof Error ? cause.message : String(cause)}`
1713
+ );
1714
+ }
1715
+ }
1716
+ };
1717
+ }
1718
+
1466
1719
  // src/core/shutdown.ts
1467
1720
  var ShutdownManager = class {
1468
1721
  hooks = [];
@@ -2156,7 +2409,7 @@ function headerGetter(headers) {
2156
2409
 
2157
2410
  // src/core/runtime.ts
2158
2411
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
2159
- var SDK_VERSION = "0.19.0".length > 0 ? "0.19.0" : "0.0.0-dev";
2412
+ var SDK_VERSION = "0.20.0".length > 0 ? "0.20.0" : "0.0.0-dev";
2160
2413
  var FartherShore = class {
2161
2414
  bootstrapClient;
2162
2415
  fetchImpl;
@@ -2169,6 +2422,7 @@ var FartherShore = class {
2169
2422
  /** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
2170
2423
  contextSecrets;
2171
2424
  nonceCache;
2425
+ replayProtectionDiagnostic;
2172
2426
  shutdownManager = new ShutdownManager();
2173
2427
  jwks = null;
2174
2428
  meteringClient = null;
@@ -2186,7 +2440,9 @@ var FartherShore = class {
2186
2440
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
2187
2441
  this.tunnelOptions = options.tunnel ?? {};
2188
2442
  this.instanceId = options.instanceId;
2189
- this.nonceCache = options.nonceStore ?? new NonceCache();
2443
+ const replay = resolveReplayProtection({ nonceStore: options.nonceStore });
2444
+ this.nonceCache = replay.store;
2445
+ this.replayProtectionDiagnostic = replay.diagnostic;
2190
2446
  this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
2191
2447
  this.bootstrapClient = new BootstrapClient({
2192
2448
  runtimeToken,
@@ -2276,14 +2532,19 @@ var FartherShore = class {
2276
2532
  buildReportSink() {
2277
2533
  const post = async (path, body) => {
2278
2534
  const base = this.coreUrl.replace(/\/$/, "");
2279
- const res = await this.fetchImpl(`${base}${path}`, {
2280
- method: "POST",
2281
- headers: {
2282
- "content-type": "application/json",
2283
- authorization: `Bearer ${this.runtimeToken}`
2535
+ const res = await fetchWithDeadline(
2536
+ this.fetchImpl,
2537
+ `${base}${path}`,
2538
+ {
2539
+ method: "POST",
2540
+ headers: {
2541
+ "content-type": "application/json",
2542
+ authorization: `Bearer ${this.runtimeToken}`
2543
+ },
2544
+ body: JSON.stringify(body)
2284
2545
  },
2285
- body: JSON.stringify(body)
2286
- });
2546
+ "report"
2547
+ );
2287
2548
  if (!res.ok) throw new Error(`runtime report ${path} -> ${res.status}`);
2288
2549
  };
2289
2550
  return {
@@ -2410,6 +2671,15 @@ var FartherShore = class {
2410
2671
  return { ok: false, reason };
2411
2672
  }
2412
2673
  }
2674
+ /**
2675
+ * How far replay protection actually reaches — `"shared"` (enforced across
2676
+ * every replica) or `"single-instance"` (this process only). Deployment
2677
+ * diagnostic: log it at boot, or assert on it in a smoke test, so the mode is
2678
+ * never a surprise. See {@link FartherShoreInitOptions.nonceStore}.
2679
+ */
2680
+ replayProtection() {
2681
+ return this.replayProtectionDiagnostic;
2682
+ }
2413
2683
  /** Current local health report. */
2414
2684
  health() {
2415
2685
  const config = this.bootstrapClient.peek();
@@ -12,6 +12,11 @@ export type BootstrapClientOptions = {
12
12
  now?: () => number;
13
13
  /** Minimum seconds between refreshes regardless of server hint. */
14
14
  minRefreshSeconds?: number;
15
+ /**
16
+ * Maximum age for cached bootstrap authorization metadata during transient
17
+ * refresh failures. Defaults to 5 minutes.
18
+ */
19
+ maxStaleSeconds?: number;
15
20
  };
16
21
  /**
17
22
  * Caches the bootstrap response and refreshes it lazily. `get()` returns the
@@ -24,6 +29,7 @@ export declare class BootstrapClient {
24
29
  private readonly fetchImpl;
25
30
  private readonly now;
26
31
  private readonly minRefreshSeconds;
32
+ private readonly maxStaleMs;
27
33
  private cached;
28
34
  private fetchedAt;
29
35
  private refreshAfterMs;
@@ -36,5 +42,7 @@ export declare class BootstrapClient {
36
42
  /** Last cached value without triggering a refresh (null until bootstrapped). */
37
43
  peek(): RuntimeBootstrapResponse | null;
38
44
  private isStale;
45
+ private isHardStale;
46
+ private cachedOrThrowOnHardStale;
39
47
  private doBootstrap;
40
48
  }