@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.
@@ -194,20 +194,6 @@ var init_reconcile = __esm({
194
194
  import { generateKeyPairSync, randomBytes } from "node:crypto";
195
195
 
196
196
  // src/generated/runtime-contract.ts
197
- var RUNTIME_BODY_HASH_CONTRACT = {
198
- algorithm: "SHA-256",
199
- encoding: "hex-lower",
200
- source: "raw-request-bytes",
201
- emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
202
- maxBodyBytes: 10485760,
203
- streamingExemptToken: "STREAM",
204
- streamingExemptContentTypes: [
205
- "text/event-stream",
206
- "application/octet-stream",
207
- "multipart/form-data"
208
- ],
209
- overMaxStatus: 413
210
- };
211
197
  var RUNTIME_ERROR_CODES = {
212
198
  missingSignature: "missing_signature",
213
199
  malformedSignature: "malformed_signature",
@@ -228,6 +214,20 @@ var RUNTIME_ERROR_CODES = {
228
214
  serviceSubjectRequired: "service_subject_required",
229
215
  surfaceNotAllowed: "surface_not_allowed"
230
216
  };
217
+ var RUNTIME_BODY_HASH_CONTRACT = {
218
+ algorithm: "SHA-256",
219
+ encoding: "hex-lower",
220
+ source: "raw-request-bytes",
221
+ emptyBodyHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
222
+ maxBodyBytes: 10485760,
223
+ streamingExemptToken: "STREAM",
224
+ streamingExemptContentTypes: [
225
+ "text/event-stream",
226
+ "application/octet-stream",
227
+ "multipart/form-data"
228
+ ],
229
+ overMaxStatus: 413
230
+ };
231
231
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
232
232
  headers: {
233
233
  payload: "x-fs-metering",
@@ -248,14 +248,18 @@ var RUNTIME_RESPONSE_METERING_CONTRACT = {
248
248
  payload: {
249
249
  method: "string",
250
250
  path: "string",
251
- rawDimsUnits: "Record<string, number>",
251
+ rawDimsUnits: "Record<string, number>?",
252
252
  measureContext: "Record<string, unknown>?",
253
- creditUnitsConsumed: "Record<string, number>?"
253
+ creditUnitsConsumed: "Record<string, number>?",
254
+ measurementsVersion: "1?",
255
+ measurements: "Array<{ meter: string; values: Record<string, number>; dims?: Record<string, string> }>?",
256
+ quote: "{ currency: string; amountNanos: string }?"
254
257
  },
255
258
  errors: {
256
259
  missingToken: "missing_token",
257
260
  invalidMeterKey: "invalid_meter_key",
258
- invalidMeterValue: "invalid_meter_value"
261
+ invalidMeterValue: "invalid_meter_value",
262
+ invalidQuote: "invalid_quote"
259
263
  },
260
264
  httpAdapter: {
261
265
  input: "Request",
@@ -1281,185 +1285,6 @@ async function reportHealth(options) {
1281
1285
  }
1282
1286
  }
1283
1287
 
1284
- // src/core/backoff.ts
1285
- function computeBackoff(attempt, options) {
1286
- const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
1287
- const exponent = Math.max(0, attempt - 1);
1288
- const cap = Math.min(baseMs * 2 ** exponent, maxMs);
1289
- switch (jitter) {
1290
- case "none":
1291
- return cap;
1292
- case "full":
1293
- return random() * cap;
1294
- case "equal":
1295
- default:
1296
- return cap / 2 + random() * (cap / 2);
1297
- }
1298
- }
1299
-
1300
- // src/core/metering.ts
1301
- var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
1302
- var DEFAULT_BASE_DELAY_MS = 200;
1303
- var DEFAULT_MAX_DELAY_MS = 1e4;
1304
- function isTransientStatus(status) {
1305
- return status === 429 || status >= 500;
1306
- }
1307
- function retryAfterMs(headers) {
1308
- const raw = headers.get("retry-after");
1309
- if (raw === null) return null;
1310
- const trimmed = raw.trim();
1311
- if (!/^\d+$/.test(trimmed)) return null;
1312
- const secs = Number(trimmed);
1313
- return Number.isFinite(secs) ? secs * 1e3 : null;
1314
- }
1315
- var DEFAULT_MAX_RETRIES = 3;
1316
- var MeteringClient = class {
1317
- config;
1318
- endpoint;
1319
- businessId;
1320
- backendId;
1321
- fetchImpl;
1322
- maxRetries;
1323
- baseDelayMs;
1324
- maxDelayMs;
1325
- sleep;
1326
- random;
1327
- newId;
1328
- now;
1329
- buffer = [];
1330
- constructor(options) {
1331
- this.config = options.config;
1332
- this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
1333
- this.businessId = options.businessId;
1334
- this.backendId = options.backendId;
1335
- this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1336
- this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
1337
- this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
1338
- this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
1339
- this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1340
- this.random = options.random ?? Math.random;
1341
- this.newId = options.newId ?? (() => crypto.randomUUID());
1342
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1343
- }
1344
- /**
1345
- * Record `qty` of `meter`. Enforces meter-key shape, non-negative finite qty,
1346
- * the bootstrap allowedMeters/allowedRoutes scope, and the per-event sanity
1347
- * max, then enqueues and flushes (best-effort; failures stay buffered).
1348
- */
1349
- async meter(meter, qty, options = {}) {
1350
- if (!this.config.enabled) {
1351
- throw new FartherShoreError(
1352
- "invalid_token",
1353
- "metering is not enabled for this runtime token"
1354
- );
1355
- }
1356
- if (!METER_KEY_RE.test(meter)) {
1357
- throw new FartherShoreError(
1358
- "invalid_token",
1359
- `meter key '${meter}' must be lowercase alphanumeric with underscores`
1360
- );
1361
- }
1362
- if (!Number.isFinite(qty) || qty < 0) {
1363
- throw new FartherShoreError(
1364
- "invalid_token",
1365
- `meter '${meter}' qty must be a non-negative finite number`
1366
- );
1367
- }
1368
- if (this.config.allowedMeters.length > 0 && !this.config.allowedMeters.includes(meter)) {
1369
- throw new FartherShoreError(
1370
- "invalid_token",
1371
- `meter '${meter}' is not in the token's allowedMeters`
1372
- );
1373
- }
1374
- if (this.config.allowedRoutes.length > 0) {
1375
- if (!options.routeId) {
1376
- throw new FartherShoreError(
1377
- "invalid_token",
1378
- "routeId is required because this runtime token is route-scoped"
1379
- );
1380
- }
1381
- if (!this.config.allowedRoutes.includes(options.routeId)) {
1382
- throw new FartherShoreError(
1383
- "invalid_token",
1384
- `route '${options.routeId}' is not in the token's allowedRoutes`
1385
- );
1386
- }
1387
- }
1388
- if (this.config.perEventMax > 0 && qty > this.config.perEventMax) {
1389
- throw new FartherShoreError(
1390
- "invalid_token",
1391
- `meter '${meter}' qty ${qty} exceeds the per-event max ${this.config.perEventMax}`
1392
- );
1393
- }
1394
- const event = {
1395
- event_id: options.eventId ?? this.newId(),
1396
- business_id: this.businessId,
1397
- backend_id: this.backendId,
1398
- meter,
1399
- qty,
1400
- timestamp: options.timestamp ?? this.now().toISOString(),
1401
- ...options.routeId ? { route_id: options.routeId } : {},
1402
- ...options.requestId ? { request_id: options.requestId } : {},
1403
- ...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
1404
- };
1405
- this.buffer.push(event);
1406
- await this.flush();
1407
- }
1408
- /** Drain the buffer. Events that fail all retries stay buffered (at-least-once). */
1409
- async flush() {
1410
- const pending = this.buffer.splice(0, this.buffer.length);
1411
- const stillPending = [];
1412
- for (const event of pending) {
1413
- const sent = await this.sendWithRetry(event);
1414
- if (!sent) stillPending.push(event);
1415
- }
1416
- if (stillPending.length > 0) this.buffer.unshift(...stillPending);
1417
- }
1418
- /** Buffered-but-unsent count (observability/tests). */
1419
- get pending() {
1420
- return this.buffer.length;
1421
- }
1422
- async sendWithRetry(event) {
1423
- for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
1424
- let retryAfter = null;
1425
- try {
1426
- const response = await fetchWithDeadline(
1427
- this.fetchImpl,
1428
- this.endpoint,
1429
- {
1430
- method: "POST",
1431
- headers: {
1432
- authorization: `Bearer ${this.config.credential}`,
1433
- "content-type": "application/json",
1434
- accept: "application/json"
1435
- },
1436
- body: JSON.stringify(event)
1437
- },
1438
- "metering"
1439
- );
1440
- if (response.ok) return true;
1441
- if (!isTransientStatus(response.status)) return false;
1442
- retryAfter = retryAfterMs(response.headers);
1443
- } catch {
1444
- }
1445
- const isLast = attempt === this.maxRetries - 1;
1446
- if (isLast) break;
1447
- const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
1448
- baseMs: this.baseDelayMs,
1449
- maxMs: this.maxDelayMs,
1450
- random: this.random
1451
- });
1452
- await this.sleep(delay);
1453
- }
1454
- return false;
1455
- }
1456
- };
1457
- function resolveEndpoint(endpoint, coreUrl) {
1458
- if (/^https?:\/\//.test(endpoint)) return endpoint;
1459
- if (!coreUrl) return endpoint;
1460
- return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1461
- }
1462
-
1463
1288
  // src/response-metering.ts
1464
1289
  var RESPONSE_METERING_HEADERS = RUNTIME_RESPONSE_METERING_CONTRACT.headers;
1465
1290
  var RESPONSE_METERING_ERROR_CODES = RUNTIME_RESPONSE_METERING_CONTRACT.errors;
@@ -1471,6 +1296,49 @@ var METERING_PAYLOAD_HEADER = RESPONSE_METERING_HEADERS.payload;
1471
1296
  var METERING_SIGNATURE_HEADER = RESPONSE_METERING_HEADERS.signature;
1472
1297
  var METERING_TOKEN_HEADER = RESPONSE_METERING_HEADERS.token;
1473
1298
  var DEFAULT_TOKEN_ENV = RUNTIME_RESPONSE_METERING_CONTRACT.token.environmentVariable;
1299
+ var MeteringError = class extends Error {
1300
+ code;
1301
+ constructor(code, message) {
1302
+ super(message);
1303
+ this.name = "MeteringError";
1304
+ this.code = code;
1305
+ }
1306
+ };
1307
+ async function computeMeteringHeaders(payload, options = {}) {
1308
+ try {
1309
+ const token = resolveTokenSoft(options);
1310
+ if (!token) {
1311
+ skip(`${DEFAULT_TOKEN_ENV} is not set`, options);
1312
+ return {};
1313
+ }
1314
+ const json2 = JSON.stringify(payload);
1315
+ const signature = await signPayload(json2, token);
1316
+ devMeteringHooks?.record?.(payload, options.requestId);
1317
+ return {
1318
+ [METERING_PAYLOAD_HEADER]: json2,
1319
+ [METERING_SIGNATURE_HEADER]: signature,
1320
+ [METERING_TOKEN_HEADER]: token
1321
+ };
1322
+ } catch (error) {
1323
+ skip(error instanceof Error ? error.message : String(error), options);
1324
+ return {};
1325
+ }
1326
+ }
1327
+ function skip(reason, options) {
1328
+ if (options.onSkip) {
1329
+ options.onSkip(reason);
1330
+ } else {
1331
+ console.warn(`metering headers skipped: ${reason}`);
1332
+ }
1333
+ devMeteringHooks?.onSkip?.(reason, options.requestId);
1334
+ }
1335
+ function resolveTokenSoft(options) {
1336
+ return options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV) ?? devMeteringHooks?.fallbackToken?.();
1337
+ }
1338
+ function processEnv(key2) {
1339
+ const maybeProcess = globalThis.process;
1340
+ return maybeProcess?.env?.[key2];
1341
+ }
1474
1342
  async function signPayload(payload, token) {
1475
1343
  const key2 = await crypto.subtle.importKey(
1476
1344
  "raw",
@@ -1495,7 +1363,7 @@ function base64url(bytes) {
1495
1363
  }
1496
1364
 
1497
1365
  // src/core/post-stream-usage.ts
1498
- var METER_KEY_RE2 = /^[a-z0-9_]{1,64}$/;
1366
+ var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
1499
1367
  var PostStreamUsageClient = class {
1500
1368
  config;
1501
1369
  endpoint;
@@ -1507,7 +1375,7 @@ var PostStreamUsageClient = class {
1507
1375
  maxRetryDelayMs;
1508
1376
  constructor(options) {
1509
1377
  this.config = options.config;
1510
- this.endpoint = resolveEndpoint2(options.config.endpoint, options.coreUrl);
1378
+ this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
1511
1379
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1512
1380
  this.newNonce = options.newNonce ?? (() => crypto.randomUUID());
1513
1381
  this.logger = options.logger ?? ((message) => console.warn(message));
@@ -1524,6 +1392,11 @@ var PostStreamUsageClient = class {
1524
1392
  requestId: input.requestId,
1525
1393
  subscriptionId: input.subscriptionId,
1526
1394
  nonce: this.newNonce(),
1395
+ // The token's `allowedMeters` scope is enforced on BOTH lanes
1396
+ // independently (P0-1): `meters` is the flat METER-keyed projection
1397
+ // (the billed lane), so its keys must be in scope regardless of
1398
+ // whether the measurement lane is also present; `measurements[].meter`
1399
+ // is scoped in validateMeasurements below.
1527
1400
  meters: validateAndSortUsage(input.meters, "meters", this.config, true),
1528
1401
  ...input.creditUnitsConsumed ? {
1529
1402
  creditUnitsConsumed: validateAndSortUsage(
@@ -1533,7 +1406,13 @@ var PostStreamUsageClient = class {
1533
1406
  false
1534
1407
  )
1535
1408
  } : {},
1536
- ...input.measureContext ? { measureContext: input.measureContext } : {}
1409
+ ...input.measureContext ? { measureContext: input.measureContext } : {},
1410
+ // Key ORDER is load-bearing: core recomputes the HMAC over
1411
+ // JSON.stringify(unsigned) rebuilt in its zod schema's field order, so
1412
+ // these additive fields must sit in the same position on both sides.
1413
+ ...input.measurementsVersion !== void 0 ? { measurementsVersion: input.measurementsVersion } : {},
1414
+ ...input.measurements ? { measurements: this.validateMeasurements(input.measurements) } : {},
1415
+ ...input.quote ? { quote: input.quote } : {}
1537
1416
  };
1538
1417
  const signature = await signPayload(
1539
1418
  JSON.stringify(unsigned),
@@ -1541,6 +1420,7 @@ var PostStreamUsageClient = class {
1541
1420
  );
1542
1421
  const event = { ...unsigned, signature };
1543
1422
  const body = JSON.stringify(event);
1423
+ const headerSignature = await signPayload(body, this.config.credential);
1544
1424
  for (let attempt = 0; ; attempt += 1) {
1545
1425
  let response;
1546
1426
  try {
@@ -1552,7 +1432,8 @@ var PostStreamUsageClient = class {
1552
1432
  headers: {
1553
1433
  authorization: `Bearer ${this.config.credential}`,
1554
1434
  "content-type": "application/json",
1555
- accept: "application/json"
1435
+ accept: "application/json",
1436
+ [RUNTIME_RESPONSE_METERING_CONTRACT.headers.signature]: headerSignature
1556
1437
  },
1557
1438
  body
1558
1439
  },
@@ -1569,7 +1450,7 @@ var PostStreamUsageClient = class {
1569
1450
  const retryable = requestNotFound || isRetryableStatus(response.status);
1570
1451
  const delayMs = this.retryDelayForAttempt(
1571
1452
  attempt,
1572
- retryAfterMs2(response.headers)
1453
+ retryAfterMs(response.headers)
1573
1454
  );
1574
1455
  if (!retryable || delayMs === null) {
1575
1456
  throw new Error(`metering endpoint returned ${response.status}`);
@@ -1582,11 +1463,30 @@ var PostStreamUsageClient = class {
1582
1463
  return { ok: false, reason };
1583
1464
  }
1584
1465
  }
1585
- retryDelayForAttempt(attempt, retryAfterMs3) {
1466
+ /** Enforce the token's meter scope + per-event bounds on the measurement lane. */
1467
+ validateMeasurements(measurements) {
1468
+ const allowed = this.config.allowedMeters;
1469
+ for (const measurement of measurements) {
1470
+ if (allowed.length > 0 && !allowed.includes(measurement.meter)) {
1471
+ throw new Error(
1472
+ `meter '${measurement.meter}' is not in the token's allowedMeters`
1473
+ );
1474
+ }
1475
+ for (const [measure, value] of Object.entries(measurement.values)) {
1476
+ if (this.config.perEventMax > 0 && value > this.config.perEventMax) {
1477
+ throw new Error(
1478
+ `measure '${measure}' value ${value} exceeds the per-event max ${this.config.perEventMax}`
1479
+ );
1480
+ }
1481
+ }
1482
+ }
1483
+ return measurements;
1484
+ }
1485
+ retryDelayForAttempt(attempt, retryAfterMs2) {
1586
1486
  const fallback = this.retryDelaysMs[attempt];
1587
1487
  if (fallback === void 0) return null;
1588
- if (retryAfterMs3 === null) return fallback;
1589
- return Math.min(retryAfterMs3, this.maxRetryDelayMs);
1488
+ if (retryAfterMs2 === null) return fallback;
1489
+ return Math.min(retryAfterMs2, this.maxRetryDelayMs);
1590
1490
  }
1591
1491
  };
1592
1492
  async function isPostStreamRequestNotFound(response) {
@@ -1601,7 +1501,7 @@ async function isPostStreamRequestNotFound(response) {
1601
1501
  function isRetryableStatus(status) {
1602
1502
  return status === 429 || status >= 500 && status <= 599;
1603
1503
  }
1604
- function retryAfterMs2(headers) {
1504
+ function retryAfterMs(headers) {
1605
1505
  const raw = headers.get("retry-after");
1606
1506
  if (!raw) return null;
1607
1507
  const seconds = Number(raw);
@@ -1618,7 +1518,7 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1618
1518
  ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1619
1519
  );
1620
1520
  for (const [meter, qty] of entries) {
1621
- if (!METER_KEY_RE2.test(meter)) {
1521
+ if (!METER_KEY_RE.test(meter)) {
1622
1522
  throw new Error(
1623
1523
  `${label} key '${meter}' must be lowercase alphanumeric with underscores`
1624
1524
  );
@@ -1637,12 +1537,303 @@ function validateAndSortUsage(usage, label, config, enforceMeterScope) {
1637
1537
  }
1638
1538
  return Object.fromEntries(entries);
1639
1539
  }
1640
- function resolveEndpoint2(endpoint, coreUrl) {
1540
+ function resolveEndpoint(endpoint, coreUrl) {
1641
1541
  if (/^https?:\/\//.test(endpoint)) return endpoint;
1642
1542
  if (!coreUrl) return endpoint;
1643
1543
  return `${coreUrl.replace(/\/+$/, "")}${endpoint}`;
1644
1544
  }
1645
1545
 
1546
+ // src/core/report.ts
1547
+ var MEASUREMENTS_VERSION = 1;
1548
+ var KEY_RE = /^[a-z0-9_]{1,64}$/;
1549
+ var DIMENSION_VALUE_RE = /^[\w.:-]{1,128}$/;
1550
+ var CURRENCY_RE = /^[A-Za-z]{3}$/;
1551
+ var DECIMAL_INTEGER_RE = /^\d{1,30}$/;
1552
+ function createReportFn(channels) {
1553
+ let stampedMeasurements = [];
1554
+ let stampedQuote;
1555
+ let inBandTail = Promise.resolve();
1556
+ let postStreamFinalized = false;
1557
+ let pendingPostStreamBatch = null;
1558
+ const deliverPostStream = async (reported, quote) => {
1559
+ if (pendingPostStreamBatch) {
1560
+ if (!quotesEqual(pendingPostStreamBatch.quote, quote)) {
1561
+ return {
1562
+ ok: false,
1563
+ transport: "post_stream",
1564
+ reason: "quote conflicts with this request's pending post-stream batch: one served request carries one quote across all measurements"
1565
+ };
1566
+ }
1567
+ if (reported.some(
1568
+ (measurement) => !dimsEqual(
1569
+ pendingPostStreamBatch.measurements[0]?.dims,
1570
+ measurement.dims
1571
+ )
1572
+ )) {
1573
+ return {
1574
+ ok: false,
1575
+ transport: "post_stream",
1576
+ reason: "dims conflict with this request's pending post-stream batch: the request receipt rates under ONE dims tuple"
1577
+ };
1578
+ }
1579
+ pendingPostStreamBatch.measurements.push(...reported);
1580
+ return pendingPostStreamBatch.flush;
1581
+ }
1582
+ if (postStreamFinalized) {
1583
+ return {
1584
+ ok: false,
1585
+ transport: "post_stream",
1586
+ 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"
1587
+ };
1588
+ }
1589
+ if (stampedMeasurements.length > 0) {
1590
+ return {
1591
+ ok: false,
1592
+ transport: "post_stream",
1593
+ reason: "this request already reported in-band; every report on one request must share the stamped aggregate (same quote, before the response is sent)"
1594
+ };
1595
+ }
1596
+ postStreamFinalized = true;
1597
+ const batch = {
1598
+ measurements: [...reported],
1599
+ quote,
1600
+ flush: void 0
1601
+ };
1602
+ batch.flush = new Promise((resolve) => setTimeout(resolve, 0)).then(
1603
+ async () => {
1604
+ pendingPostStreamBatch = null;
1605
+ const result = await channels.postStream({
1606
+ measurements: batch.measurements,
1607
+ ...batch.quote ? { quote: batch.quote } : {}
1608
+ });
1609
+ return result.ok ? { ok: true, transport: "post_stream" } : {
1610
+ ok: false,
1611
+ transport: "post_stream",
1612
+ reason: result.reason ?? "post-stream delivery failed"
1613
+ };
1614
+ }
1615
+ );
1616
+ pendingPostStreamBatch = batch;
1617
+ return batch.flush;
1618
+ };
1619
+ const tryInBand = (reported, quote) => {
1620
+ const run = inBandTail.then(async () => {
1621
+ const sink = channels.responseSink;
1622
+ if (!sink || !channels.request || !sink.canStampHeaders()) return null;
1623
+ if (postStreamFinalized) return null;
1624
+ if (!quotesEqual(stampedQuote, quote) && stampedMeasurements.length > 0) {
1625
+ return null;
1626
+ }
1627
+ if (stampedMeasurements.length > 0 && reported.some(
1628
+ (measurement) => !dimsEqual(stampedMeasurements[0].dims, measurement.dims)
1629
+ )) {
1630
+ return null;
1631
+ }
1632
+ const measurements = [...stampedMeasurements, ...reported];
1633
+ const payload = buildInBandPayload(channels.request, measurements, quote);
1634
+ const headers = await channels.computeHeaders(payload);
1635
+ if (Object.keys(headers).length === 0 || !sink.canStampHeaders()) {
1636
+ return null;
1637
+ }
1638
+ sink.stampHeaders(headers);
1639
+ stampedMeasurements = measurements;
1640
+ stampedQuote = quote;
1641
+ return { ok: true, transport: "in_band" };
1642
+ });
1643
+ inBandTail = run.then(
1644
+ () => void 0,
1645
+ () => void 0
1646
+ );
1647
+ return run;
1648
+ };
1649
+ return async (input) => {
1650
+ const inputs = Array.isArray(input) ? input : [input];
1651
+ if (inputs.length === 0) {
1652
+ throw new MeteringError(
1653
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1654
+ "report([]) is empty: a batched report needs at least one measurement"
1655
+ );
1656
+ }
1657
+ const measurements = inputs.map((entry) => validateMeasurement(entry));
1658
+ for (const measurement of measurements) {
1659
+ if (!dimsEqual(measurements[0].dims, measurement.dims)) {
1660
+ throw new MeteringError(
1661
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1662
+ "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"
1663
+ );
1664
+ }
1665
+ }
1666
+ let quote;
1667
+ for (const entry of inputs) {
1668
+ if (entry.quote === void 0) continue;
1669
+ const validated = validateQuote(entry.quote);
1670
+ if (quote === void 0) {
1671
+ quote = validated;
1672
+ } else if (!quotesEqual(quote, validated)) {
1673
+ throw new MeteringError(
1674
+ RESPONSE_METERING_ERROR_CODES.invalidQuote,
1675
+ "a batched report carries ONE quote: two entries supplied different quotes"
1676
+ );
1677
+ }
1678
+ }
1679
+ const inBand = await tryInBand(measurements, quote);
1680
+ if (inBand) return inBand;
1681
+ return deliverPostStream(measurements, quote);
1682
+ };
1683
+ }
1684
+ function unattachedReport() {
1685
+ return () => Promise.reject(
1686
+ new MeteringError(
1687
+ RESPONSE_METERING_ERROR_CODES.missingToken,
1688
+ "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"
1689
+ )
1690
+ );
1691
+ }
1692
+ function rawDimsUnitsOf(measurement) {
1693
+ let total = 0;
1694
+ for (const value of Object.values(measurement.values)) total += value;
1695
+ return { [measurement.meter]: total };
1696
+ }
1697
+ function buildInBandPayload(request, measurements, quote) {
1698
+ const rawDimsUnits = {};
1699
+ for (const measurement of measurements) {
1700
+ for (const [meter, units] of Object.entries(rawDimsUnitsOf(measurement))) {
1701
+ rawDimsUnits[meter] = (rawDimsUnits[meter] ?? 0) + units;
1702
+ }
1703
+ }
1704
+ return {
1705
+ method: request.method.toUpperCase(),
1706
+ path: request.path,
1707
+ rawDimsUnits,
1708
+ measurementsVersion: MEASUREMENTS_VERSION,
1709
+ measurements,
1710
+ ...quote ? { quote } : {}
1711
+ };
1712
+ }
1713
+ function dimsEqual(left, right) {
1714
+ const l = Object.entries(left ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
1715
+ const r = Object.entries(right ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
1716
+ if (l.length !== r.length) return false;
1717
+ return l.every(([k, v], i) => r[i][0] === k && r[i][1] === v);
1718
+ }
1719
+ function quotesEqual(left, right) {
1720
+ return left === right || left !== void 0 && right !== void 0 && left.currency === right.currency && left.amountNanos === right.amountNanos;
1721
+ }
1722
+ function validateMeasurement(input) {
1723
+ if (!input || typeof input !== "object") {
1724
+ throw invalidKey("report() requires a { meter, values } object");
1725
+ }
1726
+ const meter = assertKey(input.meter, "meter");
1727
+ const values = assertValues(input.values);
1728
+ const dims = input.dims === void 0 ? void 0 : assertDims(input.dims);
1729
+ return {
1730
+ meter,
1731
+ values,
1732
+ ...dims && Object.keys(dims).length > 0 ? { dims } : {}
1733
+ };
1734
+ }
1735
+ function validateQuote(quote) {
1736
+ if (!quote || typeof quote !== "object" || Array.isArray(quote)) {
1737
+ throw invalidQuote(
1738
+ "quote must be an object of the form { currency, amountNanos }"
1739
+ );
1740
+ }
1741
+ const { currency, amountNanos } = quote;
1742
+ if (typeof currency !== "string" || !CURRENCY_RE.test(currency)) {
1743
+ throw invalidQuote("quote.currency must be a 3-letter currency code");
1744
+ }
1745
+ return {
1746
+ currency: currency.toLowerCase(),
1747
+ amountNanos: assertAmountNanos(amountNanos)
1748
+ };
1749
+ }
1750
+ function assertAmountNanos(value) {
1751
+ if (typeof value === "bigint") {
1752
+ if (value < 0n) throw negativeAmountNanos();
1753
+ return value.toString();
1754
+ }
1755
+ if (typeof value === "number") {
1756
+ if (!Number.isSafeInteger(value)) {
1757
+ throw invalidQuote(
1758
+ "quote.amountNanos must be a safe integer number of nanodollars (pass a string or bigint for larger amounts)"
1759
+ );
1760
+ }
1761
+ if (value < 0) throw negativeAmountNanos();
1762
+ return String(value);
1763
+ }
1764
+ if (typeof value === "string") {
1765
+ if (/^-/.test(value)) throw negativeAmountNanos();
1766
+ if (DECIMAL_INTEGER_RE.test(value)) return value;
1767
+ }
1768
+ throw invalidQuote(
1769
+ "quote.amountNanos must be a non-negative integer number of nanodollars"
1770
+ );
1771
+ }
1772
+ function negativeAmountNanos() {
1773
+ return invalidQuote(
1774
+ "quote.amountNanos must be non-negative: a quote is a proposed rate, never a credit \u2014 refunds are platform operations"
1775
+ );
1776
+ }
1777
+ function assertValues(values) {
1778
+ if (!values || typeof values !== "object" || Array.isArray(values)) {
1779
+ throw invalidKey("report() requires a values object");
1780
+ }
1781
+ const entries = Object.entries(values).sort(
1782
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1783
+ );
1784
+ if (entries.length === 0) {
1785
+ throw invalidKey("report() requires at least one measure in values");
1786
+ }
1787
+ const out = {};
1788
+ for (const [measure, value] of entries) {
1789
+ assertKey(measure, "measure");
1790
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
1791
+ throw new MeteringError(
1792
+ RESPONSE_METERING_ERROR_CODES.invalidMeterValue,
1793
+ `values.${measure} must be a non-negative safe integer`
1794
+ );
1795
+ }
1796
+ out[measure] = value;
1797
+ }
1798
+ return out;
1799
+ }
1800
+ function assertDims(dims) {
1801
+ if (!dims || typeof dims !== "object" || Array.isArray(dims)) {
1802
+ throw invalidKey("report() dims must be an object of dimension selectors");
1803
+ }
1804
+ const entries = Object.entries(dims).sort(
1805
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
1806
+ );
1807
+ const out = {};
1808
+ for (const [dimension, value] of entries) {
1809
+ assertKey(dimension, "dimension");
1810
+ if (typeof value !== "string" || !DIMENSION_VALUE_RE.test(value)) {
1811
+ throw invalidKey(
1812
+ `dims.${dimension} must be a 1-128 character selector value`
1813
+ );
1814
+ }
1815
+ out[dimension] = value;
1816
+ }
1817
+ return out;
1818
+ }
1819
+ function assertKey(value, label) {
1820
+ if (typeof value !== "string" || !KEY_RE.test(value)) {
1821
+ throw invalidKey(
1822
+ `${label} key ${JSON.stringify(value)} must be 1-64 lowercase alphanumeric characters or underscores`
1823
+ );
1824
+ }
1825
+ return value;
1826
+ }
1827
+ function invalidKey(message) {
1828
+ return new MeteringError(
1829
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1830
+ message
1831
+ );
1832
+ }
1833
+ function invalidQuote(message) {
1834
+ return new MeteringError(RESPONSE_METERING_ERROR_CODES.invalidQuote, message);
1835
+ }
1836
+
1646
1837
  // src/core/nonceCache.ts
1647
1838
  var DEFAULT_MAX_ENTRIES = 25e4;
1648
1839
  var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
@@ -2236,22 +2427,7 @@ async function verifyRequest(input, deps) {
2236
2427
  "x-fs-timestamp is not an integer"
2237
2428
  );
2238
2429
  }
2239
- const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
2240
- const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
2241
- const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
2242
- const delta = now - timestamp;
2243
- if (Math.abs(delta) > window) {
2244
- if (Math.abs(delta) <= window + skew) {
2245
- throw new FartherShoreError(
2246
- "clock_skew",
2247
- "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
2248
- );
2249
- }
2250
- throw new FartherShoreError(
2251
- "expired_signature",
2252
- "x-fs-timestamp is outside the replay window"
2253
- );
2254
- }
2430
+ assertTimestampWithinWindow(timestamp, deps);
2255
2431
  const computedBodyHash = await computeBodyHash(input);
2256
2432
  if (signedBodyHash !== computedBodyHash) {
2257
2433
  throw new FartherShoreError(
@@ -2259,31 +2435,7 @@ async function verifyRequest(input, deps) {
2259
2435
  "recomputed body hash does not match the signed x-fs-body-hash"
2260
2436
  );
2261
2437
  }
2262
- if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
2263
- throw new FartherShoreError(
2264
- "route_mismatch",
2265
- "signed business-id does not match this backend's business"
2266
- );
2267
- }
2268
- if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
2269
- if (!deps.backendIds.has(signedBackendId)) {
2270
- throw new FartherShoreError(
2271
- "route_mismatch",
2272
- "signed backend-id is not one this deployment serves"
2273
- );
2274
- }
2275
- } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
2276
- throw new FartherShoreError(
2277
- "route_mismatch",
2278
- "signed backend-id does not match this backend"
2279
- );
2280
- }
2281
- if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
2282
- throw new FartherShoreError(
2283
- "route_mismatch",
2284
- "signed route-id is not served by this backend"
2285
- );
2286
- }
2438
+ assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId);
2287
2439
  const contextToken = h("x-fs-context") ?? null;
2288
2440
  const canonicalInput = {
2289
2441
  method: input.method,
@@ -2354,9 +2506,57 @@ async function verifyRequest(input, deps) {
2354
2506
  ...principal ? { principal } : {},
2355
2507
  ...permissions !== void 0 ? { permissions } : {},
2356
2508
  ...roles !== void 0 ? { roles } : {},
2357
- ...signedContext ? { signedContext } : {}
2509
+ ...signedContext ? { signedContext } : {},
2510
+ // The bare primitive has no metering channel; the runtime facade replaces
2511
+ // this with the real bound verb (see FartherShore.verifyRequest).
2512
+ report: unattachedReport()
2358
2513
  };
2359
2514
  }
2515
+ function assertTimestampWithinWindow(timestamp, deps) {
2516
+ const skew = deps.clockSkewSeconds ?? RUNTIME_CLOCK_SKEW_SECONDS;
2517
+ const window = deps.replayWindowSeconds ?? RUNTIME_REPLAY_WINDOW_SECONDS;
2518
+ const now = (deps.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
2519
+ const delta = now - timestamp;
2520
+ if (Math.abs(delta) > window) {
2521
+ if (Math.abs(delta) <= window + skew) {
2522
+ throw new FartherShoreError(
2523
+ "clock_skew",
2524
+ "x-fs-timestamp is outside the replay window but within clock-skew tolerance"
2525
+ );
2526
+ }
2527
+ throw new FartherShoreError(
2528
+ "expired_signature",
2529
+ "x-fs-timestamp is outside the replay window"
2530
+ );
2531
+ }
2532
+ }
2533
+ function assertClaimBinding(deps, signedBusinessId, signedBackendId, signedRouteId) {
2534
+ if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
2535
+ throw new FartherShoreError(
2536
+ "route_mismatch",
2537
+ "signed business-id does not match this backend's business"
2538
+ );
2539
+ }
2540
+ if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
2541
+ if (!deps.backendIds.has(signedBackendId)) {
2542
+ throw new FartherShoreError(
2543
+ "route_mismatch",
2544
+ "signed backend-id is not one this deployment serves"
2545
+ );
2546
+ }
2547
+ } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
2548
+ throw new FartherShoreError(
2549
+ "route_mismatch",
2550
+ "signed backend-id does not match this backend"
2551
+ );
2552
+ }
2553
+ if (deps.knownRouteIds !== void 0 && signedRouteId !== "" && !deps.knownRouteIds.has(signedRouteId)) {
2554
+ throw new FartherShoreError(
2555
+ "route_mismatch",
2556
+ "signed route-id is not served by this backend"
2557
+ );
2558
+ }
2559
+ }
2360
2560
  async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
2361
2561
  if (!contextToken) return null;
2362
2562
  const signedContext = decodeContextClaims(contextToken);
@@ -2409,7 +2609,7 @@ function headerGetter(headers) {
2409
2609
 
2410
2610
  // src/core/runtime.ts
2411
2611
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
2412
- var SDK_VERSION = "0.20.0".length > 0 ? "0.20.0" : "0.0.0-dev";
2612
+ var SDK_VERSION = "0.21.0".length > 0 ? "0.21.0" : "0.0.0-dev";
2413
2613
  var FartherShore = class {
2414
2614
  bootstrapClient;
2415
2615
  fetchImpl;
@@ -2425,14 +2625,13 @@ var FartherShore = class {
2425
2625
  replayProtectionDiagnostic;
2426
2626
  shutdownManager = new ShutdownManager();
2427
2627
  jwks = null;
2428
- meteringClient = null;
2429
2628
  postStreamUsageClient = null;
2430
2629
  tunnel = null;
2431
2630
  bootstrapped = false;
2432
2631
  constructor(options = {}) {
2433
2632
  const env = options.env ?? readProcessEnv();
2434
2633
  const runtimeToken = options.runtimeToken ?? env[FS_RUNTIME_TOKEN_ENV] ?? "";
2435
- const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? env.FARTHERSHORE_CORE_URL ?? DEFAULT_CORE_URL;
2634
+ const coreUrl = options.coreUrl ?? env.FS_CORE_URL ?? DEFAULT_CORE_URL;
2436
2635
  this.runtimeToken = runtimeToken;
2437
2636
  this.coreUrl = coreUrl;
2438
2637
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
@@ -2454,9 +2653,6 @@ var FartherShore = class {
2454
2653
  ...options.instanceId ? { instanceId: options.instanceId } : {}
2455
2654
  }
2456
2655
  });
2457
- this.shutdownManager.register(async () => {
2458
- await this.meteringClient?.flush();
2459
- });
2460
2656
  this.shutdownManager.register(async () => {
2461
2657
  await reportHealth({
2462
2658
  runtimeToken: this.runtimeToken,
@@ -2476,14 +2672,7 @@ var FartherShore = class {
2476
2672
  fetchImpl: this.fetchImpl
2477
2673
  });
2478
2674
  }
2479
- if (!this.meteringClient && config.metering.enabled) {
2480
- this.meteringClient = new MeteringClient({
2481
- config: config.metering,
2482
- businessId: config.business.id,
2483
- backendId: config.backend.id,
2484
- coreUrl: this.coreUrl,
2485
- fetchImpl: this.fetchImpl
2486
- });
2675
+ if (!this.postStreamUsageClient && config.metering.enabled) {
2487
2676
  this.postStreamUsageClient = new PostStreamUsageClient({
2488
2677
  config: config.metering,
2489
2678
  coreUrl: this.coreUrl,
@@ -2556,7 +2745,7 @@ var FartherShore = class {
2556
2745
  * Framework-neutral verification primitive. Fail-closed: throws a typed
2557
2746
  * FartherShoreError on any verification failure. Returns the verified context.
2558
2747
  */
2559
- async verifyRequest(input) {
2748
+ async verifyRequest(input, options = {}) {
2560
2749
  const config = await this.ensureBootstrapped();
2561
2750
  if (!this.jwks) {
2562
2751
  throw new FartherShoreError(
@@ -2582,21 +2771,52 @@ var FartherShore = class {
2582
2771
  });
2583
2772
  return {
2584
2773
  ...context,
2585
- reportUsage: (report) => {
2586
- const subscriptionId = report.subscriptionId ?? context.signedContext?.subscriptionId;
2774
+ report: this.buildReportFn(context, input, options.responseSink)
2775
+ };
2776
+ }
2777
+ /**
2778
+ * Bind the ONE reporting verb to a verified context. Identity comes from the
2779
+ * context (`signedContext.subscriptionId` + `requestId`) — never from the
2780
+ * caller — so a handler cannot forget it, and a background job that is handed
2781
+ * this context keeps reporting against the SAME served identity.
2782
+ */
2783
+ buildReportFn(context, input, responseSink) {
2784
+ const channels = {
2785
+ request: { method: input.method, path: input.path },
2786
+ ...responseSink ? { responseSink } : {},
2787
+ computeHeaders: (payload) => computeMeteringHeaders(payload, {
2788
+ token: this.runtimeToken,
2789
+ requestId: context.requestId,
2790
+ onSkip: () => {
2791
+ }
2792
+ }),
2793
+ postStream: async ({ measurements, quote }) => {
2794
+ const subscriptionId = context.signedContext?.subscriptionId;
2587
2795
  if (!subscriptionId) {
2588
- return Promise.resolve({
2796
+ return {
2589
2797
  ok: false,
2590
- reason: "subscriptionId is required"
2591
- });
2798
+ 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"
2799
+ };
2592
2800
  }
2593
- return this.reportUsage({
2594
- ...report,
2595
- requestId: report.requestId ?? context.requestId,
2596
- subscriptionId
2801
+ const meters = {};
2802
+ for (const measurement of measurements) {
2803
+ for (const [meter, qty] of Object.entries(
2804
+ rawDimsUnitsOf(measurement)
2805
+ )) {
2806
+ meters[meter] = (meters[meter] ?? 0) + qty;
2807
+ }
2808
+ }
2809
+ return this.reportPostStreamUsage({
2810
+ requestId: context.requestId,
2811
+ subscriptionId,
2812
+ meters,
2813
+ measurementsVersion: MEASUREMENTS_VERSION,
2814
+ measurements,
2815
+ ...quote ? { quote } : {}
2597
2816
  });
2598
2817
  }
2599
2818
  };
2819
+ return createReportFn(channels);
2600
2820
  }
2601
2821
  /** Whether verification is required (bootstrap × opt-out). */
2602
2822
  async verificationRequired() {
@@ -2645,20 +2865,12 @@ var FartherShore = class {
2645
2865
  });
2646
2866
  await supervisor.start();
2647
2867
  }
2648
- /** Record metering usage (billing-only). */
2649
- async meter(meter, qty, options = {}) {
2650
- await this.ensureBootstrapped();
2651
- if (!this.meteringEnabledOverride) return;
2652
- if (!this.meteringClient) {
2653
- throw new FartherShoreError(
2654
- "invalid_token",
2655
- "metering is not enabled for this runtime token"
2656
- );
2657
- }
2658
- await this.meteringClient.meter(meter, qty, options);
2659
- }
2660
- /** Best-effort attested post-stream usage callback. Never rejects. */
2661
- async reportUsage(input) {
2868
+ /**
2869
+ * PRIVATE transport for the post-stream lane of `ctx.report()`. Never rejects
2870
+ * — a metering hiccup must not break a builder's endpoint. This is machinery,
2871
+ * not surface: the ONE public reporting verb is `ctx.report()`.
2872
+ */
2873
+ async reportPostStreamUsage(input) {
2662
2874
  try {
2663
2875
  await this.ensureBootstrapped();
2664
2876
  if (!this.meteringEnabledOverride || !this.postStreamUsageClient) {
@@ -2690,7 +2902,7 @@ var FartherShore = class {
2690
2902
  // fs.start() launches an embedded tunnel; otherwise the supervisor state.
2691
2903
  tunnel: this.tunnel ? this.tunnel.healthString() : null,
2692
2904
  verification: this.verificationEnabled && config !== null,
2693
- metering: this.meteringClient !== null
2905
+ metering: this.postStreamUsageClient !== null
2694
2906
  });
2695
2907
  }
2696
2908
  /** Graceful shutdown: flush metering + send a stopping heartbeat. */
@@ -2734,7 +2946,12 @@ function permissionSatisfies(required, granted) {
2734
2946
  const idx = required.indexOf(":");
2735
2947
  if (idx > 0 && idx < required.length - 1) {
2736
2948
  const subject = required.slice(0, idx);
2737
- if (granted.includes(`${subject}:${WILDCARD}`)) return true;
2949
+ if (subject !== WILDCARD && granted.includes(`${subject}:${WILDCARD}`))
2950
+ return true;
2951
+ if (required.slice(idx + 1) === WILDCARD && subject !== WILDCARD) {
2952
+ const prefix = `${subject}:`;
2953
+ return granted.some((permission) => permission.startsWith(prefix));
2954
+ }
2738
2955
  }
2739
2956
  return false;
2740
2957
  }
@@ -2769,14 +2986,17 @@ async function runMiddleware(fs, options, req, res, next) {
2769
2986
  const contentType = headerValue(req.headers, "content-type");
2770
2987
  const streamingExempt = isStreamingExempt(contentType);
2771
2988
  const body = streamingExempt ? null : extractRawBody(req);
2772
- const ctx = await fs.verifyRequest({
2773
- method: req.method,
2774
- path,
2775
- query,
2776
- headers: req.headers,
2777
- body,
2778
- streamingExempt
2779
- });
2989
+ const ctx = await fs.verifyRequest(
2990
+ {
2991
+ method: req.method,
2992
+ path,
2993
+ query,
2994
+ headers: req.headers,
2995
+ body,
2996
+ streamingExempt
2997
+ },
2998
+ { responseSink: expressResponseSink(res) }
2999
+ );
2780
3000
  req.fartherShore = ctx;
2781
3001
  stripFartherShoreHeaders(req);
2782
3002
  next();
@@ -2784,6 +3004,16 @@ async function runMiddleware(fs, options, req, res, next) {
2784
3004
  fail(res, error, options, req);
2785
3005
  }
2786
3006
  }
3007
+ function expressResponseSink(res) {
3008
+ return {
3009
+ canStampHeaders: () => res.headersSent !== true,
3010
+ stampHeaders: (headers) => {
3011
+ for (const [name, value] of Object.entries(headers)) {
3012
+ res.setHeader(name, value);
3013
+ }
3014
+ }
3015
+ };
3016
+ }
2787
3017
  function fail(res, error, options, req) {
2788
3018
  const code = error instanceof FartherShoreError ? error.code : "bad_signature";
2789
3019
  const status = error instanceof FartherShoreError ? error.status : 401;
@@ -2824,7 +3054,12 @@ function stripFartherShoreHeaders(req) {
2824
3054
  withRaw.rawHeaders = cleaned;
2825
3055
  }
2826
3056
  }
2827
- function createExpressHandler(handler) {
3057
+ function createExpressHandler(optionsOrHandler, maybeHandler) {
3058
+ const options = typeof optionsOrHandler === "function" ? {} : optionsOrHandler;
3059
+ const handler = typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler;
3060
+ if (typeof handler !== "function") {
3061
+ throw new TypeError("fs.handler(options, cb) requires a handler callback");
3062
+ }
2828
3063
  return (req, res, next) => {
2829
3064
  const ctx = req.fartherShore;
2830
3065
  if (!ctx) {
@@ -2835,10 +3070,22 @@ function createExpressHandler(handler) {
2835
3070
  res.status(401).json({ error: "principal_required" });
2836
3071
  return;
2837
3072
  }
3073
+ if (!ctx.signedContext) {
3074
+ res.status(401).json({ error: "context_unverified" });
3075
+ return;
3076
+ }
2838
3077
  const verified = ctx;
2839
- void Promise.resolve().then(
2840
- () => handler(verified, req, res, next)
2841
- ).catch((error) => failHandler(res, next, error));
3078
+ void Promise.resolve().then(() => {
3079
+ if (options.permission !== void 0) {
3080
+ requirePermission(verified, options.permission);
3081
+ }
3082
+ return handler(
3083
+ verified,
3084
+ req,
3085
+ res,
3086
+ next
3087
+ );
3088
+ }).catch((error) => failHandler(res, next, error));
2842
3089
  };
2843
3090
  }
2844
3091
  function failHandler(res, next, error) {
@@ -2899,7 +3146,7 @@ function readProcessEnv2() {
2899
3146
  // src/testing/usageSink.ts
2900
3147
  var DevUsageSink = class {
2901
3148
  events = [];
2902
- /** Record a signed response-metering payload (withUsage / computeMeteringHeaders). */
3149
+ /** Record a signed response-metering payload (report() in-band / computeMeteringHeaders). */
2903
3150
  recordResponse(payload, requestId) {
2904
3151
  const raw = payload.rawDimsUnits;
2905
3152
  const meters = raw && typeof raw === "object" ? raw : {};
@@ -3179,6 +3426,7 @@ function createDevRuntime(options) {
3179
3426
  }
3180
3427
  fs.middleware = middleware;
3181
3428
  fs.handler = createExpressHandler;
3429
+ fs.authz = tracedAuthz;
3182
3430
  const devRuntime = {
3183
3431
  fs,
3184
3432
  asPersona: (name) => personaClient.asPersona(name),
@@ -3291,6 +3539,69 @@ function splitUrl2(req) {
3291
3539
  if (qIndex === -1) return { path: raw, query: "" };
3292
3540
  return { path: raw.slice(0, qIndex), query: raw.slice(qIndex + 1) };
3293
3541
  }
3542
+
3543
+ // ../contracts/dist/webhooks/standard-webhooks.js
3544
+ import { createHmac, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
3545
+ var WEBHOOK_ID_HEADER = "webhook-id";
3546
+ var WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
3547
+ var WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
3548
+ var WEBHOOK_SIGNATURE_VERSION = "v1";
3549
+ var WEBHOOK_SECRET_PREFIX = "fswh_";
3550
+ function webhookSecretKeyBytes(secret) {
3551
+ const key2 = secret.startsWith(WEBHOOK_SECRET_PREFIX) ? Buffer.from(secret.slice(WEBHOOK_SECRET_PREFIX.length), "base64") : secret.startsWith("whsec_") ? Buffer.from(secret.slice("whsec_".length), "base64") : Buffer.from(secret, "utf8");
3552
+ if (key2.length === 0) {
3553
+ throw new Error("webhook secret resolves to an empty signing key \u2014 the configured secret must be a non-empty fswh_ value");
3554
+ }
3555
+ return key2;
3556
+ }
3557
+ function webhookSignedContent(id, timestamp, body) {
3558
+ return `${id}.${timestamp}.${body}`;
3559
+ }
3560
+ function signWebhookContent(secret, id, timestamp, body) {
3561
+ const mac = createHmac("sha256", webhookSecretKeyBytes(secret)).update(webhookSignedContent(id, timestamp, body)).digest("base64");
3562
+ return `${WEBHOOK_SIGNATURE_VERSION},${mac}`;
3563
+ }
3564
+ function signWebhook(input) {
3565
+ if (input.secrets.length === 0) {
3566
+ throw new Error("signWebhook: at least one secret is required");
3567
+ }
3568
+ const signature = input.secrets.map((secret) => signWebhookContent(secret, input.id, input.timestamp, input.body)).join(" ");
3569
+ return {
3570
+ [WEBHOOK_ID_HEADER]: input.id,
3571
+ [WEBHOOK_TIMESTAMP_HEADER]: String(input.timestamp),
3572
+ [WEBHOOK_SIGNATURE_HEADER]: signature
3573
+ };
3574
+ }
3575
+
3576
+ // src/testing/webhooks.ts
3577
+ function signWebhookForTesting(input) {
3578
+ const secrets = typeof input.secret === "string" ? [input.secret] : [...input.secret];
3579
+ const id = input.id ?? `test_${crypto.randomUUID()}`;
3580
+ const envelope = {
3581
+ id,
3582
+ type: input.type,
3583
+ createdAt: input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3584
+ businessId: input.businessId ?? "biz_test",
3585
+ environmentId: input.environmentId ?? null,
3586
+ data: input.data
3587
+ };
3588
+ const body = JSON.stringify(envelope);
3589
+ const timestamp = input.timestamp ?? Math.floor(Date.now() / 1e3);
3590
+ const signed = signWebhook({ id, timestamp, body, secrets });
3591
+ const headers = {
3592
+ "content-type": "application/json",
3593
+ ...signed,
3594
+ "x-fs-webhook-event": input.type
3595
+ };
3596
+ return {
3597
+ envelope,
3598
+ body,
3599
+ headers,
3600
+ request(url = "https://receiver.test/webhooks/farthershore") {
3601
+ return new Request(url, { method: "POST", headers, body });
3602
+ }
3603
+ };
3604
+ }
3294
3605
  export {
3295
3606
  CONTEXT_HEADER_NAME,
3296
3607
  DEFAULT_KEYS_FILE,
@@ -3323,6 +3634,7 @@ export {
3323
3634
  readDevKeysFile,
3324
3635
  redactValue,
3325
3636
  signContextToken,
3637
+ signWebhookForTesting,
3326
3638
  unreachableJwks,
3327
3639
  writeDevKeysFile
3328
3640
  };