@farthershore/backend 0.18.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 +56 -0
- package/README.md +29 -1
- package/dist/index.js +351 -72
- package/dist/testing/index.js +351 -72
- 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/core/verifyRequest.d.ts +14 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/runtime-types.d.ts +15 -0
- package/package.json +11 -11
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
|
-
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
637
|
+
"bootstrap"
|
|
638
|
+
);
|
|
534
639
|
} catch (cause) {
|
|
535
|
-
if (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)
|
|
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
|
|
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
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
594
|
-
|
|
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
|
|
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
|
|
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())
|
|
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)
|
|
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.
|
|
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
|
|
680
|
-
|
|
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.
|
|
851
|
+
this.handleRefreshFailure(cause);
|
|
684
852
|
return;
|
|
685
853
|
}
|
|
686
854
|
if (!response.ok) {
|
|
687
|
-
this.
|
|
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
|
|
862
|
+
doc = await readBoundedJson(response);
|
|
695
863
|
} catch (cause) {
|
|
696
|
-
this.
|
|
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
|
-
*
|
|
710
|
-
*
|
|
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
|
-
|
|
713
|
-
if (this.hasFetchedOnce)
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
|
1139
|
-
|
|
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 = [];
|
|
@@ -1781,7 +2034,14 @@ async function verifyRequest(input, deps) {
|
|
|
1781
2034
|
"signed business-id does not match this backend's business"
|
|
1782
2035
|
);
|
|
1783
2036
|
}
|
|
1784
|
-
if (deps.
|
|
2037
|
+
if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
|
|
2038
|
+
if (!deps.backendIds.has(signedBackendId)) {
|
|
2039
|
+
throw new FartherShoreError(
|
|
2040
|
+
"route_mismatch",
|
|
2041
|
+
"signed backend-id is not one this deployment serves"
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
} else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
|
|
1785
2045
|
throw new FartherShoreError(
|
|
1786
2046
|
"route_mismatch",
|
|
1787
2047
|
"signed backend-id does not match this backend"
|
|
@@ -1918,7 +2178,7 @@ function headerGetter(headers) {
|
|
|
1918
2178
|
|
|
1919
2179
|
// src/core/runtime.ts
|
|
1920
2180
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1921
|
-
var SDK_VERSION = "0.
|
|
2181
|
+
var SDK_VERSION = "0.20.0".length > 0 ? "0.20.0" : "0.0.0-dev";
|
|
1922
2182
|
var FartherShore = class {
|
|
1923
2183
|
bootstrapClient;
|
|
1924
2184
|
fetchImpl;
|
|
@@ -1931,6 +2191,7 @@ var FartherShore = class {
|
|
|
1931
2191
|
/** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
|
|
1932
2192
|
contextSecrets;
|
|
1933
2193
|
nonceCache;
|
|
2194
|
+
replayProtectionDiagnostic;
|
|
1934
2195
|
shutdownManager = new ShutdownManager();
|
|
1935
2196
|
jwks = null;
|
|
1936
2197
|
meteringClient = null;
|
|
@@ -1948,7 +2209,9 @@ var FartherShore = class {
|
|
|
1948
2209
|
this.meteringEnabledOverride = options.metering?.enabled ?? true;
|
|
1949
2210
|
this.tunnelOptions = options.tunnel ?? {};
|
|
1950
2211
|
this.instanceId = options.instanceId;
|
|
1951
|
-
|
|
2212
|
+
const replay = resolveReplayProtection({ nonceStore: options.nonceStore });
|
|
2213
|
+
this.nonceCache = replay.store;
|
|
2214
|
+
this.replayProtectionDiagnostic = replay.diagnostic;
|
|
1952
2215
|
this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
|
|
1953
2216
|
this.bootstrapClient = new BootstrapClient({
|
|
1954
2217
|
runtimeToken,
|
|
@@ -2038,14 +2301,19 @@ var FartherShore = class {
|
|
|
2038
2301
|
buildReportSink() {
|
|
2039
2302
|
const post = async (path, body) => {
|
|
2040
2303
|
const base = this.coreUrl.replace(/\/$/, "");
|
|
2041
|
-
const res = await
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
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)
|
|
2046
2314
|
},
|
|
2047
|
-
|
|
2048
|
-
|
|
2315
|
+
"report"
|
|
2316
|
+
);
|
|
2049
2317
|
if (!res.ok) throw new Error(`runtime report ${path} -> ${res.status}`);
|
|
2050
2318
|
};
|
|
2051
2319
|
return {
|
|
@@ -2066,11 +2334,13 @@ var FartherShore = class {
|
|
|
2066
2334
|
);
|
|
2067
2335
|
}
|
|
2068
2336
|
const knownRouteIds = new Set(config.routes.map((r) => r.id));
|
|
2337
|
+
const backendIds = config.backendIds?.length ? new Set(config.backendIds) : void 0;
|
|
2069
2338
|
const context = await verifyRequest(input, {
|
|
2070
2339
|
jwks: this.jwks,
|
|
2071
2340
|
nonceCache: this.nonceCache,
|
|
2072
2341
|
businessId: config.business.id,
|
|
2073
2342
|
backendId: config.backend.id,
|
|
2343
|
+
...backendIds ? { backendIds } : {},
|
|
2074
2344
|
knownRouteIds,
|
|
2075
2345
|
clockSkewSeconds: config.verification.clockSkewSeconds,
|
|
2076
2346
|
replayWindowSeconds: config.verification.replayWindowSeconds,
|
|
@@ -2170,6 +2440,15 @@ var FartherShore = class {
|
|
|
2170
2440
|
return { ok: false, reason };
|
|
2171
2441
|
}
|
|
2172
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
|
+
}
|
|
2173
2452
|
/** Current local health report. */
|
|
2174
2453
|
health() {
|
|
2175
2454
|
const config = this.bootstrapClient.peek();
|