@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/dist/index.js CHANGED
@@ -306,7 +306,7 @@ var RUNTIME_HEADER_NAMES = {
306
306
  keyId: "x-fs-key-id",
307
307
  requestId: "x-fs-request-id",
308
308
  timestamp: "x-fs-timestamp",
309
- productId: "x-fs-product-id",
309
+ businessId: "x-fs-business-id",
310
310
  backendId: "x-fs-backend-id",
311
311
  routeId: "x-fs-route-id",
312
312
  policyVersion: "x-fs-policy-version",
@@ -358,7 +358,7 @@ var CANONICAL_SIGNING_FIELDS = [
358
358
  "body-hash",
359
359
  "request-id",
360
360
  "timestamp",
361
- "product-id",
361
+ "business-id",
362
362
  "backend-id",
363
363
  "route-id",
364
364
  "policy-version"
@@ -395,7 +395,7 @@ function buildCanonicalSigningString(input) {
395
395
  "body-hash": input.bodyHash,
396
396
  "request-id": input.requestId,
397
397
  timestamp: String(input.timestamp),
398
- "product-id": input.productId,
398
+ "business-id": input.businessId,
399
399
  "backend-id": input.backendId,
400
400
  "route-id": input.routeId,
401
401
  "policy-version": input.policyVersion
@@ -761,7 +761,7 @@ var DEFAULT_MAX_RETRIES = 3;
761
761
  var MeteringClient = class {
762
762
  config;
763
763
  endpoint;
764
- productId;
764
+ businessId;
765
765
  backendId;
766
766
  fetchImpl;
767
767
  maxRetries;
@@ -775,7 +775,7 @@ var MeteringClient = class {
775
775
  constructor(options) {
776
776
  this.config = options.config;
777
777
  this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
778
- this.productId = options.productId;
778
+ this.businessId = options.businessId;
779
779
  this.backendId = options.backendId;
780
780
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
781
781
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
@@ -838,13 +838,14 @@ var MeteringClient = class {
838
838
  }
839
839
  const event = {
840
840
  event_id: options.eventId ?? this.newId(),
841
- product_id: this.productId,
841
+ business_id: this.businessId,
842
842
  backend_id: this.backendId,
843
843
  meter,
844
844
  qty,
845
845
  timestamp: options.timestamp ?? this.now().toISOString(),
846
846
  ...options.routeId ? { route_id: options.routeId } : {},
847
- ...options.requestId ? { request_id: options.requestId } : {}
847
+ ...options.requestId ? { request_id: options.requestId } : {},
848
+ ...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
848
849
  };
849
850
  this.buffer.push(event);
850
851
  await this.flush();
@@ -899,6 +900,300 @@ function resolveEndpoint(endpoint, coreUrl) {
899
900
  return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
900
901
  }
901
902
 
903
+ // src/response-metering.ts
904
+ var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
905
+ var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
906
+ var devMeteringHooks = null;
907
+ function __setDevMeteringHooks(hooks) {
908
+ devMeteringHooks = hooks;
909
+ }
910
+ var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
911
+ var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
912
+ var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
913
+ var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
914
+ var MeteringError = class extends Error {
915
+ code;
916
+ constructor(code, message) {
917
+ super(message);
918
+ this.name = "MeteringError";
919
+ this.code = code;
920
+ }
921
+ };
922
+ function createUsage(request, options = {}) {
923
+ const usage = {};
924
+ const reporter = {
925
+ report(meter, value) {
926
+ usage[assertMeterKey(meter)] = assertMeterValue(meter, value);
927
+ return reporter;
928
+ },
929
+ async wrap(response, wrapOptions = {}) {
930
+ return signResponse(request, response, usage, options, wrapOptions);
931
+ }
932
+ };
933
+ return reporter;
934
+ }
935
+ async function withUsage(request, response, usage, options = {}) {
936
+ const reporter = createUsage(request, options);
937
+ for (const [meter, value] of Object.entries(usage)) {
938
+ reporter.report(meter, value);
939
+ }
940
+ return reporter.wrap(response);
941
+ }
942
+ async function signResponse(request, response, usage, options, wrapOptions) {
943
+ const payload = buildPayload(request, usage, options, wrapOptions);
944
+ const requestId = request.headers.get("x-fs-request-id") ?? void 0;
945
+ const headers = await computeMeteringHeaders(payload, {
946
+ ...options.token !== void 0 ? { token: options.token } : {},
947
+ ...options.env !== void 0 ? { env: options.env } : {},
948
+ ...requestId ? { requestId } : {},
949
+ onSkip: () => {
950
+ }
951
+ });
952
+ if (Object.keys(headers).length === 0) {
953
+ throw new MeteringError(
954
+ RESPONSE_METERING_ERROR_CODES.missingToken,
955
+ `${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
956
+ );
957
+ }
958
+ const merged = new Headers(response.headers);
959
+ for (const [name, value] of Object.entries(headers)) merged.set(name, value);
960
+ return new Response(response.body, {
961
+ status: response.status,
962
+ statusText: response.statusText,
963
+ headers: merged
964
+ });
965
+ }
966
+ async function computeMeteringHeaders(payload, options = {}) {
967
+ try {
968
+ const token = resolveTokenSoft(options);
969
+ if (!token) {
970
+ skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
971
+ return {};
972
+ }
973
+ const json2 = JSON.stringify(payload);
974
+ const signature = await signPayload(json2, token);
975
+ devMeteringHooks?.record?.(payload, options.requestId);
976
+ return {
977
+ [METERING_PAYLOAD_HEADER]: json2,
978
+ [METERING_SIGNATURE_HEADER]: signature,
979
+ [METERING_TOKEN_HEADER]: token
980
+ };
981
+ } catch (error) {
982
+ skip(error instanceof Error ? error.message : String(error), options);
983
+ return {};
984
+ }
985
+ }
986
+ function skip(reason, options) {
987
+ if (options.onSkip) {
988
+ options.onSkip(reason);
989
+ } else {
990
+ console.warn(`metering headers skipped: ${reason}`);
991
+ }
992
+ devMeteringHooks?.onSkip?.(reason, options.requestId);
993
+ }
994
+ function resolveTokenSoft(options) {
995
+ return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
996
+ }
997
+ function buildPayload(request, usage, options, wrapOptions) {
998
+ const url = new URL(request.url);
999
+ const measureContext = wrapOptions.measureContext ?? options.measureContext;
1000
+ const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
1001
+ const operationKey = wrapOptions.operationKey ?? options.operationKey;
1002
+ const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
1003
+ const payload = {
1004
+ method: request.method.toUpperCase(),
1005
+ path: url.pathname,
1006
+ rawDimsUnits: sortUsage(usage),
1007
+ ...measureContext ? { measureContext } : {},
1008
+ ...creditUnitsConsumed ? {
1009
+ creditUnitsConsumed: sortUsage(
1010
+ validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
1011
+ )
1012
+ } : {},
1013
+ ...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
1014
+ ...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
1015
+ };
1016
+ return payload;
1017
+ }
1018
+ function sortUsage(usage) {
1019
+ return Object.fromEntries(
1020
+ Object.entries(usage).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
1021
+ );
1022
+ }
1023
+ function validateUsageMap(usage, label) {
1024
+ return Object.fromEntries(
1025
+ Object.entries(usage).map(([meter, value]) => [
1026
+ assertMeterKey(meter),
1027
+ assertMeterValue(`${label}.${meter}`, value)
1028
+ ])
1029
+ );
1030
+ }
1031
+ function assertMeterKey(meter) {
1032
+ if (!/^[a-z0-9_]{1,64}$/.test(meter)) {
1033
+ throw new MeteringError(
1034
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1035
+ `meter key "${meter}" must be lowercase alphanumeric with underscores`
1036
+ );
1037
+ }
1038
+ return meter;
1039
+ }
1040
+ function assertMeterValue(meter, value) {
1041
+ if (!Number.isFinite(value) || value < 0) {
1042
+ throw new MeteringError(
1043
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1044
+ `meter "${meter}" value must be a non-negative finite number`
1045
+ );
1046
+ }
1047
+ return value;
1048
+ }
1049
+ function assertIdentifier(value) {
1050
+ if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
1051
+ throw new MeteringError(
1052
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1053
+ `operation and usage policy identifiers must be 1-128 URL-safe characters`
1054
+ );
1055
+ }
1056
+ return value;
1057
+ }
1058
+ function processEnv(key2) {
1059
+ const maybeProcess = globalThis.process;
1060
+ return maybeProcess?.env?.[key2];
1061
+ }
1062
+ async function signPayload(payload, token) {
1063
+ const key2 = await crypto.subtle.importKey(
1064
+ "raw",
1065
+ new TextEncoder().encode(token),
1066
+ { name: "HMAC", hash: "SHA-256" },
1067
+ false,
1068
+ ["sign"]
1069
+ );
1070
+ const signature = await crypto.subtle.sign(
1071
+ "HMAC",
1072
+ key2,
1073
+ new TextEncoder().encode(payload)
1074
+ );
1075
+ return base64url(new Uint8Array(signature));
1076
+ }
1077
+ function base64url(bytes) {
1078
+ let binary = "";
1079
+ for (const byte of bytes) {
1080
+ binary += String.fromCharCode(byte);
1081
+ }
1082
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1083
+ }
1084
+
1085
+ // src/core/post-stream-usage.ts
1086
+ var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
1087
+ var PostStreamUsageClient = class {
1088
+ config;
1089
+ endpoint;
1090
+ fetchImpl;
1091
+ newNonce;
1092
+ logger;
1093
+ sleep;
1094
+ retryDelaysMs;
1095
+ constructor(options) {
1096
+ this.config = options.config;
1097
+ this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
1098
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1099
+ this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
1100
+ this.logger = options.logger ?? ((message) => console.warn(message));
1101
+ this.sleep = options.sleep ?? sleep;
1102
+ this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
1103
+ }
1104
+ async reportUsage(input) {
1105
+ try {
1106
+ if (!this.config.enabled) throw new Error("metering is not enabled");
1107
+ if (!input.requestId) throw new Error("requestId is required");
1108
+ if (!input.subscriptionId) throw new Error("subscriptionId is required");
1109
+ const unsigned = {
1110
+ requestId: input.requestId,
1111
+ subscriptionId: input.subscriptionId,
1112
+ nonce: this.newNonce(),
1113
+ meters: validateAndSortUsage(input.meters, "meters", this.config, true),
1114
+ ...input.creditUnitsConsumed ? {
1115
+ creditUnitsConsumed: validateAndSortUsage(
1116
+ input.creditUnitsConsumed,
1117
+ "creditUnitsConsumed",
1118
+ this.config,
1119
+ false
1120
+ )
1121
+ } : {},
1122
+ ...input.measureContext ? { measureContext: input.measureContext } : {}
1123
+ };
1124
+ const signature = await signPayload(
1125
+ JSON.stringify(unsigned),
1126
+ this.config.credential
1127
+ );
1128
+ const event = { ...unsigned, signature };
1129
+ const body = JSON.stringify(event);
1130
+ for (let attempt = 0; ; attempt += 1) {
1131
+ const response = await this.fetchImpl(this.endpoint, {
1132
+ method: "POST",
1133
+ headers: {
1134
+ authorization: `Bearer ${this.config.credential}`,
1135
+ "content-type": "application/json",
1136
+ accept: "application/json"
1137
+ },
1138
+ body
1139
+ });
1140
+ if (response.ok) return { ok: true };
1141
+ const requestNotFound = await isPostStreamRequestNotFound(response);
1142
+ const delayMs = this.retryDelaysMs[attempt];
1143
+ if (!requestNotFound || delayMs === void 0) {
1144
+ throw new Error(`metering endpoint returned ${response.status}`);
1145
+ }
1146
+ await this.sleep(delayMs);
1147
+ }
1148
+ } catch (error) {
1149
+ const reason = error instanceof Error ? error.message : String(error);
1150
+ this.logger(`post-stream usage report skipped: ${reason}`);
1151
+ return { ok: false, reason };
1152
+ }
1153
+ }
1154
+ };
1155
+ async function isPostStreamRequestNotFound(response) {
1156
+ if (response.status !== 422) return false;
1157
+ try {
1158
+ const body = await response.json();
1159
+ return body.error?.code === "post_stream_request_not_found";
1160
+ } catch {
1161
+ return false;
1162
+ }
1163
+ }
1164
+ function sleep(delayMs) {
1165
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
1166
+ }
1167
+ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1168
+ const entries = Object.entries(usage).sort(
1169
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1170
+ );
1171
+ for (const [meter, qty] of entries) {
1172
+ if (!METER_KEY_RE2.test(meter)) {
1173
+ throw new Error(
1174
+ `${label} key '${meter}' must be lowercase alphanumeric with underscores`
1175
+ );
1176
+ }
1177
+ if (!Number.isFinite(qty) || qty < 0) {
1178
+ throw new Error(`${label}.${meter} must be a non-negative finite number`);
1179
+ }
1180
+ if (enforceMeterScope && config.allowedMeters.length > 0 && !config.allowedMeters.includes(meter)) {
1181
+ throw new Error(`meter '${meter}' is not in the token's allowedMeters`);
1182
+ }
1183
+ if (enforceMeterScope && config.perEventMax > 0 && qty > config.perEventMax) {
1184
+ throw new Error(
1185
+ `meter '${meter}' qty ${qty} exceeds the per-event max ${config.perEventMax}`
1186
+ );
1187
+ }
1188
+ }
1189
+ return Object.fromEntries(entries);
1190
+ }
1191
+ function resolveEndpoint2(endpoint, coreUrl) {
1192
+ if (/^https?:\/\//.test(endpoint)) return endpoint;
1193
+ if (!coreUrl) return endpoint;
1194
+ return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1195
+ }
1196
+
902
1197
  // src/core/nonceCache.ts
903
1198
  var DEFAULT_MAX_ENTRIES = 1e5;
904
1199
  var DEFAULT_TTL_MS = 6e5;
@@ -1411,12 +1706,12 @@ async function verifyRequest(input, deps) {
1411
1706
  const kid = h(RUNTIME_HEADER_NAMES.keyId);
1412
1707
  const requestId = h(RUNTIME_HEADER_NAMES.requestId);
1413
1708
  const timestampRaw = h(RUNTIME_HEADER_NAMES.timestamp);
1414
- const signedProductId = h(RUNTIME_HEADER_NAMES.productId);
1709
+ const signedBusinessId = h(RUNTIME_HEADER_NAMES.businessId);
1415
1710
  const signedBackendId = h(RUNTIME_HEADER_NAMES.backendId);
1416
1711
  const signedRouteId = h(RUNTIME_HEADER_NAMES.routeId) ?? "";
1417
1712
  const policyVersion = h(RUNTIME_HEADER_NAMES.policyVersion);
1418
1713
  const signedBodyHash = h(RUNTIME_HEADER_NAMES.bodyHash);
1419
- if (!kid || !requestId || !timestampRaw || !signedProductId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
1714
+ if (!kid || !requestId || !timestampRaw || !signedBusinessId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
1420
1715
  throw new FartherShoreError(
1421
1716
  "malformed_signature",
1422
1717
  "request is missing one or more required x-fs-* headers"
@@ -1452,10 +1747,10 @@ async function verifyRequest(input, deps) {
1452
1747
  "recomputed body hash does not match the signed x-fs-body-hash"
1453
1748
  );
1454
1749
  }
1455
- if (deps.productId !== void 0 && signedProductId !== deps.productId) {
1750
+ if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
1456
1751
  throw new FartherShoreError(
1457
1752
  "route_mismatch",
1458
- "signed product-id does not match this backend's product"
1753
+ "signed business-id does not match this backend's business"
1459
1754
  );
1460
1755
  }
1461
1756
  if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
@@ -1477,7 +1772,7 @@ async function verifyRequest(input, deps) {
1477
1772
  bodyHash: computedBodyHash,
1478
1773
  requestId,
1479
1774
  timestamp,
1480
- productId: signedProductId,
1775
+ businessId: signedBusinessId,
1481
1776
  backendId: signedBackendId,
1482
1777
  routeId: signedRouteId,
1483
1778
  policyVersion
@@ -1517,10 +1812,10 @@ async function verifyRequest(input, deps) {
1517
1812
  if (signedContext === null && deps.contextVerification === "required") {
1518
1813
  throw contextRequiredError("failed verification");
1519
1814
  }
1520
- if (signedContext && signedContext.productId !== signedProductId) {
1815
+ if (signedContext && signedContext.productId !== signedBusinessId) {
1521
1816
  throw new FartherShoreError(
1522
1817
  "context_unverified",
1523
- "X-Fs-Context was minted for a different product than the signed request"
1818
+ "X-Fs-Context was minted for a different business than the signed request"
1524
1819
  );
1525
1820
  }
1526
1821
  } else if (deps.contextVerification === "required") {
@@ -1540,7 +1835,7 @@ async function verifyRequest(input, deps) {
1540
1835
  }
1541
1836
  return {
1542
1837
  requestId,
1543
- productId: signedProductId,
1838
+ businessId: signedBusinessId,
1544
1839
  backendId: signedBackendId,
1545
1840
  routeId: signedRouteId,
1546
1841
  policyVersion,
@@ -1583,8 +1878,8 @@ function headerGetter(headers) {
1583
1878
 
1584
1879
  // src/core/runtime.ts
1585
1880
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1586
- var SDK_VERSION = "0.13.0".length > 0 ? "0.13.0" : "0.0.0-dev";
1587
- var CONTRACTS_FP = "220bea90107ed396".length > 0 ? "220bea90107ed396" : "0000000000000000";
1881
+ var SDK_VERSION = "0.15.0".length > 0 ? "0.15.0" : "0.0.0-dev";
1882
+ var CONTRACTS_FP = "4b6a36b4cb1f0b68".length > 0 ? "4b6a36b4cb1f0b68" : "0000000000000000";
1588
1883
  var FartherShore = class {
1589
1884
  bootstrapClient;
1590
1885
  fetchImpl;
@@ -1602,6 +1897,7 @@ var FartherShore = class {
1602
1897
  shutdownManager = new ShutdownManager();
1603
1898
  jwks = null;
1604
1899
  meteringClient = null;
1900
+ postStreamUsageClient = null;
1605
1901
  tunnel = null;
1606
1902
  bootstrapped = false;
1607
1903
  constructor(options = {}) {
@@ -1652,11 +1948,16 @@ var FartherShore = class {
1652
1948
  if (!this.meteringClient && config.metering.enabled) {
1653
1949
  this.meteringClient = new MeteringClient({
1654
1950
  config: config.metering,
1655
- productId: config.product.id,
1951
+ businessId: config.business.id,
1656
1952
  backendId: config.backend.id,
1657
1953
  coreUrl: this.coreUrl,
1658
1954
  fetchImpl: this.fetchImpl
1659
1955
  });
1956
+ this.postStreamUsageClient = new PostStreamUsageClient({
1957
+ config: config.metering,
1958
+ coreUrl: this.coreUrl,
1959
+ fetchImpl: this.fetchImpl
1960
+ });
1660
1961
  }
1661
1962
  this.bootstrapped = true;
1662
1963
  return config;
@@ -1728,10 +2029,10 @@ var FartherShore = class {
1728
2029
  );
1729
2030
  }
1730
2031
  const knownRouteIds = new Set(config.routes.map((r) => r.id));
1731
- return verifyRequest(input, {
2032
+ const context = await verifyRequest(input, {
1732
2033
  jwks: this.jwks,
1733
2034
  nonceCache: this.nonceCache,
1734
- productId: config.product.id,
2035
+ businessId: config.business.id,
1735
2036
  backendId: config.backend.id,
1736
2037
  knownRouteIds,
1737
2038
  clockSkewSeconds: config.verification.clockSkewSeconds,
@@ -1743,6 +2044,23 @@ var FartherShore = class {
1743
2044
  contextSecrets: this.contextSecrets,
1744
2045
  contextVerification: this.contextVerification
1745
2046
  });
2047
+ return {
2048
+ ...context,
2049
+ reportUsage: (report) => {
2050
+ const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
2051
+ if (!subscriptionId) {
2052
+ return Promise.resolve({
2053
+ ok: false,
2054
+ reason: "subscriptionId is required"
2055
+ });
2056
+ }
2057
+ return this.reportUsage({
2058
+ ...report,
2059
+ requestId: report.requestId ?? context.requestId,
2060
+ subscriptionId
2061
+ });
2062
+ }
2063
+ };
1746
2064
  }
1747
2065
  /** Whether verification is required (bootstrap × opt-out). */
1748
2066
  async verificationRequired() {
@@ -1803,6 +2121,20 @@ var FartherShore = class {
1803
2121
  }
1804
2122
  await this.meteringClient.meter(meter, qty, options);
1805
2123
  }
2124
+ /** Best-effort attested post-stream usage callback. Never rejects. */
2125
+ async reportUsage(input) {
2126
+ try {
2127
+ await this.ensureBootstrapped();
2128
+ if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
2129
+ return { ok: false, reason: "metering is not enabled" };
2130
+ }
2131
+ return await this.postStreamUsageClient.reportUsage(input);
2132
+ } catch (error) {
2133
+ const reason = error instanceof Error ? error.message : String(error);
2134
+ console.warn(`post-stream usage report skipped: ${reason}`);
2135
+ return { ok: false, reason };
2136
+ }
2137
+ }
1806
2138
  /** Current local health report. */
1807
2139
  health() {
1808
2140
  const config = this.bootstrapClient.peek();
@@ -1903,188 +2235,6 @@ function headerValue(headers, name) {
1903
2235
  return value;
1904
2236
  }
1905
2237
 
1906
- // src/response-metering.ts
1907
- var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
1908
- var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
1909
- var devMeteringHooks = null;
1910
- function __setDevMeteringHooks(hooks) {
1911
- devMeteringHooks = hooks;
1912
- }
1913
- var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
1914
- var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
1915
- var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
1916
- var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
1917
- var MeteringError = class extends Error {
1918
- code;
1919
- constructor(code, message) {
1920
- super(message);
1921
- this.name = "MeteringError";
1922
- this.code = code;
1923
- }
1924
- };
1925
- function createUsage(request, options = {}) {
1926
- const usage = {};
1927
- const reporter = {
1928
- report(meter, value) {
1929
- usage[assertMeterKey(meter)] = assertMeterValue(meter, value);
1930
- return reporter;
1931
- },
1932
- async wrap(response, wrapOptions = {}) {
1933
- return signResponse(request, response, usage, options, wrapOptions);
1934
- }
1935
- };
1936
- return reporter;
1937
- }
1938
- async function withUsage(request, response, usage, options = {}) {
1939
- const reporter = createUsage(request, options);
1940
- for (const [meter, value] of Object.entries(usage)) {
1941
- reporter.report(meter, value);
1942
- }
1943
- return reporter.wrap(response);
1944
- }
1945
- async function signResponse(request, response, usage, options, wrapOptions) {
1946
- const payload = buildPayload(request, usage, options, wrapOptions);
1947
- const requestId = request.headers.get("x-fs-request-id") ?? void 0;
1948
- const headers = await computeMeteringHeaders(payload, {
1949
- ...options.token !== void 0 ? { token: options.token } : {},
1950
- ...options.env !== void 0 ? { env: options.env } : {},
1951
- ...requestId ? { requestId } : {},
1952
- onSkip: () => {
1953
- }
1954
- });
1955
- if (Object.keys(headers).length === 0) {
1956
- throw new MeteringError(
1957
- RESPONSE_METERING_ERROR_CODES.missingToken,
1958
- `${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
1959
- );
1960
- }
1961
- const merged = new Headers(response.headers);
1962
- for (const [name, value] of Object.entries(headers)) merged.set(name, value);
1963
- return new Response(response.body, {
1964
- status: response.status,
1965
- statusText: response.statusText,
1966
- headers: merged
1967
- });
1968
- }
1969
- async function computeMeteringHeaders(payload, options = {}) {
1970
- try {
1971
- const token = resolveTokenSoft(options);
1972
- if (!token) {
1973
- skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
1974
- return {};
1975
- }
1976
- const json2 = JSON.stringify(payload);
1977
- const signature = await signPayload(json2, token);
1978
- devMeteringHooks?.record?.(payload, options.requestId);
1979
- return {
1980
- [METERING_PAYLOAD_HEADER]: json2,
1981
- [METERING_SIGNATURE_HEADER]: signature,
1982
- [METERING_TOKEN_HEADER]: token
1983
- };
1984
- } catch (error) {
1985
- skip(error instanceof Error ? error.message : String(error), options);
1986
- return {};
1987
- }
1988
- }
1989
- function skip(reason, options) {
1990
- if (options.onSkip) {
1991
- options.onSkip(reason);
1992
- } else {
1993
- console.warn(`metering headers skipped: ${reason}`);
1994
- }
1995
- devMeteringHooks?.onSkip?.(reason, options.requestId);
1996
- }
1997
- function resolveTokenSoft(options) {
1998
- return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
1999
- }
2000
- function buildPayload(request, usage, options, wrapOptions) {
2001
- const url = new URL(request.url);
2002
- const measureContext = wrapOptions.measureContext ?? options.measureContext;
2003
- const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
2004
- const operationKey = wrapOptions.operationKey ?? options.operationKey;
2005
- const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
2006
- const payload = {
2007
- method: request.method.toUpperCase(),
2008
- path: url.pathname,
2009
- rawDimsUnits: sortUsage(usage),
2010
- ...measureContext ? { measureContext } : {},
2011
- ...creditUnitsConsumed ? {
2012
- creditUnitsConsumed: sortUsage(
2013
- validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
2014
- )
2015
- } : {},
2016
- ...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
2017
- ...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
2018
- };
2019
- return payload;
2020
- }
2021
- function sortUsage(usage) {
2022
- return Object.fromEntries(
2023
- Object.entries(usage).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
2024
- );
2025
- }
2026
- function validateUsageMap(usage, label) {
2027
- return Object.fromEntries(
2028
- Object.entries(usage).map(([meter, value]) => [
2029
- assertMeterKey(meter),
2030
- assertMeterValue(`${label}.${meter}`, value)
2031
- ])
2032
- );
2033
- }
2034
- function assertMeterKey(meter) {
2035
- if (!/^[a-z0-9_]{1,64}$/.test(meter)) {
2036
- throw new MeteringError(
2037
- RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
2038
- `meter key "${meter}" must be lowercase alphanumeric with underscores`
2039
- );
2040
- }
2041
- return meter;
2042
- }
2043
- function assertMeterValue(meter, value) {
2044
- if (!Number.isFinite(value) || value < 0) {
2045
- throw new MeteringError(
2046
- RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
2047
- `meter "${meter}" value must be a non-negative finite number`
2048
- );
2049
- }
2050
- return value;
2051
- }
2052
- function assertIdentifier(value) {
2053
- if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
2054
- throw new MeteringError(
2055
- RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
2056
- `operation and usage policy identifiers must be 1-128 URL-safe characters`
2057
- );
2058
- }
2059
- return value;
2060
- }
2061
- function processEnv(key2) {
2062
- const maybeProcess = globalThis.process;
2063
- return maybeProcess?.env?.[key2];
2064
- }
2065
- async function signPayload(payload, token) {
2066
- const key2 = await crypto.subtle.importKey(
2067
- "raw",
2068
- new TextEncoder().encode(token),
2069
- { name: "HMAC", hash: "SHA-256" },
2070
- false,
2071
- ["sign"]
2072
- );
2073
- const signature = await crypto.subtle.sign(
2074
- "HMAC",
2075
- key2,
2076
- new TextEncoder().encode(payload)
2077
- );
2078
- return base64url(new Uint8Array(signature));
2079
- }
2080
- function base64url(bytes) {
2081
- let binary = "";
2082
- for (const byte of bytes) {
2083
- binary += String.fromCharCode(byte);
2084
- }
2085
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2086
- }
2087
-
2088
2238
  // src/testing/signers.ts
2089
2239
  import { generateKeyPairSync, randomBytes } from "node:crypto";
2090
2240
  var TEST_KID = "fs-runtime-test-2026";
@@ -2112,7 +2262,7 @@ async function makeSignedRequest(spec = {}) {
2112
2262
  bodyHash,
2113
2263
  requestId: spec.requestId ?? `req_${cryptoRandom()}`,
2114
2264
  timestamp: spec.timestamp ?? Math.floor(Date.now() / 1e3),
2115
- productId: spec.productId ?? "prod_test",
2265
+ businessId: spec.businessId ?? "biz_test",
2116
2266
  backendId: spec.backendId ?? "be_test",
2117
2267
  routeId: spec.routeId ?? "route_test",
2118
2268
  policyVersion: spec.policyVersion ?? "pv_1"
@@ -2124,7 +2274,7 @@ async function makeSignedRequest(spec = {}) {
2124
2274
  [RUNTIME_HEADER_NAMES.keyId]: kid,
2125
2275
  [RUNTIME_HEADER_NAMES.requestId]: claim.requestId,
2126
2276
  [RUNTIME_HEADER_NAMES.timestamp]: String(claim.timestamp),
2127
- [RUNTIME_HEADER_NAMES.productId]: claim.productId,
2277
+ [RUNTIME_HEADER_NAMES.businessId]: claim.businessId,
2128
2278
  [RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
2129
2279
  [RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
2130
2280
  [RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
@@ -2225,13 +2375,13 @@ function mergeHeaders(initHeaders, signedHeaders) {
2225
2375
  }
2226
2376
  return headers;
2227
2377
  }
2228
- function buildContextClaim(persona, productId) {
2378
+ function buildContextClaim(persona, businessId) {
2229
2379
  return {
2230
2380
  orgId: persona.orgId ?? "org_dev",
2231
2381
  actor: persona.actor ?? { type: "user", id: `user_${persona.name}` },
2232
- // Product binding: the signed context productId MUST equal the signed
2233
- // request productId or verifyRequest rejects it as tamper evidence.
2234
- productId,
2382
+ // Business binding: the retained signed-context productId claim MUST equal
2383
+ // the signed request businessId or verifyRequest rejects it as tamper evidence.
2384
+ productId: businessId,
2235
2385
  compiledPlanId: persona.compiledPlanId ?? "plan_dev",
2236
2386
  subscriptionId: persona.subscriptionId ?? "sub_dev",
2237
2387
  subscriberId: persona.subscriberId ?? "subscriber_dev",
@@ -2259,7 +2409,7 @@ function createPersonaClient(ctx) {
2259
2409
  query: spec.query ?? "",
2260
2410
  body: spec.body ?? null,
2261
2411
  streamingExempt: spec.streamingExempt ?? false,
2262
- productId: ctx.productId,
2412
+ businessId: ctx.businessId,
2263
2413
  backendId: ctx.backendId,
2264
2414
  routeId: spec.routeId ?? "",
2265
2415
  privateJwk: ctx.keys.privateJwk,
@@ -2269,7 +2419,7 @@ function createPersonaClient(ctx) {
2269
2419
  });
2270
2420
  const headers = { ...signed.headers };
2271
2421
  if (!persona.anonymous) {
2272
- const claim = buildContextClaim(persona, ctx.productId);
2422
+ const claim = buildContextClaim(persona, ctx.businessId);
2273
2423
  headers["x-fs-context"] = await signContextToken(
2274
2424
  claim,
2275
2425
  ctx.contextSecret,
@@ -2342,11 +2492,12 @@ var DEV_CORE_URL = "https://dev-gateway.farthershore.local";
2342
2492
  var DEV_JWKS_URL = `${DEV_CORE_URL}/.well-known/jwks.json`;
2343
2493
  var DEV_METERING_ENDPOINT = `${DEV_CORE_URL}/v1/metering/events`;
2344
2494
  function createDevGateway(options) {
2345
- const productId = options.productId ?? "prod_dev";
2495
+ const businessId = options.businessId ?? "biz_dev";
2346
2496
  const backendId = options.backendId ?? "be_dev";
2347
2497
  const meterEvents = [];
2498
+ const reportUsageEvents = [];
2348
2499
  const bootstrap = {
2349
- product: { id: productId, slug: options.productSlug ?? "dev-product" },
2500
+ business: { id: businessId, slug: options.businessSlug ?? "dev-business" },
2350
2501
  backend: {
2351
2502
  id: backendId,
2352
2503
  slug: options.backendSlug ?? "dev-backend",
@@ -2394,7 +2545,10 @@ function createDevGateway(options) {
2394
2545
  }
2395
2546
  if (url.includes("/v1/metering/events")) {
2396
2547
  const event = await readJsonBody(init, input);
2397
- if (event) {
2548
+ if (event && "meters" in event) {
2549
+ reportUsageEvents.push(event);
2550
+ options.onReportUsage?.(event);
2551
+ } else if (event) {
2398
2552
  meterEvents.push(event);
2399
2553
  options.onMeterEvent?.(event);
2400
2554
  }
@@ -2412,7 +2566,8 @@ function createDevGateway(options) {
2412
2566
  fetchImpl,
2413
2567
  bootstrap,
2414
2568
  meterEvents,
2415
- productId,
2569
+ reportUsageEvents,
2570
+ businessId,
2416
2571
  backendId,
2417
2572
  jwksUrl: DEV_JWKS_URL
2418
2573
  };
@@ -2488,6 +2643,16 @@ var DevUsageSink = class {
2488
2643
  at: Date.now()
2489
2644
  });
2490
2645
  }
2646
+ /** Record an attested post-stream report captured by the dev gateway. */
2647
+ recordReportUsage(event) {
2648
+ this.events.push({
2649
+ source: "reportUsage",
2650
+ meters: { ...event.meters },
2651
+ event,
2652
+ requestId: event.requestId,
2653
+ at: Date.now()
2654
+ });
2655
+ }
2491
2656
  /** Total quantity per meter key across every recorded event. */
2492
2657
  byMeter() {
2493
2658
  const out = {};
@@ -2604,12 +2769,16 @@ function createDevRuntime(options) {
2604
2769
  const gateway = createDevGateway({
2605
2770
  mode,
2606
2771
  keys,
2607
- ...options.productId ? { productId: options.productId } : {},
2772
+ ...options.businessId ? { businessId: options.businessId } : {},
2608
2773
  ...options.backendId ? { backendId: options.backendId } : {},
2609
2774
  ...options.routes ? { routeIds: options.routes } : {},
2610
2775
  onMeterEvent: (event) => {
2611
2776
  usage.recordMeterEvent(event);
2612
2777
  options.usageJsonl?.(JSON.stringify({ source: "meter", event }));
2778
+ },
2779
+ onReportUsage: (event) => {
2780
+ usage.recordReportUsage(event);
2781
+ options.usageJsonl?.(JSON.stringify({ source: "reportUsage", event }));
2613
2782
  }
2614
2783
  });
2615
2784
  __setDevMeteringHooks({
@@ -2629,7 +2798,7 @@ function createDevRuntime(options) {
2629
2798
  const personas = buildPersonaMap(options.personas);
2630
2799
  const personaClient = createPersonaClient({
2631
2800
  keys,
2632
- productId: gateway.productId,
2801
+ businessId: gateway.businessId,
2633
2802
  backendId: gateway.backendId,
2634
2803
  contextSecret: keys.contextSecret,
2635
2804
  contextKid: keys.contextKid,
@@ -2759,7 +2928,7 @@ function createDevRuntimeFromEnv(env = readProcessEnv3()) {
2759
2928
  version: 1,
2760
2929
  mode,
2761
2930
  keys,
2762
- productId: runtime.gateway.productId,
2931
+ businessId: runtime.gateway.businessId,
2763
2932
  backendId: runtime.gateway.backendId,
2764
2933
  personas: mapToRecord(runtime.personas)
2765
2934
  };
@@ -2772,7 +2941,7 @@ function printBanner(mode, runtime, tracePath) {
2772
2941
  "============================================================",
2773
2942
  " \u26A0 FARTHER SHORE DEV MODE ACTIVE \u2014 NOT FOR PRODUCTION",
2774
2943
  ` mode: ${mode.toUpperCase()}`,
2775
- ` product: ${runtime.gateway.productId}`,
2944
+ ` business: ${runtime.gateway.businessId}`,
2776
2945
  ` backend: ${runtime.gateway.backendId}`,
2777
2946
  ` personas: ${[...runtime.personas.keys()].join(", ")}`,
2778
2947
  ` usage log: ${USAGE_JSONL_PATH}`,
@@ -2858,6 +3027,7 @@ export {
2858
3027
  MeteringClient,
2859
3028
  MeteringError,
2860
3029
  NonceCache,
3030
+ PostStreamUsageClient,
2861
3031
  REDACTED_TOKEN,
2862
3032
  RUNTIME_CLOCK_SKEW_SECONDS,
2863
3033
  RUNTIME_ERROR_CODES,