@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/testing/index.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
|
|
2
2
|
var __defProp = Object.defineProperty;
|
|
3
3
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
-
var __esm = (fn, res) => function __init() {
|
|
5
|
-
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
6
11
|
};
|
|
7
12
|
var __export = (target, all) => {
|
|
8
13
|
for (var name in all)
|
|
@@ -189,20 +194,6 @@ var init_reconcile = __esm({
|
|
|
189
194
|
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
190
195
|
|
|
191
196
|
// src/generated/runtime-contract.ts
|
|
192
|
-
var RUNTIME_BODY_HASH_CONTRACT = {
|
|
193
|
-
algorithm: "SHA-256",
|
|
194
|
-
encoding: "hex-lower",
|
|
195
|
-
source: "raw-request-bytes",
|
|
196
|
-
emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
197
|
-
maxBodyBytes: 10485760,
|
|
198
|
-
streamingExemptToken: "STREAM",
|
|
199
|
-
streamingExemptContentTypes: [
|
|
200
|
-
"text/event-stream",
|
|
201
|
-
"application/octet-stream",
|
|
202
|
-
"multipart/form-data"
|
|
203
|
-
],
|
|
204
|
-
overMaxStatus: 413
|
|
205
|
-
};
|
|
206
197
|
var RUNTIME_ERROR_CODES = {
|
|
207
198
|
missingSignature: "missing_signature",
|
|
208
199
|
malformedSignature: "malformed_signature",
|
|
@@ -223,6 +214,20 @@ var RUNTIME_ERROR_CODES = {
|
|
|
223
214
|
serviceSubjectRequired: "service_subject_required",
|
|
224
215
|
surfaceNotAllowed: "surface_not_allowed"
|
|
225
216
|
};
|
|
217
|
+
var RUNTIME_BODY_HASH_CONTRACT = {
|
|
218
|
+
algorithm: "SHA-256",
|
|
219
|
+
encoding: "hex-lower",
|
|
220
|
+
source: "raw-request-bytes",
|
|
221
|
+
emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
222
|
+
maxBodyBytes: 10485760,
|
|
223
|
+
streamingExemptToken: "STREAM",
|
|
224
|
+
streamingExemptContentTypes: [
|
|
225
|
+
"text/event-stream",
|
|
226
|
+
"application/octet-stream",
|
|
227
|
+
"multipart/form-data"
|
|
228
|
+
],
|
|
229
|
+
overMaxStatus: 413
|
|
230
|
+
};
|
|
226
231
|
var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
|
227
232
|
headers: {
|
|
228
233
|
payload: "x-fs-metering",
|
|
@@ -243,14 +248,18 @@ var RUNTIME_RESPONSE_METERING_CONTRACT = {
|
|
|
243
248
|
payload: {
|
|
244
249
|
method: "string",
|
|
245
250
|
path: "string",
|
|
246
|
-
rawDimsUnits: "Record<string, number
|
|
251
|
+
rawDimsUnits: "Record<string, number>?",
|
|
247
252
|
measureContext: "Record<string, unknown>?",
|
|
248
|
-
creditUnitsConsumed: "Record<string, number>?"
|
|
253
|
+
creditUnitsConsumed: "Record<string, number>?",
|
|
254
|
+
measurementsVersion: "1?",
|
|
255
|
+
measurements: "Array<{ meter: string; values: Record<string, number>; dims?: Record<string, string> }>?",
|
|
256
|
+
quote: "{ currency: string; amountNanos: string }?"
|
|
249
257
|
},
|
|
250
258
|
errors: {
|
|
251
259
|
missingToken: "missing_token",
|
|
252
260
|
invalidMeterKey: "invalid_meter_key",
|
|
253
|
-
invalidMeterValue: "invalid_meter_value"
|
|
261
|
+
invalidMeterValue: "invalid_meter_value",
|
|
262
|
+
invalidQuote: "invalid_quote"
|
|
254
263
|
},
|
|
255
264
|
httpAdapter: {
|
|
256
265
|
input: "Request",
|
|
@@ -452,16 +461,106 @@ function statusForCode(code) {
|
|
|
452
461
|
return 401;
|
|
453
462
|
}
|
|
454
463
|
|
|
464
|
+
// src/core/deadline.ts
|
|
465
|
+
var DEADLINE_MS = {
|
|
466
|
+
/** Boot-blocking; generous because it runs once and gates startup. */
|
|
467
|
+
bootstrap: 1e4,
|
|
468
|
+
/** On the inbound verification path — must not hold a request open. */
|
|
469
|
+
jwks: 5e3,
|
|
470
|
+
/** Background economic report, retried by the caller. */
|
|
471
|
+
metering: 1e4,
|
|
472
|
+
/** Background attested usage callback. */
|
|
473
|
+
postStreamUsage: 1e4,
|
|
474
|
+
/** Best-effort heartbeat; never blocks anything. */
|
|
475
|
+
health: 5e3,
|
|
476
|
+
/** Boot-time route drift report; fail-open at the caller. */
|
|
477
|
+
report: 1e4
|
|
478
|
+
};
|
|
479
|
+
var MAX_RESPONSE_BYTES = 1048576;
|
|
480
|
+
var ResponseTooLargeError = class extends Error {
|
|
481
|
+
constructor(limit) {
|
|
482
|
+
super(`response body exceeded ${limit} bytes and was cancelled`);
|
|
483
|
+
this.name = "ResponseTooLargeError";
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
var DeadlineExceededError = class extends Error {
|
|
487
|
+
operation;
|
|
488
|
+
constructor(operation, timeoutMs) {
|
|
489
|
+
super(`${operation} exceeded its ${timeoutMs}ms deadline`);
|
|
490
|
+
this.name = "TimeoutError";
|
|
491
|
+
this.operation = operation;
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
async function fetchWithDeadline(fetchImpl, input, init, operation, options = {}) {
|
|
495
|
+
const timeoutMs = options.timeoutMs ?? DEADLINE_MS[operation];
|
|
496
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
497
|
+
const signal = options.callerSignal ? AbortSignal.any([options.callerSignal, timeout]) : timeout;
|
|
498
|
+
try {
|
|
499
|
+
return await fetchImpl(input, { ...init, signal });
|
|
500
|
+
} catch (cause) {
|
|
501
|
+
if (options.callerSignal?.aborted) throw cause;
|
|
502
|
+
if (timeout.aborted) throw new DeadlineExceededError(operation, timeoutMs);
|
|
503
|
+
throw cause;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function readBoundedText(response, limit = MAX_RESPONSE_BYTES) {
|
|
507
|
+
const body = response.body;
|
|
508
|
+
if (!body) {
|
|
509
|
+
const text = await response.text();
|
|
510
|
+
if (byteLength(text) > limit) throw new ResponseTooLargeError(limit);
|
|
511
|
+
return text;
|
|
512
|
+
}
|
|
513
|
+
const reader = body.getReader();
|
|
514
|
+
const chunks = [];
|
|
515
|
+
let total = 0;
|
|
516
|
+
try {
|
|
517
|
+
for (; ; ) {
|
|
518
|
+
const { done, value } = await reader.read();
|
|
519
|
+
if (done) break;
|
|
520
|
+
if (!value) continue;
|
|
521
|
+
total += value.byteLength;
|
|
522
|
+
if (total > limit) {
|
|
523
|
+
await reader.cancel();
|
|
524
|
+
throw new ResponseTooLargeError(limit);
|
|
525
|
+
}
|
|
526
|
+
chunks.push(value);
|
|
527
|
+
}
|
|
528
|
+
} finally {
|
|
529
|
+
reader.releaseLock();
|
|
530
|
+
}
|
|
531
|
+
const joined = new Uint8Array(total);
|
|
532
|
+
let offset = 0;
|
|
533
|
+
for (const chunk of chunks) {
|
|
534
|
+
joined.set(chunk, offset);
|
|
535
|
+
offset += chunk.byteLength;
|
|
536
|
+
}
|
|
537
|
+
return new TextDecoder().decode(joined);
|
|
538
|
+
}
|
|
539
|
+
async function readBoundedJson(response, limit = MAX_RESPONSE_BYTES) {
|
|
540
|
+
return JSON.parse(await readBoundedText(response, limit));
|
|
541
|
+
}
|
|
542
|
+
function byteLength(text) {
|
|
543
|
+
return new TextEncoder().encode(text).byteLength;
|
|
544
|
+
}
|
|
545
|
+
|
|
455
546
|
// src/core/jwks.ts
|
|
456
547
|
var DEFAULT_CACHE_TTL_MS = 5 * 6e4;
|
|
548
|
+
var DEFAULT_HARD_STALE_MS = 15 * 6e4;
|
|
457
549
|
var DEFAULT_NEGATIVE_CACHE_MS = 3e4;
|
|
550
|
+
function finiteMsOr(value, fallback) {
|
|
551
|
+
if (value === void 0) return fallback;
|
|
552
|
+
if (!Number.isFinite(value) || value < 0) return fallback;
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
458
555
|
var MAX_NEGATIVE_KIDS = 1e3;
|
|
459
556
|
var JwksClient = class {
|
|
460
557
|
jwksUrl;
|
|
461
558
|
fetchImpl;
|
|
462
559
|
cacheTtlMs;
|
|
560
|
+
hardStaleMs;
|
|
463
561
|
negativeCacheMs;
|
|
464
562
|
now;
|
|
563
|
+
onObservation;
|
|
465
564
|
keysByKid = /* @__PURE__ */ new Map();
|
|
466
565
|
fetchedAt = 0;
|
|
467
566
|
hasFetchedOnce = false;
|
|
@@ -470,21 +569,34 @@ var JwksClient = class {
|
|
|
470
569
|
constructor(options) {
|
|
471
570
|
this.jwksUrl = options.jwksUrl;
|
|
472
571
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
473
|
-
this.cacheTtlMs = options.cacheTtlMs
|
|
572
|
+
this.cacheTtlMs = finiteMsOr(options.cacheTtlMs, DEFAULT_CACHE_TTL_MS);
|
|
573
|
+
this.hardStaleMs = Math.max(
|
|
574
|
+
finiteMsOr(options.hardStaleMs, DEFAULT_HARD_STALE_MS),
|
|
575
|
+
this.cacheTtlMs
|
|
576
|
+
);
|
|
474
577
|
this.negativeCacheMs = options.negativeCacheMs ?? DEFAULT_NEGATIVE_CACHE_MS;
|
|
475
578
|
this.now = options.now ?? (() => Date.now());
|
|
579
|
+
this.onObservation = options.onObservation;
|
|
476
580
|
}
|
|
477
581
|
/**
|
|
478
582
|
* Resolve a public JWK for `kid`, fail-closed. Throws FartherShoreError with
|
|
479
|
-
* `jwks_unavailable` (cold cache
|
|
583
|
+
* `jwks_unavailable` (cold cache, or a hard-stale cache whose refresh is
|
|
584
|
+
* failing) or `unknown_key_id`.
|
|
480
585
|
*/
|
|
481
586
|
async getKey(kid) {
|
|
482
587
|
const cached = this.keysByKid.get(kid);
|
|
483
|
-
if (cached && !this.isStale())
|
|
588
|
+
if (cached && !this.isStale()) {
|
|
589
|
+
this.observe("fresh", kid);
|
|
590
|
+
return cached;
|
|
591
|
+
}
|
|
484
592
|
const negAt = this.negativeKids.get(kid);
|
|
485
593
|
if (negAt !== void 0 && this.now() - negAt < this.negativeCacheMs) {
|
|
486
594
|
const warm = this.keysByKid.get(kid);
|
|
487
|
-
if (warm)
|
|
595
|
+
if (warm) {
|
|
596
|
+
this.assertWithinHardStale();
|
|
597
|
+
this.observe(this.cacheState(), kid);
|
|
598
|
+
return warm;
|
|
599
|
+
}
|
|
488
600
|
throw new FartherShoreError(
|
|
489
601
|
"unknown_key_id",
|
|
490
602
|
`signing key '${kid}' is not present in the JWKS`
|
|
@@ -494,6 +606,7 @@ var JwksClient = class {
|
|
|
494
606
|
const key2 = this.keysByKid.get(kid);
|
|
495
607
|
if (key2) {
|
|
496
608
|
this.negativeKids.delete(kid);
|
|
609
|
+
this.observe(this.cacheState(), kid);
|
|
497
610
|
return key2;
|
|
498
611
|
}
|
|
499
612
|
this.rememberMissingKid(kid);
|
|
@@ -513,8 +626,37 @@ var JwksClient = class {
|
|
|
513
626
|
}
|
|
514
627
|
this.negativeKids.set(kid, this.now());
|
|
515
628
|
}
|
|
629
|
+
ageMs() {
|
|
630
|
+
return this.now() - this.fetchedAt;
|
|
631
|
+
}
|
|
516
632
|
isStale() {
|
|
517
|
-
return this.
|
|
633
|
+
return this.ageMs() >= this.cacheTtlMs;
|
|
634
|
+
}
|
|
635
|
+
isHardStale() {
|
|
636
|
+
return this.ageMs() >= this.hardStaleMs;
|
|
637
|
+
}
|
|
638
|
+
/** Current freshness of the cached key set. */
|
|
639
|
+
cacheState() {
|
|
640
|
+
if (!this.hasFetchedOnce) return "cold";
|
|
641
|
+
if (this.isHardStale()) return "hard_stale";
|
|
642
|
+
if (this.isStale()) return "soft_stale";
|
|
643
|
+
return "fresh";
|
|
644
|
+
}
|
|
645
|
+
observe(state, kid) {
|
|
646
|
+
this.onObservation?.({
|
|
647
|
+
state,
|
|
648
|
+
ageMs: this.hasFetchedOnce ? this.ageMs() : 0,
|
|
649
|
+
...kid !== void 0 ? { kid } : {}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/** Fail closed when the cached key set is past the hard-stale ceiling. */
|
|
653
|
+
assertWithinHardStale() {
|
|
654
|
+
if (!this.isHardStale()) return;
|
|
655
|
+
this.observe("hard_stale");
|
|
656
|
+
throw new FartherShoreError(
|
|
657
|
+
"jwks_unavailable",
|
|
658
|
+
`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`
|
|
659
|
+
);
|
|
518
660
|
}
|
|
519
661
|
/** Single-flight refresh: concurrent callers share one fetch. */
|
|
520
662
|
async refresh() {
|
|
@@ -527,24 +669,27 @@ var JwksClient = class {
|
|
|
527
669
|
async doFetch() {
|
|
528
670
|
let response;
|
|
529
671
|
try {
|
|
530
|
-
response = await
|
|
531
|
-
|
|
532
|
-
|
|
672
|
+
response = await fetchWithDeadline(
|
|
673
|
+
this.fetchImpl,
|
|
674
|
+
this.jwksUrl,
|
|
675
|
+
{ headers: { accept: "application/json" } },
|
|
676
|
+
"jwks"
|
|
677
|
+
);
|
|
533
678
|
} catch (cause) {
|
|
534
|
-
this.
|
|
679
|
+
this.handleRefreshFailure(cause);
|
|
535
680
|
return;
|
|
536
681
|
}
|
|
537
682
|
if (!response.ok) {
|
|
538
|
-
this.
|
|
683
|
+
this.handleRefreshFailure(
|
|
539
684
|
new Error(`JWKS endpoint returned HTTP ${response.status}`)
|
|
540
685
|
);
|
|
541
686
|
return;
|
|
542
687
|
}
|
|
543
688
|
let doc;
|
|
544
689
|
try {
|
|
545
|
-
doc = await response
|
|
690
|
+
doc = await readBoundedJson(response);
|
|
546
691
|
} catch (cause) {
|
|
547
|
-
this.
|
|
692
|
+
this.handleRefreshFailure(cause);
|
|
548
693
|
return;
|
|
549
694
|
}
|
|
550
695
|
const next = /* @__PURE__ */ new Map();
|
|
@@ -557,15 +702,27 @@ var JwksClient = class {
|
|
|
557
702
|
this.negativeKids.clear();
|
|
558
703
|
}
|
|
559
704
|
/**
|
|
560
|
-
*
|
|
561
|
-
*
|
|
705
|
+
* BOUNDED stale-while-revalidate. A COLD cache fails closed. A warm cache
|
|
706
|
+
* inside the soft window swallows the failure and keeps serving. Past the
|
|
707
|
+
* hard-stale ceiling it fails closed too — availability is worth a bounded
|
|
708
|
+
* window of degraded trust, not an unbounded one.
|
|
562
709
|
*/
|
|
563
|
-
|
|
564
|
-
if (this.hasFetchedOnce)
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
710
|
+
handleRefreshFailure(cause) {
|
|
711
|
+
if (!this.hasFetchedOnce) {
|
|
712
|
+
this.observe("cold");
|
|
713
|
+
throw new FartherShoreError(
|
|
714
|
+
"jwks_unavailable",
|
|
715
|
+
`JWKS unavailable on a cold cache: ${stringifyCause(cause)}`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
if (this.isHardStale()) {
|
|
719
|
+
this.observe("hard_stale");
|
|
720
|
+
throw new FartherShoreError(
|
|
721
|
+
"jwks_unavailable",
|
|
722
|
+
`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)}`
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
this.observe("soft_stale");
|
|
569
726
|
}
|
|
570
727
|
};
|
|
571
728
|
function stringifyCause(cause) {
|
|
@@ -968,6 +1125,7 @@ import { dirname as dirname2 } from "node:path";
|
|
|
968
1125
|
// src/core/bootstrap.ts
|
|
969
1126
|
var BOOTSTRAP_PATH = "/v1/runtime/bootstrap";
|
|
970
1127
|
var DEFAULT_MIN_REFRESH_SECONDS = 30;
|
|
1128
|
+
var DEFAULT_MAX_STALE_SECONDS = 300;
|
|
971
1129
|
var BootstrapClient = class {
|
|
972
1130
|
runtimeToken;
|
|
973
1131
|
endpoint;
|
|
@@ -975,6 +1133,7 @@ var BootstrapClient = class {
|
|
|
975
1133
|
fetchImpl;
|
|
976
1134
|
now;
|
|
977
1135
|
minRefreshSeconds;
|
|
1136
|
+
maxStaleMs;
|
|
978
1137
|
cached = null;
|
|
979
1138
|
fetchedAt = 0;
|
|
980
1139
|
refreshAfterMs = 0;
|
|
@@ -998,6 +1157,7 @@ var BootstrapClient = class {
|
|
|
998
1157
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
999
1158
|
this.now = options.now ?? (() => Date.now());
|
|
1000
1159
|
this.minRefreshSeconds = options.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS;
|
|
1160
|
+
this.maxStaleMs = (options.maxStaleSeconds ?? DEFAULT_MAX_STALE_SECONDS) * 1e3;
|
|
1001
1161
|
}
|
|
1002
1162
|
/** Cached config when fresh; otherwise refreshes. */
|
|
1003
1163
|
async get() {
|
|
@@ -1019,20 +1179,37 @@ var BootstrapClient = class {
|
|
|
1019
1179
|
isStale() {
|
|
1020
1180
|
return this.now() - this.fetchedAt >= this.refreshAfterMs;
|
|
1021
1181
|
}
|
|
1182
|
+
isHardStale() {
|
|
1183
|
+
return this.now() - this.fetchedAt >= this.maxStaleMs;
|
|
1184
|
+
}
|
|
1185
|
+
cachedOrThrowOnHardStale(reason) {
|
|
1186
|
+
if (this.cached && !this.isHardStale()) return this.cached;
|
|
1187
|
+
throw new FartherShoreError(
|
|
1188
|
+
"jwks_unavailable",
|
|
1189
|
+
`bootstrap refresh failed with stale cached authorization metadata: ${reason}`
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1022
1192
|
async doBootstrap() {
|
|
1023
1193
|
let response;
|
|
1024
1194
|
try {
|
|
1025
|
-
response = await
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1195
|
+
response = await fetchWithDeadline(
|
|
1196
|
+
this.fetchImpl,
|
|
1197
|
+
this.endpoint,
|
|
1198
|
+
{
|
|
1199
|
+
method: "POST",
|
|
1200
|
+
headers: {
|
|
1201
|
+
authorization: `Bearer ${this.runtimeToken}`,
|
|
1202
|
+
"content-type": "application/json",
|
|
1203
|
+
accept: "application/json"
|
|
1204
|
+
},
|
|
1205
|
+
body: JSON.stringify(this.request)
|
|
1031
1206
|
},
|
|
1032
|
-
|
|
1033
|
-
|
|
1207
|
+
"bootstrap"
|
|
1208
|
+
);
|
|
1034
1209
|
} catch (cause) {
|
|
1035
|
-
if (this.cached)
|
|
1210
|
+
if (this.cached) {
|
|
1211
|
+
return this.cachedOrThrowOnHardStale(stringify(cause));
|
|
1212
|
+
}
|
|
1036
1213
|
throw new FartherShoreError(
|
|
1037
1214
|
"jwks_unavailable",
|
|
1038
1215
|
`bootstrap request failed: ${stringify(cause)}`
|
|
@@ -1045,13 +1222,15 @@ var BootstrapClient = class {
|
|
|
1045
1222
|
);
|
|
1046
1223
|
}
|
|
1047
1224
|
if (!response.ok) {
|
|
1048
|
-
if (this.cached)
|
|
1225
|
+
if (this.cached) {
|
|
1226
|
+
return this.cachedOrThrowOnHardStale(`HTTP ${response.status}`);
|
|
1227
|
+
}
|
|
1049
1228
|
throw new FartherShoreError(
|
|
1050
1229
|
"jwks_unavailable",
|
|
1051
1230
|
`bootstrap returned HTTP ${response.status}`
|
|
1052
1231
|
);
|
|
1053
1232
|
}
|
|
1054
|
-
const body = await response
|
|
1233
|
+
const body = await readBoundedJson(response);
|
|
1055
1234
|
this.cached = body;
|
|
1056
1235
|
this.fetchedAt = this.now();
|
|
1057
1236
|
const refreshSeconds = Math.max(
|
|
@@ -1084,197 +1263,28 @@ async function reportHealth(options) {
|
|
|
1084
1263
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1085
1264
|
const endpoint = `${options.coreUrl.replace(/\/+$/, "")}${HEALTH_PATH}`;
|
|
1086
1265
|
try {
|
|
1087
|
-
const response = await
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1266
|
+
const response = await fetchWithDeadline(
|
|
1267
|
+
fetchImpl,
|
|
1268
|
+
endpoint,
|
|
1269
|
+
{
|
|
1270
|
+
method: "POST",
|
|
1271
|
+
headers: {
|
|
1272
|
+
authorization: `Bearer ${options.runtimeToken}`,
|
|
1273
|
+
"content-type": "application/json"
|
|
1274
|
+
},
|
|
1275
|
+
body: JSON.stringify({
|
|
1276
|
+
status: options.status,
|
|
1277
|
+
...options.instanceId ? { instanceId: options.instanceId } : {}
|
|
1278
|
+
})
|
|
1092
1279
|
},
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
...options.instanceId ? { instanceId: options.instanceId } : {}
|
|
1096
|
-
})
|
|
1097
|
-
});
|
|
1280
|
+
"health"
|
|
1281
|
+
);
|
|
1098
1282
|
return response.ok;
|
|
1099
1283
|
} catch {
|
|
1100
1284
|
return false;
|
|
1101
1285
|
}
|
|
1102
1286
|
}
|
|
1103
1287
|
|
|
1104
|
-
// src/core/backoff.ts
|
|
1105
|
-
function computeBackoff(attempt, options) {
|
|
1106
|
-
const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
|
|
1107
|
-
const exponent = Math.max(0, attempt - 1);
|
|
1108
|
-
const cap = Math.min(baseMs * 2 ** exponent, maxMs);
|
|
1109
|
-
switch (jitter) {
|
|
1110
|
-
case "none":
|
|
1111
|
-
return cap;
|
|
1112
|
-
case "full":
|
|
1113
|
-
return random() * cap;
|
|
1114
|
-
case "equal":
|
|
1115
|
-
default:
|
|
1116
|
-
return cap / 2 + random() * (cap / 2);
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
// src/core/metering.ts
|
|
1121
|
-
var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
1122
|
-
var DEFAULT_BASE_DELAY_MS = 200;
|
|
1123
|
-
var DEFAULT_MAX_DELAY_MS = 1e4;
|
|
1124
|
-
function isTransientStatus(status) {
|
|
1125
|
-
return status === 429 || status >= 500;
|
|
1126
|
-
}
|
|
1127
|
-
function retryAfterMs(headers) {
|
|
1128
|
-
const raw = headers.get("retry-after");
|
|
1129
|
-
if (raw === null) return null;
|
|
1130
|
-
const trimmed = raw.trim();
|
|
1131
|
-
if (!/^\d+$/.test(trimmed)) return null;
|
|
1132
|
-
const secs = Number(trimmed);
|
|
1133
|
-
return Number.isFinite(secs) ? secs * 1e3 : null;
|
|
1134
|
-
}
|
|
1135
|
-
var DEFAULT_MAX_RETRIES = 3;
|
|
1136
|
-
var MeteringClient = class {
|
|
1137
|
-
config;
|
|
1138
|
-
endpoint;
|
|
1139
|
-
businessId;
|
|
1140
|
-
backendId;
|
|
1141
|
-
fetchImpl;
|
|
1142
|
-
maxRetries;
|
|
1143
|
-
baseDelayMs;
|
|
1144
|
-
maxDelayMs;
|
|
1145
|
-
sleep;
|
|
1146
|
-
random;
|
|
1147
|
-
newId;
|
|
1148
|
-
now;
|
|
1149
|
-
buffer = [];
|
|
1150
|
-
constructor(options) {
|
|
1151
|
-
this.config = options.config;
|
|
1152
|
-
this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
|
|
1153
|
-
this.businessId = options.businessId;
|
|
1154
|
-
this.backendId = options.backendId;
|
|
1155
|
-
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1156
|
-
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1157
|
-
this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
1158
|
-
this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
1159
|
-
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1160
|
-
this.random = options.random ?? Math.random;
|
|
1161
|
-
this.newId = options.newId ?? (() => crypto.randomUUID());
|
|
1162
|
-
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1163
|
-
}
|
|
1164
|
-
/**
|
|
1165
|
-
* Record `qty` of `meter`. Enforces meter-key shape, non-negative finite qty,
|
|
1166
|
-
* the bootstrap allowedMeters/allowedRoutes scope, and the per-event sanity
|
|
1167
|
-
* max, then enqueues and flushes (best-effort; failures stay buffered).
|
|
1168
|
-
*/
|
|
1169
|
-
async meter(meter, qty, options = {}) {
|
|
1170
|
-
if (!this.config.enabled) {
|
|
1171
|
-
throw new FartherShoreError(
|
|
1172
|
-
"invalid_token",
|
|
1173
|
-
"metering is not enabled for this runtime token"
|
|
1174
|
-
);
|
|
1175
|
-
}
|
|
1176
|
-
if (!METER_KEY_RE.test(meter)) {
|
|
1177
|
-
throw new FartherShoreError(
|
|
1178
|
-
"invalid_token",
|
|
1179
|
-
`meter key '${meter}' must be lowercase alphanumeric with underscores`
|
|
1180
|
-
);
|
|
1181
|
-
}
|
|
1182
|
-
if (!Number.isFinite(qty) || qty < 0) {
|
|
1183
|
-
throw new FartherShoreError(
|
|
1184
|
-
"invalid_token",
|
|
1185
|
-
`meter '${meter}' qty must be a non-negative finite number`
|
|
1186
|
-
);
|
|
1187
|
-
}
|
|
1188
|
-
if (this.config.allowedMeters.length > 0 && !this.config.allowedMeters.includes(meter)) {
|
|
1189
|
-
throw new FartherShoreError(
|
|
1190
|
-
"invalid_token",
|
|
1191
|
-
`meter '${meter}' is not in the token's allowedMeters`
|
|
1192
|
-
);
|
|
1193
|
-
}
|
|
1194
|
-
if (this.config.allowedRoutes.length > 0) {
|
|
1195
|
-
if (!options.routeId) {
|
|
1196
|
-
throw new FartherShoreError(
|
|
1197
|
-
"invalid_token",
|
|
1198
|
-
"routeId is required because this runtime token is route-scoped"
|
|
1199
|
-
);
|
|
1200
|
-
}
|
|
1201
|
-
if (!this.config.allowedRoutes.includes(options.routeId)) {
|
|
1202
|
-
throw new FartherShoreError(
|
|
1203
|
-
"invalid_token",
|
|
1204
|
-
`route '${options.routeId}' is not in the token's allowedRoutes`
|
|
1205
|
-
);
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
if (this.config.perEventMax > 0 && qty > this.config.perEventMax) {
|
|
1209
|
-
throw new FartherShoreError(
|
|
1210
|
-
"invalid_token",
|
|
1211
|
-
`meter '${meter}' qty ${qty} exceeds the per-event max ${this.config.perEventMax}`
|
|
1212
|
-
);
|
|
1213
|
-
}
|
|
1214
|
-
const event = {
|
|
1215
|
-
event_id: options.eventId ?? this.newId(),
|
|
1216
|
-
business_id: this.businessId,
|
|
1217
|
-
backend_id: this.backendId,
|
|
1218
|
-
meter,
|
|
1219
|
-
qty,
|
|
1220
|
-
timestamp: options.timestamp ?? this.now().toISOString(),
|
|
1221
|
-
...options.routeId ? { route_id: options.routeId } : {},
|
|
1222
|
-
...options.requestId ? { request_id: options.requestId } : {},
|
|
1223
|
-
...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
|
|
1224
|
-
};
|
|
1225
|
-
this.buffer.push(event);
|
|
1226
|
-
await this.flush();
|
|
1227
|
-
}
|
|
1228
|
-
/** Drain the buffer. Events that fail all retries stay buffered (at-least-once). */
|
|
1229
|
-
async flush() {
|
|
1230
|
-
const pending = this.buffer.splice(0, this.buffer.length);
|
|
1231
|
-
const stillPending = [];
|
|
1232
|
-
for (const event of pending) {
|
|
1233
|
-
const sent = await this.sendWithRetry(event);
|
|
1234
|
-
if (!sent) stillPending.push(event);
|
|
1235
|
-
}
|
|
1236
|
-
if (stillPending.length > 0) this.buffer.unshift(...stillPending);
|
|
1237
|
-
}
|
|
1238
|
-
/** Buffered-but-unsent count (observability/tests). */
|
|
1239
|
-
get pending() {
|
|
1240
|
-
return this.buffer.length;
|
|
1241
|
-
}
|
|
1242
|
-
async sendWithRetry(event) {
|
|
1243
|
-
for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
|
|
1244
|
-
let retryAfter = null;
|
|
1245
|
-
try {
|
|
1246
|
-
const response = await this.fetchImpl(this.endpoint, {
|
|
1247
|
-
method: "POST",
|
|
1248
|
-
headers: {
|
|
1249
|
-
authorization: `Bearer ${this.config.credential}`,
|
|
1250
|
-
"content-type": "application/json",
|
|
1251
|
-
accept: "application/json"
|
|
1252
|
-
},
|
|
1253
|
-
body: JSON.stringify(event)
|
|
1254
|
-
});
|
|
1255
|
-
if (response.ok) return true;
|
|
1256
|
-
if (!isTransientStatus(response.status)) return false;
|
|
1257
|
-
retryAfter = retryAfterMs(response.headers);
|
|
1258
|
-
} catch {
|
|
1259
|
-
}
|
|
1260
|
-
const isLast = attempt === this.maxRetries - 1;
|
|
1261
|
-
if (isLast) break;
|
|
1262
|
-
const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
|
|
1263
|
-
baseMs: this.baseDelayMs,
|
|
1264
|
-
maxMs: this.maxDelayMs,
|
|
1265
|
-
random: this.random
|
|
1266
|
-
});
|
|
1267
|
-
await this.sleep(delay);
|
|
1268
|
-
}
|
|
1269
|
-
return false;
|
|
1270
|
-
}
|
|
1271
|
-
};
|
|
1272
|
-
function resolveEndpoint(endpoint, coreUrl) {
|
|
1273
|
-
if (/^https?:\/\//.test(endpoint)) return endpoint;
|
|
1274
|
-
if (!coreUrl) return endpoint;
|
|
1275
|
-
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
1288
|
// src/response-metering.ts
|
|
1279
1289
|
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
1280
1290
|
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
@@ -1286,6 +1296,49 @@ var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
|
1286
1296
|
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
1287
1297
|
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
1288
1298
|
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
1299
|
+
var MeteringError = class extends Error {
|
|
1300
|
+
code;
|
|
1301
|
+
constructor(code, message) {
|
|
1302
|
+
super(message);
|
|
1303
|
+
this.name = "MeteringError";
|
|
1304
|
+
this.code = code;
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
async function computeMeteringHeaders(payload, options = {}) {
|
|
1308
|
+
try {
|
|
1309
|
+
const token = resolveTokenSoft(options);
|
|
1310
|
+
if (!token) {
|
|
1311
|
+
skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
|
|
1312
|
+
return {};
|
|
1313
|
+
}
|
|
1314
|
+
const json2 = JSON.stringify(payload);
|
|
1315
|
+
const signature = await signPayload(json2, token);
|
|
1316
|
+
devMeteringHooks?.record?.(payload, options.requestId);
|
|
1317
|
+
return {
|
|
1318
|
+
[METERING_PAYLOAD_HEADER]: json2,
|
|
1319
|
+
[METERING_SIGNATURE_HEADER]: signature,
|
|
1320
|
+
[METERING_TOKEN_HEADER]: token
|
|
1321
|
+
};
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
skip(error instanceof Error ? error.message : String(error), options);
|
|
1324
|
+
return {};
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function skip(reason, options) {
|
|
1328
|
+
if (options.onSkip) {
|
|
1329
|
+
options.onSkip(reason);
|
|
1330
|
+
} else {
|
|
1331
|
+
console.warn(`metering headers skipped: ${reason}`);
|
|
1332
|
+
}
|
|
1333
|
+
devMeteringHooks?.onSkip?.(reason, options.requestId);
|
|
1334
|
+
}
|
|
1335
|
+
function resolveTokenSoft(options) {
|
|
1336
|
+
return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
|
|
1337
|
+
}
|
|
1338
|
+
function processEnv(key2) {
|
|
1339
|
+
const maybeProcess = globalThis.process;
|
|
1340
|
+
return maybeProcess?.env?.[key2];
|
|
1341
|
+
}
|
|
1289
1342
|
async function signPayload(payload, token) {
|
|
1290
1343
|
const key2 = await crypto.subtle.importKey(
|
|
1291
1344
|
"raw",
|
|
@@ -1310,7 +1363,7 @@ function base64url(bytes) {
|
|
|
1310
1363
|
}
|
|
1311
1364
|
|
|
1312
1365
|
// src/core/post-stream-usage.ts
|
|
1313
|
-
var
|
|
1366
|
+
var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
1314
1367
|
var PostStreamUsageClient = class {
|
|
1315
1368
|
config;
|
|
1316
1369
|
endpoint;
|
|
@@ -1319,14 +1372,16 @@ var PostStreamUsageClient = class {
|
|
|
1319
1372
|
logger;
|
|
1320
1373
|
sleep;
|
|
1321
1374
|
retryDelaysMs;
|
|
1375
|
+
maxRetryDelayMs;
|
|
1322
1376
|
constructor(options) {
|
|
1323
1377
|
this.config = options.config;
|
|
1324
|
-
this.endpoint =
|
|
1378
|
+
this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
|
|
1325
1379
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1326
1380
|
this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
|
|
1327
1381
|
this.logger = options.logger ?? ((message) => console.warn(message));
|
|
1328
1382
|
this.sleep = options.sleep ?? sleep;
|
|
1329
1383
|
this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
|
|
1384
|
+
this.maxRetryDelayMs = options.maxRetryDelayMs ?? 1e4;
|
|
1330
1385
|
}
|
|
1331
1386
|
async reportUsage(input) {
|
|
1332
1387
|
try {
|
|
@@ -1337,6 +1392,11 @@ var PostStreamUsageClient = class {
|
|
|
1337
1392
|
requestId: input.requestId,
|
|
1338
1393
|
subscriptionId: input.subscriptionId,
|
|
1339
1394
|
nonce: this.newNonce(),
|
|
1395
|
+
// The token's `allowedMeters` scope is enforced on BOTH lanes
|
|
1396
|
+
// independently (P0-1): `meters` is the flat METER-keyed projection
|
|
1397
|
+
// (the billed lane), so its keys must be in scope regardless of
|
|
1398
|
+
// whether the measurement lane is also present; `measurements[].meter`
|
|
1399
|
+
// is scoped in validateMeasurements below.
|
|
1340
1400
|
meters: validateAndSortUsage(input.meters, "meters", this.config, true),
|
|
1341
1401
|
...input.creditUnitsConsumed ? {
|
|
1342
1402
|
creditUnitsConsumed: validateAndSortUsage(
|
|
@@ -1346,7 +1406,13 @@ var PostStreamUsageClient = class {
|
|
|
1346
1406
|
false
|
|
1347
1407
|
)
|
|
1348
1408
|
} : {},
|
|
1349
|
-
...input.measureContext ? { measureContext: input.measureContext } : {}
|
|
1409
|
+
...input.measureContext ? { measureContext: input.measureContext } : {},
|
|
1410
|
+
// Key ORDER is load-bearing: core recomputes the HMAC over
|
|
1411
|
+
// JSON.stringify(unsigned) rebuilt in its zod schema's field order, so
|
|
1412
|
+
// these additive fields must sit in the same position on both sides.
|
|
1413
|
+
...input.measurementsVersion !== void 0 ? { measurementsVersion: input.measurementsVersion } : {},
|
|
1414
|
+
...input.measurements ? { measurements: this.validateMeasurements(input.measurements) } : {},
|
|
1415
|
+
...input.quote ? { quote: input.quote } : {}
|
|
1350
1416
|
};
|
|
1351
1417
|
const signature = await signPayload(
|
|
1352
1418
|
JSON.stringify(unsigned),
|
|
@@ -1354,20 +1420,39 @@ var PostStreamUsageClient = class {
|
|
|
1354
1420
|
);
|
|
1355
1421
|
const event = { ...unsigned, signature };
|
|
1356
1422
|
const body = JSON.stringify(event);
|
|
1423
|
+
const headerSignature = await signPayload(body, this.config.credential);
|
|
1357
1424
|
for (let attempt = 0; ; attempt += 1) {
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1425
|
+
let response;
|
|
1426
|
+
try {
|
|
1427
|
+
response = await fetchWithDeadline(
|
|
1428
|
+
this.fetchImpl,
|
|
1429
|
+
this.endpoint,
|
|
1430
|
+
{
|
|
1431
|
+
method: "POST",
|
|
1432
|
+
headers: {
|
|
1433
|
+
authorization: `Bearer ${this.config.credential}`,
|
|
1434
|
+
"content-type": "application/json",
|
|
1435
|
+
accept: "application/json",
|
|
1436
|
+
[RUNTIME_RESPONSE_METERING_CONTRACT.headers.signature]: headerSignature
|
|
1437
|
+
},
|
|
1438
|
+
body
|
|
1439
|
+
},
|
|
1440
|
+
"postStreamUsage"
|
|
1441
|
+
);
|
|
1442
|
+
} catch (cause) {
|
|
1443
|
+
const delayMs2 = this.retryDelayForAttempt(attempt, null);
|
|
1444
|
+
if (delayMs2 === null) throw cause;
|
|
1445
|
+
await this.sleep(delayMs2);
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1367
1448
|
if (response.ok) return { ok: true };
|
|
1368
1449
|
const requestNotFound = await isPostStreamRequestNotFound(response);
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1450
|
+
const retryable = requestNotFound || isRetryableStatus(response.status);
|
|
1451
|
+
const delayMs = this.retryDelayForAttempt(
|
|
1452
|
+
attempt,
|
|
1453
|
+
retryAfterMs(response.headers)
|
|
1454
|
+
);
|
|
1455
|
+
if (!retryable || delayMs === null) {
|
|
1371
1456
|
throw new Error(`metering endpoint returned ${response.status}`);
|
|
1372
1457
|
}
|
|
1373
1458
|
await this.sleep(delayMs);
|
|
@@ -1378,6 +1463,31 @@ var PostStreamUsageClient = class {
|
|
|
1378
1463
|
return { ok: false, reason };
|
|
1379
1464
|
}
|
|
1380
1465
|
}
|
|
1466
|
+
/** Enforce the token's meter scope + per-event bounds on the measurement lane. */
|
|
1467
|
+
validateMeasurements(measurements) {
|
|
1468
|
+
const allowed = this.config.allowedMeters;
|
|
1469
|
+
for (const measurement of measurements) {
|
|
1470
|
+
if (allowed.length > 0 && !allowed.includes(measurement.meter)) {
|
|
1471
|
+
throw new Error(
|
|
1472
|
+
`meter '${measurement.meter}' is not in the token's allowedMeters`
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
for (const [measure, value] of Object.entries(measurement.values)) {
|
|
1476
|
+
if (this.config.perEventMax > 0 && value > this.config.perEventMax) {
|
|
1477
|
+
throw new Error(
|
|
1478
|
+
`measure '${measure}' value ${value} exceeds the per-event max ${this.config.perEventMax}`
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
return measurements;
|
|
1484
|
+
}
|
|
1485
|
+
retryDelayForAttempt(attempt, retryAfterMs2) {
|
|
1486
|
+
const fallback = this.retryDelaysMs[attempt];
|
|
1487
|
+
if (fallback === void 0) return null;
|
|
1488
|
+
if (retryAfterMs2 === null) return fallback;
|
|
1489
|
+
return Math.min(retryAfterMs2, this.maxRetryDelayMs);
|
|
1490
|
+
}
|
|
1381
1491
|
};
|
|
1382
1492
|
async function isPostStreamRequestNotFound(response) {
|
|
1383
1493
|
if (response.status !== 422) return false;
|
|
@@ -1388,6 +1498,18 @@ async function isPostStreamRequestNotFound(response) {
|
|
|
1388
1498
|
return false;
|
|
1389
1499
|
}
|
|
1390
1500
|
}
|
|
1501
|
+
function isRetryableStatus(status) {
|
|
1502
|
+
return status === 429 || status >= 500 && status <= 599;
|
|
1503
|
+
}
|
|
1504
|
+
function retryAfterMs(headers) {
|
|
1505
|
+
const raw = headers.get("retry-after");
|
|
1506
|
+
if (!raw) return null;
|
|
1507
|
+
const seconds = Number(raw);
|
|
1508
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
1509
|
+
const dateMs = Date.parse(raw);
|
|
1510
|
+
if (!Number.isFinite(dateMs)) return null;
|
|
1511
|
+
return Math.max(0, dateMs - Date.now());
|
|
1512
|
+
}
|
|
1391
1513
|
function sleep(delayMs) {
|
|
1392
1514
|
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1393
1515
|
}
|
|
@@ -1396,7 +1518,7 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
|
|
|
1396
1518
|
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
1397
1519
|
);
|
|
1398
1520
|
for (const [meter, qty] of entries) {
|
|
1399
|
-
if (!
|
|
1521
|
+
if (!METER_KEY_RE.test(meter)) {
|
|
1400
1522
|
throw new Error(
|
|
1401
1523
|
`${label} key '${meter}' must be lowercase alphanumeric with underscores`
|
|
1402
1524
|
);
|
|
@@ -1415,12 +1537,303 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
|
|
|
1415
1537
|
}
|
|
1416
1538
|
return Object.fromEntries(entries);
|
|
1417
1539
|
}
|
|
1418
|
-
function
|
|
1540
|
+
function resolveEndpoint(endpoint, coreUrl) {
|
|
1419
1541
|
if (/^https?:\/\//.test(endpoint)) return endpoint;
|
|
1420
1542
|
if (!coreUrl) return endpoint;
|
|
1421
1543
|
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
1422
1544
|
}
|
|
1423
1545
|
|
|
1546
|
+
// src/core/report.ts
|
|
1547
|
+
var MEASUREMENTS_VERSION = 1;
|
|
1548
|
+
var KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
1549
|
+
var DIMENSION_VALUE_RE = /^[\w.:-]{1,128}$/;
|
|
1550
|
+
var CURRENCY_RE = /^[A-Za-z]{3}$/;
|
|
1551
|
+
var DECIMAL_INTEGER_RE = /^\d{1,30}$/;
|
|
1552
|
+
function createReportFn(channels) {
|
|
1553
|
+
let stampedMeasurements = [];
|
|
1554
|
+
let stampedQuote;
|
|
1555
|
+
let inBandTail = Promise.resolve();
|
|
1556
|
+
let postStreamFinalized = false;
|
|
1557
|
+
let pendingPostStreamBatch = null;
|
|
1558
|
+
const deliverPostStream = async (reported, quote) => {
|
|
1559
|
+
if (pendingPostStreamBatch) {
|
|
1560
|
+
if (!quotesEqual(pendingPostStreamBatch.quote, quote)) {
|
|
1561
|
+
return {
|
|
1562
|
+
ok: false,
|
|
1563
|
+
transport: "post_stream",
|
|
1564
|
+
reason: "quote conflicts with this request's pending post-stream batch: one served request carries one quote across all measurements"
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
if (reported.some(
|
|
1568
|
+
(measurement) => !dimsEqual(
|
|
1569
|
+
pendingPostStreamBatch.measurements[0]?.dims,
|
|
1570
|
+
measurement.dims
|
|
1571
|
+
)
|
|
1572
|
+
)) {
|
|
1573
|
+
return {
|
|
1574
|
+
ok: false,
|
|
1575
|
+
transport: "post_stream",
|
|
1576
|
+
reason: "dims conflict with this request's pending post-stream batch: the request receipt rates under ONE dims tuple"
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
pendingPostStreamBatch.measurements.push(...reported);
|
|
1580
|
+
return pendingPostStreamBatch.flush;
|
|
1581
|
+
}
|
|
1582
|
+
if (postStreamFinalized) {
|
|
1583
|
+
return {
|
|
1584
|
+
ok: false,
|
|
1585
|
+
transport: "post_stream",
|
|
1586
|
+
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"
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
if (stampedMeasurements.length > 0) {
|
|
1590
|
+
return {
|
|
1591
|
+
ok: false,
|
|
1592
|
+
transport: "post_stream",
|
|
1593
|
+
reason: "this request already reported in-band; every report on one request must share the stamped aggregate (same quote, before the response is sent)"
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
postStreamFinalized = true;
|
|
1597
|
+
const batch = {
|
|
1598
|
+
measurements: [...reported],
|
|
1599
|
+
quote,
|
|
1600
|
+
flush: void 0
|
|
1601
|
+
};
|
|
1602
|
+
batch.flush = new Promise((resolve) => setTimeout(resolve, 0)).then(
|
|
1603
|
+
async () => {
|
|
1604
|
+
pendingPostStreamBatch = null;
|
|
1605
|
+
const result = await channels.postStream({
|
|
1606
|
+
measurements: batch.measurements,
|
|
1607
|
+
...batch.quote ? { quote: batch.quote } : {}
|
|
1608
|
+
});
|
|
1609
|
+
return result.ok ? { ok: true, transport: "post_stream" } : {
|
|
1610
|
+
ok: false,
|
|
1611
|
+
transport: "post_stream",
|
|
1612
|
+
reason: result.reason ?? "post-stream delivery failed"
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
);
|
|
1616
|
+
pendingPostStreamBatch = batch;
|
|
1617
|
+
return batch.flush;
|
|
1618
|
+
};
|
|
1619
|
+
const tryInBand = (reported, quote) => {
|
|
1620
|
+
const run = inBandTail.then(async () => {
|
|
1621
|
+
const sink = channels.responseSink;
|
|
1622
|
+
if (!sink || !channels.request || !sink.canStampHeaders()) return null;
|
|
1623
|
+
if (postStreamFinalized) return null;
|
|
1624
|
+
if (!quotesEqual(stampedQuote, quote) && stampedMeasurements.length > 0) {
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
if (stampedMeasurements.length > 0 && reported.some(
|
|
1628
|
+
(measurement) => !dimsEqual(stampedMeasurements[0].dims, measurement.dims)
|
|
1629
|
+
)) {
|
|
1630
|
+
return null;
|
|
1631
|
+
}
|
|
1632
|
+
const measurements = [...stampedMeasurements, ...reported];
|
|
1633
|
+
const payload = buildInBandPayload(channels.request, measurements, quote);
|
|
1634
|
+
const headers = await channels.computeHeaders(payload);
|
|
1635
|
+
if (Object.keys(headers).length === 0 || !sink.canStampHeaders()) {
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
sink.stampHeaders(headers);
|
|
1639
|
+
stampedMeasurements = measurements;
|
|
1640
|
+
stampedQuote = quote;
|
|
1641
|
+
return { ok: true, transport: "in_band" };
|
|
1642
|
+
});
|
|
1643
|
+
inBandTail = run.then(
|
|
1644
|
+
() => void 0,
|
|
1645
|
+
() => void 0
|
|
1646
|
+
);
|
|
1647
|
+
return run;
|
|
1648
|
+
};
|
|
1649
|
+
return async (input) => {
|
|
1650
|
+
const inputs = Array.isArray(input) ? input : [input];
|
|
1651
|
+
if (inputs.length === 0) {
|
|
1652
|
+
throw new MeteringError(
|
|
1653
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
1654
|
+
"report([]) is empty: a batched report needs at least one measurement"
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
const measurements = inputs.map((entry) => validateMeasurement(entry));
|
|
1658
|
+
for (const measurement of measurements) {
|
|
1659
|
+
if (!dimsEqual(measurements[0].dims, measurement.dims)) {
|
|
1660
|
+
throw new MeteringError(
|
|
1661
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
|
|
1662
|
+
"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"
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
let quote;
|
|
1667
|
+
for (const entry of inputs) {
|
|
1668
|
+
if (entry.quote === void 0) continue;
|
|
1669
|
+
const validated = validateQuote(entry.quote);
|
|
1670
|
+
if (quote === void 0) {
|
|
1671
|
+
quote = validated;
|
|
1672
|
+
} else if (!quotesEqual(quote, validated)) {
|
|
1673
|
+
throw new MeteringError(
|
|
1674
|
+
RESPONSE_METERING_ERROR_CODES.invalidQuote,
|
|
1675
|
+
"a batched report carries ONE quote: two entries supplied different quotes"
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
const inBand = await tryInBand(measurements, quote);
|
|
1680
|
+
if (inBand) return inBand;
|
|
1681
|
+
return deliverPostStream(measurements, quote);
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
function unattachedReport() {
|
|
1685
|
+
return () => Promise.reject(
|
|
1686
|
+
new MeteringError(
|
|
1687
|
+
RESPONSE_METERING_ERROR_CODES.missingToken,
|
|
1688
|
+
"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"
|
|
1689
|
+
)
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
function rawDimsUnitsOf(measurement) {
|
|
1693
|
+
let total = 0;
|
|
1694
|
+
for (const value of Object.values(measurement.values)) total += value;
|
|
1695
|
+
return { [measurement.meter]: total };
|
|
1696
|
+
}
|
|
1697
|
+
function buildInBandPayload(request, measurements, quote) {
|
|
1698
|
+
const rawDimsUnits = {};
|
|
1699
|
+
for (const measurement of measurements) {
|
|
1700
|
+
for (const [meter, units] of Object.entries(rawDimsUnitsOf(measurement))) {
|
|
1701
|
+
rawDimsUnits[meter] = (rawDimsUnits[meter] ?? 0) + units;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
method: request.method.toUpperCase(),
|
|
1706
|
+
path: request.path,
|
|
1707
|
+
rawDimsUnits,
|
|
1708
|
+
measurementsVersion: MEASUREMENTS_VERSION,
|
|
1709
|
+
measurements,
|
|
1710
|
+
...quote ? { quote } : {}
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
function dimsEqual(left, right) {
|
|
1714
|
+
const l = Object.entries(left ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
|
|
1715
|
+
const r = Object.entries(right ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
|
|
1716
|
+
if (l.length !== r.length) return false;
|
|
1717
|
+
return l.every(([k, v], i) => r[i][0] === k && r[i][1] === v);
|
|
1718
|
+
}
|
|
1719
|
+
function quotesEqual(left, right) {
|
|
1720
|
+
return left === right || left !== void 0 && right !== void 0 && left.currency === right.currency && left.amountNanos === right.amountNanos;
|
|
1721
|
+
}
|
|
1722
|
+
function validateMeasurement(input) {
|
|
1723
|
+
if (!input || typeof input !== "object") {
|
|
1724
|
+
throw invalidKey("report() requires a { meter, values } object");
|
|
1725
|
+
}
|
|
1726
|
+
const meter = assertKey(input.meter, "meter");
|
|
1727
|
+
const values = assertValues(input.values);
|
|
1728
|
+
const dims = input.dims === void 0 ? void 0 : assertDims(input.dims);
|
|
1729
|
+
return {
|
|
1730
|
+
meter,
|
|
1731
|
+
values,
|
|
1732
|
+
...dims && Object.keys(dims).length > 0 ? { dims } : {}
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
function validateQuote(quote) {
|
|
1736
|
+
if (!quote || typeof quote !== "object" || Array.isArray(quote)) {
|
|
1737
|
+
throw invalidQuote(
|
|
1738
|
+
"quote must be an object of the form { currency, amountNanos }"
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
const { currency, amountNanos } = quote;
|
|
1742
|
+
if (typeof currency !== "string" || !CURRENCY_RE.test(currency)) {
|
|
1743
|
+
throw invalidQuote("quote.currency must be a 3-letter currency code");
|
|
1744
|
+
}
|
|
1745
|
+
return {
|
|
1746
|
+
currency: currency.toLowerCase(),
|
|
1747
|
+
amountNanos: assertAmountNanos(amountNanos)
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
function assertAmountNanos(value) {
|
|
1751
|
+
if (typeof value === "bigint") {
|
|
1752
|
+
if (value < 0n) throw negativeAmountNanos();
|
|
1753
|
+
return value.toString();
|
|
1754
|
+
}
|
|
1755
|
+
if (typeof value === "number") {
|
|
1756
|
+
if (!Number.isSafeInteger(value)) {
|
|
1757
|
+
throw invalidQuote(
|
|
1758
|
+
"quote.amountNanos must be a safe integer number of nanodollars (pass a string or bigint for larger amounts)"
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
if (value < 0) throw negativeAmountNanos();
|
|
1762
|
+
return String(value);
|
|
1763
|
+
}
|
|
1764
|
+
if (typeof value === "string") {
|
|
1765
|
+
if (/^-/.test(value)) throw negativeAmountNanos();
|
|
1766
|
+
if (DECIMAL_INTEGER_RE.test(value)) return value;
|
|
1767
|
+
}
|
|
1768
|
+
throw invalidQuote(
|
|
1769
|
+
"quote.amountNanos must be a non-negative integer number of nanodollars"
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
function negativeAmountNanos() {
|
|
1773
|
+
return invalidQuote(
|
|
1774
|
+
"quote.amountNanos must be non-negative: a quote is a proposed rate, never a credit \u2014 refunds are platform operations"
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
function assertValues(values) {
|
|
1778
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) {
|
|
1779
|
+
throw invalidKey("report() requires a values object");
|
|
1780
|
+
}
|
|
1781
|
+
const entries = Object.entries(values).sort(
|
|
1782
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
1783
|
+
);
|
|
1784
|
+
if (entries.length === 0) {
|
|
1785
|
+
throw invalidKey("report() requires at least one measure in values");
|
|
1786
|
+
}
|
|
1787
|
+
const out = {};
|
|
1788
|
+
for (const [measure, value] of entries) {
|
|
1789
|
+
assertKey(measure, "measure");
|
|
1790
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
1791
|
+
throw new MeteringError(
|
|
1792
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
|
|
1793
|
+
`values.${measure} must be a non-negative safe integer`
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
out[measure] = value;
|
|
1797
|
+
}
|
|
1798
|
+
return out;
|
|
1799
|
+
}
|
|
1800
|
+
function assertDims(dims) {
|
|
1801
|
+
if (!dims || typeof dims !== "object" || Array.isArray(dims)) {
|
|
1802
|
+
throw invalidKey("report() dims must be an object of dimension selectors");
|
|
1803
|
+
}
|
|
1804
|
+
const entries = Object.entries(dims).sort(
|
|
1805
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
1806
|
+
);
|
|
1807
|
+
const out = {};
|
|
1808
|
+
for (const [dimension, value] of entries) {
|
|
1809
|
+
assertKey(dimension, "dimension");
|
|
1810
|
+
if (typeof value !== "string" || !DIMENSION_VALUE_RE.test(value)) {
|
|
1811
|
+
throw invalidKey(
|
|
1812
|
+
`dims.${dimension} must be a 1-128 character selector value`
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
out[dimension] = value;
|
|
1816
|
+
}
|
|
1817
|
+
return out;
|
|
1818
|
+
}
|
|
1819
|
+
function assertKey(value, label) {
|
|
1820
|
+
if (typeof value !== "string" || !KEY_RE.test(value)) {
|
|
1821
|
+
throw invalidKey(
|
|
1822
|
+
`${label} key ${JSON.stringify(value)} must be 1-64 lowercase alphanumeric characters or underscores`
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
return value;
|
|
1826
|
+
}
|
|
1827
|
+
function invalidKey(message) {
|
|
1828
|
+
return new MeteringError(
|
|
1829
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
1830
|
+
message
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
function invalidQuote(message) {
|
|
1834
|
+
return new MeteringError(RESPONSE_METERING_ERROR_CODES.invalidQuote, message);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1424
1837
|
// src/core/nonceCache.ts
|
|
1425
1838
|
var DEFAULT_MAX_ENTRIES = 25e4;
|
|
1426
1839
|
var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
|
|
@@ -1463,6 +1876,37 @@ var NonceCache = class {
|
|
|
1463
1876
|
}
|
|
1464
1877
|
};
|
|
1465
1878
|
|
|
1879
|
+
// src/core/replay-protection.ts
|
|
1880
|
+
function resolveReplayProtection(input = {}) {
|
|
1881
|
+
if (input.nonceStore) {
|
|
1882
|
+
return {
|
|
1883
|
+
// An opted-in shared store that is DOWN must not degrade to "no replay
|
|
1884
|
+
// check" — that would make knocking it over a way to switch the
|
|
1885
|
+
// protection off entirely.
|
|
1886
|
+
store: failClosed(input.nonceStore),
|
|
1887
|
+
diagnostic: { mode: "shared", crossReplica: true }
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
return {
|
|
1891
|
+
store: new NonceCache(),
|
|
1892
|
+
diagnostic: { mode: "single-instance", crossReplica: false }
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
function failClosed(store) {
|
|
1896
|
+
return {
|
|
1897
|
+
async checkAndRemember(id) {
|
|
1898
|
+
try {
|
|
1899
|
+
return await store.checkAndRemember(id);
|
|
1900
|
+
} catch (cause) {
|
|
1901
|
+
throw new FartherShoreError(
|
|
1902
|
+
"replayed_nonce",
|
|
1903
|
+
`replay store is unavailable, refusing the request rather than skipping one-time-use enforcement: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1466
1910
|
// src/core/shutdown.ts
|
|
1467
1911
|
var ShutdownManager = class {
|
|
1468
1912
|
hooks = [];
|
|
@@ -1983,22 +2427,7 @@ async function verifyRequest(input, deps) {
|
|
|
1983
2427
|
"x-fs-timestamp is not an integer"
|
|
1984
2428
|
);
|
|
1985
2429
|
}
|
|
1986
|
-
|
|
1987
|
-
const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
|
|
1988
|
-
const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
|
|
1989
|
-
const delta = now - timestamp;
|
|
1990
|
-
if (Math.abs(delta) > window) {
|
|
1991
|
-
if (Math.abs(delta) <= window + skew) {
|
|
1992
|
-
throw new FartherShoreError(
|
|
1993
|
-
"clock_skew",
|
|
1994
|
-
"x-fs-timestamp is outside the replay window but within clock-skew tolerance"
|
|
1995
|
-
);
|
|
1996
|
-
}
|
|
1997
|
-
throw new FartherShoreError(
|
|
1998
|
-
"expired_signature",
|
|
1999
|
-
"x-fs-timestamp is outside the replay window"
|
|
2000
|
-
);
|
|
2001
|
-
}
|
|
2430
|
+
assertTimestampWithinWindow(timestamp, deps);
|
|
2002
2431
|
const computedBodyHash = await computeBodyHash(input);
|
|
2003
2432
|
if (signedBodyHash !== computedBodyHash) {
|
|
2004
2433
|
throw new FartherShoreError(
|
|
@@ -2006,31 +2435,7 @@ async function verifyRequest(input, deps) {
|
|
|
2006
2435
|
"recomputed body hash does not match the signed x-fs-body-hash"
|
|
2007
2436
|
);
|
|
2008
2437
|
}
|
|
2009
|
-
|
|
2010
|
-
throw new FartherShoreError(
|
|
2011
|
-
"route_mismatch",
|
|
2012
|
-
"signed business-id does not match this backend's business"
|
|
2013
|
-
);
|
|
2014
|
-
}
|
|
2015
|
-
if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
|
|
2016
|
-
if (!deps.backendIds.has(signedBackendId)) {
|
|
2017
|
-
throw new FartherShoreError(
|
|
2018
|
-
"route_mismatch",
|
|
2019
|
-
"signed backend-id is not one this deployment serves"
|
|
2020
|
-
);
|
|
2021
|
-
}
|
|
2022
|
-
} else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
|
|
2023
|
-
throw new FartherShoreError(
|
|
2024
|
-
"route_mismatch",
|
|
2025
|
-
"signed backend-id does not match this backend"
|
|
2026
|
-
);
|
|
2027
|
-
}
|
|
2028
|
-
if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
|
|
2029
|
-
throw new FartherShoreError(
|
|
2030
|
-
"route_mismatch",
|
|
2031
|
-
"signed route-id is not served by this backend"
|
|
2032
|
-
);
|
|
2033
|
-
}
|
|
2438
|
+
assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId);
|
|
2034
2439
|
const contextToken = h("x-fs-context") ?? null;
|
|
2035
2440
|
const canonicalInput = {
|
|
2036
2441
|
method: input.method,
|
|
@@ -2101,9 +2506,57 @@ async function verifyRequest(input, deps) {
|
|
|
2101
2506
|
...principal ? { principal } : {},
|
|
2102
2507
|
...permissions !== void 0 ? { permissions } : {},
|
|
2103
2508
|
...roles !== void 0 ? { roles } : {},
|
|
2104
|
-
...signedContext ? { signedContext } : {}
|
|
2509
|
+
...signedContext ? { signedContext } : {},
|
|
2510
|
+
// The bare primitive has no metering channel; the runtime facade replaces
|
|
2511
|
+
// this with the real bound verb (see FartherShore.verifyRequest).
|
|
2512
|
+
report: unattachedReport()
|
|
2105
2513
|
};
|
|
2106
2514
|
}
|
|
2515
|
+
function assertTimestampWithinWindow(timestamp, deps) {
|
|
2516
|
+
const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
|
|
2517
|
+
const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
|
|
2518
|
+
const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
|
|
2519
|
+
const delta = now - timestamp;
|
|
2520
|
+
if (Math.abs(delta) > window) {
|
|
2521
|
+
if (Math.abs(delta) <= window + skew) {
|
|
2522
|
+
throw new FartherShoreError(
|
|
2523
|
+
"clock_skew",
|
|
2524
|
+
"x-fs-timestamp is outside the replay window but within clock-skew tolerance"
|
|
2525
|
+
);
|
|
2526
|
+
}
|
|
2527
|
+
throw new FartherShoreError(
|
|
2528
|
+
"expired_signature",
|
|
2529
|
+
"x-fs-timestamp is outside the replay window"
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
function assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId) {
|
|
2534
|
+
if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
|
|
2535
|
+
throw new FartherShoreError(
|
|
2536
|
+
"route_mismatch",
|
|
2537
|
+
"signed business-id does not match this backend's business"
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
|
|
2541
|
+
if (!deps.backendIds.has(signedBackendId)) {
|
|
2542
|
+
throw new FartherShoreError(
|
|
2543
|
+
"route_mismatch",
|
|
2544
|
+
"signed backend-id is not one this deployment serves"
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
} else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
|
|
2548
|
+
throw new FartherShoreError(
|
|
2549
|
+
"route_mismatch",
|
|
2550
|
+
"signed backend-id does not match this backend"
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
|
|
2554
|
+
throw new FartherShoreError(
|
|
2555
|
+
"route_mismatch",
|
|
2556
|
+
"signed route-id is not served by this backend"
|
|
2557
|
+
);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2107
2560
|
async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
|
|
2108
2561
|
if (!contextToken) return null;
|
|
2109
2562
|
const signedContext = decodeContextClaims(contextToken);
|
|
@@ -2156,7 +2609,7 @@ function headerGetter(headers) {
|
|
|
2156
2609
|
|
|
2157
2610
|
// src/core/runtime.ts
|
|
2158
2611
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
2159
|
-
var SDK_VERSION = "0.
|
|
2612
|
+
var SDK_VERSION = "0.21.0".length > 0 ? "0.21.0" : "0.0.0-dev";
|
|
2160
2613
|
var FartherShore = class {
|
|
2161
2614
|
bootstrapClient;
|
|
2162
2615
|
fetchImpl;
|
|
@@ -2169,16 +2622,16 @@ var FartherShore = class {
|
|
|
2169
2622
|
/** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
|
|
2170
2623
|
contextSecrets;
|
|
2171
2624
|
nonceCache;
|
|
2625
|
+
replayProtectionDiagnostic;
|
|
2172
2626
|
shutdownManager = new ShutdownManager();
|
|
2173
2627
|
jwks = null;
|
|
2174
|
-
meteringClient = null;
|
|
2175
2628
|
postStreamUsageClient = null;
|
|
2176
2629
|
tunnel = null;
|
|
2177
2630
|
bootstrapped = false;
|
|
2178
2631
|
constructor(options = {}) {
|
|
2179
2632
|
const env = options.env ?? readProcessEnv();
|
|
2180
2633
|
const runtimeToken = options.runtimeToken ?? env[FS_RUNTIME_TOKEN_ENV] ?? "";
|
|
2181
|
-
const coreUrl = options.coreUrl ?? env.FS_CORE_URL ??
|
|
2634
|
+
const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? DEFAULT_CORE_URL;
|
|
2182
2635
|
this.runtimeToken = runtimeToken;
|
|
2183
2636
|
this.coreUrl = coreUrl;
|
|
2184
2637
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
@@ -2186,7 +2639,9 @@ var FartherShore = class {
|
|
|
2186
2639
|
this.meteringEnabledOverride = options.metering?.enabled ?? true;
|
|
2187
2640
|
this.tunnelOptions = options.tunnel ?? {};
|
|
2188
2641
|
this.instanceId = options.instanceId;
|
|
2189
|
-
|
|
2642
|
+
const replay = resolveReplayProtection({ nonceStore: options.nonceStore });
|
|
2643
|
+
this.nonceCache = replay.store;
|
|
2644
|
+
this.replayProtectionDiagnostic = replay.diagnostic;
|
|
2190
2645
|
this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
|
|
2191
2646
|
this.bootstrapClient = new BootstrapClient({
|
|
2192
2647
|
runtimeToken,
|
|
@@ -2198,9 +2653,6 @@ var FartherShore = class {
|
|
|
2198
2653
|
...options.instanceId ? { instanceId: options.instanceId } : {}
|
|
2199
2654
|
}
|
|
2200
2655
|
});
|
|
2201
|
-
this.shutdownManager.register(async () => {
|
|
2202
|
-
await this.meteringClient?.flush();
|
|
2203
|
-
});
|
|
2204
2656
|
this.shutdownManager.register(async () => {
|
|
2205
2657
|
await reportHealth({
|
|
2206
2658
|
runtimeToken: this.runtimeToken,
|
|
@@ -2220,14 +2672,7 @@ var FartherShore = class {
|
|
|
2220
2672
|
fetchImpl: this.fetchImpl
|
|
2221
2673
|
});
|
|
2222
2674
|
}
|
|
2223
|
-
if (!this.
|
|
2224
|
-
this.meteringClient = new MeteringClient({
|
|
2225
|
-
config: config.metering,
|
|
2226
|
-
businessId: config.business.id,
|
|
2227
|
-
backendId: config.backend.id,
|
|
2228
|
-
coreUrl: this.coreUrl,
|
|
2229
|
-
fetchImpl: this.fetchImpl
|
|
2230
|
-
});
|
|
2675
|
+
if (!this.postStreamUsageClient && config.metering.enabled) {
|
|
2231
2676
|
this.postStreamUsageClient = new PostStreamUsageClient({
|
|
2232
2677
|
config: config.metering,
|
|
2233
2678
|
coreUrl: this.coreUrl,
|
|
@@ -2276,14 +2721,19 @@ var FartherShore = class {
|
|
|
2276
2721
|
buildReportSink() {
|
|
2277
2722
|
const post = async (path, body) => {
|
|
2278
2723
|
const base = this.coreUrl.replace(/\/$/, "");
|
|
2279
|
-
const res = await
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2724
|
+
const res = await fetchWithDeadline(
|
|
2725
|
+
this.fetchImpl,
|
|
2726
|
+
`${base}${path}`,
|
|
2727
|
+
{
|
|
2728
|
+
method: "POST",
|
|
2729
|
+
headers: {
|
|
2730
|
+
"content-type": "application/json",
|
|
2731
|
+
authorization: `Bearer ${this.runtimeToken}`
|
|
2732
|
+
},
|
|
2733
|
+
body: JSON.stringify(body)
|
|
2284
2734
|
},
|
|
2285
|
-
|
|
2286
|
-
|
|
2735
|
+
"report"
|
|
2736
|
+
);
|
|
2287
2737
|
if (!res.ok) throw new Error(`runtime report ${path} -> ${res.status}`);
|
|
2288
2738
|
};
|
|
2289
2739
|
return {
|
|
@@ -2295,7 +2745,7 @@ var FartherShore = class {
|
|
|
2295
2745
|
* Framework-neutral verification primitive. Fail-closed: throws a typed
|
|
2296
2746
|
* FartherShoreError on any verification failure. Returns the verified context.
|
|
2297
2747
|
*/
|
|
2298
|
-
async verifyRequest(input) {
|
|
2748
|
+
async verifyRequest(input, options = {}) {
|
|
2299
2749
|
const config = await this.ensureBootstrapped();
|
|
2300
2750
|
if (!this.jwks) {
|
|
2301
2751
|
throw new FartherShoreError(
|
|
@@ -2321,21 +2771,52 @@ var FartherShore = class {
|
|
|
2321
2771
|
});
|
|
2322
2772
|
return {
|
|
2323
2773
|
...context,
|
|
2324
|
-
|
|
2325
|
-
|
|
2774
|
+
report: this.buildReportFn(context, input, options.responseSink)
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* Bind the ONE reporting verb to a verified context. Identity comes from the
|
|
2779
|
+
* context (`signedContext.subscriptionId` + `requestId`) — never from the
|
|
2780
|
+
* caller — so a handler cannot forget it, and a background job that is handed
|
|
2781
|
+
* this context keeps reporting against the SAME served identity.
|
|
2782
|
+
*/
|
|
2783
|
+
buildReportFn(context, input, responseSink) {
|
|
2784
|
+
const channels = {
|
|
2785
|
+
request: { method: input.method, path: input.path },
|
|
2786
|
+
...responseSink ? { responseSink } : {},
|
|
2787
|
+
computeHeaders: (payload) => computeMeteringHeaders(payload, {
|
|
2788
|
+
token: this.runtimeToken,
|
|
2789
|
+
requestId: context.requestId,
|
|
2790
|
+
onSkip: () => {
|
|
2791
|
+
}
|
|
2792
|
+
}),
|
|
2793
|
+
postStream: async ({ measurements, quote }) => {
|
|
2794
|
+
const subscriptionId = context.signedContext?.subscriptionId;
|
|
2326
2795
|
if (!subscriptionId) {
|
|
2327
|
-
return
|
|
2796
|
+
return {
|
|
2328
2797
|
ok: false,
|
|
2329
|
-
reason: "
|
|
2330
|
-
}
|
|
2798
|
+
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"
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
const meters = {};
|
|
2802
|
+
for (const measurement of measurements) {
|
|
2803
|
+
for (const [meter, qty] of Object.entries(
|
|
2804
|
+
rawDimsUnitsOf(measurement)
|
|
2805
|
+
)) {
|
|
2806
|
+
meters[meter] = (meters[meter] ?? 0) + qty;
|
|
2807
|
+
}
|
|
2331
2808
|
}
|
|
2332
|
-
return this.
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2809
|
+
return this.reportPostStreamUsage({
|
|
2810
|
+
requestId: context.requestId,
|
|
2811
|
+
subscriptionId,
|
|
2812
|
+
meters,
|
|
2813
|
+
measurementsVersion: MEASUREMENTS_VERSION,
|
|
2814
|
+
measurements,
|
|
2815
|
+
...quote ? { quote } : {}
|
|
2336
2816
|
});
|
|
2337
2817
|
}
|
|
2338
2818
|
};
|
|
2819
|
+
return createReportFn(channels);
|
|
2339
2820
|
}
|
|
2340
2821
|
/** Whether verification is required (bootstrap × opt-out). */
|
|
2341
2822
|
async verificationRequired() {
|
|
@@ -2384,20 +2865,12 @@ var FartherShore = class {
|
|
|
2384
2865
|
});
|
|
2385
2866
|
await supervisor.start();
|
|
2386
2867
|
}
|
|
2387
|
-
/**
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
"invalid_token",
|
|
2394
|
-
"metering is not enabled for this runtime token"
|
|
2395
|
-
);
|
|
2396
|
-
}
|
|
2397
|
-
await this.meteringClient.meter(meter, qty, options);
|
|
2398
|
-
}
|
|
2399
|
-
/** Best-effort attested post-stream usage callback. Never rejects. */
|
|
2400
|
-
async reportUsage(input) {
|
|
2868
|
+
/**
|
|
2869
|
+
* PRIVATE transport for the post-stream lane of `ctx.report()`. Never rejects
|
|
2870
|
+
* — a metering hiccup must not break a builder's endpoint. This is machinery,
|
|
2871
|
+
* not surface: the ONE public reporting verb is `ctx.report()`.
|
|
2872
|
+
*/
|
|
2873
|
+
async reportPostStreamUsage(input) {
|
|
2401
2874
|
try {
|
|
2402
2875
|
await this.ensureBootstrapped();
|
|
2403
2876
|
if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
|
|
@@ -2410,6 +2883,15 @@ var FartherShore = class {
|
|
|
2410
2883
|
return { ok: false, reason };
|
|
2411
2884
|
}
|
|
2412
2885
|
}
|
|
2886
|
+
/**
|
|
2887
|
+
* How far replay protection actually reaches — `"shared"` (enforced across
|
|
2888
|
+
* every replica) or `"single-instance"` (this process only). Deployment
|
|
2889
|
+
* diagnostic: log it at boot, or assert on it in a smoke test, so the mode is
|
|
2890
|
+
* never a surprise. See {@link FartherShoreInitOptions.nonceStore}.
|
|
2891
|
+
*/
|
|
2892
|
+
replayProtection() {
|
|
2893
|
+
return this.replayProtectionDiagnostic;
|
|
2894
|
+
}
|
|
2413
2895
|
/** Current local health report. */
|
|
2414
2896
|
health() {
|
|
2415
2897
|
const config = this.bootstrapClient.peek();
|
|
@@ -2420,7 +2902,7 @@ var FartherShore = class {
|
|
|
2420
2902
|
// fs.start() launches an embedded tunnel; otherwise the supervisor state.
|
|
2421
2903
|
tunnel: this.tunnel ? this.tunnel.healthString() : null,
|
|
2422
2904
|
verification: this.verificationEnabled && config !== null,
|
|
2423
|
-
metering: this.
|
|
2905
|
+
metering: this.postStreamUsageClient !== null
|
|
2424
2906
|
});
|
|
2425
2907
|
}
|
|
2426
2908
|
/** Graceful shutdown: flush metering + send a stopping heartbeat. */
|
|
@@ -2464,7 +2946,12 @@ function permissionSatisfies(required, granted) {
|
|
|
2464
2946
|
const idx = required.indexOf(":");
|
|
2465
2947
|
if (idx > 0 && idx < required.length - 1) {
|
|
2466
2948
|
const subject = required.slice(0, idx);
|
|
2467
|
-
if (granted.includes(`${subject}:${WILDCARD}`))
|
|
2949
|
+
if (subject !== WILDCARD && granted.includes(`${subject}:${WILDCARD}`))
|
|
2950
|
+
return true;
|
|
2951
|
+
if (required.slice(idx + 1) === WILDCARD && subject !== WILDCARD) {
|
|
2952
|
+
const prefix = `${subject}:`;
|
|
2953
|
+
return granted.some((permission) => permission.startsWith(prefix));
|
|
2954
|
+
}
|
|
2468
2955
|
}
|
|
2469
2956
|
return false;
|
|
2470
2957
|
}
|
|
@@ -2499,14 +2986,17 @@ async function runMiddleware(fs, options, req, res, next) {
|
|
|
2499
2986
|
const contentType = headerValue(req.headers, "content-type");
|
|
2500
2987
|
const streamingExempt = isStreamingExempt(contentType);
|
|
2501
2988
|
const body = streamingExempt ? null : extractRawBody(req);
|
|
2502
|
-
const ctx = await fs.verifyRequest(
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2989
|
+
const ctx = await fs.verifyRequest(
|
|
2990
|
+
{
|
|
2991
|
+
method: req.method,
|
|
2992
|
+
path,
|
|
2993
|
+
query,
|
|
2994
|
+
headers: req.headers,
|
|
2995
|
+
body,
|
|
2996
|
+
streamingExempt
|
|
2997
|
+
},
|
|
2998
|
+
{ responseSink: expressResponseSink(res) }
|
|
2999
|
+
);
|
|
2510
3000
|
req.fartherShore = ctx;
|
|
2511
3001
|
stripFartherShoreHeaders(req);
|
|
2512
3002
|
next();
|
|
@@ -2514,6 +3004,16 @@ async function runMiddleware(fs, options, req, res, next) {
|
|
|
2514
3004
|
fail(res, error, options, req);
|
|
2515
3005
|
}
|
|
2516
3006
|
}
|
|
3007
|
+
function expressResponseSink(res) {
|
|
3008
|
+
return {
|
|
3009
|
+
canStampHeaders: () => res.headersSent !== true,
|
|
3010
|
+
stampHeaders: (headers) => {
|
|
3011
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
3012
|
+
res.setHeader(name, value);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
2517
3017
|
function fail(res, error, options, req) {
|
|
2518
3018
|
const code = error instanceof FartherShoreError ? error.code : "bad_signature";
|
|
2519
3019
|
const status = error instanceof FartherShoreError ? error.status : 401;
|
|
@@ -2554,7 +3054,12 @@ function stripFartherShoreHeaders(req) {
|
|
|
2554
3054
|
withRaw.rawHeaders = cleaned;
|
|
2555
3055
|
}
|
|
2556
3056
|
}
|
|
2557
|
-
function createExpressHandler(
|
|
3057
|
+
function createExpressHandler(optionsOrHandler, maybeHandler) {
|
|
3058
|
+
const options = typeof optionsOrHandler === "function" ? {} : optionsOrHandler;
|
|
3059
|
+
const handler = typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler;
|
|
3060
|
+
if (typeof handler !== "function") {
|
|
3061
|
+
throw new TypeError("fs.handler(options, cb) requires a handler callback");
|
|
3062
|
+
}
|
|
2558
3063
|
return (req, res, next) => {
|
|
2559
3064
|
const ctx = req.fartherShore;
|
|
2560
3065
|
if (!ctx) {
|
|
@@ -2565,10 +3070,22 @@ function createExpressHandler(handler) {
|
|
|
2565
3070
|
res.status(401).json({ error: "principal_required" });
|
|
2566
3071
|
return;
|
|
2567
3072
|
}
|
|
3073
|
+
if (!ctx.signedContext) {
|
|
3074
|
+
res.status(401).json({ error: "context_unverified" });
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
2568
3077
|
const verified = ctx;
|
|
2569
|
-
void Promise.resolve().then(
|
|
2570
|
-
|
|
2571
|
-
|
|
3078
|
+
void Promise.resolve().then(() => {
|
|
3079
|
+
if (options.permission !== void 0) {
|
|
3080
|
+
requirePermission(verified, options.permission);
|
|
3081
|
+
}
|
|
3082
|
+
return handler(
|
|
3083
|
+
verified,
|
|
3084
|
+
req,
|
|
3085
|
+
res,
|
|
3086
|
+
next
|
|
3087
|
+
);
|
|
3088
|
+
}).catch((error) => failHandler(res, next, error));
|
|
2572
3089
|
};
|
|
2573
3090
|
}
|
|
2574
3091
|
function failHandler(res, next, error) {
|
|
@@ -2629,7 +3146,7 @@ function readProcessEnv2() {
|
|
|
2629
3146
|
// src/testing/usageSink.ts
|
|
2630
3147
|
var DevUsageSink = class {
|
|
2631
3148
|
events = [];
|
|
2632
|
-
/** Record a signed response-metering payload (
|
|
3149
|
+
/** Record a signed response-metering payload (report() in-band / computeMeteringHeaders). */
|
|
2633
3150
|
recordResponse(payload, requestId) {
|
|
2634
3151
|
const raw = payload.rawDimsUnits;
|
|
2635
3152
|
const meters = raw && typeof raw === "object" ? raw : {};
|
|
@@ -2909,6 +3426,7 @@ function createDevRuntime(options) {
|
|
|
2909
3426
|
}
|
|
2910
3427
|
fs.middleware = middleware;
|
|
2911
3428
|
fs.handler = createExpressHandler;
|
|
3429
|
+
fs.authz = tracedAuthz;
|
|
2912
3430
|
const devRuntime = {
|
|
2913
3431
|
fs,
|
|
2914
3432
|
asPersona: (name) => personaClient.asPersona(name),
|
|
@@ -3021,6 +3539,69 @@ function splitUrl2(req) {
|
|
|
3021
3539
|
if (qIndex === -1) return { path: raw, query: "" };
|
|
3022
3540
|
return { path: raw.slice(0, qIndex), query: raw.slice(qIndex + 1) };
|
|
3023
3541
|
}
|
|
3542
|
+
|
|
3543
|
+
// ../contracts/dist/webhooks/standard-webhooks.js
|
|
3544
|
+
import { createHmac, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
|
|
3545
|
+
var WEBHOOK_ID_HEADER = "webhook-id";
|
|
3546
|
+
var WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
3547
|
+
var WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
3548
|
+
var WEBHOOK_SIGNATURE_VERSION = "v1";
|
|
3549
|
+
var WEBHOOK_SECRET_PREFIX = "fswh_";
|
|
3550
|
+
function webhookSecretKeyBytes(secret) {
|
|
3551
|
+
const key2 = secret.startsWith(WEBHOOK_SECRET_PREFIX) ? Buffer.from(secret.slice(WEBHOOK_SECRET_PREFIX.length), "base64") : secret.startsWith("whsec_") ? Buffer.from(secret.slice("whsec_".length), "base64") : Buffer.from(secret, "utf8");
|
|
3552
|
+
if (key2.length === 0) {
|
|
3553
|
+
throw new Error("webhook secret resolves to an empty signing key \u2014 the configured secret must be a non-empty fswh_ value");
|
|
3554
|
+
}
|
|
3555
|
+
return key2;
|
|
3556
|
+
}
|
|
3557
|
+
function webhookSignedContent(id, timestamp, body) {
|
|
3558
|
+
return `${id}.${timestamp}.${body}`;
|
|
3559
|
+
}
|
|
3560
|
+
function signWebhookContent(secret, id, timestamp, body) {
|
|
3561
|
+
const mac = createHmac("sha256", webhookSecretKeyBytes(secret)).update(webhookSignedContent(id, timestamp, body)).digest("base64");
|
|
3562
|
+
return `${WEBHOOK_SIGNATURE_VERSION},${mac}`;
|
|
3563
|
+
}
|
|
3564
|
+
function signWebhook(input) {
|
|
3565
|
+
if (input.secrets.length === 0) {
|
|
3566
|
+
throw new Error("signWebhook: at least one secret is required");
|
|
3567
|
+
}
|
|
3568
|
+
const signature = input.secrets.map((secret) => signWebhookContent(secret, input.id, input.timestamp, input.body)).join(" ");
|
|
3569
|
+
return {
|
|
3570
|
+
[WEBHOOK_ID_HEADER]: input.id,
|
|
3571
|
+
[WEBHOOK_TIMESTAMP_HEADER]: String(input.timestamp),
|
|
3572
|
+
[WEBHOOK_SIGNATURE_HEADER]: signature
|
|
3573
|
+
};
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
// src/testing/webhooks.ts
|
|
3577
|
+
function signWebhookForTesting(input) {
|
|
3578
|
+
const secrets = typeof input.secret === "string" ? [input.secret] : [...input.secret];
|
|
3579
|
+
const id = input.id ?? `test_${crypto.randomUUID()}`;
|
|
3580
|
+
const envelope = {
|
|
3581
|
+
id,
|
|
3582
|
+
type: input.type,
|
|
3583
|
+
createdAt: input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
3584
|
+
businessId: input.businessId ?? "biz_test",
|
|
3585
|
+
environmentId: input.environmentId ?? null,
|
|
3586
|
+
data: input.data
|
|
3587
|
+
};
|
|
3588
|
+
const body = JSON.stringify(envelope);
|
|
3589
|
+
const timestamp = input.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
3590
|
+
const signed = signWebhook({ id, timestamp, body, secrets });
|
|
3591
|
+
const headers = {
|
|
3592
|
+
"content-type": "application/json",
|
|
3593
|
+
...signed,
|
|
3594
|
+
"x-fs-webhook-event": input.type
|
|
3595
|
+
};
|
|
3596
|
+
return {
|
|
3597
|
+
envelope,
|
|
3598
|
+
body,
|
|
3599
|
+
headers,
|
|
3600
|
+
request(url = "https://receiver.test/webhooks/farthershore") {
|
|
3601
|
+
return new Request(url, { method: "POST", headers, body });
|
|
3602
|
+
}
|
|
3603
|
+
};
|
|
3604
|
+
}
|
|
3024
3605
|
export {
|
|
3025
3606
|
CONTEXT_HEADER_NAME,
|
|
3026
3607
|
DEFAULT_KEYS_FILE,
|
|
@@ -3053,6 +3634,7 @@ export {
|
|
|
3053
3634
|
readDevKeysFile,
|
|
3054
3635
|
redactValue,
|
|
3055
3636
|
signContextToken,
|
|
3637
|
+
signWebhookForTesting,
|
|
3056
3638
|
unreachableJwks,
|
|
3057
3639
|
writeDevKeysFile
|
|
3058
3640
|
};
|