@farthershore/backend 0.13.0 → 0.14.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 CHANGED
@@ -12,7 +12,7 @@ graceful lifecycle (health + shutdown). Everything else — your product, backen
12
12
  and environment ids, the verification keys, and the metering endpoint — is
13
13
  fetched automatically from the token at startup.
14
14
 
15
- > **Status: `0.13.0`.** Pre-1.0: minor releases may include breaking changes, so
15
+ > **Status: `0.14.0`.** Pre-1.0: minor releases may include breaking changes, so
16
16
  > pin this package to an exact version (or a patch-only range) and upgrade
17
17
  > deliberately.
18
18
 
@@ -142,6 +142,40 @@ Delivery is at-least-once; the event idempotency key keeps ingestion safe.
142
142
  Background usage is tallied and billed after the cycle, not enforced in
143
143
  real time.
144
144
 
145
+ ## Post-stream usage reporting
146
+
147
+ Use `fs.reportUsage()` when a gateway request streams its response and the
148
+ billable total is known only after the stream completes. Declare that route
149
+ with `postStreamBilling: true`, then report from the request-scoped verified
150
+ context so the SDK retains the attested subscription subject:
151
+
152
+ ```ts
153
+ const context = await fs.verifyRequest({ method, path, query, headers, body });
154
+
155
+ await streamResponse(context);
156
+ await context.reportUsage?.({
157
+ meters: { output_tokens: 1280 },
158
+ measureContext: { model: "apsu-1" },
159
+ });
160
+ ```
161
+
162
+ The callback is HMAC-attested and requires a subscription subject. Core binds it
163
+ to the immutable gateway request row and writes one billable `UsageEvent` with
164
+ the gateway-known request units merged with reported actual units, the served
165
+ plan, served route, and served request time. The gateway evidence row is
166
+ unbilled and omits only response-derived dimensions. This is a billing-only channel: it
167
+ does not settle or mutate Durable Object enforcement windows, so units that are
168
+ unknown at admission cannot be hard-enforced. Knowable request dimensions still
169
+ follow the normal admission path and are billed once on the merged callback.
170
+
171
+ Gateway evidence is published asynchronously. If the callback arrives first,
172
+ Core parks the verified payload and the SDK retries only
173
+ `post_stream_request_not_found` with bounded backoff, reusing the exact signed
174
+ payload and nonce. A maintenance pass binds any remaining parked callback once
175
+ evidence lands and durably alerts if it expires. The SDK method is best-effort and resolves
176
+ `{ ok: false, reason }` instead of rejecting, so handle or log a failed report
177
+ according to your service's delivery policy.
178
+
145
179
  ## Lifecycle
146
180
 
147
181
  - `fs.health()` returns the current local health report (token present, bootstrap
@@ -211,6 +245,7 @@ See `templates/3-simulated-authz.test.ts` for the full fail-closed + usage flow.
211
245
  | `withUsage()` / `createUsage()` | Response-bound usage reporting (no network call). |
212
246
  | `computeMeteringHeaders()` | Metering headers as a plain map — never throws. |
213
247
  | `fs.meter(meter, qty, opts)` | Async/background usage event. |
248
+ | `fs.reportUsage(input)` | Attested post-stream usage callback. |
214
249
  | `fs.health()` / `fs.shutdown()` | Health report and graceful shutdown. |
215
250
  | `FartherShoreError`, `MeteringError` | Typed errors. |
216
251
  | `@farthershore/backend/testing` | Dev-mode + persona test harness (dev/test only). |
@@ -220,12 +255,17 @@ types directly if you prefer to wire the middleware yourself.
220
255
 
221
256
  ## Metering channels
222
257
 
223
- Two usage channels exist and are **not** interchangeable:
258
+ Three usage channels exist and are **not** interchangeable:
224
259
 
225
260
  - **Response-bound** (`withUsage` / `createUsage` / `computeMeteringHeaders`) is
226
261
  the attested, request-bound settlement channel: the gateway verifies the HMAC
227
262
  and settles the reported units against the request's lease in the same
228
263
  lifecycle. Wire recipe (any language): [`docs/response-metering-wire.md`](docs/response-metering-wire.md).
264
+ - **Post-stream** (`fs.reportUsage` or the request-scoped
265
+ `context.reportUsage`) is attested and request-bound for streaming routes
266
+ declared with `postStreamBilling: true`. It writes the sole billable row for
267
+ gateway-known plus reported actual units and never mutates real-time
268
+ enforcement windows.
229
269
  - **Background** (`fs.meter`) is a billing-only, unattested, post-cycle tally for
230
270
  usage not tied to a gateway response. It never settles a lease.
231
271
 
@@ -173,14 +173,29 @@ var RUNTIME_METERING_CONTRACT = {
173
173
  backend_id: "string",
174
174
  route_id: "string?",
175
175
  request_id: "string?",
176
+ requestId: "string?",
177
+ subscriptionId: "string",
178
+ nonce: "string?",
176
179
  meter: "string",
177
180
  qty: "number",
178
181
  timestamp: "string"
179
182
  },
183
+ postStreamEvent: {
184
+ requestId: "string",
185
+ subscriptionId: "string?",
186
+ nonce: "string",
187
+ meters: "Record<string, number>",
188
+ creditUnitsConsumed: "Record<string, number>?",
189
+ measureContext: "Record<string, unknown>?",
190
+ signature: "string"
191
+ },
180
192
  idempotencyKey: "event_id",
181
193
  delivery: "at-least-once",
182
194
  billingOnly: true,
183
195
  realtimeEnforced: false,
196
+ postStreamBillingOnly: true,
197
+ postStreamRealtimeEnforced: false,
198
+ postStreamTrustModel: "HMAC-attested and bound to one served postStreamBilling gateway request. Core writes one billable UsageEvent using the served plan and time. The callback never mutates Durable Object enforcement windows.",
184
199
  trustModel: "upstream-reported values are NOT cryptographically attested; a buggy or compromised upstream can self-report arbitrary values for its OWN product only. Core enforces allowedMeters/allowedRoutes from the authoritative token record at ingest, applies a per-event sanity max (perEventMax), and raises an implausible-volume alert."
185
200
  };
186
201
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
package/dist/index.js CHANGED
@@ -899,6 +899,300 @@ function resolveEndpoint(endpoint, coreUrl) {
899
899
  return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
900
900
  }
901
901
 
902
+ // src/response-metering.ts
903
+ var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
904
+ var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
905
+ var devMeteringHooks = null;
906
+ function __setDevMeteringHooks(hooks) {
907
+ devMeteringHooks = hooks;
908
+ }
909
+ var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
910
+ var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
911
+ var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
912
+ var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
913
+ var MeteringError = class extends Error {
914
+ code;
915
+ constructor(code, message) {
916
+ super(message);
917
+ this.name = "MeteringError";
918
+ this.code = code;
919
+ }
920
+ };
921
+ function createUsage(request, options = {}) {
922
+ const usage = {};
923
+ const reporter = {
924
+ report(meter, value) {
925
+ usage[assertMeterKey(meter)] = assertMeterValue(meter, value);
926
+ return reporter;
927
+ },
928
+ async wrap(response, wrapOptions = {}) {
929
+ return signResponse(request, response, usage, options, wrapOptions);
930
+ }
931
+ };
932
+ return reporter;
933
+ }
934
+ async function withUsage(request, response, usage, options = {}) {
935
+ const reporter = createUsage(request, options);
936
+ for (const [meter, value] of Object.entries(usage)) {
937
+ reporter.report(meter, value);
938
+ }
939
+ return reporter.wrap(response);
940
+ }
941
+ async function signResponse(request, response, usage, options, wrapOptions) {
942
+ const payload = buildPayload(request, usage, options, wrapOptions);
943
+ const requestId = request.headers.get("x-fs-request-id") ?? void 0;
944
+ const headers = await computeMeteringHeaders(payload, {
945
+ ...options.token !== void 0 ? { token: options.token } : {},
946
+ ...options.env !== void 0 ? { env: options.env } : {},
947
+ ...requestId ? { requestId } : {},
948
+ onSkip: () => {
949
+ }
950
+ });
951
+ if (Object.keys(headers).length === 0) {
952
+ throw new MeteringError(
953
+ RESPONSE_METERING_ERROR_CODES.missingToken,
954
+ `${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
955
+ );
956
+ }
957
+ const merged = new Headers(response.headers);
958
+ for (const [name, value] of Object.entries(headers)) merged.set(name, value);
959
+ return new Response(response.body, {
960
+ status: response.status,
961
+ statusText: response.statusText,
962
+ headers: merged
963
+ });
964
+ }
965
+ async function computeMeteringHeaders(payload, options = {}) {
966
+ try {
967
+ const token = resolveTokenSoft(options);
968
+ if (!token) {
969
+ skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
970
+ return {};
971
+ }
972
+ const json2 = JSON.stringify(payload);
973
+ const signature = await signPayload(json2, token);
974
+ devMeteringHooks?.record?.(payload, options.requestId);
975
+ return {
976
+ [METERING_PAYLOAD_HEADER]: json2,
977
+ [METERING_SIGNATURE_HEADER]: signature,
978
+ [METERING_TOKEN_HEADER]: token
979
+ };
980
+ } catch (error) {
981
+ skip(error instanceof Error ? error.message : String(error), options);
982
+ return {};
983
+ }
984
+ }
985
+ function skip(reason, options) {
986
+ if (options.onSkip) {
987
+ options.onSkip(reason);
988
+ } else {
989
+ console.warn(`metering headers skipped: ${reason}`);
990
+ }
991
+ devMeteringHooks?.onSkip?.(reason, options.requestId);
992
+ }
993
+ function resolveTokenSoft(options) {
994
+ return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
995
+ }
996
+ function buildPayload(request, usage, options, wrapOptions) {
997
+ const url = new URL(request.url);
998
+ const measureContext = wrapOptions.measureContext ?? options.measureContext;
999
+ const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
1000
+ const operationKey = wrapOptions.operationKey ?? options.operationKey;
1001
+ const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
1002
+ const payload = {
1003
+ method: request.method.toUpperCase(),
1004
+ path: url.pathname,
1005
+ rawDimsUnits: sortUsage(usage),
1006
+ ...measureContext ? { measureContext } : {},
1007
+ ...creditUnitsConsumed ? {
1008
+ creditUnitsConsumed: sortUsage(
1009
+ validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
1010
+ )
1011
+ } : {},
1012
+ ...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
1013
+ ...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
1014
+ };
1015
+ return payload;
1016
+ }
1017
+ function sortUsage(usage) {
1018
+ return Object.fromEntries(
1019
+ Object.entries(usage).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
1020
+ );
1021
+ }
1022
+ function validateUsageMap(usage, label) {
1023
+ return Object.fromEntries(
1024
+ Object.entries(usage).map(([meter, value]) => [
1025
+ assertMeterKey(meter),
1026
+ assertMeterValue(`${label}.${meter}`, value)
1027
+ ])
1028
+ );
1029
+ }
1030
+ function assertMeterKey(meter) {
1031
+ if (!/^[a-z0-9_]{1,64}$/.test(meter)) {
1032
+ throw new MeteringError(
1033
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1034
+ `meter key "${meter}" must be lowercase alphanumeric with underscores`
1035
+ );
1036
+ }
1037
+ return meter;
1038
+ }
1039
+ function assertMeterValue(meter, value) {
1040
+ if (!Number.isFinite(value) || value < 0) {
1041
+ throw new MeteringError(
1042
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1043
+ `meter "${meter}" value must be a non-negative finite number`
1044
+ );
1045
+ }
1046
+ return value;
1047
+ }
1048
+ function assertIdentifier(value) {
1049
+ if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
1050
+ throw new MeteringError(
1051
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1052
+ `operation and usage policy identifiers must be 1-128 URL-safe characters`
1053
+ );
1054
+ }
1055
+ return value;
1056
+ }
1057
+ function processEnv(key2) {
1058
+ const maybeProcess = globalThis.process;
1059
+ return maybeProcess?.env?.[key2];
1060
+ }
1061
+ async function signPayload(payload, token) {
1062
+ const key2 = await crypto.subtle.importKey(
1063
+ "raw",
1064
+ new TextEncoder().encode(token),
1065
+ { name: "HMAC", hash: "SHA-256" },
1066
+ false,
1067
+ ["sign"]
1068
+ );
1069
+ const signature = await crypto.subtle.sign(
1070
+ "HMAC",
1071
+ key2,
1072
+ new TextEncoder().encode(payload)
1073
+ );
1074
+ return base64url(new Uint8Array(signature));
1075
+ }
1076
+ function base64url(bytes) {
1077
+ let binary = "";
1078
+ for (const byte of bytes) {
1079
+ binary += String.fromCharCode(byte);
1080
+ }
1081
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1082
+ }
1083
+
1084
+ // src/core/post-stream-usage.ts
1085
+ var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
1086
+ var PostStreamUsageClient = class {
1087
+ config;
1088
+ endpoint;
1089
+ fetchImpl;
1090
+ newNonce;
1091
+ logger;
1092
+ sleep;
1093
+ retryDelaysMs;
1094
+ constructor(options) {
1095
+ this.config = options.config;
1096
+ this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
1097
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1098
+ this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
1099
+ this.logger = options.logger ?? ((message) => console.warn(message));
1100
+ this.sleep = options.sleep ?? sleep;
1101
+ this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
1102
+ }
1103
+ async reportUsage(input) {
1104
+ try {
1105
+ if (!this.config.enabled) throw new Error("metering is not enabled");
1106
+ if (!input.requestId) throw new Error("requestId is required");
1107
+ if (!input.subscriptionId) throw new Error("subscriptionId is required");
1108
+ const unsigned = {
1109
+ requestId: input.requestId,
1110
+ subscriptionId: input.subscriptionId,
1111
+ nonce: this.newNonce(),
1112
+ meters: validateAndSortUsage(input.meters, "meters", this.config, true),
1113
+ ...input.creditUnitsConsumed ? {
1114
+ creditUnitsConsumed: validateAndSortUsage(
1115
+ input.creditUnitsConsumed,
1116
+ "creditUnitsConsumed",
1117
+ this.config,
1118
+ false
1119
+ )
1120
+ } : {},
1121
+ ...input.measureContext ? { measureContext: input.measureContext } : {}
1122
+ };
1123
+ const signature = await signPayload(
1124
+ JSON.stringify(unsigned),
1125
+ this.config.credential
1126
+ );
1127
+ const event = { ...unsigned, signature };
1128
+ const body = JSON.stringify(event);
1129
+ for (let attempt = 0; ; attempt += 1) {
1130
+ const response = await this.fetchImpl(this.endpoint, {
1131
+ method: "POST",
1132
+ headers: {
1133
+ authorization: `Bearer ${this.config.credential}`,
1134
+ "content-type": "application/json",
1135
+ accept: "application/json"
1136
+ },
1137
+ body
1138
+ });
1139
+ if (response.ok) return { ok: true };
1140
+ const requestNotFound = await isPostStreamRequestNotFound(response);
1141
+ const delayMs = this.retryDelaysMs[attempt];
1142
+ if (!requestNotFound || delayMs === void 0) {
1143
+ throw new Error(`metering endpoint returned ${response.status}`);
1144
+ }
1145
+ await this.sleep(delayMs);
1146
+ }
1147
+ } catch (error) {
1148
+ const reason = error instanceof Error ? error.message : String(error);
1149
+ this.logger(`post-stream usage report skipped: ${reason}`);
1150
+ return { ok: false, reason };
1151
+ }
1152
+ }
1153
+ };
1154
+ async function isPostStreamRequestNotFound(response) {
1155
+ if (response.status !== 422) return false;
1156
+ try {
1157
+ const body = await response.json();
1158
+ return body.error?.code === "post_stream_request_not_found";
1159
+ } catch {
1160
+ return false;
1161
+ }
1162
+ }
1163
+ function sleep(delayMs) {
1164
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
1165
+ }
1166
+ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1167
+ const entries = Object.entries(usage).sort(
1168
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1169
+ );
1170
+ for (const [meter, qty] of entries) {
1171
+ if (!METER_KEY_RE2.test(meter)) {
1172
+ throw new Error(
1173
+ `${label} key '${meter}' must be lowercase alphanumeric with underscores`
1174
+ );
1175
+ }
1176
+ if (!Number.isFinite(qty) || qty < 0) {
1177
+ throw new Error(`${label}.${meter} must be a non-negative finite number`);
1178
+ }
1179
+ if (enforceMeterScope && config.allowedMeters.length > 0 && !config.allowedMeters.includes(meter)) {
1180
+ throw new Error(`meter '${meter}' is not in the token's allowedMeters`);
1181
+ }
1182
+ if (enforceMeterScope && config.perEventMax > 0 && qty > config.perEventMax) {
1183
+ throw new Error(
1184
+ `meter '${meter}' qty ${qty} exceeds the per-event max ${config.perEventMax}`
1185
+ );
1186
+ }
1187
+ }
1188
+ return Object.fromEntries(entries);
1189
+ }
1190
+ function resolveEndpoint2(endpoint, coreUrl) {
1191
+ if (/^https?:\/\//.test(endpoint)) return endpoint;
1192
+ if (!coreUrl) return endpoint;
1193
+ return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1194
+ }
1195
+
902
1196
  // src/core/nonceCache.ts
903
1197
  var DEFAULT_MAX_ENTRIES = 1e5;
904
1198
  var DEFAULT_TTL_MS = 6e5;
@@ -1583,8 +1877,8 @@ function headerGetter(headers) {
1583
1877
 
1584
1878
  // src/core/runtime.ts
1585
1879
  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";
1880
+ var SDK_VERSION = "0.14.0".length > 0 ? "0.14.0" : "0.0.0-dev";
1881
+ var CONTRACTS_FP = "1feb5a4a80b447ad".length > 0 ? "1feb5a4a80b447ad" : "0000000000000000";
1588
1882
  var FartherShore = class {
1589
1883
  bootstrapClient;
1590
1884
  fetchImpl;
@@ -1602,6 +1896,7 @@ var FartherShore = class {
1602
1896
  shutdownManager = new ShutdownManager();
1603
1897
  jwks = null;
1604
1898
  meteringClient = null;
1899
+ postStreamUsageClient = null;
1605
1900
  tunnel = null;
1606
1901
  bootstrapped = false;
1607
1902
  constructor(options = {}) {
@@ -1657,6 +1952,11 @@ var FartherShore = class {
1657
1952
  coreUrl: this.coreUrl,
1658
1953
  fetchImpl: this.fetchImpl
1659
1954
  });
1955
+ this.postStreamUsageClient = new PostStreamUsageClient({
1956
+ config: config.metering,
1957
+ coreUrl: this.coreUrl,
1958
+ fetchImpl: this.fetchImpl
1959
+ });
1660
1960
  }
1661
1961
  this.bootstrapped = true;
1662
1962
  return config;
@@ -1728,7 +2028,7 @@ var FartherShore = class {
1728
2028
  );
1729
2029
  }
1730
2030
  const knownRouteIds = new Set(config.routes.map((r) => r.id));
1731
- return verifyRequest(input, {
2031
+ const context = await verifyRequest(input, {
1732
2032
  jwks: this.jwks,
1733
2033
  nonceCache: this.nonceCache,
1734
2034
  productId: config.product.id,
@@ -1743,6 +2043,23 @@ var FartherShore = class {
1743
2043
  contextSecrets: this.contextSecrets,
1744
2044
  contextVerification: this.contextVerification
1745
2045
  });
2046
+ return {
2047
+ ...context,
2048
+ reportUsage: (report) => {
2049
+ const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
2050
+ if (!subscriptionId) {
2051
+ return Promise.resolve({
2052
+ ok: false,
2053
+ reason: "subscriptionId is required"
2054
+ });
2055
+ }
2056
+ return this.reportUsage({
2057
+ ...report,
2058
+ requestId: report.requestId ?? context.requestId,
2059
+ subscriptionId
2060
+ });
2061
+ }
2062
+ };
1746
2063
  }
1747
2064
  /** Whether verification is required (bootstrap × opt-out). */
1748
2065
  async verificationRequired() {
@@ -1803,6 +2120,20 @@ var FartherShore = class {
1803
2120
  }
1804
2121
  await this.meteringClient.meter(meter, qty, options);
1805
2122
  }
2123
+ /** Best-effort attested post-stream usage callback. Never rejects. */
2124
+ async reportUsage(input) {
2125
+ try {
2126
+ await this.ensureBootstrapped();
2127
+ if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
2128
+ return { ok: false, reason: "metering is not enabled" };
2129
+ }
2130
+ return await this.postStreamUsageClient.reportUsage(input);
2131
+ } catch (error) {
2132
+ const reason = error instanceof Error ? error.message : String(error);
2133
+ console.warn(`post-stream usage report skipped: ${reason}`);
2134
+ return { ok: false, reason };
2135
+ }
2136
+ }
1806
2137
  /** Current local health report. */
1807
2138
  health() {
1808
2139
  const config = this.bootstrapClient.peek();
@@ -1903,188 +2234,6 @@ function headerValue(headers, name) {
1903
2234
  return value;
1904
2235
  }
1905
2236
 
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
2237
  // src/testing/signers.ts
2089
2238
  import { generateKeyPairSync, randomBytes } from "node:crypto";
2090
2239
  var TEST_KID = "fs-runtime-test-2026";
@@ -2345,6 +2494,7 @@ function createDevGateway(options) {
2345
2494
  const productId = options.productId ?? "prod_dev";
2346
2495
  const backendId = options.backendId ?? "be_dev";
2347
2496
  const meterEvents = [];
2497
+ const reportUsageEvents = [];
2348
2498
  const bootstrap = {
2349
2499
  product: { id: productId, slug: options.productSlug ?? "dev-product" },
2350
2500
  backend: {
@@ -2394,7 +2544,10 @@ function createDevGateway(options) {
2394
2544
  }
2395
2545
  if (url.includes("/v1/metering/events")) {
2396
2546
  const event = await readJsonBody(init, input);
2397
- if (event) {
2547
+ if (event && "meters" in event) {
2548
+ reportUsageEvents.push(event);
2549
+ options.onReportUsage?.(event);
2550
+ } else if (event) {
2398
2551
  meterEvents.push(event);
2399
2552
  options.onMeterEvent?.(event);
2400
2553
  }
@@ -2412,6 +2565,7 @@ function createDevGateway(options) {
2412
2565
  fetchImpl,
2413
2566
  bootstrap,
2414
2567
  meterEvents,
2568
+ reportUsageEvents,
2415
2569
  productId,
2416
2570
  backendId,
2417
2571
  jwksUrl: DEV_JWKS_URL
@@ -2488,6 +2642,16 @@ var DevUsageSink = class {
2488
2642
  at: Date.now()
2489
2643
  });
2490
2644
  }
2645
+ /** Record an attested post-stream report captured by the dev gateway. */
2646
+ recordReportUsage(event) {
2647
+ this.events.push({
2648
+ source: "reportUsage",
2649
+ meters: { ...event.meters },
2650
+ event,
2651
+ requestId: event.requestId,
2652
+ at: Date.now()
2653
+ });
2654
+ }
2491
2655
  /** Total quantity per meter key across every recorded event. */
2492
2656
  byMeter() {
2493
2657
  const out = {};
@@ -2610,6 +2774,10 @@ function createDevRuntime(options) {
2610
2774
  onMeterEvent: (event) => {
2611
2775
  usage.recordMeterEvent(event);
2612
2776
  options.usageJsonl?.(JSON.stringify({ source: "meter", event }));
2777
+ },
2778
+ onReportUsage: (event) => {
2779
+ usage.recordReportUsage(event);
2780
+ options.usageJsonl?.(JSON.stringify({ source: "reportUsage", event }));
2613
2781
  }
2614
2782
  });
2615
2783
  __setDevMeteringHooks({
@@ -2858,6 +3026,7 @@ export {
2858
3026
  MeteringClient,
2859
3027
  MeteringError,
2860
3028
  NonceCache,
3029
+ PostStreamUsageClient,
2861
3030
  REDACTED_TOKEN,
2862
3031
  RUNTIME_CLOCK_SKEW_SECONDS,
2863
3032
  RUNTIME_ERROR_CODES,
@@ -864,6 +864,7 @@ function createDevGateway(options) {
864
864
  const productId = options.productId ?? "prod_dev";
865
865
  const backendId = options.backendId ?? "be_dev";
866
866
  const meterEvents = [];
867
+ const reportUsageEvents = [];
867
868
  const bootstrap = {
868
869
  product: { id: productId, slug: options.productSlug ?? "dev-product" },
869
870
  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,6 +935,7 @@ function createDevGateway(options) {
931
935
  fetchImpl,
932
936
  bootstrap,
933
937
  meterEvents,
938
+ reportUsageEvents,
934
939
  productId,
935
940
  backendId,
936
941
  jwksUrl: DEV_JWKS_URL
@@ -1269,6 +1274,152 @@ function resolveEndpoint(endpoint, coreUrl) {
1269
1274
  return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1270
1275
  }
1271
1276
 
1277
+ // src/response-metering.ts
1278
+ var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
1279
+ var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
1280
+ var devMeteringHooks = null;
1281
+ function __setDevMeteringHooks(hooks) {
1282
+ devMeteringHooks = hooks;
1283
+ }
1284
+ var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
1285
+ var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
1286
+ var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
1287
+ var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
1288
+ async function signPayload(payload, token) {
1289
+ const key2 = await crypto.subtle.importKey(
1290
+ "raw",
1291
+ new TextEncoder().encode(token),
1292
+ { name: "HMAC", hash: "SHA-256" },
1293
+ false,
1294
+ ["sign"]
1295
+ );
1296
+ const signature = await crypto.subtle.sign(
1297
+ "HMAC",
1298
+ key2,
1299
+ new TextEncoder().encode(payload)
1300
+ );
1301
+ return base64url(new Uint8Array(signature));
1302
+ }
1303
+ function base64url(bytes) {
1304
+ let binary = "";
1305
+ for (const byte of bytes) {
1306
+ binary += String.fromCharCode(byte);
1307
+ }
1308
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1309
+ }
1310
+
1311
+ // src/core/post-stream-usage.ts
1312
+ var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
1313
+ var PostStreamUsageClient = class {
1314
+ config;
1315
+ endpoint;
1316
+ fetchImpl;
1317
+ newNonce;
1318
+ logger;
1319
+ sleep;
1320
+ retryDelaysMs;
1321
+ constructor(options) {
1322
+ this.config = options.config;
1323
+ this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
1324
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1325
+ this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
1326
+ this.logger = options.logger ?? ((message) => console.warn(message));
1327
+ this.sleep = options.sleep ?? sleep;
1328
+ this.retryDelaysMs = options.retryDelaysMs ?? [100, 250, 500];
1329
+ }
1330
+ async reportUsage(input) {
1331
+ try {
1332
+ if (!this.config.enabled) throw new Error("metering is not enabled");
1333
+ if (!input.requestId) throw new Error("requestId is required");
1334
+ if (!input.subscriptionId) throw new Error("subscriptionId is required");
1335
+ const unsigned = {
1336
+ requestId: input.requestId,
1337
+ subscriptionId: input.subscriptionId,
1338
+ nonce: this.newNonce(),
1339
+ meters: validateAndSortUsage(input.meters, "meters", this.config, true),
1340
+ ...input.creditUnitsConsumed ? {
1341
+ creditUnitsConsumed: validateAndSortUsage(
1342
+ input.creditUnitsConsumed,
1343
+ "creditUnitsConsumed",
1344
+ this.config,
1345
+ false
1346
+ )
1347
+ } : {},
1348
+ ...input.measureContext ? { measureContext: input.measureContext } : {}
1349
+ };
1350
+ const signature = await signPayload(
1351
+ JSON.stringify(unsigned),
1352
+ this.config.credential
1353
+ );
1354
+ const event = { ...unsigned, signature };
1355
+ const body = JSON.stringify(event);
1356
+ for (let attempt = 0; ; attempt += 1) {
1357
+ const response = await this.fetchImpl(this.endpoint, {
1358
+ method: "POST",
1359
+ headers: {
1360
+ authorization: `Bearer ${this.config.credential}`,
1361
+ "content-type": "application/json",
1362
+ accept: "application/json"
1363
+ },
1364
+ body
1365
+ });
1366
+ if (response.ok) return { ok: true };
1367
+ const requestNotFound = await isPostStreamRequestNotFound(response);
1368
+ const delayMs = this.retryDelaysMs[attempt];
1369
+ if (!requestNotFound || delayMs === void 0) {
1370
+ throw new Error(`metering endpoint returned ${response.status}`);
1371
+ }
1372
+ await this.sleep(delayMs);
1373
+ }
1374
+ } catch (error) {
1375
+ const reason = error instanceof Error ? error.message : String(error);
1376
+ this.logger(`post-stream usage report skipped: ${reason}`);
1377
+ return { ok: false, reason };
1378
+ }
1379
+ }
1380
+ };
1381
+ async function isPostStreamRequestNotFound(response) {
1382
+ if (response.status !== 422) return false;
1383
+ try {
1384
+ const body = await response.json();
1385
+ return body.error?.code === "post_stream_request_not_found";
1386
+ } catch {
1387
+ return false;
1388
+ }
1389
+ }
1390
+ function sleep(delayMs) {
1391
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
1392
+ }
1393
+ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1394
+ const entries = Object.entries(usage).sort(
1395
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1396
+ );
1397
+ for (const [meter, qty] of entries) {
1398
+ if (!METER_KEY_RE2.test(meter)) {
1399
+ throw new Error(
1400
+ `${label} key '${meter}' must be lowercase alphanumeric with underscores`
1401
+ );
1402
+ }
1403
+ if (!Number.isFinite(qty) || qty < 0) {
1404
+ throw new Error(`${label}.${meter} must be a non-negative finite number`);
1405
+ }
1406
+ if (enforceMeterScope && config.allowedMeters.length > 0 && !config.allowedMeters.includes(meter)) {
1407
+ throw new Error(`meter '${meter}' is not in the token's allowedMeters`);
1408
+ }
1409
+ if (enforceMeterScope && config.perEventMax > 0 && qty > config.perEventMax) {
1410
+ throw new Error(
1411
+ `meter '${meter}' qty ${qty} exceeds the per-event max ${config.perEventMax}`
1412
+ );
1413
+ }
1414
+ }
1415
+ return Object.fromEntries(entries);
1416
+ }
1417
+ function resolveEndpoint2(endpoint, coreUrl) {
1418
+ if (/^https?:\/\//.test(endpoint)) return endpoint;
1419
+ if (!coreUrl) return endpoint;
1420
+ return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1421
+ }
1422
+
1272
1423
  // src/core/nonceCache.ts
1273
1424
  var DEFAULT_MAX_ENTRIES = 1e5;
1274
1425
  var DEFAULT_TTL_MS = 6e5;
@@ -1947,8 +2098,8 @@ function headerGetter(headers) {
1947
2098
 
1948
2099
  // src/core/runtime.ts
1949
2100
  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";
2101
+ var SDK_VERSION = "0.14.0".length > 0 ? "0.14.0" : "0.0.0-dev";
2102
+ var CONTRACTS_FP = "1feb5a4a80b447ad".length > 0 ? "1feb5a4a80b447ad" : "0000000000000000";
1952
2103
  var FartherShore = class {
1953
2104
  bootstrapClient;
1954
2105
  fetchImpl;
@@ -1966,6 +2117,7 @@ var FartherShore = class {
1966
2117
  shutdownManager = new ShutdownManager();
1967
2118
  jwks = null;
1968
2119
  meteringClient = null;
2120
+ postStreamUsageClient = null;
1969
2121
  tunnel = null;
1970
2122
  bootstrapped = false;
1971
2123
  constructor(options = {}) {
@@ -2021,6 +2173,11 @@ var FartherShore = class {
2021
2173
  coreUrl: this.coreUrl,
2022
2174
  fetchImpl: this.fetchImpl
2023
2175
  });
2176
+ this.postStreamUsageClient = new PostStreamUsageClient({
2177
+ config: config.metering,
2178
+ coreUrl: this.coreUrl,
2179
+ fetchImpl: this.fetchImpl
2180
+ });
2024
2181
  }
2025
2182
  this.bootstrapped = true;
2026
2183
  return config;
@@ -2092,7 +2249,7 @@ var FartherShore = class {
2092
2249
  );
2093
2250
  }
2094
2251
  const knownRouteIds = new Set(config.routes.map((r) => r.id));
2095
- return verifyRequest(input, {
2252
+ const context = await verifyRequest(input, {
2096
2253
  jwks: this.jwks,
2097
2254
  nonceCache: this.nonceCache,
2098
2255
  productId: config.product.id,
@@ -2107,6 +2264,23 @@ var FartherShore = class {
2107
2264
  contextSecrets: this.contextSecrets,
2108
2265
  contextVerification: this.contextVerification
2109
2266
  });
2267
+ return {
2268
+ ...context,
2269
+ reportUsage: (report) => {
2270
+ const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
2271
+ if (!subscriptionId) {
2272
+ return Promise.resolve({
2273
+ ok: false,
2274
+ reason: "subscriptionId is required"
2275
+ });
2276
+ }
2277
+ return this.reportUsage({
2278
+ ...report,
2279
+ requestId: report.requestId ?? context.requestId,
2280
+ subscriptionId
2281
+ });
2282
+ }
2283
+ };
2110
2284
  }
2111
2285
  /** Whether verification is required (bootstrap × opt-out). */
2112
2286
  async verificationRequired() {
@@ -2167,6 +2341,20 @@ var FartherShore = class {
2167
2341
  }
2168
2342
  await this.meteringClient.meter(meter, qty, options);
2169
2343
  }
2344
+ /** Best-effort attested post-stream usage callback. Never rejects. */
2345
+ async reportUsage(input) {
2346
+ try {
2347
+ await this.ensureBootstrapped();
2348
+ if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
2349
+ return { ok: false, reason: "metering is not enabled" };
2350
+ }
2351
+ return await this.postStreamUsageClient.reportUsage(input);
2352
+ } catch (error) {
2353
+ const reason = error instanceof Error ? error.message : String(error);
2354
+ console.warn(`post-stream usage report skipped: ${reason}`);
2355
+ return { ok: false, reason };
2356
+ }
2357
+ }
2170
2358
  /** Current local health report. */
2171
2359
  health() {
2172
2360
  const config = this.bootstrapClient.peek();
@@ -2267,18 +2455,6 @@ function headerValue(headers, name) {
2267
2455
  return value;
2268
2456
  }
2269
2457
 
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
2458
  // src/testing/prodGuard.ts
2283
2459
  function isProductionEnv(env = readProcessEnv2()) {
2284
2460
  return (env.NODE_ENV ?? "").trim().toLowerCase() === "production";
@@ -2326,6 +2502,16 @@ var DevUsageSink = class {
2326
2502
  at: Date.now()
2327
2503
  });
2328
2504
  }
2505
+ /** Record an attested post-stream report captured by the dev gateway. */
2506
+ recordReportUsage(event) {
2507
+ this.events.push({
2508
+ source: "reportUsage",
2509
+ meters: { ...event.meters },
2510
+ event,
2511
+ requestId: event.requestId,
2512
+ at: Date.now()
2513
+ });
2514
+ }
2329
2515
  /** Total quantity per meter key across every recorded event. */
2330
2516
  byMeter() {
2331
2517
  const out = {};
@@ -2470,6 +2656,10 @@ function createDevRuntime(options) {
2470
2656
  onMeterEvent: (event) => {
2471
2657
  usage.recordMeterEvent(event);
2472
2658
  options.usageJsonl?.(JSON.stringify({ source: "meter", event }));
2659
+ },
2660
+ onReportUsage: (event) => {
2661
+ usage.recordReportUsage(event);
2662
+ options.usageJsonl?.(JSON.stringify({ source: "reportUsage", event }));
2473
2663
  }
2474
2664
  });
2475
2665
  __setDevMeteringHooks({
@@ -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. */
@@ -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. */
@@ -1,3 +1,4 @@
1
+ import type { ReportUsageResult, RequestScopedReportUsageInput } from "./post-stream-usage.js";
1
2
  import { type FartherShoreSignedContext } from "./verifyContext.js";
2
3
  import type { JwksClient } from "./jwks.js";
3
4
  import type { NonceCache } from "./nonceCache.js";
@@ -48,6 +49,8 @@ export type FartherShoreRequestContext = {
48
49
  signedContext?: FartherShoreSignedContext;
49
50
  /** Managed-RBAC role keys the acting user holds (display/audit only). */
50
51
  roles?: string[];
52
+ /** Request-bound post-stream reporter, attached by the runtime facade. */
53
+ reportUsage?: (input: RequestScopedReportUsageInput) => Promise<ReportUsageResult>;
51
54
  };
52
55
  export type VerifyRequestDeps = {
53
56
  jwks: JwksClient;
@@ -149,14 +149,29 @@ export declare const RUNTIME_METERING_CONTRACT: {
149
149
  readonly backend_id: "string";
150
150
  readonly route_id: "string?";
151
151
  readonly request_id: "string?";
152
+ readonly requestId: "string?";
153
+ readonly subscriptionId: "string";
154
+ readonly nonce: "string?";
152
155
  readonly meter: "string";
153
156
  readonly qty: "number";
154
157
  readonly timestamp: "string";
155
158
  };
159
+ readonly postStreamEvent: {
160
+ readonly requestId: "string";
161
+ readonly subscriptionId: "string?";
162
+ readonly nonce: "string";
163
+ readonly meters: "Record<string, number>";
164
+ readonly creditUnitsConsumed: "Record<string, number>?";
165
+ readonly measureContext: "Record<string, unknown>?";
166
+ readonly signature: "string";
167
+ };
156
168
  readonly idempotencyKey: "event_id";
157
169
  readonly delivery: "at-least-once";
158
170
  readonly billingOnly: true;
159
171
  readonly realtimeEnforced: false;
172
+ readonly postStreamBillingOnly: true;
173
+ readonly postStreamRealtimeEnforced: false;
174
+ readonly postStreamTrustModel: "HMAC-attested and bound to one served postStreamBilling gateway request. Core writes one billable UsageEvent using the served plan and time. The callback never mutates Durable Object enforcement windows.";
160
175
  readonly trustModel: "upstream-reported values are NOT cryptographically attested; a buggy or compromised upstream can self-report arbitrary values for its OWN product only. Core enforces allowedMeters/allowedRoutes from the authoritative token record at ingest, applies a per-event sanity max (perEventMax), and raises an implausible-volume alert.";
161
176
  };
162
177
  export declare const RUNTIME_RESPONSE_METERING_CONTRACT: {
@@ -11,6 +11,7 @@ export { JwksClient, type Jwk, type JwksClientOptions } from "./core/jwks.js";
11
11
  export { NonceCache, type NonceCacheOptions } from "./core/nonceCache.js";
12
12
  export { BootstrapClient, type BootstrapClientOptions, } from "./core/bootstrap.js";
13
13
  export { MeteringClient, type MeteringClientOptions, type MeterOptions, } from "./core/metering.js";
14
+ export { PostStreamUsageClient, type PostStreamUsageClientOptions, type ReportUsageInput, type RequestScopedReportUsageInput, type ReportUsageResult, } from "./core/post-stream-usage.js";
14
15
  export { buildHealthReport, reportHealth, type HealthSnapshot, type HealthStatus, type HeartbeatOptions, } from "./core/health.js";
15
16
  export { ShutdownManager, type ShutdownHook } from "./core/shutdown.js";
16
17
  export { CloudflaredSupervisor, nodeSpawn, REDACTED_TOKEN, type SpawnFn, type SpawnedTunnelProcess, type CloudflaredSupervisorOptions, type TunnelState, type TunnelStatus, } from "./core/tunnel.js";
@@ -255,6 +255,16 @@ export type RuntimeMeteringEvent = {
255
255
  qty: number;
256
256
  timestamp: string;
257
257
  };
258
+ /** Attested, request-bound billing callback sent after a stream completes. */
259
+ export type RuntimePostStreamUsageEvent = {
260
+ requestId: string;
261
+ subscriptionId: string;
262
+ nonce: string;
263
+ meters: Record<string, number>;
264
+ creditUnitsConsumed?: Record<string, number>;
265
+ measureContext?: Record<string, unknown>;
266
+ signature: string;
267
+ };
258
268
  export declare const RUNTIME_READINESS_STATES: readonly ["UNKNOWN", "WAITING", "READY", "DEGRADED", "OFFLINE"];
259
269
  export type RuntimeReadinessState = (typeof RUNTIME_READINESS_STATES)[number];
260
270
  export type RuntimeHealthReport = {
@@ -1,4 +1,4 @@
1
- import { type RuntimeBootstrapResponse, type RuntimeMeteringEvent } from "../runtime-types.js";
1
+ import { type RuntimeBootstrapResponse, type RuntimeMeteringEvent, type RuntimePostStreamUsageEvent } from "../runtime-types.js";
2
2
  import type { DevMode } from "./traceSink.js";
3
3
  import type { DevSignerKeys } from "./signers.js";
4
4
  export declare const DEV_CORE_URL = "https://dev-gateway.farthershore.local";
@@ -15,6 +15,8 @@ export type DevGatewayOptions = {
15
15
  routeIds?: string[];
16
16
  /** Called for each captured metering event (at-least-once ACK). */
17
17
  onMeterEvent?: (event: RuntimeMeteringEvent) => void;
18
+ /** Called for each captured attested post-stream report. */
19
+ onReportUsage?: (event: RuntimePostStreamUsageEvent) => void;
18
20
  };
19
21
  export type DevGateway = {
20
22
  /** Inject this as `fetchImpl` when constructing the FartherShore runtime. */
@@ -23,6 +25,8 @@ export type DevGateway = {
23
25
  bootstrap: RuntimeBootstrapResponse;
24
26
  /** Every captured background metering event (a capture == an ACK). */
25
27
  meterEvents: RuntimeMeteringEvent[];
28
+ /** Every captured attested post-stream usage report. */
29
+ reportUsageEvents: RuntimePostStreamUsageEvent[];
26
30
  productId: string;
27
31
  backendId: string;
28
32
  jwksUrl: string;
@@ -1,4 +1,4 @@
1
- import type { RuntimeMeteringEvent } from "../runtime-types.js";
1
+ import type { RuntimeMeteringEvent, RuntimePostStreamUsageEvent } from "../runtime-types.js";
2
2
  /** One recorded usage observation. `source` distinguishes the two channels. */
3
3
  export type DevUsageEvent = {
4
4
  source: "response";
@@ -16,6 +16,12 @@ export type DevUsageEvent = {
16
16
  event: RuntimeMeteringEvent;
17
17
  requestId?: string;
18
18
  at: number;
19
+ } | {
20
+ source: "reportUsage";
21
+ meters: Record<string, number>;
22
+ event: RuntimePostStreamUsageEvent;
23
+ requestId: string;
24
+ at: number;
19
25
  };
20
26
  /** In-memory, assertable sink for all dev usage. */
21
27
  export declare class DevUsageSink {
@@ -24,6 +30,8 @@ export declare class DevUsageSink {
24
30
  recordResponse(payload: Record<string, unknown>, requestId?: string): void;
25
31
  /** Record a background `fs.meter()` event captured by the dev gateway. */
26
32
  recordMeterEvent(event: RuntimeMeteringEvent): void;
33
+ /** Record an attested post-stream report captured by the dev gateway. */
34
+ recordReportUsage(event: RuntimePostStreamUsageEvent): void;
27
35
  /** Total quantity per meter key across every recorded event. */
28
36
  byMeter(): Record<string, number>;
29
37
  /** Clear all recorded usage. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farthershore/backend",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Farther Shore backend SDK for builder upstreams: signed response usage, fail-closed gateway request verification, health, and lifecycle from FS_RUNTIME_TOKEN",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "optionalDependencies": {
39
39
  "@farthershore/cloudflared-linux-x64": "0.0.0",
40
- "@farthershore/cloudflared-darwin-arm64": "0.0.0",
41
40
  "@farthershore/cloudflared-linux-arm64": "0.0.0",
41
+ "@farthershore/cloudflared-darwin-arm64": "0.0.0",
42
42
  "@farthershore/cloudflared-darwin-x64": "0.0.0"
43
43
  },
44
44
  "peerDependencies": {