@farthershore/backend 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -191,20 +191,6 @@ var init_reconcile = __esm({
191
191
  });
192
192
 
193
193
  // src/generated/runtime-contract.ts
194
- var RUNTIME_BODY_HASH_CONTRACT = {
195
- algorithm: "SHA-256",
196
- encoding: "hex-lower",
197
- source: "raw-request-bytes",
198
- emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
199
- maxBodyBytes: 10485760,
200
- streamingExemptToken: "STREAM",
201
- streamingExemptContentTypes: [
202
- "text/event-stream",
203
- "application/octet-stream",
204
- "multipart/form-data"
205
- ],
206
- overMaxStatus: 413
207
- };
208
194
  var RUNTIME_ERROR_CODES = {
209
195
  missingSignature: "missing_signature",
210
196
  malformedSignature: "malformed_signature",
@@ -225,6 +211,20 @@ var RUNTIME_ERROR_CODES = {
225
211
  serviceSubjectRequired: "service_subject_required",
226
212
  surfaceNotAllowed: "surface_not_allowed"
227
213
  };
214
+ var RUNTIME_BODY_HASH_CONTRACT = {
215
+ algorithm: "SHA-256",
216
+ encoding: "hex-lower",
217
+ source: "raw-request-bytes",
218
+ emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
219
+ maxBodyBytes: 10485760,
220
+ streamingExemptToken: "STREAM",
221
+ streamingExemptContentTypes: [
222
+ "text/event-stream",
223
+ "application/octet-stream",
224
+ "multipart/form-data"
225
+ ],
226
+ overMaxStatus: 413
227
+ };
228
228
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
229
229
  headers: {
230
230
  payload: "x-fs-metering",
@@ -245,14 +245,18 @@ var RUNTIME_RESPONSE_METERING_CONTRACT = {
245
245
  payload: {
246
246
  method: "string",
247
247
  path: "string",
248
- rawDimsUnits: "Record<string, number>",
248
+ rawDimsUnits: "Record<string, number>?",
249
249
  measureContext: "Record<string, unknown>?",
250
- creditUnitsConsumed: "Record<string, number>?"
250
+ creditUnitsConsumed: "Record<string, number>?",
251
+ measurementsVersion: "1?",
252
+ measurements: "Array<{ meter: string; values: Record<string, number>; dims?: Record<string, string> }>?",
253
+ quote: "{ currency: string; amountNanos: string }?"
251
254
  },
252
255
  errors: {
253
256
  missingToken: "missing_token",
254
257
  invalidMeterKey: "invalid_meter_key",
255
- invalidMeterValue: "invalid_meter_value"
258
+ invalidMeterValue: "invalid_meter_value",
259
+ invalidQuote: "invalid_quote"
256
260
  },
257
261
  httpAdapter: {
258
262
  input: "Request",
@@ -902,185 +906,6 @@ function stringifyCause(cause) {
902
906
  return String(cause);
903
907
  }
904
908
 
905
- // src/core/backoff.ts
906
- function computeBackoff(attempt, options) {
907
- const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
908
- const exponent = Math.max(0, attempt - 1);
909
- const cap = Math.min(baseMs * 2 ** exponent, maxMs);
910
- switch (jitter) {
911
- case "none":
912
- return cap;
913
- case "full":
914
- return random() * cap;
915
- case "equal":
916
- default:
917
- return cap / 2 + random() * (cap / 2);
918
- }
919
- }
920
-
921
- // src/core/metering.ts
922
- var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
923
- var DEFAULT_BASE_DELAY_MS = 200;
924
- var DEFAULT_MAX_DELAY_MS = 1e4;
925
- function isTransientStatus(status) {
926
- return status === 429 || status >= 500;
927
- }
928
- function retryAfterMs(headers) {
929
- const raw = headers.get("retry-after");
930
- if (raw === null) return null;
931
- const trimmed = raw.trim();
932
- if (!/^\d+$/.test(trimmed)) return null;
933
- const secs = Number(trimmed);
934
- return Number.isFinite(secs) ? secs * 1e3 : null;
935
- }
936
- var DEFAULT_MAX_RETRIES = 3;
937
- var MeteringClient = class {
938
- config;
939
- endpoint;
940
- businessId;
941
- backendId;
942
- fetchImpl;
943
- maxRetries;
944
- baseDelayMs;
945
- maxDelayMs;
946
- sleep;
947
- random;
948
- newId;
949
- now;
950
- buffer = [];
951
- constructor(options) {
952
- this.config = options.config;
953
- this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
954
- this.businessId = options.businessId;
955
- this.backendId = options.backendId;
956
- this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
957
- this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
958
- this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
959
- this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
960
- this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
961
- this.random = options.random ?? Math.random;
962
- this.newId = options.newId ?? (() => crypto.randomUUID());
963
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
964
- }
965
- /**
966
- * Record `qty` of `meter`. Enforces meter-key shape, non-negative finite qty,
967
- * the bootstrap allowedMeters/allowedRoutes scope, and the per-event sanity
968
- * max, then enqueues and flushes (best-effort; failures stay buffered).
969
- */
970
- async meter(meter, qty, options = {}) {
971
- if (!this.config.enabled) {
972
- throw new FartherShoreError(
973
- "invalid_token",
974
- "metering is not enabled for this runtime token"
975
- );
976
- }
977
- if (!METER_KEY_RE.test(meter)) {
978
- throw new FartherShoreError(
979
- "invalid_token",
980
- `meter key '${meter}' must be lowercase alphanumeric with underscores`
981
- );
982
- }
983
- if (!Number.isFinite(qty) || qty < 0) {
984
- throw new FartherShoreError(
985
- "invalid_token",
986
- `meter '${meter}' qty must be a non-negative finite number`
987
- );
988
- }
989
- if (this.config.allowedMeters.length > 0 && !this.config.allowedMeters.includes(meter)) {
990
- throw new FartherShoreError(
991
- "invalid_token",
992
- `meter '${meter}' is not in the token's allowedMeters`
993
- );
994
- }
995
- if (this.config.allowedRoutes.length > 0) {
996
- if (!options.routeId) {
997
- throw new FartherShoreError(
998
- "invalid_token",
999
- "routeId is required because this runtime token is route-scoped"
1000
- );
1001
- }
1002
- if (!this.config.allowedRoutes.includes(options.routeId)) {
1003
- throw new FartherShoreError(
1004
- "invalid_token",
1005
- `route '${options.routeId}' is not in the token's allowedRoutes`
1006
- );
1007
- }
1008
- }
1009
- if (this.config.perEventMax > 0 && qty > this.config.perEventMax) {
1010
- throw new FartherShoreError(
1011
- "invalid_token",
1012
- `meter '${meter}' qty ${qty} exceeds the per-event max ${this.config.perEventMax}`
1013
- );
1014
- }
1015
- const event = {
1016
- event_id: options.eventId ?? this.newId(),
1017
- business_id: this.businessId,
1018
- backend_id: this.backendId,
1019
- meter,
1020
- qty,
1021
- timestamp: options.timestamp ?? this.now().toISOString(),
1022
- ...options.routeId ? { route_id: options.routeId } : {},
1023
- ...options.requestId ? { request_id: options.requestId } : {},
1024
- ...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
1025
- };
1026
- this.buffer.push(event);
1027
- await this.flush();
1028
- }
1029
- /** Drain the buffer. Events that fail all retries stay buffered (at-least-once). */
1030
- async flush() {
1031
- const pending = this.buffer.splice(0, this.buffer.length);
1032
- const stillPending = [];
1033
- for (const event of pending) {
1034
- const sent = await this.sendWithRetry(event);
1035
- if (!sent) stillPending.push(event);
1036
- }
1037
- if (stillPending.length > 0) this.buffer.unshift(...stillPending);
1038
- }
1039
- /** Buffered-but-unsent count (observability/tests). */
1040
- get pending() {
1041
- return this.buffer.length;
1042
- }
1043
- async sendWithRetry(event) {
1044
- for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
1045
- let retryAfter = null;
1046
- try {
1047
- const response = await fetchWithDeadline(
1048
- this.fetchImpl,
1049
- this.endpoint,
1050
- {
1051
- method: "POST",
1052
- headers: {
1053
- authorization: `Bearer ${this.config.credential}`,
1054
- "content-type": "application/json",
1055
- accept: "application/json"
1056
- },
1057
- body: JSON.stringify(event)
1058
- },
1059
- "metering"
1060
- );
1061
- if (response.ok) return true;
1062
- if (!isTransientStatus(response.status)) return false;
1063
- retryAfter = retryAfterMs(response.headers);
1064
- } catch {
1065
- }
1066
- const isLast = attempt === this.maxRetries - 1;
1067
- if (isLast) break;
1068
- const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
1069
- baseMs: this.baseDelayMs,
1070
- maxMs: this.maxDelayMs,
1071
- random: this.random
1072
- });
1073
- await this.sleep(delay);
1074
- }
1075
- return false;
1076
- }
1077
- };
1078
- function resolveEndpoint(endpoint, coreUrl) {
1079
- if (/^https?:\/\//.test(endpoint)) return endpoint;
1080
- if (!coreUrl) return endpoint;
1081
- return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1082
- }
1083
-
1084
909
  // src/response-metering.ts
1085
910
  var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
1086
911
  var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
@@ -1100,50 +925,6 @@ var MeteringError = class extends Error {
1100
925
  this.code = code;
1101
926
  }
1102
927
  };
1103
- function createUsage(request, options = {}) {
1104
- const usage = {};
1105
- const reporter = {
1106
- report(meter, value) {
1107
- usage[assertMeterKey(meter)] = assertMeterValue(meter, value);
1108
- return reporter;
1109
- },
1110
- async wrap(response, wrapOptions = {}) {
1111
- return signResponse(request, response, usage, options, wrapOptions);
1112
- }
1113
- };
1114
- return reporter;
1115
- }
1116
- async function withUsage(request, response, usage, options = {}) {
1117
- const reporter = createUsage(request, options);
1118
- for (const [meter, value] of Object.entries(usage)) {
1119
- reporter.report(meter, value);
1120
- }
1121
- return reporter.wrap(response);
1122
- }
1123
- async function signResponse(request, response, usage, options, wrapOptions) {
1124
- const payload = buildPayload(request, usage, options, wrapOptions);
1125
- const requestId = options.requestId ?? request.headers.get("x-fs-request-id") ?? void 0;
1126
- const headers = await computeMeteringHeaders(payload, {
1127
- ...options.token !== void 0 ? { token: options.token } : {},
1128
- ...options.env !== void 0 ? { env: options.env } : {},
1129
- ...requestId ? { requestId } : {},
1130
- onSkip: () => {
1131
- }
1132
- });
1133
- if (Object.keys(headers).length === 0) {
1134
- throw new MeteringError(
1135
- RESPONSE_METERING_ERROR_CODES.missingToken,
1136
- `${DEFAULT_TOKEN_ENV} is required to sign Farther Shore metering reports`
1137
- );
1138
- }
1139
- const merged = new Headers(response.headers);
1140
- for (const [name, value] of Object.entries(headers)) merged.set(name, value);
1141
- return new Response(response.body, {
1142
- status: response.status,
1143
- statusText: response.statusText,
1144
- headers: merged
1145
- });
1146
- }
1147
928
  async function computeMeteringHeaders(payload, options = {}) {
1148
929
  try {
1149
930
  const token = resolveTokenSoft(options);
@@ -1175,67 +956,6 @@ function skip(reason, options) {
1175
956
  function resolveTokenSoft(options) {
1176
957
  return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
1177
958
  }
1178
- function buildPayload(request, usage, options, wrapOptions) {
1179
- const url = new URL(request.url);
1180
- const measureContext = wrapOptions.measureContext ?? options.measureContext;
1181
- const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
1182
- const operationKey = wrapOptions.operationKey ?? options.operationKey;
1183
- const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
1184
- const payload = {
1185
- method: request.method.toUpperCase(),
1186
- path: url.pathname,
1187
- rawDimsUnits: sortUsage(usage),
1188
- ...measureContext ? { measureContext } : {},
1189
- ...creditUnitsConsumed ? {
1190
- creditUnitsConsumed: sortUsage(
1191
- validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
1192
- )
1193
- } : {},
1194
- ...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
1195
- ...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
1196
- };
1197
- return payload;
1198
- }
1199
- function sortUsage(usage) {
1200
- return Object.fromEntries(
1201
- Object.entries(usage).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
1202
- );
1203
- }
1204
- function validateUsageMap(usage, label) {
1205
- return Object.fromEntries(
1206
- Object.entries(usage).map(([meter, value]) => [
1207
- assertMeterKey(meter),
1208
- assertMeterValue(`${label}.${meter}`, value)
1209
- ])
1210
- );
1211
- }
1212
- function assertMeterKey(meter) {
1213
- if (!/^[a-z0-9_]{1,64}$/.test(meter)) {
1214
- throw new MeteringError(
1215
- RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1216
- `meter key "${meter}" must be lowercase alphanumeric with underscores`
1217
- );
1218
- }
1219
- return meter;
1220
- }
1221
- function assertMeterValue(meter, value) {
1222
- if (!Number.isFinite(value) || value < 0) {
1223
- throw new MeteringError(
1224
- RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1225
- `meter "${meter}" value must be a non-negative finite number`
1226
- );
1227
- }
1228
- return value;
1229
- }
1230
- function assertIdentifier(value) {
1231
- if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
1232
- throw new MeteringError(
1233
- RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1234
- `operation and usage policy identifiers must be 1-128 URL-safe characters`
1235
- );
1236
- }
1237
- return value;
1238
- }
1239
959
  function processEnv(key2) {
1240
960
  const maybeProcess = globalThis.process;
1241
961
  return maybeProcess?.env?.[key2];
@@ -1264,7 +984,7 @@ function base64url(bytes) {
1264
984
  }
1265
985
 
1266
986
  // src/core/post-stream-usage.ts
1267
- var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
987
+ var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
1268
988
  var PostStreamUsageClient = class {
1269
989
  config;
1270
990
  endpoint;
@@ -1276,7 +996,7 @@ var PostStreamUsageClient = class {
1276
996
  maxRetryDelayMs;
1277
997
  constructor(options) {
1278
998
  this.config = options.config;
1279
- this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
999
+ this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
1280
1000
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1281
1001
  this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
1282
1002
  this.logger = options.logger ?? ((message) => console.warn(message));
@@ -1293,6 +1013,11 @@ var PostStreamUsageClient = class {
1293
1013
  requestId: input.requestId,
1294
1014
  subscriptionId: input.subscriptionId,
1295
1015
  nonce: this.newNonce(),
1016
+ // The token's `allowedMeters` scope is enforced on BOTH lanes
1017
+ // independently (P0-1): `meters` is the flat METER-keyed projection
1018
+ // (the billed lane), so its keys must be in scope regardless of
1019
+ // whether the measurement lane is also present; `measurements[].meter`
1020
+ // is scoped in validateMeasurements below.
1296
1021
  meters: validateAndSortUsage(input.meters, "meters", this.config, true),
1297
1022
  ...input.creditUnitsConsumed ? {
1298
1023
  creditUnitsConsumed: validateAndSortUsage(
@@ -1302,7 +1027,13 @@ var PostStreamUsageClient = class {
1302
1027
  false
1303
1028
  )
1304
1029
  } : {},
1305
- ...input.measureContext ? { measureContext: input.measureContext } : {}
1030
+ ...input.measureContext ? { measureContext: input.measureContext } : {},
1031
+ // Key ORDER is load-bearing: core recomputes the HMAC over
1032
+ // JSON.stringify(unsigned) rebuilt in its zod schema's field order, so
1033
+ // these additive fields must sit in the same position on both sides.
1034
+ ...input.measurementsVersion !== void 0 ? { measurementsVersion: input.measurementsVersion } : {},
1035
+ ...input.measurements ? { measurements: this.validateMeasurements(input.measurements) } : {},
1036
+ ...input.quote ? { quote: input.quote } : {}
1306
1037
  };
1307
1038
  const signature = await signPayload(
1308
1039
  JSON.stringify(unsigned),
@@ -1310,6 +1041,7 @@ var PostStreamUsageClient = class {
1310
1041
  );
1311
1042
  const event = { ...unsigned, signature };
1312
1043
  const body = JSON.stringify(event);
1044
+ const headerSignature = await signPayload(body, this.config.credential);
1313
1045
  for (let attempt = 0; ; attempt += 1) {
1314
1046
  let response;
1315
1047
  try {
@@ -1321,7 +1053,8 @@ var PostStreamUsageClient = class {
1321
1053
  headers: {
1322
1054
  authorization: `Bearer ${this.config.credential}`,
1323
1055
  "content-type": "application/json",
1324
- accept: "application/json"
1056
+ accept: "application/json",
1057
+ [RUNTIME_RESPONSE_METERING_CONTRACT.headers.signature]: headerSignature
1325
1058
  },
1326
1059
  body
1327
1060
  },
@@ -1338,7 +1071,7 @@ var PostStreamUsageClient = class {
1338
1071
  const retryable = requestNotFound || isRetryableStatus(response.status);
1339
1072
  const delayMs = this.retryDelayForAttempt(
1340
1073
  attempt,
1341
- retryAfterMs2(response.headers)
1074
+ retryAfterMs(response.headers)
1342
1075
  );
1343
1076
  if (!retryable || delayMs === null) {
1344
1077
  throw new Error(`metering endpoint returned ${response.status}`);
@@ -1351,11 +1084,30 @@ var PostStreamUsageClient = class {
1351
1084
  return { ok: false, reason };
1352
1085
  }
1353
1086
  }
1354
- retryDelayForAttempt(attempt, retryAfterMs3) {
1087
+ /** Enforce the token's meter scope + per-event bounds on the measurement lane. */
1088
+ validateMeasurements(measurements) {
1089
+ const allowed = this.config.allowedMeters;
1090
+ for (const measurement of measurements) {
1091
+ if (allowed.length > 0 && !allowed.includes(measurement.meter)) {
1092
+ throw new Error(
1093
+ `meter '${measurement.meter}' is not in the token's allowedMeters`
1094
+ );
1095
+ }
1096
+ for (const [measure, value] of Object.entries(measurement.values)) {
1097
+ if (this.config.perEventMax > 0 && value > this.config.perEventMax) {
1098
+ throw new Error(
1099
+ `measure '${measure}' value ${value} exceeds the per-event max ${this.config.perEventMax}`
1100
+ );
1101
+ }
1102
+ }
1103
+ }
1104
+ return measurements;
1105
+ }
1106
+ retryDelayForAttempt(attempt, retryAfterMs2) {
1355
1107
  const fallback = this.retryDelaysMs[attempt];
1356
1108
  if (fallback === void 0) return null;
1357
- if (retryAfterMs3 === null) return fallback;
1358
- return Math.min(retryAfterMs3, this.maxRetryDelayMs);
1109
+ if (retryAfterMs2 === null) return fallback;
1110
+ return Math.min(retryAfterMs2, this.maxRetryDelayMs);
1359
1111
  }
1360
1112
  };
1361
1113
  async function isPostStreamRequestNotFound(response) {
@@ -1370,7 +1122,7 @@ async function isPostStreamRequestNotFound(response) {
1370
1122
  function isRetryableStatus(status) {
1371
1123
  return status === 429 || status >= 500 && status <= 599;
1372
1124
  }
1373
- function retryAfterMs2(headers) {
1125
+ function retryAfterMs(headers) {
1374
1126
  const raw = headers.get("retry-after");
1375
1127
  if (!raw) return null;
1376
1128
  const seconds = Number(raw);
@@ -1387,7 +1139,7 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1387
1139
  ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1388
1140
  );
1389
1141
  for (const [meter, qty] of entries) {
1390
- if (!METER_KEY_RE2.test(meter)) {
1142
+ if (!METER_KEY_RE.test(meter)) {
1391
1143
  throw new Error(
1392
1144
  `${label} key '${meter}' must be lowercase alphanumeric with underscores`
1393
1145
  );
@@ -1406,12 +1158,303 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1406
1158
  }
1407
1159
  return Object.fromEntries(entries);
1408
1160
  }
1409
- function resolveEndpoint2(endpoint, coreUrl) {
1161
+ function resolveEndpoint(endpoint, coreUrl) {
1410
1162
  if (/^https?:\/\//.test(endpoint)) return endpoint;
1411
1163
  if (!coreUrl) return endpoint;
1412
1164
  return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1413
1165
  }
1414
1166
 
1167
+ // src/core/report.ts
1168
+ var MEASUREMENTS_VERSION = 1;
1169
+ var KEY_RE = /^[a-z0-9_]{1,64}$/;
1170
+ var DIMENSION_VALUE_RE = /^[\w.:-]{1,128}$/;
1171
+ var CURRENCY_RE = /^[A-Za-z]{3}$/;
1172
+ var DECIMAL_INTEGER_RE = /^\d{1,30}$/;
1173
+ function createReportFn(channels) {
1174
+ let stampedMeasurements = [];
1175
+ let stampedQuote;
1176
+ let inBandTail = Promise.resolve();
1177
+ let postStreamFinalized = false;
1178
+ let pendingPostStreamBatch = null;
1179
+ const deliverPostStream = async (reported, quote) => {
1180
+ if (pendingPostStreamBatch) {
1181
+ if (!quotesEqual(pendingPostStreamBatch.quote, quote)) {
1182
+ return {
1183
+ ok: false,
1184
+ transport: "post_stream",
1185
+ reason: "quote conflicts with this request's pending post-stream batch: one served request carries one quote across all measurements"
1186
+ };
1187
+ }
1188
+ if (reported.some(
1189
+ (measurement) => !dimsEqual(
1190
+ pendingPostStreamBatch.measurements[0]?.dims,
1191
+ measurement.dims
1192
+ )
1193
+ )) {
1194
+ return {
1195
+ ok: false,
1196
+ transport: "post_stream",
1197
+ reason: "dims conflict with this request's pending post-stream batch: the request receipt rates under ONE dims tuple"
1198
+ };
1199
+ }
1200
+ pendingPostStreamBatch.measurements.push(...reported);
1201
+ return pendingPostStreamBatch.flush;
1202
+ }
1203
+ if (postStreamFinalized) {
1204
+ return {
1205
+ ok: false,
1206
+ transport: "post_stream",
1207
+ reason: "the served request already used its post-stream callback; report multiple meters in ONE call \u2014 ctx.report([a, b]) \u2014 or before the flush"
1208
+ };
1209
+ }
1210
+ if (stampedMeasurements.length > 0) {
1211
+ return {
1212
+ ok: false,
1213
+ transport: "post_stream",
1214
+ reason: "this request already reported in-band; every report on one request must share the stamped aggregate (same quote, before the response is sent)"
1215
+ };
1216
+ }
1217
+ postStreamFinalized = true;
1218
+ const batch = {
1219
+ measurements: [...reported],
1220
+ quote,
1221
+ flush: void 0
1222
+ };
1223
+ batch.flush = new Promise((resolve) => setTimeout(resolve, 0)).then(
1224
+ async () => {
1225
+ pendingPostStreamBatch = null;
1226
+ const result = await channels.postStream({
1227
+ measurements: batch.measurements,
1228
+ ...batch.quote ? { quote: batch.quote } : {}
1229
+ });
1230
+ return result.ok ? { ok: true, transport: "post_stream" } : {
1231
+ ok: false,
1232
+ transport: "post_stream",
1233
+ reason: result.reason ?? "post-stream delivery failed"
1234
+ };
1235
+ }
1236
+ );
1237
+ pendingPostStreamBatch = batch;
1238
+ return batch.flush;
1239
+ };
1240
+ const tryInBand = (reported, quote) => {
1241
+ const run = inBandTail.then(async () => {
1242
+ const sink = channels.responseSink;
1243
+ if (!sink || !channels.request || !sink.canStampHeaders()) return null;
1244
+ if (postStreamFinalized) return null;
1245
+ if (!quotesEqual(stampedQuote, quote) && stampedMeasurements.length > 0) {
1246
+ return null;
1247
+ }
1248
+ if (stampedMeasurements.length > 0 && reported.some(
1249
+ (measurement) => !dimsEqual(stampedMeasurements[0].dims, measurement.dims)
1250
+ )) {
1251
+ return null;
1252
+ }
1253
+ const measurements = [...stampedMeasurements, ...reported];
1254
+ const payload = buildInBandPayload(channels.request, measurements, quote);
1255
+ const headers = await channels.computeHeaders(payload);
1256
+ if (Object.keys(headers).length === 0 || !sink.canStampHeaders()) {
1257
+ return null;
1258
+ }
1259
+ sink.stampHeaders(headers);
1260
+ stampedMeasurements = measurements;
1261
+ stampedQuote = quote;
1262
+ return { ok: true, transport: "in_band" };
1263
+ });
1264
+ inBandTail = run.then(
1265
+ () => void 0,
1266
+ () => void 0
1267
+ );
1268
+ return run;
1269
+ };
1270
+ return async (input) => {
1271
+ const inputs = Array.isArray(input) ? input : [input];
1272
+ if (inputs.length === 0) {
1273
+ throw new MeteringError(
1274
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1275
+ "report([]) is empty: a batched report needs at least one measurement"
1276
+ );
1277
+ }
1278
+ const measurements = inputs.map((entry) => validateMeasurement(entry));
1279
+ for (const measurement of measurements) {
1280
+ if (!dimsEqual(measurements[0].dims, measurement.dims)) {
1281
+ throw new MeteringError(
1282
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1283
+ "a batched report carries ONE dims tuple: the request receipt rates under (route, dims), so mixed dims are unratable \u2014 report each dims tuple on its own request"
1284
+ );
1285
+ }
1286
+ }
1287
+ let quote;
1288
+ for (const entry of inputs) {
1289
+ if (entry.quote === void 0) continue;
1290
+ const validated = validateQuote(entry.quote);
1291
+ if (quote === void 0) {
1292
+ quote = validated;
1293
+ } else if (!quotesEqual(quote, validated)) {
1294
+ throw new MeteringError(
1295
+ RESPONSE_METERING_ERROR_CODES.invalidQuote,
1296
+ "a batched report carries ONE quote: two entries supplied different quotes"
1297
+ );
1298
+ }
1299
+ }
1300
+ const inBand = await tryInBand(measurements, quote);
1301
+ if (inBand) return inBand;
1302
+ return deliverPostStream(measurements, quote);
1303
+ };
1304
+ }
1305
+ function unattachedReport() {
1306
+ return () => Promise.reject(
1307
+ new MeteringError(
1308
+ RESPONSE_METERING_ERROR_CODES.missingToken,
1309
+ "report() has no metering channel on this context: verify through the runtime (fs.middleware() / fs.verifyRequest()) instead of the bare verifyRequest() primitive, and pass that context to background jobs"
1310
+ )
1311
+ );
1312
+ }
1313
+ function rawDimsUnitsOf(measurement) {
1314
+ let total = 0;
1315
+ for (const value of Object.values(measurement.values)) total += value;
1316
+ return { [measurement.meter]: total };
1317
+ }
1318
+ function buildInBandPayload(request, measurements, quote) {
1319
+ const rawDimsUnits = {};
1320
+ for (const measurement of measurements) {
1321
+ for (const [meter, units] of Object.entries(rawDimsUnitsOf(measurement))) {
1322
+ rawDimsUnits[meter] = (rawDimsUnits[meter] ?? 0) + units;
1323
+ }
1324
+ }
1325
+ return {
1326
+ method: request.method.toUpperCase(),
1327
+ path: request.path,
1328
+ rawDimsUnits,
1329
+ measurementsVersion: MEASUREMENTS_VERSION,
1330
+ measurements,
1331
+ ...quote ? { quote } : {}
1332
+ };
1333
+ }
1334
+ function dimsEqual(left, right) {
1335
+ const l = Object.entries(left ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
1336
+ const r = Object.entries(right ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
1337
+ if (l.length !== r.length) return false;
1338
+ return l.every(([k, v], i) => r[i][0] === k && r[i][1] === v);
1339
+ }
1340
+ function quotesEqual(left, right) {
1341
+ return left === right || left !== void 0 && right !== void 0 && left.currency === right.currency && left.amountNanos === right.amountNanos;
1342
+ }
1343
+ function validateMeasurement(input) {
1344
+ if (!input || typeof input !== "object") {
1345
+ throw invalidKey("report() requires a { meter, values } object");
1346
+ }
1347
+ const meter = assertKey(input.meter, "meter");
1348
+ const values = assertValues(input.values);
1349
+ const dims = input.dims === void 0 ? void 0 : assertDims(input.dims);
1350
+ return {
1351
+ meter,
1352
+ values,
1353
+ ...dims && Object.keys(dims).length > 0 ? { dims } : {}
1354
+ };
1355
+ }
1356
+ function validateQuote(quote) {
1357
+ if (!quote || typeof quote !== "object" || Array.isArray(quote)) {
1358
+ throw invalidQuote(
1359
+ "quote must be an object of the form { currency, amountNanos }"
1360
+ );
1361
+ }
1362
+ const { currency, amountNanos } = quote;
1363
+ if (typeof currency !== "string" || !CURRENCY_RE.test(currency)) {
1364
+ throw invalidQuote("quote.currency must be a 3-letter currency code");
1365
+ }
1366
+ return {
1367
+ currency: currency.toLowerCase(),
1368
+ amountNanos: assertAmountNanos(amountNanos)
1369
+ };
1370
+ }
1371
+ function assertAmountNanos(value) {
1372
+ if (typeof value === "bigint") {
1373
+ if (value < 0n) throw negativeAmountNanos();
1374
+ return value.toString();
1375
+ }
1376
+ if (typeof value === "number") {
1377
+ if (!Number.isSafeInteger(value)) {
1378
+ throw invalidQuote(
1379
+ "quote.amountNanos must be a safe integer number of nanodollars (pass a string or bigint for larger amounts)"
1380
+ );
1381
+ }
1382
+ if (value < 0) throw negativeAmountNanos();
1383
+ return String(value);
1384
+ }
1385
+ if (typeof value === "string") {
1386
+ if (/^-/.test(value)) throw negativeAmountNanos();
1387
+ if (DECIMAL_INTEGER_RE.test(value)) return value;
1388
+ }
1389
+ throw invalidQuote(
1390
+ "quote.amountNanos must be a non-negative integer number of nanodollars"
1391
+ );
1392
+ }
1393
+ function negativeAmountNanos() {
1394
+ return invalidQuote(
1395
+ "quote.amountNanos must be non-negative: a quote is a proposed rate, never a credit \u2014 refunds are platform operations"
1396
+ );
1397
+ }
1398
+ function assertValues(values) {
1399
+ if (!values || typeof values !== "object" || Array.isArray(values)) {
1400
+ throw invalidKey("report() requires a values object");
1401
+ }
1402
+ const entries = Object.entries(values).sort(
1403
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1404
+ );
1405
+ if (entries.length === 0) {
1406
+ throw invalidKey("report() requires at least one measure in values");
1407
+ }
1408
+ const out = {};
1409
+ for (const [measure, value] of entries) {
1410
+ assertKey(measure, "measure");
1411
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
1412
+ throw new MeteringError(
1413
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1414
+ `values.${measure} must be a non-negative safe integer`
1415
+ );
1416
+ }
1417
+ out[measure] = value;
1418
+ }
1419
+ return out;
1420
+ }
1421
+ function assertDims(dims) {
1422
+ if (!dims || typeof dims !== "object" || Array.isArray(dims)) {
1423
+ throw invalidKey("report() dims must be an object of dimension selectors");
1424
+ }
1425
+ const entries = Object.entries(dims).sort(
1426
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1427
+ );
1428
+ const out = {};
1429
+ for (const [dimension, value] of entries) {
1430
+ assertKey(dimension, "dimension");
1431
+ if (typeof value !== "string" || !DIMENSION_VALUE_RE.test(value)) {
1432
+ throw invalidKey(
1433
+ `dims.${dimension} must be a 1-128 character selector value`
1434
+ );
1435
+ }
1436
+ out[dimension] = value;
1437
+ }
1438
+ return out;
1439
+ }
1440
+ function assertKey(value, label) {
1441
+ if (typeof value !== "string" || !KEY_RE.test(value)) {
1442
+ throw invalidKey(
1443
+ `${label} key ${JSON.stringify(value)} must be 1-64 lowercase alphanumeric characters or underscores`
1444
+ );
1445
+ }
1446
+ return value;
1447
+ }
1448
+ function invalidKey(message) {
1449
+ return new MeteringError(
1450
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1451
+ message
1452
+ );
1453
+ }
1454
+ function invalidQuote(message) {
1455
+ return new MeteringError(RESPONSE_METERING_ERROR_CODES.invalidQuote, message);
1456
+ }
1457
+
1415
1458
  // src/core/nonceCache.ts
1416
1459
  var DEFAULT_MAX_ENTRIES = 25e4;
1417
1460
  var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
@@ -2005,22 +2048,7 @@ async function verifyRequest(input, deps) {
2005
2048
  "x-fs-timestamp is not an integer"
2006
2049
  );
2007
2050
  }
2008
- const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
2009
- const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
2010
- const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
2011
- const delta = now - timestamp;
2012
- if (Math.abs(delta) > window) {
2013
- if (Math.abs(delta) <= window + skew) {
2014
- throw new FartherShoreError(
2015
- "clock_skew",
2016
- "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
2017
- );
2018
- }
2019
- throw new FartherShoreError(
2020
- "expired_signature",
2021
- "x-fs-timestamp is outside the replay window"
2022
- );
2023
- }
2051
+ assertTimestampWithinWindow(timestamp, deps);
2024
2052
  const computedBodyHash = await computeBodyHash(input);
2025
2053
  if (signedBodyHash !== computedBodyHash) {
2026
2054
  throw new FartherShoreError(
@@ -2028,31 +2056,7 @@ async function verifyRequest(input, deps) {
2028
2056
  "recomputed body hash does not match the signed x-fs-body-hash"
2029
2057
  );
2030
2058
  }
2031
- if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
2032
- throw new FartherShoreError(
2033
- "route_mismatch",
2034
- "signed business-id does not match this backend's business"
2035
- );
2036
- }
2037
- if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
2038
- if (!deps.backendIds.has(signedBackendId)) {
2039
- throw new FartherShoreError(
2040
- "route_mismatch",
2041
- "signed backend-id is not one this deployment serves"
2042
- );
2043
- }
2044
- } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
2045
- throw new FartherShoreError(
2046
- "route_mismatch",
2047
- "signed backend-id does not match this backend"
2048
- );
2049
- }
2050
- if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
2051
- throw new FartherShoreError(
2052
- "route_mismatch",
2053
- "signed route-id is not served by this backend"
2054
- );
2055
- }
2059
+ assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId);
2056
2060
  const contextToken = h("x-fs-context") ?? null;
2057
2061
  const canonicalInput = {
2058
2062
  method: input.method,
@@ -2123,9 +2127,57 @@ async function verifyRequest(input, deps) {
2123
2127
  ...principal ? { principal } : {},
2124
2128
  ...permissions !== void 0 ? { permissions } : {},
2125
2129
  ...roles !== void 0 ? { roles } : {},
2126
- ...signedContext ? { signedContext } : {}
2130
+ ...signedContext ? { signedContext } : {},
2131
+ // The bare primitive has no metering channel; the runtime facade replaces
2132
+ // this with the real bound verb (see FartherShore.verifyRequest).
2133
+ report: unattachedReport()
2127
2134
  };
2128
2135
  }
2136
+ function assertTimestampWithinWindow(timestamp, deps) {
2137
+ const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
2138
+ const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
2139
+ const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
2140
+ const delta = now - timestamp;
2141
+ if (Math.abs(delta) > window) {
2142
+ if (Math.abs(delta) <= window + skew) {
2143
+ throw new FartherShoreError(
2144
+ "clock_skew",
2145
+ "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
2146
+ );
2147
+ }
2148
+ throw new FartherShoreError(
2149
+ "expired_signature",
2150
+ "x-fs-timestamp is outside the replay window"
2151
+ );
2152
+ }
2153
+ }
2154
+ function assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId) {
2155
+ if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
2156
+ throw new FartherShoreError(
2157
+ "route_mismatch",
2158
+ "signed business-id does not match this backend's business"
2159
+ );
2160
+ }
2161
+ if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
2162
+ if (!deps.backendIds.has(signedBackendId)) {
2163
+ throw new FartherShoreError(
2164
+ "route_mismatch",
2165
+ "signed backend-id is not one this deployment serves"
2166
+ );
2167
+ }
2168
+ } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
2169
+ throw new FartherShoreError(
2170
+ "route_mismatch",
2171
+ "signed backend-id does not match this backend"
2172
+ );
2173
+ }
2174
+ if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
2175
+ throw new FartherShoreError(
2176
+ "route_mismatch",
2177
+ "signed route-id is not served by this backend"
2178
+ );
2179
+ }
2180
+ }
2129
2181
  async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
2130
2182
  if (!contextToken) return null;
2131
2183
  const signedContext = decodeContextClaims(contextToken);
@@ -2178,7 +2230,7 @@ function headerGetter(headers) {
2178
2230
 
2179
2231
  // src/core/runtime.ts
2180
2232
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
2181
- var SDK_VERSION = "0.20.0".length > 0 ? "0.20.0" : "0.0.0-dev";
2233
+ var SDK_VERSION = "0.21.0".length > 0 ? "0.21.0" : "0.0.0-dev";
2182
2234
  var FartherShore = class {
2183
2235
  bootstrapClient;
2184
2236
  fetchImpl;
@@ -2194,14 +2246,13 @@ var FartherShore = class {
2194
2246
  replayProtectionDiagnostic;
2195
2247
  shutdownManager = new ShutdownManager();
2196
2248
  jwks = null;
2197
- meteringClient = null;
2198
2249
  postStreamUsageClient = null;
2199
2250
  tunnel = null;
2200
2251
  bootstrapped = false;
2201
2252
  constructor(options = {}) {
2202
2253
  const env = options.env ?? readProcessEnv();
2203
2254
  const runtimeToken = options.runtimeToken ?? env[FS_RUNTIME_TOKEN_ENV] ?? "";
2204
- const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? env.FARTHERSHORE_CORE_URL ?? DEFAULT_CORE_URL;
2255
+ const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? DEFAULT_CORE_URL;
2205
2256
  this.runtimeToken = runtimeToken;
2206
2257
  this.coreUrl = coreUrl;
2207
2258
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
@@ -2223,9 +2274,6 @@ var FartherShore = class {
2223
2274
  ...options.instanceId ? { instanceId: options.instanceId } : {}
2224
2275
  }
2225
2276
  });
2226
- this.shutdownManager.register(async () => {
2227
- await this.meteringClient?.flush();
2228
- });
2229
2277
  this.shutdownManager.register(async () => {
2230
2278
  await reportHealth({
2231
2279
  runtimeToken: this.runtimeToken,
@@ -2245,14 +2293,7 @@ var FartherShore = class {
2245
2293
  fetchImpl: this.fetchImpl
2246
2294
  });
2247
2295
  }
2248
- if (!this.meteringClient && config.metering.enabled) {
2249
- this.meteringClient = new MeteringClient({
2250
- config: config.metering,
2251
- businessId: config.business.id,
2252
- backendId: config.backend.id,
2253
- coreUrl: this.coreUrl,
2254
- fetchImpl: this.fetchImpl
2255
- });
2296
+ if (!this.postStreamUsageClient && config.metering.enabled) {
2256
2297
  this.postStreamUsageClient = new PostStreamUsageClient({
2257
2298
  config: config.metering,
2258
2299
  coreUrl: this.coreUrl,
@@ -2325,7 +2366,7 @@ var FartherShore = class {
2325
2366
  * Framework-neutral verification primitive. Fail-closed: throws a typed
2326
2367
  * FartherShoreError on any verification failure. Returns the verified context.
2327
2368
  */
2328
- async verifyRequest(input) {
2369
+ async verifyRequest(input, options = {}) {
2329
2370
  const config = await this.ensureBootstrapped();
2330
2371
  if (!this.jwks) {
2331
2372
  throw new FartherShoreError(
@@ -2351,21 +2392,52 @@ var FartherShore = class {
2351
2392
  });
2352
2393
  return {
2353
2394
  ...context,
2354
- reportUsage: (report) => {
2355
- const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
2395
+ report: this.buildReportFn(context, input, options.responseSink)
2396
+ };
2397
+ }
2398
+ /**
2399
+ * Bind the ONE reporting verb to a verified context. Identity comes from the
2400
+ * context (`signedContext.subscriptionId` + `requestId`) — never from the
2401
+ * caller — so a handler cannot forget it, and a background job that is handed
2402
+ * this context keeps reporting against the SAME served identity.
2403
+ */
2404
+ buildReportFn(context, input, responseSink) {
2405
+ const channels = {
2406
+ request: { method: input.method, path: input.path },
2407
+ ...responseSink ? { responseSink } : {},
2408
+ computeHeaders: (payload) => computeMeteringHeaders(payload, {
2409
+ token: this.runtimeToken,
2410
+ requestId: context.requestId,
2411
+ onSkip: () => {
2412
+ }
2413
+ }),
2414
+ postStream: async ({ measurements, quote }) => {
2415
+ const subscriptionId = context.signedContext?.subscriptionId;
2356
2416
  if (!subscriptionId) {
2357
- return Promise.resolve({
2417
+ return {
2358
2418
  ok: false,
2359
- reason: "subscriptionId is required"
2360
- });
2419
+ reason: "this request carries no subscription identity, so late usage cannot be attributed \u2014 report before the response is sent, or serve the route through a subscribed surface"
2420
+ };
2421
+ }
2422
+ const meters = {};
2423
+ for (const measurement of measurements) {
2424
+ for (const [meter, qty] of Object.entries(
2425
+ rawDimsUnitsOf(measurement)
2426
+ )) {
2427
+ meters[meter] = (meters[meter] ?? 0) + qty;
2428
+ }
2361
2429
  }
2362
- return this.reportUsage({
2363
- ...report,
2364
- requestId: report.requestId ?? context.requestId,
2365
- subscriptionId
2430
+ return this.reportPostStreamUsage({
2431
+ requestId: context.requestId,
2432
+ subscriptionId,
2433
+ meters,
2434
+ measurementsVersion: MEASUREMENTS_VERSION,
2435
+ measurements,
2436
+ ...quote ? { quote } : {}
2366
2437
  });
2367
2438
  }
2368
2439
  };
2440
+ return createReportFn(channels);
2369
2441
  }
2370
2442
  /** Whether verification is required (bootstrap × opt-out). */
2371
2443
  async verificationRequired() {
@@ -2414,20 +2486,12 @@ var FartherShore = class {
2414
2486
  });
2415
2487
  await supervisor.start();
2416
2488
  }
2417
- /** Record metering usage (billing-only). */
2418
- async meter(meter, qty, options = {}) {
2419
- await this.ensureBootstrapped();
2420
- if (!this.meteringEnabledOverride) return;
2421
- if (!this.meteringClient) {
2422
- throw new FartherShoreError(
2423
- "invalid_token",
2424
- "metering is not enabled for this runtime token"
2425
- );
2426
- }
2427
- await this.meteringClient.meter(meter, qty, options);
2428
- }
2429
- /** Best-effort attested post-stream usage callback. Never rejects. */
2430
- async reportUsage(input) {
2489
+ /**
2490
+ * PRIVATE transport for the post-stream lane of `ctx.report()`. Never rejects
2491
+ * — a metering hiccup must not break a builder's endpoint. This is machinery,
2492
+ * not surface: the ONE public reporting verb is `ctx.report()`.
2493
+ */
2494
+ async reportPostStreamUsage(input) {
2431
2495
  try {
2432
2496
  await this.ensureBootstrapped();
2433
2497
  if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
@@ -2459,7 +2523,7 @@ var FartherShore = class {
2459
2523
  // fs.start() launches an embedded tunnel; otherwise the supervisor state.
2460
2524
  tunnel: this.tunnel ? this.tunnel.healthString() : null,
2461
2525
  verification: this.verificationEnabled && config !== null,
2462
- metering: this.meteringClient !== null
2526
+ metering: this.postStreamUsageClient !== null
2463
2527
  });
2464
2528
  }
2465
2529
  /** Graceful shutdown: flush metering + send a stopping heartbeat. */
@@ -2496,10 +2560,13 @@ var FartherShorePermissionError = class extends Error {
2496
2560
  this.requiredPermission = requiredPermission;
2497
2561
  }
2498
2562
  };
2499
- function permissionGrants(permissions, key2) {
2500
- if (permissions === void 0) return true;
2501
- if (permissions.includes(WILDCARD)) return true;
2502
- return permissions.includes(key2);
2563
+ var READ_METHODS = /* @__PURE__ */ new Set([
2564
+ "GET",
2565
+ "HEAD",
2566
+ "OPTIONS"
2567
+ ]);
2568
+ function routePermission(subject, method) {
2569
+ return READ_METHODS.has(method.toUpperCase()) ? `${subject}:read` : `${subject}:write`;
2503
2570
  }
2504
2571
  function permissionSatisfies(required, granted) {
2505
2572
  if (granted === void 0) return true;
@@ -2508,7 +2575,12 @@ function permissionSatisfies(required, granted) {
2508
2575
  const idx = required.indexOf(":");
2509
2576
  if (idx > 0 && idx < required.length - 1) {
2510
2577
  const subject = required.slice(0, idx);
2511
- if (granted.includes(`${subject}:${WILDCARD}`)) return true;
2578
+ if (subject !== WILDCARD && granted.includes(`${subject}:${WILDCARD}`))
2579
+ return true;
2580
+ if (required.slice(idx + 1) === WILDCARD && subject !== WILDCARD) {
2581
+ const prefix = `${subject}:`;
2582
+ return granted.some((permission) => permission.startsWith(prefix));
2583
+ }
2512
2584
  }
2513
2585
  return false;
2514
2586
  }
@@ -2543,14 +2615,17 @@ async function runMiddleware(fs, options, req, res, next) {
2543
2615
  const contentType = headerValue(req.headers, "content-type");
2544
2616
  const streamingExempt = isStreamingExempt(contentType);
2545
2617
  const body = streamingExempt ? null : extractRawBody(req);
2546
- const ctx = await fs.verifyRequest({
2547
- method: req.method,
2548
- path,
2549
- query,
2550
- headers: req.headers,
2551
- body,
2552
- streamingExempt
2553
- });
2618
+ const ctx = await fs.verifyRequest(
2619
+ {
2620
+ method: req.method,
2621
+ path,
2622
+ query,
2623
+ headers: req.headers,
2624
+ body,
2625
+ streamingExempt
2626
+ },
2627
+ { responseSink: expressResponseSink(res) }
2628
+ );
2554
2629
  req.fartherShore = ctx;
2555
2630
  stripFartherShoreHeaders(req);
2556
2631
  next();
@@ -2558,6 +2633,16 @@ async function runMiddleware(fs, options, req, res, next) {
2558
2633
  fail(res, error, options, req);
2559
2634
  }
2560
2635
  }
2636
+ function expressResponseSink(res) {
2637
+ return {
2638
+ canStampHeaders: () => res.headersSent !== true,
2639
+ stampHeaders: (headers) => {
2640
+ for (const [name, value] of Object.entries(headers)) {
2641
+ res.setHeader(name, value);
2642
+ }
2643
+ }
2644
+ };
2645
+ }
2561
2646
  function fail(res, error, options, req) {
2562
2647
  const code = error instanceof FartherShoreError ? error.code : "bad_signature";
2563
2648
  const status = error instanceof FartherShoreError ? error.status : 401;
@@ -2598,7 +2683,12 @@ function stripFartherShoreHeaders(req) {
2598
2683
  withRaw.rawHeaders = cleaned;
2599
2684
  }
2600
2685
  }
2601
- function createExpressHandler(handler) {
2686
+ function createExpressHandler(optionsOrHandler, maybeHandler) {
2687
+ const options = typeof optionsOrHandler === "function" ? {} : optionsOrHandler;
2688
+ const handler = typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler;
2689
+ if (typeof handler !== "function") {
2690
+ throw new TypeError("fs.handler(options, cb) requires a handler callback");
2691
+ }
2602
2692
  return (req, res, next) => {
2603
2693
  const ctx = req.fartherShore;
2604
2694
  if (!ctx) {
@@ -2609,10 +2699,22 @@ function createExpressHandler(handler) {
2609
2699
  res.status(401).json({ error: "principal_required" });
2610
2700
  return;
2611
2701
  }
2702
+ if (!ctx.signedContext) {
2703
+ res.status(401).json({ error: "context_unverified" });
2704
+ return;
2705
+ }
2612
2706
  const verified = ctx;
2613
- void Promise.resolve().then(
2614
- () => handler(verified, req, res, next)
2615
- ).catch((error) => failHandler(res, next, error));
2707
+ void Promise.resolve().then(() => {
2708
+ if (options.permission !== void 0) {
2709
+ requirePermission(verified, options.permission);
2710
+ }
2711
+ return handler(
2712
+ verified,
2713
+ req,
2714
+ res,
2715
+ next
2716
+ );
2717
+ }).catch((error) => failHandler(res, next, error));
2616
2718
  };
2617
2719
  }
2618
2720
  function failHandler(res, next, error) {
@@ -3074,7 +3176,7 @@ function readProcessEnv2() {
3074
3176
  // src/testing/usageSink.ts
3075
3177
  var DevUsageSink = class {
3076
3178
  events = [];
3077
- /** Record a signed response-metering payload (withUsage / computeMeteringHeaders). */
3179
+ /** Record a signed response-metering payload (report() in-band / computeMeteringHeaders). */
3078
3180
  recordResponse(payload, requestId) {
3079
3181
  const raw = payload.rawDimsUnits;
3080
3182
  const meters = raw && typeof raw === "object" ? raw : {};
@@ -3332,6 +3434,7 @@ function createDevRuntime(options) {
3332
3434
  }
3333
3435
  fs.middleware = middleware;
3334
3436
  fs.handler = createExpressHandler;
3437
+ fs.authz = tracedAuthz;
3335
3438
  const devRuntime = {
3336
3439
  fs,
3337
3440
  asPersona: (name) => personaClient.asPersona(name),
@@ -3460,6 +3563,7 @@ var fartherShore = {
3460
3563
  const fs = initFromEnv(options);
3461
3564
  fs.middleware = (mwOptions) => createExpressMiddleware(fs, mwOptions);
3462
3565
  fs.handler = createExpressHandler;
3566
+ fs.authz = { hasPermission, requirePermission };
3463
3567
  return fs;
3464
3568
  }
3465
3569
  };
@@ -3477,13 +3581,13 @@ export {
3477
3581
  FartherShorePermissionError,
3478
3582
  JwksClient,
3479
3583
  MAX_BODY_BYTES,
3584
+ MEASUREMENTS_VERSION,
3480
3585
  METERING_PAYLOAD_HEADER,
3481
3586
  METERING_SIGNATURE_HEADER,
3482
3587
  METERING_TOKEN_HEADER,
3483
- MeteringClient,
3484
3588
  MeteringError,
3485
3589
  NonceCache,
3486
- PostStreamUsageClient,
3590
+ READ_METHODS,
3487
3591
  REDACTED_TOKEN,
3488
3592
  RUNTIME_CLOCK_SKEW_SECONDS,
3489
3593
  RUNTIME_ERROR_CODES,
@@ -3500,7 +3604,6 @@ export {
3500
3604
  computeMeteringHeaders,
3501
3605
  createExpressHandler,
3502
3606
  createExpressMiddleware,
3503
- createUsage,
3504
3607
  credentialKind,
3505
3608
  decodeContextClaims,
3506
3609
  fartherShore,
@@ -3509,19 +3612,18 @@ export {
3509
3612
  initFromEnv2 as initFromEnv,
3510
3613
  isPortalSession,
3511
3614
  nodeSpawn,
3512
- permissionGrants,
3513
3615
  permissionSatisfies,
3514
3616
  principalFromContextClaims2 as principalFromContextClaims,
3515
3617
  reportHealth,
3516
3618
  requireMember,
3517
3619
  requirePermission,
3518
3620
  requireService,
3621
+ routePermission,
3519
3622
  runtimeErrorToErrorCode,
3520
3623
  runtimeTokenKind2 as runtimeTokenKind,
3521
3624
  signCanonicalString2 as signCanonicalString,
3522
3625
  statusForCode,
3523
3626
  verifyCanonicalSignature2 as verifyCanonicalSignature,
3524
3627
  verifyContext,
3525
- verifyRequest,
3526
- withUsage
3628
+ verifyRequest
3527
3629
  };