@farthershore/backend 0.13.0 → 0.15.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/README.md +47 -7
- package/dist/generated/runtime-contract.js +20 -5
- package/dist/index.js +388 -218
- package/dist/testing/index.js +240 -49
- package/dist/types/core/metering.d.ts +8 -2
- package/dist/types/core/post-stream-usage.d.ts +45 -0
- package/dist/types/core/runtime.d.ts +7 -3
- package/dist/types/core/verifyRequest.d.ts +6 -3
- package/dist/types/generated/runtime-contract.d.ts +20 -5
- package/dist/types/index.d.ts +1 -0
- package/dist/types/runtime-types.d.ts +19 -4
- package/dist/types/testing/devGateway.d.ts +8 -4
- package/dist/types/testing/devRuntime.d.ts +1 -1
- package/dist/types/testing/keysFile.d.ts +1 -1
- package/dist/types/testing/personas.d.ts +2 -2
- package/dist/types/testing/signers.d.ts +2 -2
- package/dist/types/testing/usageSink.d.ts +9 -1
- package/package.json +1 -1
package/dist/testing/index.js
CHANGED
|
@@ -294,7 +294,7 @@ var RUNTIME_HEADER_NAMES = {
|
|
|
294
294
|
keyId: "x-fs-key-id",
|
|
295
295
|
requestId: "x-fs-request-id",
|
|
296
296
|
timestamp: "x-fs-timestamp",
|
|
297
|
-
|
|
297
|
+
businessId: "x-fs-business-id",
|
|
298
298
|
backendId: "x-fs-backend-id",
|
|
299
299
|
routeId: "x-fs-route-id",
|
|
300
300
|
policyVersion: "x-fs-policy-version",
|
|
@@ -346,7 +346,7 @@ var CANONICAL_SIGNING_FIELDS = [
|
|
|
346
346
|
"body-hash",
|
|
347
347
|
"request-id",
|
|
348
348
|
"timestamp",
|
|
349
|
-
"
|
|
349
|
+
"business-id",
|
|
350
350
|
"backend-id",
|
|
351
351
|
"route-id",
|
|
352
352
|
"policy-version"
|
|
@@ -383,7 +383,7 @@ function buildCanonicalSigningString(input) {
|
|
|
383
383
|
"body-hash": input.bodyHash,
|
|
384
384
|
"request-id": input.requestId,
|
|
385
385
|
timestamp: String(input.timestamp),
|
|
386
|
-
"
|
|
386
|
+
"business-id": input.businessId,
|
|
387
387
|
"backend-id": input.backendId,
|
|
388
388
|
"route-id": input.routeId,
|
|
389
389
|
"policy-version": input.policyVersion
|
|
@@ -608,7 +608,7 @@ async function makeSignedRequest(spec = {}) {
|
|
|
608
608
|
bodyHash,
|
|
609
609
|
requestId: spec.requestId ?? `req_${cryptoRandom()}`,
|
|
610
610
|
timestamp: spec.timestamp ?? Math.floor(Date.now() / 1e3),
|
|
611
|
-
|
|
611
|
+
businessId: spec.businessId ?? "biz_test",
|
|
612
612
|
backendId: spec.backendId ?? "be_test",
|
|
613
613
|
routeId: spec.routeId ?? "route_test",
|
|
614
614
|
policyVersion: spec.policyVersion ?? "pv_1"
|
|
@@ -620,7 +620,7 @@ async function makeSignedRequest(spec = {}) {
|
|
|
620
620
|
[RUNTIME_HEADER_NAMES.keyId]: kid,
|
|
621
621
|
[RUNTIME_HEADER_NAMES.requestId]: claim.requestId,
|
|
622
622
|
[RUNTIME_HEADER_NAMES.timestamp]: String(claim.timestamp),
|
|
623
|
-
[RUNTIME_HEADER_NAMES.
|
|
623
|
+
[RUNTIME_HEADER_NAMES.businessId]: claim.businessId,
|
|
624
624
|
[RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
|
|
625
625
|
[RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
|
|
626
626
|
[RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
|
|
@@ -742,13 +742,13 @@ function mergeHeaders(initHeaders, signedHeaders) {
|
|
|
742
742
|
}
|
|
743
743
|
return headers;
|
|
744
744
|
}
|
|
745
|
-
function buildContextClaim(persona,
|
|
745
|
+
function buildContextClaim(persona, businessId) {
|
|
746
746
|
return {
|
|
747
747
|
orgId: persona.orgId ?? "org_dev",
|
|
748
748
|
actor: persona.actor ?? { type: "user", id: `user_${persona.name}` },
|
|
749
|
-
//
|
|
750
|
-
// request
|
|
751
|
-
productId,
|
|
749
|
+
// Business binding: the retained signed-context productId claim MUST equal
|
|
750
|
+
// the signed request businessId or verifyRequest rejects it as tamper evidence.
|
|
751
|
+
productId: businessId,
|
|
752
752
|
compiledPlanId: persona.compiledPlanId ?? "plan_dev",
|
|
753
753
|
subscriptionId: persona.subscriptionId ?? "sub_dev",
|
|
754
754
|
subscriberId: persona.subscriberId ?? "subscriber_dev",
|
|
@@ -776,7 +776,7 @@ function createPersonaClient(ctx) {
|
|
|
776
776
|
query: spec.query ?? "",
|
|
777
777
|
body: spec.body ?? null,
|
|
778
778
|
streamingExempt: spec.streamingExempt ?? false,
|
|
779
|
-
|
|
779
|
+
businessId: ctx.businessId,
|
|
780
780
|
backendId: ctx.backendId,
|
|
781
781
|
routeId: spec.routeId ?? "",
|
|
782
782
|
privateJwk: ctx.keys.privateJwk,
|
|
@@ -786,7 +786,7 @@ function createPersonaClient(ctx) {
|
|
|
786
786
|
});
|
|
787
787
|
const headers = { ...signed.headers };
|
|
788
788
|
if (!persona.anonymous) {
|
|
789
|
-
const claim = buildContextClaim(persona, ctx.
|
|
789
|
+
const claim = buildContextClaim(persona, ctx.businessId);
|
|
790
790
|
headers["x-fs-context"] = await signContextToken(
|
|
791
791
|
claim,
|
|
792
792
|
ctx.contextSecret,
|
|
@@ -861,11 +861,12 @@ var DEV_CORE_URL = "https://dev-gateway.farthershore.local";
|
|
|
861
861
|
var DEV_JWKS_URL = `${DEV_CORE_URL}/.well-known/jwks.json`;
|
|
862
862
|
var DEV_METERING_ENDPOINT = `${DEV_CORE_URL}/v1/metering/events`;
|
|
863
863
|
function createDevGateway(options) {
|
|
864
|
-
const
|
|
864
|
+
const businessId = options.businessId ?? "biz_dev";
|
|
865
865
|
const backendId = options.backendId ?? "be_dev";
|
|
866
866
|
const meterEvents = [];
|
|
867
|
+
const reportUsageEvents = [];
|
|
867
868
|
const bootstrap = {
|
|
868
|
-
|
|
869
|
+
business: { id: businessId, slug: options.businessSlug ?? "dev-business" },
|
|
869
870
|
backend: {
|
|
870
871
|
id: backendId,
|
|
871
872
|
slug: options.backendSlug ?? "dev-backend",
|
|
@@ -913,7 +914,10 @@ function createDevGateway(options) {
|
|
|
913
914
|
}
|
|
914
915
|
if (url.includes("/v1/metering/events")) {
|
|
915
916
|
const event = await readJsonBody(init, input);
|
|
916
|
-
if (event) {
|
|
917
|
+
if (event && "meters" in event) {
|
|
918
|
+
reportUsageEvents.push(event);
|
|
919
|
+
options.onReportUsage?.(event);
|
|
920
|
+
} else if (event) {
|
|
917
921
|
meterEvents.push(event);
|
|
918
922
|
options.onMeterEvent?.(event);
|
|
919
923
|
}
|
|
@@ -931,7 +935,8 @@ function createDevGateway(options) {
|
|
|
931
935
|
fetchImpl,
|
|
932
936
|
bootstrap,
|
|
933
937
|
meterEvents,
|
|
934
|
-
|
|
938
|
+
reportUsageEvents,
|
|
939
|
+
businessId,
|
|
935
940
|
backendId,
|
|
936
941
|
jwksUrl: DEV_JWKS_URL
|
|
937
942
|
};
|
|
@@ -1131,7 +1136,7 @@ var DEFAULT_MAX_RETRIES = 3;
|
|
|
1131
1136
|
var MeteringClient = class {
|
|
1132
1137
|
config;
|
|
1133
1138
|
endpoint;
|
|
1134
|
-
|
|
1139
|
+
businessId;
|
|
1135
1140
|
backendId;
|
|
1136
1141
|
fetchImpl;
|
|
1137
1142
|
maxRetries;
|
|
@@ -1145,7 +1150,7 @@ var MeteringClient = class {
|
|
|
1145
1150
|
constructor(options) {
|
|
1146
1151
|
this.config = options.config;
|
|
1147
1152
|
this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
|
|
1148
|
-
this.
|
|
1153
|
+
this.businessId = options.businessId;
|
|
1149
1154
|
this.backendId = options.backendId;
|
|
1150
1155
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1151
1156
|
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
@@ -1208,13 +1213,14 @@ var MeteringClient = class {
|
|
|
1208
1213
|
}
|
|
1209
1214
|
const event = {
|
|
1210
1215
|
event_id: options.eventId ?? this.newId(),
|
|
1211
|
-
|
|
1216
|
+
business_id: this.businessId,
|
|
1212
1217
|
backend_id: this.backendId,
|
|
1213
1218
|
meter,
|
|
1214
1219
|
qty,
|
|
1215
1220
|
timestamp: options.timestamp ?? this.now().toISOString(),
|
|
1216
1221
|
...options.routeId ? { route_id: options.routeId } : {},
|
|
1217
|
-
...options.requestId ? { request_id: options.requestId } : {}
|
|
1222
|
+
...options.requestId ? { request_id: options.requestId } : {},
|
|
1223
|
+
...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
|
|
1218
1224
|
};
|
|
1219
1225
|
this.buffer.push(event);
|
|
1220
1226
|
await this.flush();
|
|
@@ -1269,6 +1275,152 @@ function resolveEndpoint(endpoint, coreUrl) {
|
|
|
1269
1275
|
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
1270
1276
|
}
|
|
1271
1277
|
|
|
1278
|
+
// src/response-metering.ts
|
|
1279
|
+
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
1280
|
+
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
1281
|
+
var devMeteringHooks = null;
|
|
1282
|
+
function __setDevMeteringHooks(hooks) {
|
|
1283
|
+
devMeteringHooks = hooks;
|
|
1284
|
+
}
|
|
1285
|
+
var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
1286
|
+
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
1287
|
+
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
1288
|
+
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
1289
|
+
async function signPayload(payload, token) {
|
|
1290
|
+
const key2 = await crypto.subtle.importKey(
|
|
1291
|
+
"raw",
|
|
1292
|
+
new TextEncoder().encode(token),
|
|
1293
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1294
|
+
false,
|
|
1295
|
+
["sign"]
|
|
1296
|
+
);
|
|
1297
|
+
const signature = await crypto.subtle.sign(
|
|
1298
|
+
"HMAC",
|
|
1299
|
+
key2,
|
|
1300
|
+
new TextEncoder().encode(payload)
|
|
1301
|
+
);
|
|
1302
|
+
return base64url(new Uint8Array(signature));
|
|
1303
|
+
}
|
|
1304
|
+
function base64url(bytes) {
|
|
1305
|
+
let binary = "";
|
|
1306
|
+
for (const byte of bytes) {
|
|
1307
|
+
binary += String.fromCharCode(byte);
|
|
1308
|
+
}
|
|
1309
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/core/post-stream-usage.ts
|
|
1313
|
+
var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
|
|
1314
|
+
var PostStreamUsageClient = class {
|
|
1315
|
+
config;
|
|
1316
|
+
endpoint;
|
|
1317
|
+
fetchImpl;
|
|
1318
|
+
newNonce;
|
|
1319
|
+
logger;
|
|
1320
|
+
sleep;
|
|
1321
|
+
retryDelaysMs;
|
|
1322
|
+
constructor(options) {
|
|
1323
|
+
this.config = options.config;
|
|
1324
|
+
this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
|
|
1325
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1326
|
+
this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
|
|
1327
|
+
this.logger = options.logger ?? ((message) => console.warn(message));
|
|
1328
|
+
this.sleep = options.sleep ?? sleep;
|
|
1329
|
+
this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
|
|
1330
|
+
}
|
|
1331
|
+
async reportUsage(input) {
|
|
1332
|
+
try {
|
|
1333
|
+
if (!this.config.enabled) throw new Error("metering is not enabled");
|
|
1334
|
+
if (!input.requestId) throw new Error("requestId is required");
|
|
1335
|
+
if (!input.subscriptionId) throw new Error("subscriptionId is required");
|
|
1336
|
+
const unsigned = {
|
|
1337
|
+
requestId: input.requestId,
|
|
1338
|
+
subscriptionId: input.subscriptionId,
|
|
1339
|
+
nonce: this.newNonce(),
|
|
1340
|
+
meters: validateAndSortUsage(input.meters, "meters", this.config, true),
|
|
1341
|
+
...input.creditUnitsConsumed ? {
|
|
1342
|
+
creditUnitsConsumed: validateAndSortUsage(
|
|
1343
|
+
input.creditUnitsConsumed,
|
|
1344
|
+
"creditUnitsConsumed",
|
|
1345
|
+
this.config,
|
|
1346
|
+
false
|
|
1347
|
+
)
|
|
1348
|
+
} : {},
|
|
1349
|
+
...input.measureContext ? { measureContext: input.measureContext } : {}
|
|
1350
|
+
};
|
|
1351
|
+
const signature = await signPayload(
|
|
1352
|
+
JSON.stringify(unsigned),
|
|
1353
|
+
this.config.credential
|
|
1354
|
+
);
|
|
1355
|
+
const event = { ...unsigned, signature };
|
|
1356
|
+
const body = JSON.stringify(event);
|
|
1357
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1358
|
+
const response = await this.fetchImpl(this.endpoint, {
|
|
1359
|
+
method: "POST",
|
|
1360
|
+
headers: {
|
|
1361
|
+
authorization: `Bearer ${this.config.credential}`,
|
|
1362
|
+
"content-type": "application/json",
|
|
1363
|
+
accept: "application/json"
|
|
1364
|
+
},
|
|
1365
|
+
body
|
|
1366
|
+
});
|
|
1367
|
+
if (response.ok) return { ok: true };
|
|
1368
|
+
const requestNotFound = await isPostStreamRequestNotFound(response);
|
|
1369
|
+
const delayMs = this.retryDelaysMs[attempt];
|
|
1370
|
+
if (!requestNotFound || delayMs === void 0) {
|
|
1371
|
+
throw new Error(`metering endpoint returned ${response.status}`);
|
|
1372
|
+
}
|
|
1373
|
+
await this.sleep(delayMs);
|
|
1374
|
+
}
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1377
|
+
this.logger(`post-stream usage report skipped: ${reason}`);
|
|
1378
|
+
return { ok: false, reason };
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
async function isPostStreamRequestNotFound(response) {
|
|
1383
|
+
if (response.status !== 422) return false;
|
|
1384
|
+
try {
|
|
1385
|
+
const body = await response.json();
|
|
1386
|
+
return body.error?.code === "post_stream_request_not_found";
|
|
1387
|
+
} catch {
|
|
1388
|
+
return false;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
function sleep(delayMs) {
|
|
1392
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1393
|
+
}
|
|
1394
|
+
function validateAndSortUsage(usage, label, config, enforceMeterScope) {
|
|
1395
|
+
const entries = Object.entries(usage).sort(
|
|
1396
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
1397
|
+
);
|
|
1398
|
+
for (const [meter, qty] of entries) {
|
|
1399
|
+
if (!METER_KEY_RE2.test(meter)) {
|
|
1400
|
+
throw new Error(
|
|
1401
|
+
`${label} key '${meter}' must be lowercase alphanumeric with underscores`
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
if (!Number.isFinite(qty) || qty < 0) {
|
|
1405
|
+
throw new Error(`${label}.${meter} must be a non-negative finite number`);
|
|
1406
|
+
}
|
|
1407
|
+
if (enforceMeterScope && config.allowedMeters.length > 0 && !config.allowedMeters.includes(meter)) {
|
|
1408
|
+
throw new Error(`meter '${meter}' is not in the token's allowedMeters`);
|
|
1409
|
+
}
|
|
1410
|
+
if (enforceMeterScope && config.perEventMax > 0 && qty > config.perEventMax) {
|
|
1411
|
+
throw new Error(
|
|
1412
|
+
`meter '${meter}' qty ${qty} exceeds the per-event max ${config.perEventMax}`
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return Object.fromEntries(entries);
|
|
1417
|
+
}
|
|
1418
|
+
function resolveEndpoint2(endpoint, coreUrl) {
|
|
1419
|
+
if (/^https?:\/\//.test(endpoint)) return endpoint;
|
|
1420
|
+
if (!coreUrl) return endpoint;
|
|
1421
|
+
return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1272
1424
|
// src/core/nonceCache.ts
|
|
1273
1425
|
var DEFAULT_MAX_ENTRIES = 1e5;
|
|
1274
1426
|
var DEFAULT_TTL_MS = 6e5;
|
|
@@ -1775,12 +1927,12 @@ async function verifyRequest(input, deps) {
|
|
|
1775
1927
|
const kid = h(RUNTIME_HEADER_NAMES.keyId);
|
|
1776
1928
|
const requestId = h(RUNTIME_HEADER_NAMES.requestId);
|
|
1777
1929
|
const timestampRaw = h(RUNTIME_HEADER_NAMES.timestamp);
|
|
1778
|
-
const
|
|
1930
|
+
const signedBusinessId = h(RUNTIME_HEADER_NAMES.businessId);
|
|
1779
1931
|
const signedBackendId = h(RUNTIME_HEADER_NAMES.backendId);
|
|
1780
1932
|
const signedRouteId = h(RUNTIME_HEADER_NAMES.routeId) ?? "";
|
|
1781
1933
|
const policyVersion = h(RUNTIME_HEADER_NAMES.policyVersion);
|
|
1782
1934
|
const signedBodyHash = h(RUNTIME_HEADER_NAMES.bodyHash);
|
|
1783
|
-
if (!kid || !requestId || !timestampRaw || !
|
|
1935
|
+
if (!kid || !requestId || !timestampRaw || !signedBusinessId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
|
|
1784
1936
|
throw new FartherShoreError(
|
|
1785
1937
|
"malformed_signature",
|
|
1786
1938
|
"request is missing one or more required x-fs-* headers"
|
|
@@ -1816,10 +1968,10 @@ async function verifyRequest(input, deps) {
|
|
|
1816
1968
|
"recomputed body hash does not match the signed x-fs-body-hash"
|
|
1817
1969
|
);
|
|
1818
1970
|
}
|
|
1819
|
-
if (deps.
|
|
1971
|
+
if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
|
|
1820
1972
|
throw new FartherShoreError(
|
|
1821
1973
|
"route_mismatch",
|
|
1822
|
-
"signed
|
|
1974
|
+
"signed business-id does not match this backend's business"
|
|
1823
1975
|
);
|
|
1824
1976
|
}
|
|
1825
1977
|
if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
|
|
@@ -1841,7 +1993,7 @@ async function verifyRequest(input, deps) {
|
|
|
1841
1993
|
bodyHash: computedBodyHash,
|
|
1842
1994
|
requestId,
|
|
1843
1995
|
timestamp,
|
|
1844
|
-
|
|
1996
|
+
businessId: signedBusinessId,
|
|
1845
1997
|
backendId: signedBackendId,
|
|
1846
1998
|
routeId: signedRouteId,
|
|
1847
1999
|
policyVersion
|
|
@@ -1881,10 +2033,10 @@ async function verifyRequest(input, deps) {
|
|
|
1881
2033
|
if (signedContext === null && deps.contextVerification === "required") {
|
|
1882
2034
|
throw contextRequiredError("failed verification");
|
|
1883
2035
|
}
|
|
1884
|
-
if (signedContext && signedContext.productId !==
|
|
2036
|
+
if (signedContext && signedContext.productId !== signedBusinessId) {
|
|
1885
2037
|
throw new FartherShoreError(
|
|
1886
2038
|
"context_unverified",
|
|
1887
|
-
"X-Fs-Context was minted for a different
|
|
2039
|
+
"X-Fs-Context was minted for a different business than the signed request"
|
|
1888
2040
|
);
|
|
1889
2041
|
}
|
|
1890
2042
|
} else if (deps.contextVerification === "required") {
|
|
@@ -1904,7 +2056,7 @@ async function verifyRequest(input, deps) {
|
|
|
1904
2056
|
}
|
|
1905
2057
|
return {
|
|
1906
2058
|
requestId,
|
|
1907
|
-
|
|
2059
|
+
businessId: signedBusinessId,
|
|
1908
2060
|
backendId: signedBackendId,
|
|
1909
2061
|
routeId: signedRouteId,
|
|
1910
2062
|
policyVersion,
|
|
@@ -1947,8 +2099,8 @@ function headerGetter(headers) {
|
|
|
1947
2099
|
|
|
1948
2100
|
// src/core/runtime.ts
|
|
1949
2101
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1950
|
-
var SDK_VERSION = "0.
|
|
1951
|
-
var CONTRACTS_FP = "
|
|
2102
|
+
var SDK_VERSION = "0.15.0".length > 0 ? "0.15.0" : "0.0.0-dev";
|
|
2103
|
+
var CONTRACTS_FP = "4b6a36b4cb1f0b68".length > 0 ? "4b6a36b4cb1f0b68" : "0000000000000000";
|
|
1952
2104
|
var FartherShore = class {
|
|
1953
2105
|
bootstrapClient;
|
|
1954
2106
|
fetchImpl;
|
|
@@ -1966,6 +2118,7 @@ var FartherShore = class {
|
|
|
1966
2118
|
shutdownManager = new ShutdownManager();
|
|
1967
2119
|
jwks = null;
|
|
1968
2120
|
meteringClient = null;
|
|
2121
|
+
postStreamUsageClient = null;
|
|
1969
2122
|
tunnel = null;
|
|
1970
2123
|
bootstrapped = false;
|
|
1971
2124
|
constructor(options = {}) {
|
|
@@ -2016,11 +2169,16 @@ var FartherShore = class {
|
|
|
2016
2169
|
if (!this.meteringClient && config.metering.enabled) {
|
|
2017
2170
|
this.meteringClient = new MeteringClient({
|
|
2018
2171
|
config: config.metering,
|
|
2019
|
-
|
|
2172
|
+
businessId: config.business.id,
|
|
2020
2173
|
backendId: config.backend.id,
|
|
2021
2174
|
coreUrl: this.coreUrl,
|
|
2022
2175
|
fetchImpl: this.fetchImpl
|
|
2023
2176
|
});
|
|
2177
|
+
this.postStreamUsageClient = new PostStreamUsageClient({
|
|
2178
|
+
config: config.metering,
|
|
2179
|
+
coreUrl: this.coreUrl,
|
|
2180
|
+
fetchImpl: this.fetchImpl
|
|
2181
|
+
});
|
|
2024
2182
|
}
|
|
2025
2183
|
this.bootstrapped = true;
|
|
2026
2184
|
return config;
|
|
@@ -2092,10 +2250,10 @@ var FartherShore = class {
|
|
|
2092
2250
|
);
|
|
2093
2251
|
}
|
|
2094
2252
|
const knownRouteIds = new Set(config.routes.map((r) => r.id));
|
|
2095
|
-
|
|
2253
|
+
const context = await verifyRequest(input, {
|
|
2096
2254
|
jwks: this.jwks,
|
|
2097
2255
|
nonceCache: this.nonceCache,
|
|
2098
|
-
|
|
2256
|
+
businessId: config.business.id,
|
|
2099
2257
|
backendId: config.backend.id,
|
|
2100
2258
|
knownRouteIds,
|
|
2101
2259
|
clockSkewSeconds: config.verification.clockSkewSeconds,
|
|
@@ -2107,6 +2265,23 @@ var FartherShore = class {
|
|
|
2107
2265
|
contextSecrets: this.contextSecrets,
|
|
2108
2266
|
contextVerification: this.contextVerification
|
|
2109
2267
|
});
|
|
2268
|
+
return {
|
|
2269
|
+
...context,
|
|
2270
|
+
reportUsage: (report) => {
|
|
2271
|
+
const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
|
|
2272
|
+
if (!subscriptionId) {
|
|
2273
|
+
return Promise.resolve({
|
|
2274
|
+
ok: false,
|
|
2275
|
+
reason: "subscriptionId is required"
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
return this.reportUsage({
|
|
2279
|
+
...report,
|
|
2280
|
+
requestId: report.requestId ?? context.requestId,
|
|
2281
|
+
subscriptionId
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
};
|
|
2110
2285
|
}
|
|
2111
2286
|
/** Whether verification is required (bootstrap × opt-out). */
|
|
2112
2287
|
async verificationRequired() {
|
|
@@ -2167,6 +2342,20 @@ var FartherShore = class {
|
|
|
2167
2342
|
}
|
|
2168
2343
|
await this.meteringClient.meter(meter, qty, options);
|
|
2169
2344
|
}
|
|
2345
|
+
/** Best-effort attested post-stream usage callback. Never rejects. */
|
|
2346
|
+
async reportUsage(input) {
|
|
2347
|
+
try {
|
|
2348
|
+
await this.ensureBootstrapped();
|
|
2349
|
+
if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
|
|
2350
|
+
return { ok: false, reason: "metering is not enabled" };
|
|
2351
|
+
}
|
|
2352
|
+
return await this.postStreamUsageClient.reportUsage(input);
|
|
2353
|
+
} catch (error) {
|
|
2354
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2355
|
+
console.warn(`post-stream usage report skipped: ${reason}`);
|
|
2356
|
+
return { ok: false, reason };
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2170
2359
|
/** Current local health report. */
|
|
2171
2360
|
health() {
|
|
2172
2361
|
const config = this.bootstrapClient.peek();
|
|
@@ -2267,18 +2456,6 @@ function headerValue(headers, name) {
|
|
|
2267
2456
|
return value;
|
|
2268
2457
|
}
|
|
2269
2458
|
|
|
2270
|
-
// src/response-metering.ts
|
|
2271
|
-
var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
|
|
2272
|
-
var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
|
|
2273
|
-
var devMeteringHooks = null;
|
|
2274
|
-
function __setDevMeteringHooks(hooks) {
|
|
2275
|
-
devMeteringHooks = hooks;
|
|
2276
|
-
}
|
|
2277
|
-
var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
|
|
2278
|
-
var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
|
|
2279
|
-
var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
|
|
2280
|
-
var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
|
|
2281
|
-
|
|
2282
2459
|
// src/testing/prodGuard.ts
|
|
2283
2460
|
function isProductionEnv(env = readProcessEnv2()) {
|
|
2284
2461
|
return (env.NODE_ENV ?? "").trim().toLowerCase() === "production";
|
|
@@ -2326,6 +2503,16 @@ var DevUsageSink = class {
|
|
|
2326
2503
|
at: Date.now()
|
|
2327
2504
|
});
|
|
2328
2505
|
}
|
|
2506
|
+
/** Record an attested post-stream report captured by the dev gateway. */
|
|
2507
|
+
recordReportUsage(event) {
|
|
2508
|
+
this.events.push({
|
|
2509
|
+
source: "reportUsage",
|
|
2510
|
+
meters: { ...event.meters },
|
|
2511
|
+
event,
|
|
2512
|
+
requestId: event.requestId,
|
|
2513
|
+
at: Date.now()
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2329
2516
|
/** Total quantity per meter key across every recorded event. */
|
|
2330
2517
|
byMeter() {
|
|
2331
2518
|
const out = {};
|
|
@@ -2437,7 +2624,7 @@ function personaClientFromKeysFile(path = DEFAULT_KEYS_FILE, options = {}) {
|
|
|
2437
2624
|
const file = readDevKeysFile(path);
|
|
2438
2625
|
const client = createPersonaClient({
|
|
2439
2626
|
keys: file.keys,
|
|
2440
|
-
|
|
2627
|
+
businessId: file.businessId,
|
|
2441
2628
|
backendId: file.backendId,
|
|
2442
2629
|
contextSecret: file.keys.contextSecret,
|
|
2443
2630
|
contextKid: file.keys.contextKid,
|
|
@@ -2464,12 +2651,16 @@ function createDevRuntime(options) {
|
|
|
2464
2651
|
const gateway = createDevGateway({
|
|
2465
2652
|
mode,
|
|
2466
2653
|
keys,
|
|
2467
|
-
...options.
|
|
2654
|
+
...options.businessId ? { businessId: options.businessId } : {},
|
|
2468
2655
|
...options.backendId ? { backendId: options.backendId } : {},
|
|
2469
2656
|
...options.routes ? { routeIds: options.routes } : {},
|
|
2470
2657
|
onMeterEvent: (event) => {
|
|
2471
2658
|
usage.recordMeterEvent(event);
|
|
2472
2659
|
options.usageJsonl?.(JSON.stringify({ source: "meter", event }));
|
|
2660
|
+
},
|
|
2661
|
+
onReportUsage: (event) => {
|
|
2662
|
+
usage.recordReportUsage(event);
|
|
2663
|
+
options.usageJsonl?.(JSON.stringify({ source: "reportUsage", event }));
|
|
2473
2664
|
}
|
|
2474
2665
|
});
|
|
2475
2666
|
__setDevMeteringHooks({
|
|
@@ -2489,7 +2680,7 @@ function createDevRuntime(options) {
|
|
|
2489
2680
|
const personas = buildPersonaMap(options.personas);
|
|
2490
2681
|
const personaClient = createPersonaClient({
|
|
2491
2682
|
keys,
|
|
2492
|
-
|
|
2683
|
+
businessId: gateway.businessId,
|
|
2493
2684
|
backendId: gateway.backendId,
|
|
2494
2685
|
contextSecret: keys.contextSecret,
|
|
2495
2686
|
contextKid: keys.contextKid,
|
|
@@ -2619,7 +2810,7 @@ function createDevRuntimeFromEnv(env = readProcessEnv3()) {
|
|
|
2619
2810
|
version: 1,
|
|
2620
2811
|
mode,
|
|
2621
2812
|
keys,
|
|
2622
|
-
|
|
2813
|
+
businessId: runtime.gateway.businessId,
|
|
2623
2814
|
backendId: runtime.gateway.backendId,
|
|
2624
2815
|
personas: mapToRecord(runtime.personas)
|
|
2625
2816
|
};
|
|
@@ -2632,7 +2823,7 @@ function printBanner(mode, runtime, tracePath) {
|
|
|
2632
2823
|
"============================================================",
|
|
2633
2824
|
" \u26A0 FARTHER SHORE DEV MODE ACTIVE \u2014 NOT FOR PRODUCTION",
|
|
2634
2825
|
` mode: ${mode.toUpperCase()}`,
|
|
2635
|
-
`
|
|
2826
|
+
` business: ${runtime.gateway.businessId}`,
|
|
2636
2827
|
` backend: ${runtime.gateway.backendId}`,
|
|
2637
2828
|
` personas: ${[...runtime.personas.keys()].join(", ")}`,
|
|
2638
2829
|
` usage log: ${USAGE_JSONL_PATH}`,
|
|
@@ -2,6 +2,12 @@ import type { RuntimeMeteringConfig } from "../runtime-types.js";
|
|
|
2
2
|
export type MeterOptions = {
|
|
3
3
|
requestId?: string;
|
|
4
4
|
routeId?: string;
|
|
5
|
+
/** Subscription to attribute the usage to (billing identity). Pass the
|
|
6
|
+
* verified request context's `signedContext.subscriptionId` when metering
|
|
7
|
+
* inside a request handler. Without it (and without `requestId`, which core
|
|
8
|
+
* can resolve back to the served gateway request), core persists the event
|
|
9
|
+
* UNBILLED and flags it unattributable. */
|
|
10
|
+
subscriptionId?: string;
|
|
5
11
|
/** Override event_id (idempotency key). Defaults to a random uuid. */
|
|
6
12
|
eventId?: string;
|
|
7
13
|
/** Override the timestamp (ISO-8601). Defaults to now. */
|
|
@@ -9,7 +15,7 @@ export type MeterOptions = {
|
|
|
9
15
|
};
|
|
10
16
|
export type MeteringClientOptions = {
|
|
11
17
|
config: RuntimeMeteringConfig;
|
|
12
|
-
|
|
18
|
+
businessId: string;
|
|
13
19
|
backendId: string;
|
|
14
20
|
/** Core base URL when the config endpoint is a relative path. */
|
|
15
21
|
coreUrl?: string;
|
|
@@ -36,7 +42,7 @@ export type MeteringClientOptions = {
|
|
|
36
42
|
export declare class MeteringClient {
|
|
37
43
|
private readonly config;
|
|
38
44
|
private readonly endpoint;
|
|
39
|
-
private readonly
|
|
45
|
+
private readonly businessId;
|
|
40
46
|
private readonly backendId;
|
|
41
47
|
private readonly fetchImpl;
|
|
42
48
|
private readonly maxRetries;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RuntimeMeteringConfig } from "../runtime-types.js";
|
|
2
|
+
export type ReportUsageInput = {
|
|
3
|
+
requestId: string;
|
|
4
|
+
subscriptionId: string;
|
|
5
|
+
meters: Record<string, number>;
|
|
6
|
+
creditUnitsConsumed?: Record<string, number>;
|
|
7
|
+
measureContext?: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
export type RequestScopedReportUsageInput = Omit<ReportUsageInput, "requestId" | "subscriptionId"> & {
|
|
10
|
+
requestId?: string;
|
|
11
|
+
subscriptionId?: string;
|
|
12
|
+
};
|
|
13
|
+
export type ReportUsageResult = {
|
|
14
|
+
ok: true;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
export type PostStreamUsageClientOptions = {
|
|
20
|
+
config: RuntimeMeteringConfig;
|
|
21
|
+
coreUrl?: string;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
newNonce?: () => string;
|
|
24
|
+
logger?: (message: string) => void;
|
|
25
|
+
/** Injectable for tests; defaults to a normal timer-backed delay. */
|
|
26
|
+
sleep?: (delayMs: number) => Promise<void>;
|
|
27
|
+
/** Backoff after each request-not-found response. */
|
|
28
|
+
retryDelaysMs?: readonly number[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort, attested post-stream billing reporter. The callback is
|
|
32
|
+
* request-bound in Core but never settles a Durable Object enforcement window.
|
|
33
|
+
* Every failure resolves ok:false.
|
|
34
|
+
*/
|
|
35
|
+
export declare class PostStreamUsageClient {
|
|
36
|
+
private readonly config;
|
|
37
|
+
private readonly endpoint;
|
|
38
|
+
private readonly fetchImpl;
|
|
39
|
+
private readonly newNonce;
|
|
40
|
+
private readonly logger;
|
|
41
|
+
private readonly sleep;
|
|
42
|
+
private readonly retryDelaysMs;
|
|
43
|
+
constructor(options: PostStreamUsageClientOptions);
|
|
44
|
+
reportUsage(input: ReportUsageInput): Promise<ReportUsageResult>;
|
|
45
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type RuntimeBootstrapResponse, type RuntimeHealthReport } from "../runtime-types.js";
|
|
2
2
|
import type { ReconcileResult } from "../reflect/reconcile.js";
|
|
3
3
|
import { type MeterOptions } from "./metering.js";
|
|
4
|
+
import { type ReportUsageInput, type ReportUsageResult } from "./post-stream-usage.js";
|
|
4
5
|
import { type SpawnFn } from "./tunnel.js";
|
|
5
6
|
import { type FartherShoreRequestContext, type VerifyRequestInput } from "./verifyRequest.js";
|
|
6
7
|
/** Advanced opt-in tunnel config. The embedded runner is the default DX. */
|
|
@@ -44,7 +45,7 @@ export type FartherShoreInitOptions = {
|
|
|
44
45
|
* `X-Fs-Context` claim. These are the GATEWAY CONTEXT-SIGNING keyring values
|
|
45
46
|
* (`CONTEXT_SIGNING_KEYS_JSON` — the keys `forward-upstream` stamps the
|
|
46
47
|
* header with; supply every live key during rotation — try-all). They are
|
|
47
|
-
* NOT the
|
|
48
|
+
* NOT the business's `contextTokenSecret`, which signs `fsc_` INGRESS tokens
|
|
48
49
|
* verified BY the gateway — setting that here would reject every valid
|
|
49
50
|
* gateway request in `"required"` mode. When present, a VERIFIED context's
|
|
50
51
|
* `permissions`/`roles` claims are the AUTHORITATIVE identity source
|
|
@@ -53,8 +54,8 @@ export type FartherShoreInitOptions = {
|
|
|
53
54
|
*
|
|
54
55
|
* NOTE (core-side dependency, FAR-723 publish gate): no bootstrap field
|
|
55
56
|
* carries these keys yet, and handing the raw platform keyring to builder
|
|
56
|
-
* backends is NOT the end-state (it would allow cross-
|
|
57
|
-
* forgery). The distribution mechanism — per-
|
|
57
|
+
* backends is NOT the end-state (it would allow cross-business context
|
|
58
|
+
* forgery). The distribution mechanism — per-business derived keys or an
|
|
58
59
|
* asymmetric context signature verified via JWKS like the request
|
|
59
60
|
* signature — is decided at the FAR-723 gate before the headers retire;
|
|
60
61
|
* until then this option (or `FS_CONTEXT_SECRETS`) is the manual wiring for
|
|
@@ -95,6 +96,7 @@ export declare class FartherShore {
|
|
|
95
96
|
private readonly shutdownManager;
|
|
96
97
|
private jwks;
|
|
97
98
|
private meteringClient;
|
|
99
|
+
private postStreamUsageClient;
|
|
98
100
|
private tunnel;
|
|
99
101
|
private bootstrapped;
|
|
100
102
|
constructor(options?: FartherShoreInitOptions);
|
|
@@ -137,6 +139,8 @@ export declare class FartherShore {
|
|
|
137
139
|
start(): Promise<void>;
|
|
138
140
|
/** Record metering usage (billing-only). */
|
|
139
141
|
meter(meter: string, qty: number, options?: MeterOptions): Promise<void>;
|
|
142
|
+
/** Best-effort attested post-stream usage callback. Never rejects. */
|
|
143
|
+
reportUsage(input: ReportUsageInput): Promise<ReportUsageResult>;
|
|
140
144
|
/** Current local health report. */
|
|
141
145
|
health(): RuntimeHealthReport;
|
|
142
146
|
/** Graceful shutdown: flush metering + send a stopping heartbeat. */
|