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