@farthershore/backend 0.14.0 → 0.16.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
@@ -215,7 +215,9 @@ var RUNTIME_ERROR_CODES = {
215
215
  environmentMismatch: "environment_mismatch",
216
216
  missingToken: "missing_token",
217
217
  invalidToken: "invalid_token",
218
- contextUnverified: "context_unverified"
218
+ contextUnverified: "context_unverified",
219
+ memberSubjectRequired: "member_subject_required",
220
+ serviceSubjectRequired: "service_subject_required"
219
221
  };
220
222
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
221
223
  headers: {
@@ -279,7 +281,10 @@ var RUNTIME_ERROR_CODE_TO_ERROR_CODE = {
279
281
  // the canonical code keeps the "dependency down" semantic for callers.
280
282
  [RUNTIME_ERROR_CODES.jwksUnavailable]: "SERVICE_UNAVAILABLE",
281
283
  // The single non-401 (413) — oversized request body.
282
- [RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR"
284
+ [RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR",
285
+ // Consumer-principal wave — route subject-requirement faults → FORBIDDEN (403).
286
+ [RUNTIME_ERROR_CODES.memberSubjectRequired]: "FORBIDDEN",
287
+ [RUNTIME_ERROR_CODES.serviceSubjectRequired]: "FORBIDDEN"
283
288
  };
284
289
  function runtimeErrorToErrorCode(code) {
285
290
  return RUNTIME_ERROR_CODE_TO_ERROR_CODE[code] ?? "INTERNAL_ERROR";
@@ -306,16 +311,12 @@ var RUNTIME_HEADER_NAMES = {
306
311
  keyId: "x-fs-key-id",
307
312
  requestId: "x-fs-request-id",
308
313
  timestamp: "x-fs-timestamp",
309
- productId: "x-fs-product-id",
314
+ businessId: "x-fs-business-id",
310
315
  backendId: "x-fs-backend-id",
311
316
  routeId: "x-fs-route-id",
312
317
  policyVersion: "x-fs-policy-version",
313
318
  bodyHash: "x-fs-body-hash"
314
319
  };
315
- var RUNTIME_IDENTITY_HEADER_NAMES = {
316
- permissions: "x-fs-permissions",
317
- roles: "x-fs-roles"
318
- };
319
320
  var RUNTIME_CLOCK_SKEW_SECONDS = 5;
320
321
  var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
321
322
  var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
@@ -358,34 +359,19 @@ var CANONICAL_SIGNING_FIELDS = [
358
359
  "body-hash",
359
360
  "request-id",
360
361
  "timestamp",
361
- "product-id",
362
+ "business-id",
362
363
  "backend-id",
363
364
  "route-id",
364
- "policy-version"
365
+ "policy-version",
366
+ // Consumer-principal wave (D3) — the trailing identity-context binding line.
367
+ // MUST stay last so pre-binding verifiers that stop at `policy-version`
368
+ // fail loud on a bound request rather than silently accepting a prefix.
369
+ "context-hash"
365
370
  ];
366
371
  var CANONICAL_FIELD_SEPARATOR = "\n";
367
372
  var CANONICAL_KV_SEPARATOR = ":";
368
373
  function canonicalizeQuery(query) {
369
- const raw = query.startsWith("?") ? query.slice(1) : query;
370
- if (raw === "")
371
- return "";
372
- const pairs = raw.split("&").filter((p) => p.length > 0);
373
- pairs.sort((a, b) => {
374
- const [an, ...arest] = a.split("=");
375
- const [bn, ...brest] = b.split("=");
376
- if (an < bn)
377
- return -1;
378
- if (an > bn)
379
- return 1;
380
- const av = arest.join("=");
381
- const bv = brest.join("=");
382
- if (av < bv)
383
- return -1;
384
- if (av > bv)
385
- return 1;
386
- return 0;
387
- });
388
- return pairs.join("&");
374
+ return query;
389
375
  }
390
376
  function buildCanonicalSigningString(input) {
391
377
  const values = {
@@ -395,13 +381,17 @@ function buildCanonicalSigningString(input) {
395
381
  "body-hash": input.bodyHash,
396
382
  "request-id": input.requestId,
397
383
  timestamp: String(input.timestamp),
398
- "product-id": input.productId,
384
+ "business-id": input.businessId,
399
385
  "backend-id": input.backendId,
400
386
  "route-id": input.routeId,
401
- "policy-version": input.policyVersion
387
+ "policy-version": input.policyVersion,
388
+ "context-hash": input.contextHash
402
389
  };
403
390
  return CANONICAL_SIGNING_FIELDS.map((field) => `${field}${CANONICAL_KV_SEPARATOR}${values[field]}`).join(CANONICAL_FIELD_SEPARATOR);
404
391
  }
392
+ function hashContextToken(token) {
393
+ return hashBody(new TextEncoder().encode(token ?? ""));
394
+ }
405
395
  var ED25519_ALGORITHM = "Ed25519";
406
396
  async function importEd25519PrivateKey(jwk) {
407
397
  return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["sign"]);
@@ -444,6 +434,7 @@ function base64UrlDecode(value) {
444
434
  var hashBody2 = hashBody;
445
435
  var canonicalizeQuery2 = canonicalizeQuery;
446
436
  var buildCanonicalSigningString2 = buildCanonicalSigningString;
437
+ var hashContextToken2 = hashContextToken;
447
438
  var signCanonicalString2 = signCanonicalString;
448
439
  var verifyCanonicalSignature2 = verifyCanonicalSignature;
449
440
  var runtimeTokenKind2 = runtimeTokenKind;
@@ -761,7 +752,7 @@ var DEFAULT_MAX_RETRIES = 3;
761
752
  var MeteringClient = class {
762
753
  config;
763
754
  endpoint;
764
- productId;
755
+ businessId;
765
756
  backendId;
766
757
  fetchImpl;
767
758
  maxRetries;
@@ -775,7 +766,7 @@ var MeteringClient = class {
775
766
  constructor(options) {
776
767
  this.config = options.config;
777
768
  this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
778
- this.productId = options.productId;
769
+ this.businessId = options.businessId;
779
770
  this.backendId = options.backendId;
780
771
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
781
772
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
@@ -838,13 +829,14 @@ var MeteringClient = class {
838
829
  }
839
830
  const event = {
840
831
  event_id: options.eventId ?? this.newId(),
841
- product_id: this.productId,
832
+ business_id: this.businessId,
842
833
  backend_id: this.backendId,
843
834
  meter,
844
835
  qty,
845
836
  timestamp: options.timestamp ?? this.now().toISOString(),
846
837
  ...options.routeId ? { route_id: options.routeId } : {},
847
- ...options.requestId ? { request_id: options.requestId } : {}
838
+ ...options.requestId ? { request_id: options.requestId } : {},
839
+ ...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
848
840
  };
849
841
  this.buffer.push(event);
850
842
  await this.flush();
@@ -940,7 +932,7 @@ async function withUsage(request, response, usage, options = {}) {
940
932
  }
941
933
  async function signResponse(request, response, usage, options, wrapOptions) {
942
934
  const payload = buildPayload(request, usage, options, wrapOptions);
943
- const requestId = request.headers.get("x-fs-request-id") ?? void 0;
935
+ const requestId = options.requestId ?? request.headers.get("x-fs-request-id") ?? void 0;
944
936
  const headers = await computeMeteringHeaders(payload, {
945
937
  ...options.token !== void 0 ? { token: options.token } : {},
946
938
  ...options.env !== void 0 ? { env: options.env } : {},
@@ -1194,8 +1186,8 @@ function resolveEndpoint2(endpoint, coreUrl) {
1194
1186
  }
1195
1187
 
1196
1188
  // src/core/nonceCache.ts
1197
- var DEFAULT_MAX_ENTRIES = 1e5;
1198
- var DEFAULT_TTL_MS = 6e5;
1189
+ var DEFAULT_MAX_ENTRIES = 25e4;
1190
+ var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
1199
1191
  var NonceCache = class {
1200
1192
  maxEntries;
1201
1193
  ttlMs;
@@ -1219,7 +1211,7 @@ var NonceCache = class {
1219
1211
  this.seen.delete(id);
1220
1212
  }
1221
1213
  this.evictExpired(at);
1222
- this.evictOverflow();
1214
+ if (this.seen.size >= this.maxEntries) return true;
1223
1215
  this.seen.set(id, at);
1224
1216
  return false;
1225
1217
  }
@@ -1233,13 +1225,6 @@ var NonceCache = class {
1233
1225
  this.seen.delete(id);
1234
1226
  }
1235
1227
  }
1236
- evictOverflow() {
1237
- while (this.seen.size >= this.maxEntries) {
1238
- const oldest = this.seen.keys().next().value;
1239
- if (oldest === void 0) break;
1240
- this.seen.delete(oldest);
1241
- }
1242
- }
1243
1228
  };
1244
1229
 
1245
1230
  // src/core/shutdown.ts
@@ -1574,53 +1559,82 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
1574
1559
  return `${root}${sep}${normalized}`;
1575
1560
  }
1576
1561
 
1577
- // src/core/permissions.ts
1578
- var WILDCARD = "*";
1579
- var FartherShorePermissionError = class extends Error {
1580
- code = "permission_denied";
1581
- status = 403;
1582
- /** The permission key that was required but not held. */
1583
- requiredPermission;
1584
- constructor(requiredPermission, message) {
1585
- super(message ?? `missing required permission: ${requiredPermission}`);
1586
- this.name = "FartherShorePermissionError";
1587
- this.requiredPermission = requiredPermission;
1588
- }
1589
- };
1590
- function parsePermissionHeader(raw) {
1591
- if (raw === null || raw === void 0) return void 0;
1592
- return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
1562
+ // ../contracts/dist/authz/principal.js
1563
+ var SUBJECT_KINDS = ["member", "service"];
1564
+ function isSubjectKind(value) {
1565
+ return typeof value === "string" && SUBJECT_KINDS.includes(value);
1593
1566
  }
1594
- function permissionGrants(permissions, key2) {
1595
- if (permissions === void 0) return true;
1596
- if (permissions.includes(WILDCARD)) return true;
1597
- return permissions.includes(key2);
1567
+ function isNonEmptyTrimmed(value) {
1568
+ return typeof value === "string" && value.trim().length > 0;
1598
1569
  }
1599
- function permissionSatisfies(required, granted) {
1600
- if (granted === void 0) return true;
1601
- if (granted.includes(WILDCARD)) return true;
1602
- if (granted.includes(required)) return true;
1603
- const idx = required.indexOf(":");
1604
- if (idx > 0 && idx < required.length - 1) {
1605
- const subject = required.slice(0, idx);
1606
- if (granted.includes(`${subject}:${WILDCARD}`)) return true;
1607
- }
1608
- return false;
1570
+ function normalizeIdentityId(value) {
1571
+ return value.trim();
1609
1572
  }
1610
- function hasPermission(ctx, key2) {
1611
- if (ctx.permissions === void 0) {
1612
- return ctx.signedContext !== void 0;
1613
- }
1614
- return permissionSatisfies(key2, ctx.permissions);
1573
+ function isNonEmptyStringArray(value) {
1574
+ return Array.isArray(value) && value.every((item) => isNonEmptyTrimmed(item));
1615
1575
  }
1616
- function requirePermission(ctx, key2) {
1617
- if (!hasPermission(ctx, key2)) {
1618
- throw new FartherShorePermissionError(key2);
1576
+ function principalFromContextClaims(claims) {
1577
+ if (typeof claims !== "object" || claims === null)
1578
+ return null;
1579
+ const { sub, org: orgId, businessId, subjectKind } = claims;
1580
+ if (!isNonEmptyTrimmed(sub) || !isNonEmptyTrimmed(orgId) || !isNonEmptyTrimmed(businessId)) {
1581
+ return null;
1582
+ }
1583
+ if (!isSubjectKind(subjectKind))
1584
+ return null;
1585
+ if (claims.permissions !== void 0 && !isNonEmptyStringArray(claims.permissions)) {
1586
+ return null;
1619
1587
  }
1588
+ if (claims.roles !== void 0 && !isNonEmptyStringArray(claims.roles)) {
1589
+ return null;
1590
+ }
1591
+ const clientId = claims.client_id;
1592
+ const actSub = claims.act?.sub;
1593
+ if (isNonEmptyTrimmed(clientId) && isNonEmptyTrimmed(actSub) && normalizeIdentityId(clientId) !== normalizeIdentityId(actSub)) {
1594
+ return null;
1595
+ }
1596
+ const org = { id: normalizeIdentityId(orgId) };
1597
+ if (subjectKind === "service") {
1598
+ const explicitServiceAccountId = claims.serviceAccountId;
1599
+ if (isNonEmptyTrimmed(explicitServiceAccountId) && normalizeIdentityId(explicitServiceAccountId) !== normalizeIdentityId(sub)) {
1600
+ return null;
1601
+ }
1602
+ const serviceAccountId = normalizeIdentityId(explicitServiceAccountId ?? sub);
1603
+ const keyIdSource = clientId ?? actSub ?? sub;
1604
+ if (!isNonEmptyTrimmed(serviceAccountId) || !isNonEmptyTrimmed(keyIdSource)) {
1605
+ return null;
1606
+ }
1607
+ return {
1608
+ org,
1609
+ subject: {
1610
+ kind: "service",
1611
+ serviceAccountId,
1612
+ keyId: normalizeIdentityId(keyIdSource)
1613
+ }
1614
+ };
1615
+ }
1616
+ const memberId = normalizeIdentityId(sub);
1617
+ const act = claims.act;
1618
+ if (act !== void 0 && act !== null) {
1619
+ if (typeof act !== "object" || !isNonEmptyTrimmed(act.sub))
1620
+ return null;
1621
+ return {
1622
+ org,
1623
+ subject: {
1624
+ kind: "member",
1625
+ memberId,
1626
+ via: "api_key",
1627
+ keyId: normalizeIdentityId(act.sub)
1628
+ }
1629
+ };
1630
+ }
1631
+ return { org, subject: { kind: "member", memberId, via: "session" } };
1620
1632
  }
1621
- var IDENTITY_HEADER_NAMES = RUNTIME_IDENTITY_HEADER_NAMES;
1622
1633
 
1623
1634
  // src/core/verifyContext.ts
1635
+ function principalFromContextClaims2(claims) {
1636
+ return principalFromContextClaims(claims);
1637
+ }
1624
1638
  var EXPECTED_JWT_ALG = "HS256";
1625
1639
  function base64urlDecode(value) {
1626
1640
  const padded = value.replace(/-/g, "+").replace(/_/g, "/");
@@ -1660,6 +1674,7 @@ async function verifyContext(token, secrets) {
1660
1674
  }
1661
1675
  let verified = false;
1662
1676
  for (const secret of secrets) {
1677
+ if (secret.trim().length === 0) continue;
1663
1678
  try {
1664
1679
  const key2 = await importHmacKey(secret);
1665
1680
  if (await crypto.subtle.verify(
@@ -1675,11 +1690,20 @@ async function verifyContext(token, secrets) {
1675
1690
  }
1676
1691
  }
1677
1692
  if (!verified) return null;
1693
+ return parseContextPayload(payload);
1694
+ }
1695
+ function decodeContextClaims(token) {
1696
+ const parts = token.split(".");
1697
+ if (parts.length !== 3) return null;
1698
+ return parseContextPayload(parts[1]);
1699
+ }
1700
+ function parseContextPayload(payload) {
1678
1701
  try {
1679
1702
  const parsed = JSON.parse(
1680
1703
  new TextDecoder().decode(base64urlDecode(payload))
1681
1704
  );
1682
1705
  if (typeof parsed !== "object" || parsed === null) return null;
1706
+ if (parsed.cv !== 2) return null;
1683
1707
  return parsed;
1684
1708
  } catch {
1685
1709
  return null;
@@ -1688,7 +1712,7 @@ async function verifyContext(token, secrets) {
1688
1712
  function contextRequiredError(reason) {
1689
1713
  return new FartherShoreError(
1690
1714
  "context_unverified",
1691
- `X-Fs-Context ${reason} (contextVerification is "required")`
1715
+ `X-Fs-Context ${reason} \u2014 signed context is required whenever context secrets are configured`
1692
1716
  );
1693
1717
  }
1694
1718
 
@@ -1705,12 +1729,12 @@ async function verifyRequest(input, deps) {
1705
1729
  const kid = h(RUNTIME_HEADER_NAMES.keyId);
1706
1730
  const requestId = h(RUNTIME_HEADER_NAMES.requestId);
1707
1731
  const timestampRaw = h(RUNTIME_HEADER_NAMES.timestamp);
1708
- const signedProductId = h(RUNTIME_HEADER_NAMES.productId);
1732
+ const signedBusinessId = h(RUNTIME_HEADER_NAMES.businessId);
1709
1733
  const signedBackendId = h(RUNTIME_HEADER_NAMES.backendId);
1710
1734
  const signedRouteId = h(RUNTIME_HEADER_NAMES.routeId) ?? "";
1711
1735
  const policyVersion = h(RUNTIME_HEADER_NAMES.policyVersion);
1712
1736
  const signedBodyHash = h(RUNTIME_HEADER_NAMES.bodyHash);
1713
- if (!kid || !requestId || !timestampRaw || !signedProductId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
1737
+ if (!kid || !requestId || !timestampRaw || !signedBusinessId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
1714
1738
  throw new FartherShoreError(
1715
1739
  "malformed_signature",
1716
1740
  "request is missing one or more required x-fs-* headers"
@@ -1746,10 +1770,10 @@ async function verifyRequest(input, deps) {
1746
1770
  "recomputed body hash does not match the signed x-fs-body-hash"
1747
1771
  );
1748
1772
  }
1749
- if (deps.productId !== void 0 && signedProductId !== deps.productId) {
1773
+ if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
1750
1774
  throw new FartherShoreError(
1751
1775
  "route_mismatch",
1752
- "signed product-id does not match this backend's product"
1776
+ "signed business-id does not match this backend's business"
1753
1777
  );
1754
1778
  }
1755
1779
  if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
@@ -1764,6 +1788,7 @@ async function verifyRequest(input, deps) {
1764
1788
  "signed route-id is not served by this backend"
1765
1789
  );
1766
1790
  }
1791
+ const contextToken = h("x-fs-context") ?? null;
1767
1792
  const canonicalInput = {
1768
1793
  method: input.method,
1769
1794
  path: input.path,
@@ -1771,10 +1796,16 @@ async function verifyRequest(input, deps) {
1771
1796
  bodyHash: computedBodyHash,
1772
1797
  requestId,
1773
1798
  timestamp,
1774
- productId: signedProductId,
1799
+ businessId: signedBusinessId,
1775
1800
  backendId: signedBackendId,
1776
1801
  routeId: signedRouteId,
1777
- policyVersion
1802
+ policyVersion,
1803
+ // Consumer-principal wave (D3): bind the presented X-Fs-Context hash into
1804
+ // the canonical string in exact lockstep with the gateway signer. A missing
1805
+ // context hashes the empty string, so an identity-less request still
1806
+ // verifies (it is rejected later by the fail-closed context gate when
1807
+ // secrets exist).
1808
+ contextHash: await hashContextToken2(contextToken)
1778
1809
  };
1779
1810
  const canonical = buildCanonicalSigningString2(canonicalInput);
1780
1811
  const publicJwk = await deps.jwks.getKey(kid);
@@ -1793,58 +1824,63 @@ async function verifyRequest(input, deps) {
1793
1824
  "Ed25519 signature verification failed"
1794
1825
  );
1795
1826
  }
1796
- if (deps.nonceCache.checkAndRemember(requestId)) {
1827
+ if (await deps.nonceCache.checkAndRemember(requestId)) {
1797
1828
  throw new FartherShoreError(
1798
1829
  "replayed_nonce",
1799
1830
  "x-fs-request-id has already been seen (replay)"
1800
1831
  );
1801
1832
  }
1802
- let permissions;
1803
- let roles;
1804
- let signedContext = null;
1805
- const contextSecrets = deps.contextSecrets ?? [];
1806
- const hasContextKeyring = contextSecrets.length > 0;
1807
- if (hasContextKeyring) {
1808
- const token = h("x-fs-context");
1809
- if (token) {
1810
- signedContext = await verifyContext(token, contextSecrets);
1811
- if (signedContext === null && deps.contextVerification === "required") {
1812
- throw contextRequiredError("failed verification");
1813
- }
1814
- if (signedContext && signedContext.productId !== signedProductId) {
1815
- throw new FartherShoreError(
1816
- "context_unverified",
1817
- "X-Fs-Context was minted for a different product than the signed request"
1818
- );
1819
- }
1820
- } else if (deps.contextVerification === "required") {
1821
- throw contextRequiredError("header is missing");
1822
- }
1823
- } else if (deps.contextVerification === "required") {
1824
- throw contextRequiredError("keyring is empty");
1825
- }
1833
+ const signedContext = await resolveSignedContext(
1834
+ contextToken,
1835
+ deps.contextSecrets ?? [],
1836
+ signedBusinessId
1837
+ );
1838
+ const permissions = signedContext?.permissions;
1839
+ const roles = signedContext?.roles;
1840
+ let principal;
1826
1841
  if (signedContext) {
1827
- permissions = signedContext.permissions;
1828
- roles = signedContext.roles;
1829
- } else {
1830
- permissions = parsePermissionHeader(
1831
- h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
1832
- );
1833
- roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
1842
+ const derived = principalFromContextClaims2(signedContext);
1843
+ if (derived === null) {
1844
+ throw contextRequiredError(
1845
+ "carried an invalid or incomplete consumer principal"
1846
+ );
1847
+ }
1848
+ principal = derived;
1834
1849
  }
1835
1850
  return {
1836
1851
  requestId,
1837
- productId: signedProductId,
1852
+ businessId: signedBusinessId,
1838
1853
  backendId: signedBackendId,
1839
1854
  routeId: signedRouteId,
1840
1855
  policyVersion,
1841
1856
  timestamp,
1842
1857
  bodyHash: computedBodyHash,
1858
+ ...principal ? { principal } : {},
1843
1859
  ...permissions !== void 0 ? { permissions } : {},
1844
1860
  ...roles !== void 0 ? { roles } : {},
1845
1861
  ...signedContext ? { signedContext } : {}
1846
1862
  };
1847
1863
  }
1864
+ async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
1865
+ if (!contextToken) return null;
1866
+ const signedContext = decodeContextClaims(contextToken);
1867
+ if (signedContext === null) {
1868
+ throw contextRequiredError("payload could not be parsed as a cv=2 claim");
1869
+ }
1870
+ if (contextSecrets.length > 0) {
1871
+ const hmacVerified = await verifyContext(contextToken, contextSecrets);
1872
+ if (hmacVerified === null) {
1873
+ throw contextRequiredError("failed HS256 verification");
1874
+ }
1875
+ }
1876
+ if (signedContext.businessId !== signedBusinessId) {
1877
+ throw new FartherShoreError(
1878
+ "context_unverified",
1879
+ "X-Fs-Context was minted for a different business than the signed request"
1880
+ );
1881
+ }
1882
+ return signedContext;
1883
+ }
1848
1884
  async function computeBodyHash(input) {
1849
1885
  if (input.streamingExempt) return STREAMING_EXEMPT_BODY_HASH;
1850
1886
  const body = input.body;
@@ -1877,8 +1913,8 @@ function headerGetter(headers) {
1877
1913
 
1878
1914
  // src/core/runtime.ts
1879
1915
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1880
- var SDK_VERSION = "0.14.0".length > 0 ? "0.14.0" : "0.0.0-dev";
1881
- var CONTRACTS_FP = "1feb5a4a80b447ad".length > 0 ? "1feb5a4a80b447ad" : "0000000000000000";
1916
+ var SDK_VERSION = "0.16.0".length > 0 ? "0.16.0" : "0.0.0-dev";
1917
+ var CONTRACTS_FP = "c3961d4ea07ff178".length > 0 ? "c3961d4ea07ff178" : "0000000000000000";
1882
1918
  var FartherShore = class {
1883
1919
  bootstrapClient;
1884
1920
  fetchImpl;
@@ -1888,11 +1924,9 @@ var FartherShore = class {
1888
1924
  coreUrl;
1889
1925
  instanceId;
1890
1926
  tunnelOptions;
1891
- /** FAR-723 HS256 secret(s) for verifying the signed X-Fs-Context claim. */
1927
+ /** OPTIONAL HS256 secret(s) defense-in-depth over the cv=2 X-Fs-Context. */
1892
1928
  contextSecrets;
1893
- /** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
1894
- contextVerification;
1895
- nonceCache = new NonceCache();
1929
+ nonceCache;
1896
1930
  shutdownManager = new ShutdownManager();
1897
1931
  jwks = null;
1898
1932
  meteringClient = null;
@@ -1910,8 +1944,8 @@ var FartherShore = class {
1910
1944
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
1911
1945
  this.tunnelOptions = options.tunnel ?? {};
1912
1946
  this.instanceId = options.instanceId;
1947
+ this.nonceCache = options.nonceStore ?? new NonceCache();
1913
1948
  this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
1914
- this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
1915
1949
  this.bootstrapClient = new BootstrapClient({
1916
1950
  runtimeToken,
1917
1951
  coreUrl,
@@ -1947,7 +1981,7 @@ var FartherShore = class {
1947
1981
  if (!this.meteringClient && config.metering.enabled) {
1948
1982
  this.meteringClient = new MeteringClient({
1949
1983
  config: config.metering,
1950
- productId: config.product.id,
1984
+ businessId: config.business.id,
1951
1985
  backendId: config.backend.id,
1952
1986
  coreUrl: this.coreUrl,
1953
1987
  fetchImpl: this.fetchImpl
@@ -2031,17 +2065,15 @@ var FartherShore = class {
2031
2065
  const context = await verifyRequest(input, {
2032
2066
  jwks: this.jwks,
2033
2067
  nonceCache: this.nonceCache,
2034
- productId: config.product.id,
2068
+ businessId: config.business.id,
2035
2069
  backendId: config.backend.id,
2036
2070
  knownRouteIds,
2037
2071
  clockSkewSeconds: config.verification.clockSkewSeconds,
2038
2072
  replayWindowSeconds: config.verification.replayWindowSeconds,
2039
- // FAR-723 a VERIFIED signed X-Fs-Context is the preferred (or
2040
- // required) identity source. Required mode must also fail closed when the
2041
- // keyring is empty; preferred mode preserves the transitional unsigned
2042
- // fallback until the backend-v* publish gate removes it.
2043
- contextSecrets: this.contextSecrets,
2044
- contextVerification: this.contextVerification
2073
+ // Consumer-principal wave (D3): OPTIONAL defense-in-depth. The principal
2074
+ // is derived from the Ed25519-vouched X-Fs-Context regardless; when these
2075
+ // secrets are set a presented token must ALSO pass HS256.
2076
+ contextSecrets: this.contextSecrets
2045
2077
  });
2046
2078
  return {
2047
2079
  ...context,
@@ -2168,6 +2200,45 @@ function parseContextSecrets(raw) {
2168
2200
  return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2169
2201
  }
2170
2202
 
2203
+ // src/core/permissions.ts
2204
+ var WILDCARD = "*";
2205
+ var FartherShorePermissionError = class extends Error {
2206
+ code = "permission_denied";
2207
+ status = 403;
2208
+ /** The permission key that was required but not held. */
2209
+ requiredPermission;
2210
+ constructor(requiredPermission, message) {
2211
+ super(message ?? `missing required permission: ${requiredPermission}`);
2212
+ this.name = "FartherShorePermissionError";
2213
+ this.requiredPermission = requiredPermission;
2214
+ }
2215
+ };
2216
+ function permissionGrants(permissions, key2) {
2217
+ if (permissions === void 0) return true;
2218
+ if (permissions.includes(WILDCARD)) return true;
2219
+ return permissions.includes(key2);
2220
+ }
2221
+ function permissionSatisfies(required, granted) {
2222
+ if (granted === void 0) return true;
2223
+ if (granted.includes(WILDCARD)) return true;
2224
+ if (granted.includes(required)) return true;
2225
+ const idx = required.indexOf(":");
2226
+ if (idx > 0 && idx < required.length - 1) {
2227
+ const subject = required.slice(0, idx);
2228
+ if (granted.includes(`${subject}:${WILDCARD}`)) return true;
2229
+ }
2230
+ return false;
2231
+ }
2232
+ function hasPermission(ctx, key2) {
2233
+ if (ctx.permissions === void 0) return false;
2234
+ return permissionSatisfies(key2, ctx.permissions);
2235
+ }
2236
+ function requirePermission(ctx, key2) {
2237
+ if (!hasPermission(ctx, key2)) {
2238
+ throw new FartherShorePermissionError(key2);
2239
+ }
2240
+ }
2241
+
2171
2242
  // src/adapters/express.ts
2172
2243
  var STREAMING_CONTENT_TYPES = new Set(
2173
2244
  RUNTIME_BODY_HASH_CONTRACT.streamingExemptContentTypes
@@ -2179,7 +2250,9 @@ function createExpressMiddleware(fs, options = {}) {
2179
2250
  }
2180
2251
  async function runMiddleware(fs, options, req, res, next) {
2181
2252
  try {
2182
- if (!options.always && !await fs.verificationRequired()) {
2253
+ const strict = options.always ?? true;
2254
+ if (!strict && !await fs.verificationRequired()) {
2255
+ stripFartherShoreHeaders(req);
2183
2256
  next();
2184
2257
  return;
2185
2258
  }
@@ -2196,6 +2269,7 @@ async function runMiddleware(fs, options, req, res, next) {
2196
2269
  streamingExempt
2197
2270
  });
2198
2271
  req.fartherShore = ctx;
2272
+ stripFartherShoreHeaders(req);
2199
2273
  next();
2200
2274
  } catch (error) {
2201
2275
  fail(res, error);
@@ -2208,6 +2282,51 @@ function fail(res, error) {
2208
2282
  }
2209
2283
  res.status(401).json({ error: "bad_signature" });
2210
2284
  }
2285
+ function stripFartherShoreHeaders(req) {
2286
+ const headers = req.headers;
2287
+ for (const name of Object.keys(headers)) {
2288
+ if (name.toLowerCase().startsWith("x-fs-")) {
2289
+ delete headers[name];
2290
+ }
2291
+ }
2292
+ const withRaw = req;
2293
+ const raw = withRaw.rawHeaders;
2294
+ if (Array.isArray(raw)) {
2295
+ const cleaned = [];
2296
+ for (let i = 0; i < raw.length; i += 2) {
2297
+ const key2 = raw[i];
2298
+ const value = raw[i + 1];
2299
+ if (typeof key2 !== "string" || value === void 0) continue;
2300
+ if (key2.toLowerCase().startsWith("x-fs-")) continue;
2301
+ cleaned.push(key2, value);
2302
+ }
2303
+ withRaw.rawHeaders = cleaned;
2304
+ }
2305
+ }
2306
+ function createExpressHandler(handler) {
2307
+ return (req, res, next) => {
2308
+ const ctx = req.fartherShore;
2309
+ if (!ctx) {
2310
+ res.status(401).json({ error: "context_unverified" });
2311
+ return;
2312
+ }
2313
+ if (!ctx.principal) {
2314
+ res.status(401).json({ error: "principal_required" });
2315
+ return;
2316
+ }
2317
+ const verified = ctx;
2318
+ void Promise.resolve().then(
2319
+ () => handler(verified, req, res, next)
2320
+ ).catch((error) => failHandler(res, next, error));
2321
+ };
2322
+ }
2323
+ function failHandler(res, next, error) {
2324
+ if (error instanceof FartherShoreError || error instanceof FartherShorePermissionError) {
2325
+ res.status(error.status).json({ error: error.code });
2326
+ return;
2327
+ }
2328
+ next(error);
2329
+ }
2211
2330
  function splitUrl(req) {
2212
2331
  const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
2213
2332
  const qIndex = raw.indexOf("?");
@@ -2234,6 +2353,32 @@ function headerValue(headers, name) {
2234
2353
  return value;
2235
2354
  }
2236
2355
 
2356
+ // src/core/subject.ts
2357
+ var MEMBER_SUBJECT_REQUIRED = RUNTIME_ERROR_CODES.memberSubjectRequired;
2358
+ var SERVICE_SUBJECT_REQUIRED = RUNTIME_ERROR_CODES.serviceSubjectRequired;
2359
+ function requireMember(ctx) {
2360
+ const subject = ctx.principal?.subject;
2361
+ if (!subject || subject.kind !== "member") {
2362
+ throw new FartherShoreError(
2363
+ MEMBER_SUBJECT_REQUIRED,
2364
+ "this operation requires a member subject (a user session or a personal key)",
2365
+ 403
2366
+ );
2367
+ }
2368
+ return subject;
2369
+ }
2370
+ function requireService(ctx) {
2371
+ const subject = ctx.principal?.subject;
2372
+ if (!subject || subject.kind !== "service") {
2373
+ throw new FartherShoreError(
2374
+ SERVICE_SUBJECT_REQUIRED,
2375
+ "this operation requires a service subject (an org-owned service-account key)",
2376
+ 403
2377
+ );
2378
+ }
2379
+ return subject;
2380
+ }
2381
+
2237
2382
  // src/testing/signers.ts
2238
2383
  import { generateKeyPairSync, randomBytes } from "node:crypto";
2239
2384
  var TEST_KID = "fs-runtime-test-2026";
@@ -2261,10 +2406,13 @@ async function makeSignedRequest(spec = {}) {
2261
2406
  bodyHash,
2262
2407
  requestId: spec.requestId ?? `req_${cryptoRandom()}`,
2263
2408
  timestamp: spec.timestamp ?? Math.floor(Date.now() / 1e3),
2264
- productId: spec.productId ?? "prod_test",
2409
+ businessId: spec.businessId ?? "biz_test",
2265
2410
  backendId: spec.backendId ?? "be_test",
2266
2411
  routeId: spec.routeId ?? "route_test",
2267
- policyVersion: spec.policyVersion ?? "pv_1"
2412
+ policyVersion: spec.policyVersion ?? "pv_1",
2413
+ // Consumer-principal wave (D3): bind the X-Fs-Context hash into the
2414
+ // canonical string (empty context → SHA-256 of the empty string).
2415
+ contextHash: await hashContextToken2(spec.contextToken)
2268
2416
  };
2269
2417
  const canonical = buildCanonicalSigningString2(claim);
2270
2418
  const signature = await signCanonicalString2(canonical, privateJwk);
@@ -2273,11 +2421,12 @@ async function makeSignedRequest(spec = {}) {
2273
2421
  [RUNTIME_HEADER_NAMES.keyId]: kid,
2274
2422
  [RUNTIME_HEADER_NAMES.requestId]: claim.requestId,
2275
2423
  [RUNTIME_HEADER_NAMES.timestamp]: String(claim.timestamp),
2276
- [RUNTIME_HEADER_NAMES.productId]: claim.productId,
2424
+ [RUNTIME_HEADER_NAMES.businessId]: claim.businessId,
2277
2425
  [RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
2278
2426
  [RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
2279
2427
  [RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
2280
- [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash
2428
+ [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash,
2429
+ ...spec.contextToken ? { "x-fs-context": spec.contextToken } : {}
2281
2430
  };
2282
2431
  return {
2283
2432
  input: { method, path, query, body, streamingExempt },
@@ -2295,7 +2444,7 @@ function base64urlEncodeJson(value) {
2295
2444
  }
2296
2445
  async function signContextToken(claim, secret = TEST_CONTEXT_SECRET, kid = TEST_CONTEXT_KID) {
2297
2446
  const header = base64urlEncodeJson({ alg: "HS256", typ: "JWT", kid });
2298
- const payload = base64urlEncodeJson({ cv: 1, ...claim });
2447
+ const payload = base64urlEncodeJson({ ...claim });
2299
2448
  const signingInput = `${header}.${payload}`;
2300
2449
  const key2 = await crypto.subtle.importKey(
2301
2450
  "raw",
@@ -2374,13 +2523,16 @@ function mergeHeaders(initHeaders, signedHeaders) {
2374
2523
  }
2375
2524
  return headers;
2376
2525
  }
2377
- function buildContextClaim(persona, productId) {
2526
+ function buildContextClaim(persona, businessId) {
2527
+ const memberId = persona.actor?.id ?? `user_${persona.name}`;
2378
2528
  return {
2379
- orgId: persona.orgId ?? "org_dev",
2380
- actor: persona.actor ?? { type: "user", id: `user_${persona.name}` },
2381
- // Product binding: the signed context productId MUST equal the signed
2382
- // request productId or verifyRequest rejects it as tamper evidence.
2383
- productId,
2529
+ cv: 2,
2530
+ sub: memberId,
2531
+ subjectKind: "member",
2532
+ org: persona.orgId ?? "org_dev",
2533
+ // Business binding: the signed-context businessId claim MUST equal the
2534
+ // signed request businessId or verifyRequest rejects it as tamper evidence.
2535
+ businessId,
2384
2536
  compiledPlanId: persona.compiledPlanId ?? "plan_dev",
2385
2537
  subscriptionId: persona.subscriptionId ?? "sub_dev",
2386
2538
  subscriberId: persona.subscriberId ?? "subscriber_dev",
@@ -2402,30 +2554,27 @@ function createPersonaClient(ctx) {
2402
2554
  }
2403
2555
  async function buildHeaders(persona, spec) {
2404
2556
  const method = normalizeMethod(spec.method);
2557
+ const contextToken = persona.anonymous ? void 0 : await signContextToken(
2558
+ buildContextClaim(persona, ctx.businessId),
2559
+ ctx.contextSecret,
2560
+ ctx.contextKid
2561
+ );
2405
2562
  const signed = await makeSignedRequest({
2406
2563
  method,
2407
2564
  path: spec.path ?? "/",
2408
2565
  query: spec.query ?? "",
2409
2566
  body: spec.body ?? null,
2410
2567
  streamingExempt: spec.streamingExempt ?? false,
2411
- productId: ctx.productId,
2568
+ businessId: ctx.businessId,
2412
2569
  backendId: ctx.backendId,
2413
2570
  routeId: spec.routeId ?? "",
2414
2571
  privateJwk: ctx.keys.privateJwk,
2415
2572
  kid: ctx.keys.kid,
2573
+ ...contextToken ? { contextToken } : {},
2416
2574
  ...spec.requestId ? { requestId: spec.requestId } : {},
2417
2575
  ...spec.timestamp !== void 0 ? { timestamp: spec.timestamp } : {}
2418
2576
  });
2419
- const headers = { ...signed.headers };
2420
- if (!persona.anonymous) {
2421
- const claim = buildContextClaim(persona, ctx.productId);
2422
- headers["x-fs-context"] = await signContextToken(
2423
- claim,
2424
- ctx.contextSecret,
2425
- ctx.contextKid
2426
- );
2427
- }
2428
- return headers;
2577
+ return { ...signed.headers };
2429
2578
  }
2430
2579
  function asPersona(name) {
2431
2580
  const persona = resolve(name);
@@ -2491,12 +2640,12 @@ var DEV_CORE_URL = "https://dev-gateway.farthershore.local";
2491
2640
  var DEV_JWKS_URL = `${DEV_CORE_URL}/.well-known/jwks.json`;
2492
2641
  var DEV_METERING_ENDPOINT = `${DEV_CORE_URL}/v1/metering/events`;
2493
2642
  function createDevGateway(options) {
2494
- const productId = options.productId ?? "prod_dev";
2643
+ const businessId = options.businessId ?? "biz_dev";
2495
2644
  const backendId = options.backendId ?? "be_dev";
2496
2645
  const meterEvents = [];
2497
2646
  const reportUsageEvents = [];
2498
2647
  const bootstrap = {
2499
- product: { id: productId, slug: options.productSlug ?? "dev-product" },
2648
+ business: { id: businessId, slug: options.businessSlug ?? "dev-business" },
2500
2649
  backend: {
2501
2650
  id: backendId,
2502
2651
  slug: options.backendSlug ?? "dev-backend",
@@ -2566,7 +2715,7 @@ function createDevGateway(options) {
2566
2715
  bootstrap,
2567
2716
  meterEvents,
2568
2717
  reportUsageEvents,
2569
- productId,
2718
+ businessId,
2570
2719
  backendId,
2571
2720
  jwksUrl: DEV_JWKS_URL
2572
2721
  };
@@ -2768,7 +2917,7 @@ function createDevRuntime(options) {
2768
2917
  const gateway = createDevGateway({
2769
2918
  mode,
2770
2919
  keys,
2771
- ...options.productId ? { productId: options.productId } : {},
2920
+ ...options.businessId ? { businessId: options.businessId } : {},
2772
2921
  ...options.backendId ? { backendId: options.backendId } : {},
2773
2922
  ...options.routes ? { routeIds: options.routes } : {},
2774
2923
  onMeterEvent: (event) => {
@@ -2797,7 +2946,7 @@ function createDevRuntime(options) {
2797
2946
  const personas = buildPersonaMap(options.personas);
2798
2947
  const personaClient = createPersonaClient({
2799
2948
  keys,
2800
- productId: gateway.productId,
2949
+ businessId: gateway.businessId,
2801
2950
  backendId: gateway.backendId,
2802
2951
  contextSecret: keys.contextSecret,
2803
2952
  contextKid: keys.contextKid,
@@ -2809,8 +2958,9 @@ function createDevRuntime(options) {
2809
2958
  runtimeToken: keys.runtimeToken,
2810
2959
  coreUrl: DEV_CORE_URL,
2811
2960
  fetchImpl: gateway.fetchImpl,
2961
+ // Consumer-principal wave (D3): signed cv=2 context is fail-closed whenever
2962
+ // contextSecrets are set — no per-mode verification toggle.
2812
2963
  contextSecrets: [keys.contextSecret],
2813
- contextVerification: mode === "simulated" ? "required" : "preferred",
2814
2964
  env: {}
2815
2965
  });
2816
2966
  const tracedAuthz = {
@@ -2836,7 +2986,8 @@ function createDevRuntime(options) {
2836
2986
  });
2837
2987
  }
2838
2988
  function middleware(mwOptions) {
2839
- const inner = createExpressMiddleware(fs, mwOptions);
2989
+ const resolved = mode === "passthrough" ? { always: false, ...mwOptions } : mwOptions ?? {};
2990
+ const inner = createExpressMiddleware(fs, resolved);
2840
2991
  return (req, res, next) => {
2841
2992
  const requestId = headerValue2(req.headers, "x-fs-request-id") ?? "unknown";
2842
2993
  const { path } = splitUrl2(req);
@@ -2875,6 +3026,7 @@ function createDevRuntime(options) {
2875
3026
  };
2876
3027
  }
2877
3028
  fs.middleware = middleware;
3029
+ fs.handler = createExpressHandler;
2878
3030
  const devRuntime = {
2879
3031
  fs,
2880
3032
  asPersona: (name) => personaClient.asPersona(name),
@@ -2927,7 +3079,7 @@ function createDevRuntimeFromEnv(env = readProcessEnv3()) {
2927
3079
  version: 1,
2928
3080
  mode,
2929
3081
  keys,
2930
- productId: runtime.gateway.productId,
3082
+ businessId: runtime.gateway.businessId,
2931
3083
  backendId: runtime.gateway.backendId,
2932
3084
  personas: mapToRecord(runtime.personas)
2933
3085
  };
@@ -2940,7 +3092,7 @@ function printBanner(mode, runtime, tracePath) {
2940
3092
  "============================================================",
2941
3093
  " \u26A0 FARTHER SHORE DEV MODE ACTIVE \u2014 NOT FOR PRODUCTION",
2942
3094
  ` mode: ${mode.toUpperCase()}`,
2943
- ` product: ${runtime.gateway.productId}`,
3095
+ ` business: ${runtime.gateway.businessId}`,
2944
3096
  ` backend: ${runtime.gateway.backendId}`,
2945
3097
  ` personas: ${[...runtime.personas.keys()].join(", ")}`,
2946
3098
  ` usage log: ${USAGE_JSONL_PATH}`,
@@ -3002,6 +3154,7 @@ var fartherShore = {
3002
3154
  }
3003
3155
  const fs = initFromEnv(options);
3004
3156
  fs.middleware = (mwOptions) => createExpressMiddleware(fs, mwOptions);
3157
+ fs.handler = createExpressHandler;
3005
3158
  return fs;
3006
3159
  }
3007
3160
  };
@@ -3017,7 +3170,6 @@ export {
3017
3170
  FartherShore,
3018
3171
  FartherShoreError,
3019
3172
  FartherShorePermissionError,
3020
- IDENTITY_HEADER_NAMES,
3021
3173
  JwksClient,
3022
3174
  MAX_BODY_BYTES,
3023
3175
  METERING_PAYLOAD_HEADER,
@@ -3041,18 +3193,22 @@ export {
3041
3193
  buildHealthReport,
3042
3194
  canonicalizeQuery2 as canonicalizeQuery,
3043
3195
  computeMeteringHeaders,
3196
+ createExpressHandler,
3044
3197
  createExpressMiddleware,
3045
3198
  createUsage,
3199
+ decodeContextClaims,
3046
3200
  fartherShore,
3047
3201
  hasPermission,
3048
3202
  hashBody2 as hashBody,
3049
3203
  initFromEnv2 as initFromEnv,
3050
3204
  nodeSpawn,
3051
- parsePermissionHeader,
3052
3205
  permissionGrants,
3053
3206
  permissionSatisfies,
3207
+ principalFromContextClaims2 as principalFromContextClaims,
3054
3208
  reportHealth,
3209
+ requireMember,
3055
3210
  requirePermission,
3211
+ requireService,
3056
3212
  runtimeErrorToErrorCode,
3057
3213
  runtimeTokenKind2 as runtimeTokenKind,
3058
3214
  signCanonicalString2 as signCanonicalString,