@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/CHANGELOG.md +36 -0
- package/README.md +29 -1
- package/dist/index.js +341 -71
- package/dist/testing/index.js +341 -71
- package/dist/types/core/bootstrap.d.ts +8 -0
- package/dist/types/core/deadline.d.ts +80 -0
- package/dist/types/core/jwks.d.ts +42 -7
- package/dist/types/core/post-stream-usage.d.ts +4 -0
- package/dist/types/core/replay-protection.d.ts +28 -0
- package/dist/types/core/runtime.d.ts +19 -8
- package/dist/types/index.d.ts +1 -0
- package/package.json +11 -11
package/dist/testing/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
|
-
|
|
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
|
|
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
|
|
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())
|
|
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)
|
|
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.
|
|
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
|
|
531
|
-
|
|
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.
|
|
675
|
+
this.handleRefreshFailure(cause);
|
|
535
676
|
return;
|
|
536
677
|
}
|
|
537
678
|
if (!response.ok) {
|
|
538
|
-
this.
|
|
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
|
|
686
|
+
doc = await readBoundedJson(response);
|
|
546
687
|
} catch (cause) {
|
|
547
|
-
this.
|
|
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
|
-
*
|
|
561
|
-
*
|
|
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
|
-
|
|
564
|
-
if (this.hasFetchedOnce)
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1203
|
+
"bootstrap"
|
|
1204
|
+
);
|
|
1034
1205
|
} catch (cause) {
|
|
1035
|
-
if (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)
|
|
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
|
|
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
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
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
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
|
1370
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
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
|
-
|
|
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
|
}
|