@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.
@@ -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
- productId: "x-fs-product-id",
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
- "product-id",
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
- "product-id": input.productId,
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
- productId: spec.productId ?? "prod_test",
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.productId]: claim.productId,
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, productId) {
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
- // Product binding: the signed context productId MUST equal the signed
750
- // request productId or verifyRequest rejects it as tamper evidence.
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
- productId: ctx.productId,
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.productId);
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 productId = options.productId ?? "prod_dev";
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
- product: { id: productId, slug: options.productSlug ?? "dev-product" },
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
- productId,
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
- productId;
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.productId = options.productId;
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
- product_id: this.productId,
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 signedProductId = h(RUNTIME_HEADER_NAMES.productId);
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 || !signedProductId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
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.productId !== void 0 && signedProductId !== deps.productId) {
1971
+ if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
1820
1972
  throw new FartherShoreError(
1821
1973
  "route_mismatch",
1822
- "signed product-id does not match this backend's product"
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
- productId: signedProductId,
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 !== signedProductId) {
2036
+ if (signedContext && signedContext.productId !== signedBusinessId) {
1885
2037
  throw new FartherShoreError(
1886
2038
  "context_unverified",
1887
- "X-Fs-Context was minted for a different product than the signed request"
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
- productId: signedProductId,
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.13.0".length > 0 ? "0.13.0" : "0.0.0-dev";
1951
- var CONTRACTS_FP = "220bea90107ed396".length > 0 ? "220bea90107ed396" : "0000000000000000";
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
- productId: config.product.id,
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
- return verifyRequest(input, {
2253
+ const context = await verifyRequest(input, {
2096
2254
  jwks: this.jwks,
2097
2255
  nonceCache: this.nonceCache,
2098
- productId: config.product.id,
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
- productId: file.productId,
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.productId ? { productId: options.productId } : {},
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
- productId: gateway.productId,
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
- productId: runtime.gateway.productId,
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
- ` product: ${runtime.gateway.productId}`,
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
- productId: string;
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 productId;
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 product's `contextTokenSecret`, which signs `fsc_` INGRESS tokens
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-product context
57
- * forgery). The distribution mechanism — per-product derived keys or an
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. */