@farthershore/backend 0.15.0 → 0.17.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,10 @@ 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",
221
+ surfaceNotAllowed: "surface_not_allowed"
219
222
  };
220
223
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
221
224
  headers: {
@@ -279,7 +282,12 @@ var RUNTIME_ERROR_CODE_TO_ERROR_CODE = {
279
282
  // the canonical code keeps the "dependency down" semantic for callers.
280
283
  [RUNTIME_ERROR_CODES.jwksUnavailable]: "SERVICE_UNAVAILABLE",
281
284
  // The single non-401 (413) — oversized request body.
282
- [RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR"
285
+ [RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR",
286
+ // Consumer-principal wave — route subject-requirement faults → FORBIDDEN (403).
287
+ [RUNTIME_ERROR_CODES.memberSubjectRequired]: "FORBIDDEN",
288
+ [RUNTIME_ERROR_CODES.serviceSubjectRequired]: "FORBIDDEN",
289
+ // A visible route whose surface set excludes the caller → 403.
290
+ [RUNTIME_ERROR_CODES.surfaceNotAllowed]: "FORBIDDEN"
283
291
  };
284
292
  function runtimeErrorToErrorCode(code) {
285
293
  return RUNTIME_ERROR_CODE_TO_ERROR_CODE[code] ?? "INTERNAL_ERROR";
@@ -289,16 +297,16 @@ var RUNTIME_TOKEN_PREFIXES = {
289
297
  live: "fsrt_live_",
290
298
  test: "fsrt_test_"
291
299
  };
292
- var RUNTIME_TOKEN_CAPABILITIES = [
300
+ var RUNTIME_TOKEN_OPERATIONS = [
293
301
  "gateway_verification",
294
302
  "metering",
295
303
  "health",
296
304
  "tunnel",
297
- // Hand-maintained mirror of @farthershore/contracts RUNTIME_TOKEN_CAPABILITIES
305
+ // Hand-maintained mirror of @farthershore/contracts RUNTIME_TOKEN_OPERATIONS
298
306
  // (`runtime.ts`). Bound to that source by the SET-EQUALITY + ORDER assertions
299
307
  // in `deny-taxonomy-drift.test.ts` (test-only contracts devDep) — NOT by the
300
308
  // generated runtime-contract.ts, which mirrors only RUNTIME_ERROR_CODES.
301
- // `drift_report` is the opt-in capability for reporting route drift.
309
+ // `drift_report` is the opt-in operation for reporting route drift.
302
310
  "drift_report"
303
311
  ];
304
312
  var RUNTIME_HEADER_NAMES = {
@@ -312,10 +320,6 @@ var RUNTIME_HEADER_NAMES = {
312
320
  policyVersion: "x-fs-policy-version",
313
321
  bodyHash: "x-fs-body-hash"
314
322
  };
315
- var RUNTIME_IDENTITY_HEADER_NAMES = {
316
- permissions: "x-fs-permissions",
317
- roles: "x-fs-roles"
318
- };
319
323
  var RUNTIME_CLOCK_SKEW_SECONDS = 5;
320
324
  var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
321
325
  var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
@@ -361,31 +365,16 @@ var CANONICAL_SIGNING_FIELDS = [
361
365
  "business-id",
362
366
  "backend-id",
363
367
  "route-id",
364
- "policy-version"
368
+ "policy-version",
369
+ // Consumer-principal wave (D3) — the trailing identity-context binding line.
370
+ // MUST stay last so pre-binding verifiers that stop at `policy-version`
371
+ // fail loud on a bound request rather than silently accepting a prefix.
372
+ "context-hash"
365
373
  ];
366
374
  var CANONICAL_FIELD_SEPARATOR = "\n";
367
375
  var CANONICAL_KV_SEPARATOR = ":";
368
376
  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("&");
377
+ return query;
389
378
  }
390
379
  function buildCanonicalSigningString(input) {
391
380
  const values = {
@@ -398,10 +387,14 @@ function buildCanonicalSigningString(input) {
398
387
  "business-id": input.businessId,
399
388
  "backend-id": input.backendId,
400
389
  "route-id": input.routeId,
401
- "policy-version": input.policyVersion
390
+ "policy-version": input.policyVersion,
391
+ "context-hash": input.contextHash
402
392
  };
403
393
  return CANONICAL_SIGNING_FIELDS.map((field) => `${field}${CANONICAL_KV_SEPARATOR}${values[field]}`).join(CANONICAL_FIELD_SEPARATOR);
404
394
  }
395
+ function hashContextToken(token) {
396
+ return hashBody(new TextEncoder().encode(token ?? ""));
397
+ }
405
398
  var ED25519_ALGORITHM = "Ed25519";
406
399
  async function importEd25519PrivateKey(jwk) {
407
400
  return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["sign"]);
@@ -444,6 +437,7 @@ function base64UrlDecode(value) {
444
437
  var hashBody2 = hashBody;
445
438
  var canonicalizeQuery2 = canonicalizeQuery;
446
439
  var buildCanonicalSigningString2 = buildCanonicalSigningString;
440
+ var hashContextToken2 = hashContextToken;
447
441
  var signCanonicalString2 = signCanonicalString;
448
442
  var verifyCanonicalSignature2 = verifyCanonicalSignature;
449
443
  var runtimeTokenKind2 = runtimeTokenKind;
@@ -466,7 +460,9 @@ var FartherShoreError = class extends Error {
466
460
  }
467
461
  };
468
462
  function statusForCode(code) {
469
- return code === "body_too_large" ? 413 : 401;
463
+ if (code === "body_too_large") return 413;
464
+ if (code === "surface_not_allowed") return 403;
465
+ return 401;
470
466
  }
471
467
 
472
468
  // src/core/bootstrap.ts
@@ -941,7 +937,7 @@ async function withUsage(request, response, usage, options = {}) {
941
937
  }
942
938
  async function signResponse(request, response, usage, options, wrapOptions) {
943
939
  const payload = buildPayload(request, usage, options, wrapOptions);
944
- const requestId = request.headers.get("x-fs-request-id") ?? void 0;
940
+ const requestId = options.requestId ?? request.headers.get("x-fs-request-id") ?? void 0;
945
941
  const headers = await computeMeteringHeaders(payload, {
946
942
  ...options.token !== void 0 ? { token: options.token } : {},
947
943
  ...options.env !== void 0 ? { env: options.env } : {},
@@ -1195,8 +1191,8 @@ function resolveEndpoint2(endpoint, coreUrl) {
1195
1191
  }
1196
1192
 
1197
1193
  // src/core/nonceCache.ts
1198
- var DEFAULT_MAX_ENTRIES = 1e5;
1199
- var DEFAULT_TTL_MS = 6e5;
1194
+ var DEFAULT_MAX_ENTRIES = 25e4;
1195
+ var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
1200
1196
  var NonceCache = class {
1201
1197
  maxEntries;
1202
1198
  ttlMs;
@@ -1220,7 +1216,7 @@ var NonceCache = class {
1220
1216
  this.seen.delete(id);
1221
1217
  }
1222
1218
  this.evictExpired(at);
1223
- this.evictOverflow();
1219
+ if (this.seen.size >= this.maxEntries) return true;
1224
1220
  this.seen.set(id, at);
1225
1221
  return false;
1226
1222
  }
@@ -1234,13 +1230,6 @@ var NonceCache = class {
1234
1230
  this.seen.delete(id);
1235
1231
  }
1236
1232
  }
1237
- evictOverflow() {
1238
- while (this.seen.size >= this.maxEntries) {
1239
- const oldest = this.seen.keys().next().value;
1240
- if (oldest === void 0) break;
1241
- this.seen.delete(oldest);
1242
- }
1243
- }
1244
1233
  };
1245
1234
 
1246
1235
  // src/core/shutdown.ts
@@ -1575,53 +1564,82 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
1575
1564
  return `${root}${sep}${normalized}`;
1576
1565
  }
1577
1566
 
1578
- // src/core/permissions.ts
1579
- var WILDCARD = "*";
1580
- var FartherShorePermissionError = class extends Error {
1581
- code = "permission_denied";
1582
- status = 403;
1583
- /** The permission key that was required but not held. */
1584
- requiredPermission;
1585
- constructor(requiredPermission, message) {
1586
- super(message ?? `missing required permission: ${requiredPermission}`);
1587
- this.name = "FartherShorePermissionError";
1588
- this.requiredPermission = requiredPermission;
1589
- }
1590
- };
1591
- function parsePermissionHeader(raw) {
1592
- if (raw === null || raw === void 0) return void 0;
1593
- return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
1567
+ // ../contracts/dist/authz/principal.js
1568
+ var SUBJECT_KINDS = ["member", "service"];
1569
+ function isSubjectKind(value) {
1570
+ return typeof value === "string" && SUBJECT_KINDS.includes(value);
1594
1571
  }
1595
- function permissionGrants(permissions, key2) {
1596
- if (permissions === void 0) return true;
1597
- if (permissions.includes(WILDCARD)) return true;
1598
- return permissions.includes(key2);
1572
+ function isNonEmptyTrimmed(value) {
1573
+ return typeof value === "string" && value.trim().length > 0;
1599
1574
  }
1600
- function permissionSatisfies(required, granted) {
1601
- if (granted === void 0) return true;
1602
- if (granted.includes(WILDCARD)) return true;
1603
- if (granted.includes(required)) return true;
1604
- const idx = required.indexOf(":");
1605
- if (idx > 0 && idx < required.length - 1) {
1606
- const subject = required.slice(0, idx);
1607
- if (granted.includes(`${subject}:${WILDCARD}`)) return true;
1608
- }
1609
- return false;
1575
+ function normalizeIdentityId(value) {
1576
+ return value.trim();
1610
1577
  }
1611
- function hasPermission(ctx, key2) {
1612
- if (ctx.permissions === void 0) {
1613
- return ctx.signedContext !== void 0;
1614
- }
1615
- return permissionSatisfies(key2, ctx.permissions);
1578
+ function isNonEmptyStringArray(value) {
1579
+ return Array.isArray(value) && value.every((item) => isNonEmptyTrimmed(item));
1616
1580
  }
1617
- function requirePermission(ctx, key2) {
1618
- if (!hasPermission(ctx, key2)) {
1619
- throw new FartherShorePermissionError(key2);
1581
+ function principalFromContextClaims(claims) {
1582
+ if (typeof claims !== "object" || claims === null)
1583
+ return null;
1584
+ const { sub, org: orgId, businessId, subjectKind } = claims;
1585
+ if (!isNonEmptyTrimmed(sub) || !isNonEmptyTrimmed(orgId) || !isNonEmptyTrimmed(businessId)) {
1586
+ return null;
1587
+ }
1588
+ if (!isSubjectKind(subjectKind))
1589
+ return null;
1590
+ if (claims.permissions !== void 0 && !isNonEmptyStringArray(claims.permissions)) {
1591
+ return null;
1592
+ }
1593
+ if (claims.roles !== void 0 && !isNonEmptyStringArray(claims.roles)) {
1594
+ return null;
1595
+ }
1596
+ const clientId = claims.client_id;
1597
+ const actSub = claims.act?.sub;
1598
+ if (isNonEmptyTrimmed(clientId) && isNonEmptyTrimmed(actSub) && normalizeIdentityId(clientId) !== normalizeIdentityId(actSub)) {
1599
+ return null;
1600
+ }
1601
+ const org = { id: normalizeIdentityId(orgId) };
1602
+ if (subjectKind === "service") {
1603
+ const explicitServiceAccountId = claims.serviceAccountId;
1604
+ if (isNonEmptyTrimmed(explicitServiceAccountId) && normalizeIdentityId(explicitServiceAccountId) !== normalizeIdentityId(sub)) {
1605
+ return null;
1606
+ }
1607
+ const serviceAccountId = normalizeIdentityId(explicitServiceAccountId ?? sub);
1608
+ const keyIdSource = clientId ?? actSub ?? sub;
1609
+ if (!isNonEmptyTrimmed(serviceAccountId) || !isNonEmptyTrimmed(keyIdSource)) {
1610
+ return null;
1611
+ }
1612
+ return {
1613
+ org,
1614
+ subject: {
1615
+ kind: "service",
1616
+ serviceAccountId,
1617
+ keyId: normalizeIdentityId(keyIdSource)
1618
+ }
1619
+ };
1620
+ }
1621
+ const memberId = normalizeIdentityId(sub);
1622
+ const act = claims.act;
1623
+ if (act !== void 0 && act !== null) {
1624
+ if (typeof act !== "object" || !isNonEmptyTrimmed(act.sub))
1625
+ return null;
1626
+ return {
1627
+ org,
1628
+ subject: {
1629
+ kind: "member",
1630
+ memberId,
1631
+ via: "api_key",
1632
+ keyId: normalizeIdentityId(act.sub)
1633
+ }
1634
+ };
1620
1635
  }
1636
+ return { org, subject: { kind: "member", memberId, via: "session" } };
1621
1637
  }
1622
- var IDENTITY_HEADER_NAMES = RUNTIME_IDENTITY_HEADER_NAMES;
1623
1638
 
1624
1639
  // src/core/verifyContext.ts
1640
+ function principalFromContextClaims2(claims) {
1641
+ return principalFromContextClaims(claims);
1642
+ }
1625
1643
  var EXPECTED_JWT_ALG = "HS256";
1626
1644
  function base64urlDecode(value) {
1627
1645
  const padded = value.replace(/-/g, "+").replace(/_/g, "/");
@@ -1661,6 +1679,7 @@ async function verifyContext(token, secrets) {
1661
1679
  }
1662
1680
  let verified = false;
1663
1681
  for (const secret of secrets) {
1682
+ if (secret.trim().length === 0) continue;
1664
1683
  try {
1665
1684
  const key2 = await importHmacKey(secret);
1666
1685
  if (await crypto.subtle.verify(
@@ -1676,11 +1695,20 @@ async function verifyContext(token, secrets) {
1676
1695
  }
1677
1696
  }
1678
1697
  if (!verified) return null;
1698
+ return parseContextPayload(payload);
1699
+ }
1700
+ function decodeContextClaims(token) {
1701
+ const parts = token.split(".");
1702
+ if (parts.length !== 3) return null;
1703
+ return parseContextPayload(parts[1]);
1704
+ }
1705
+ function parseContextPayload(payload) {
1679
1706
  try {
1680
1707
  const parsed = JSON.parse(
1681
1708
  new TextDecoder().decode(base64urlDecode(payload))
1682
1709
  );
1683
1710
  if (typeof parsed !== "object" || parsed === null) return null;
1711
+ if (parsed.cv !== 2) return null;
1684
1712
  return parsed;
1685
1713
  } catch {
1686
1714
  return null;
@@ -1689,7 +1717,7 @@ async function verifyContext(token, secrets) {
1689
1717
  function contextRequiredError(reason) {
1690
1718
  return new FartherShoreError(
1691
1719
  "context_unverified",
1692
- `X-Fs-Context ${reason} (contextVerification is "required")`
1720
+ `X-Fs-Context ${reason} \u2014 signed context is required whenever context secrets are configured`
1693
1721
  );
1694
1722
  }
1695
1723
 
@@ -1765,6 +1793,7 @@ async function verifyRequest(input, deps) {
1765
1793
  "signed route-id is not served by this backend"
1766
1794
  );
1767
1795
  }
1796
+ const contextToken = h("x-fs-context") ?? null;
1768
1797
  const canonicalInput = {
1769
1798
  method: input.method,
1770
1799
  path: input.path,
@@ -1775,7 +1804,13 @@ async function verifyRequest(input, deps) {
1775
1804
  businessId: signedBusinessId,
1776
1805
  backendId: signedBackendId,
1777
1806
  routeId: signedRouteId,
1778
- policyVersion
1807
+ policyVersion,
1808
+ // Consumer-principal wave (D3): bind the presented X-Fs-Context hash into
1809
+ // the canonical string in exact lockstep with the gateway signer. A missing
1810
+ // context hashes the empty string, so an identity-less request still
1811
+ // verifies (it is rejected later by the fail-closed context gate when
1812
+ // secrets exist).
1813
+ contextHash: await hashContextToken2(contextToken)
1779
1814
  };
1780
1815
  const canonical = buildCanonicalSigningString2(canonicalInput);
1781
1816
  const publicJwk = await deps.jwks.getKey(kid);
@@ -1794,44 +1829,28 @@ async function verifyRequest(input, deps) {
1794
1829
  "Ed25519 signature verification failed"
1795
1830
  );
1796
1831
  }
1797
- if (deps.nonceCache.checkAndRemember(requestId)) {
1832
+ if (await deps.nonceCache.checkAndRemember(requestId)) {
1798
1833
  throw new FartherShoreError(
1799
1834
  "replayed_nonce",
1800
1835
  "x-fs-request-id has already been seen (replay)"
1801
1836
  );
1802
1837
  }
1803
- let permissions;
1804
- let roles;
1805
- let signedContext = null;
1806
- const contextSecrets = deps.contextSecrets ?? [];
1807
- const hasContextKeyring = contextSecrets.length > 0;
1808
- if (hasContextKeyring) {
1809
- const token = h("x-fs-context");
1810
- if (token) {
1811
- signedContext = await verifyContext(token, contextSecrets);
1812
- if (signedContext === null && deps.contextVerification === "required") {
1813
- throw contextRequiredError("failed verification");
1814
- }
1815
- if (signedContext && signedContext.productId !== signedBusinessId) {
1816
- throw new FartherShoreError(
1817
- "context_unverified",
1818
- "X-Fs-Context was minted for a different business than the signed request"
1819
- );
1820
- }
1821
- } else if (deps.contextVerification === "required") {
1822
- throw contextRequiredError("header is missing");
1823
- }
1824
- } else if (deps.contextVerification === "required") {
1825
- throw contextRequiredError("keyring is empty");
1826
- }
1838
+ const signedContext = await resolveSignedContext(
1839
+ contextToken,
1840
+ deps.contextSecrets ?? [],
1841
+ signedBusinessId
1842
+ );
1843
+ const permissions = signedContext?.permissions;
1844
+ const roles = signedContext?.roles;
1845
+ let principal;
1827
1846
  if (signedContext) {
1828
- permissions = signedContext.permissions;
1829
- roles = signedContext.roles;
1830
- } else {
1831
- permissions = parsePermissionHeader(
1832
- h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
1833
- );
1834
- roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
1847
+ const derived = principalFromContextClaims2(signedContext);
1848
+ if (derived === null) {
1849
+ throw contextRequiredError(
1850
+ "carried an invalid or incomplete consumer principal"
1851
+ );
1852
+ }
1853
+ principal = derived;
1835
1854
  }
1836
1855
  return {
1837
1856
  requestId,
@@ -1841,11 +1860,32 @@ async function verifyRequest(input, deps) {
1841
1860
  policyVersion,
1842
1861
  timestamp,
1843
1862
  bodyHash: computedBodyHash,
1863
+ ...principal ? { principal } : {},
1844
1864
  ...permissions !== void 0 ? { permissions } : {},
1845
1865
  ...roles !== void 0 ? { roles } : {},
1846
1866
  ...signedContext ? { signedContext } : {}
1847
1867
  };
1848
1868
  }
1869
+ async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
1870
+ if (!contextToken) return null;
1871
+ const signedContext = decodeContextClaims(contextToken);
1872
+ if (signedContext === null) {
1873
+ throw contextRequiredError("payload could not be parsed as a cv=2 claim");
1874
+ }
1875
+ if (contextSecrets.length > 0) {
1876
+ const hmacVerified = await verifyContext(contextToken, contextSecrets);
1877
+ if (hmacVerified === null) {
1878
+ throw contextRequiredError("failed HS256 verification");
1879
+ }
1880
+ }
1881
+ if (signedContext.businessId !== signedBusinessId) {
1882
+ throw new FartherShoreError(
1883
+ "context_unverified",
1884
+ "X-Fs-Context was minted for a different business than the signed request"
1885
+ );
1886
+ }
1887
+ return signedContext;
1888
+ }
1849
1889
  async function computeBodyHash(input) {
1850
1890
  if (input.streamingExempt) return STREAMING_EXEMPT_BODY_HASH;
1851
1891
  const body = input.body;
@@ -1878,8 +1918,7 @@ function headerGetter(headers) {
1878
1918
 
1879
1919
  // src/core/runtime.ts
1880
1920
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1881
- var SDK_VERSION = "0.15.0".length > 0 ? "0.15.0" : "0.0.0-dev";
1882
- var CONTRACTS_FP = "4b6a36b4cb1f0b68".length > 0 ? "4b6a36b4cb1f0b68" : "0000000000000000";
1921
+ var SDK_VERSION = "0.17.0".length > 0 ? "0.17.0" : "0.0.0-dev";
1883
1922
  var FartherShore = class {
1884
1923
  bootstrapClient;
1885
1924
  fetchImpl;
@@ -1889,11 +1928,9 @@ var FartherShore = class {
1889
1928
  coreUrl;
1890
1929
  instanceId;
1891
1930
  tunnelOptions;
1892
- /** FAR-723 HS256 secret(s) for verifying the signed X-Fs-Context claim. */
1931
+ /** OPTIONAL HS256 secret(s) defense-in-depth over the cv=2 X-Fs-Context. */
1893
1932
  contextSecrets;
1894
- /** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
1895
- contextVerification;
1896
- nonceCache = new NonceCache();
1933
+ nonceCache;
1897
1934
  shutdownManager = new ShutdownManager();
1898
1935
  jwks = null;
1899
1936
  meteringClient = null;
@@ -1911,8 +1948,8 @@ var FartherShore = class {
1911
1948
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
1912
1949
  this.tunnelOptions = options.tunnel ?? {};
1913
1950
  this.instanceId = options.instanceId;
1951
+ this.nonceCache = options.nonceStore ?? new NonceCache();
1914
1952
  this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
1915
- this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
1916
1953
  this.bootstrapClient = new BootstrapClient({
1917
1954
  runtimeToken,
1918
1955
  coreUrl,
@@ -2037,12 +2074,10 @@ var FartherShore = class {
2037
2074
  knownRouteIds,
2038
2075
  clockSkewSeconds: config.verification.clockSkewSeconds,
2039
2076
  replayWindowSeconds: config.verification.replayWindowSeconds,
2040
- // FAR-723 a VERIFIED signed X-Fs-Context is the preferred (or
2041
- // required) identity source. Required mode must also fail closed when the
2042
- // keyring is empty; preferred mode preserves the transitional unsigned
2043
- // fallback until the backend-v* publish gate removes it.
2044
- contextSecrets: this.contextSecrets,
2045
- contextVerification: this.contextVerification
2077
+ // Consumer-principal wave (D3): OPTIONAL defense-in-depth. The principal
2078
+ // is derived from the Ed25519-vouched X-Fs-Context regardless; when these
2079
+ // secrets are set a presented token must ALSO pass HS256.
2080
+ contextSecrets: this.contextSecrets
2046
2081
  });
2047
2082
  return {
2048
2083
  ...context,
@@ -2169,6 +2204,45 @@ function parseContextSecrets(raw) {
2169
2204
  return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2170
2205
  }
2171
2206
 
2207
+ // src/core/permissions.ts
2208
+ var WILDCARD = "*";
2209
+ var FartherShorePermissionError = class extends Error {
2210
+ code = "permission_denied";
2211
+ status = 403;
2212
+ /** The permission key that was required but not held. */
2213
+ requiredPermission;
2214
+ constructor(requiredPermission, message) {
2215
+ super(message ?? `missing required permission: ${requiredPermission}`);
2216
+ this.name = "FartherShorePermissionError";
2217
+ this.requiredPermission = requiredPermission;
2218
+ }
2219
+ };
2220
+ function permissionGrants(permissions, key2) {
2221
+ if (permissions === void 0) return true;
2222
+ if (permissions.includes(WILDCARD)) return true;
2223
+ return permissions.includes(key2);
2224
+ }
2225
+ function permissionSatisfies(required, granted) {
2226
+ if (granted === void 0) return true;
2227
+ if (granted.includes(WILDCARD)) return true;
2228
+ if (granted.includes(required)) return true;
2229
+ const idx = required.indexOf(":");
2230
+ if (idx > 0 && idx < required.length - 1) {
2231
+ const subject = required.slice(0, idx);
2232
+ if (granted.includes(`${subject}:${WILDCARD}`)) return true;
2233
+ }
2234
+ return false;
2235
+ }
2236
+ function hasPermission(ctx, key2) {
2237
+ if (ctx.permissions === void 0) return false;
2238
+ return permissionSatisfies(key2, ctx.permissions);
2239
+ }
2240
+ function requirePermission(ctx, key2) {
2241
+ if (!hasPermission(ctx, key2)) {
2242
+ throw new FartherShorePermissionError(key2);
2243
+ }
2244
+ }
2245
+
2172
2246
  // src/adapters/express.ts
2173
2247
  var STREAMING_CONTENT_TYPES = new Set(
2174
2248
  RUNTIME_BODY_HASH_CONTRACT.streamingExemptContentTypes
@@ -2180,7 +2254,9 @@ function createExpressMiddleware(fs, options = {}) {
2180
2254
  }
2181
2255
  async function runMiddleware(fs, options, req, res, next) {
2182
2256
  try {
2183
- if (!options.always && !await fs.verificationRequired()) {
2257
+ const strict = options.always ?? true;
2258
+ if (!strict && !await fs.verificationRequired()) {
2259
+ stripFartherShoreHeaders(req);
2184
2260
  next();
2185
2261
  return;
2186
2262
  }
@@ -2197,6 +2273,7 @@ async function runMiddleware(fs, options, req, res, next) {
2197
2273
  streamingExempt
2198
2274
  });
2199
2275
  req.fartherShore = ctx;
2276
+ stripFartherShoreHeaders(req);
2200
2277
  next();
2201
2278
  } catch (error) {
2202
2279
  fail(res, error);
@@ -2209,6 +2286,51 @@ function fail(res, error) {
2209
2286
  }
2210
2287
  res.status(401).json({ error: "bad_signature" });
2211
2288
  }
2289
+ function stripFartherShoreHeaders(req) {
2290
+ const headers = req.headers;
2291
+ for (const name of Object.keys(headers)) {
2292
+ if (name.toLowerCase().startsWith("x-fs-")) {
2293
+ delete headers[name];
2294
+ }
2295
+ }
2296
+ const withRaw = req;
2297
+ const raw = withRaw.rawHeaders;
2298
+ if (Array.isArray(raw)) {
2299
+ const cleaned = [];
2300
+ for (let i = 0; i < raw.length; i += 2) {
2301
+ const key2 = raw[i];
2302
+ const value = raw[i + 1];
2303
+ if (typeof key2 !== "string" || value === void 0) continue;
2304
+ if (key2.toLowerCase().startsWith("x-fs-")) continue;
2305
+ cleaned.push(key2, value);
2306
+ }
2307
+ withRaw.rawHeaders = cleaned;
2308
+ }
2309
+ }
2310
+ function createExpressHandler(handler) {
2311
+ return (req, res, next) => {
2312
+ const ctx = req.fartherShore;
2313
+ if (!ctx) {
2314
+ res.status(401).json({ error: "context_unverified" });
2315
+ return;
2316
+ }
2317
+ if (!ctx.principal) {
2318
+ res.status(401).json({ error: "principal_required" });
2319
+ return;
2320
+ }
2321
+ const verified = ctx;
2322
+ void Promise.resolve().then(
2323
+ () => handler(verified, req, res, next)
2324
+ ).catch((error) => failHandler(res, next, error));
2325
+ };
2326
+ }
2327
+ function failHandler(res, next, error) {
2328
+ if (error instanceof FartherShoreError || error instanceof FartherShorePermissionError) {
2329
+ res.status(error.status).json({ error: error.code });
2330
+ return;
2331
+ }
2332
+ next(error);
2333
+ }
2212
2334
  function splitUrl(req) {
2213
2335
  const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
2214
2336
  const qIndex = raw.indexOf("?");
@@ -2235,6 +2357,42 @@ function headerValue(headers, name) {
2235
2357
  return value;
2236
2358
  }
2237
2359
 
2360
+ // src/core/subject.ts
2361
+ var MEMBER_SUBJECT_REQUIRED = RUNTIME_ERROR_CODES.memberSubjectRequired;
2362
+ var SERVICE_SUBJECT_REQUIRED = RUNTIME_ERROR_CODES.serviceSubjectRequired;
2363
+ function requireMember(ctx) {
2364
+ const subject = ctx.principal?.subject;
2365
+ if (!subject || subject.kind !== "member") {
2366
+ throw new FartherShoreError(
2367
+ MEMBER_SUBJECT_REQUIRED,
2368
+ "this operation requires a member subject (a user session or a personal key)",
2369
+ 403
2370
+ );
2371
+ }
2372
+ return subject;
2373
+ }
2374
+ function requireService(ctx) {
2375
+ const subject = ctx.principal?.subject;
2376
+ if (!subject || subject.kind !== "service") {
2377
+ throw new FartherShoreError(
2378
+ SERVICE_SUBJECT_REQUIRED,
2379
+ "this operation requires a service subject (an org-owned service-account key)",
2380
+ 403
2381
+ );
2382
+ }
2383
+ return subject;
2384
+ }
2385
+ function credentialKind(ctx) {
2386
+ const subject = ctx.principal?.subject;
2387
+ if (!subject) return void 0;
2388
+ if (subject.kind === "service") return "api_key";
2389
+ return subject.via === "session" ? "portal_session" : "api_key";
2390
+ }
2391
+ function isPortalSession(ctx) {
2392
+ const kind = credentialKind(ctx);
2393
+ return kind === void 0 ? void 0 : kind === "portal_session";
2394
+ }
2395
+
2238
2396
  // src/testing/signers.ts
2239
2397
  import { generateKeyPairSync, randomBytes } from "node:crypto";
2240
2398
  var TEST_KID = "fs-runtime-test-2026";
@@ -2265,7 +2423,10 @@ async function makeSignedRequest(spec = {}) {
2265
2423
  businessId: spec.businessId ?? "biz_test",
2266
2424
  backendId: spec.backendId ?? "be_test",
2267
2425
  routeId: spec.routeId ?? "route_test",
2268
- policyVersion: spec.policyVersion ?? "pv_1"
2426
+ policyVersion: spec.policyVersion ?? "pv_1",
2427
+ // Consumer-principal wave (D3): bind the X-Fs-Context hash into the
2428
+ // canonical string (empty context → SHA-256 of the empty string).
2429
+ contextHash: await hashContextToken2(spec.contextToken)
2269
2430
  };
2270
2431
  const canonical = buildCanonicalSigningString2(claim);
2271
2432
  const signature = await signCanonicalString2(canonical, privateJwk);
@@ -2278,7 +2439,8 @@ async function makeSignedRequest(spec = {}) {
2278
2439
  [RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
2279
2440
  [RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
2280
2441
  [RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
2281
- [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash
2442
+ [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash,
2443
+ ...spec.contextToken ? { "x-fs-context": spec.contextToken } : {}
2282
2444
  };
2283
2445
  return {
2284
2446
  input: { method, path, query, body, streamingExempt },
@@ -2296,7 +2458,7 @@ function base64urlEncodeJson(value) {
2296
2458
  }
2297
2459
  async function signContextToken(claim, secret = TEST_CONTEXT_SECRET, kid = TEST_CONTEXT_KID) {
2298
2460
  const header = base64urlEncodeJson({ alg: "HS256", typ: "JWT", kid });
2299
- const payload = base64urlEncodeJson({ cv: 1, ...claim });
2461
+ const payload = base64urlEncodeJson({ ...claim });
2300
2462
  const signingInput = `${header}.${payload}`;
2301
2463
  const key2 = await crypto.subtle.importKey(
2302
2464
  "raw",
@@ -2376,12 +2538,15 @@ function mergeHeaders(initHeaders, signedHeaders) {
2376
2538
  return headers;
2377
2539
  }
2378
2540
  function buildContextClaim(persona, businessId) {
2541
+ const memberId = persona.actor?.id ?? `user_${persona.name}`;
2379
2542
  return {
2380
- orgId: persona.orgId ?? "org_dev",
2381
- actor: persona.actor ?? { type: "user", id: `user_${persona.name}` },
2382
- // Business binding: the retained signed-context productId claim MUST equal
2383
- // the signed request businessId or verifyRequest rejects it as tamper evidence.
2384
- productId: businessId,
2543
+ cv: 2,
2544
+ sub: memberId,
2545
+ subjectKind: "member",
2546
+ org: persona.orgId ?? "org_dev",
2547
+ // Business binding: the signed-context businessId claim MUST equal the
2548
+ // signed request businessId or verifyRequest rejects it as tamper evidence.
2549
+ businessId,
2385
2550
  compiledPlanId: persona.compiledPlanId ?? "plan_dev",
2386
2551
  subscriptionId: persona.subscriptionId ?? "sub_dev",
2387
2552
  subscriberId: persona.subscriberId ?? "subscriber_dev",
@@ -2403,6 +2568,11 @@ function createPersonaClient(ctx) {
2403
2568
  }
2404
2569
  async function buildHeaders(persona, spec) {
2405
2570
  const method = normalizeMethod(spec.method);
2571
+ const contextToken = persona.anonymous ? void 0 : await signContextToken(
2572
+ buildContextClaim(persona, ctx.businessId),
2573
+ ctx.contextSecret,
2574
+ ctx.contextKid
2575
+ );
2406
2576
  const signed = await makeSignedRequest({
2407
2577
  method,
2408
2578
  path: spec.path ?? "/",
@@ -2414,19 +2584,11 @@ function createPersonaClient(ctx) {
2414
2584
  routeId: spec.routeId ?? "",
2415
2585
  privateJwk: ctx.keys.privateJwk,
2416
2586
  kid: ctx.keys.kid,
2587
+ ...contextToken ? { contextToken } : {},
2417
2588
  ...spec.requestId ? { requestId: spec.requestId } : {},
2418
2589
  ...spec.timestamp !== void 0 ? { timestamp: spec.timestamp } : {}
2419
2590
  });
2420
- const headers = { ...signed.headers };
2421
- if (!persona.anonymous) {
2422
- const claim = buildContextClaim(persona, ctx.businessId);
2423
- headers["x-fs-context"] = await signContextToken(
2424
- claim,
2425
- ctx.contextSecret,
2426
- ctx.contextKid
2427
- );
2428
- }
2429
- return headers;
2591
+ return { ...signed.headers };
2430
2592
  }
2431
2593
  function asPersona(name) {
2432
2594
  const persona = resolve(name);
@@ -2504,7 +2666,7 @@ function createDevGateway(options) {
2504
2666
  name: "Dev Backend"
2505
2667
  },
2506
2668
  environment: { id: null, kind: "test" },
2507
- capabilities: ["gateway_verification", "metering", "health"],
2669
+ operations: ["gateway_verification", "metering", "health"],
2508
2670
  verification: {
2509
2671
  required: options.mode === "simulated",
2510
2672
  jwksUrl: DEV_JWKS_URL,
@@ -2810,8 +2972,9 @@ function createDevRuntime(options) {
2810
2972
  runtimeToken: keys.runtimeToken,
2811
2973
  coreUrl: DEV_CORE_URL,
2812
2974
  fetchImpl: gateway.fetchImpl,
2975
+ // Consumer-principal wave (D3): signed cv=2 context is fail-closed whenever
2976
+ // contextSecrets are set — no per-mode verification toggle.
2813
2977
  contextSecrets: [keys.contextSecret],
2814
- contextVerification: mode === "simulated" ? "required" : "preferred",
2815
2978
  env: {}
2816
2979
  });
2817
2980
  const tracedAuthz = {
@@ -2837,7 +3000,8 @@ function createDevRuntime(options) {
2837
3000
  });
2838
3001
  }
2839
3002
  function middleware(mwOptions) {
2840
- const inner = createExpressMiddleware(fs, mwOptions);
3003
+ const resolved = mode === "passthrough" ? { always: false, ...mwOptions } : mwOptions ?? {};
3004
+ const inner = createExpressMiddleware(fs, resolved);
2841
3005
  return (req, res, next) => {
2842
3006
  const requestId = headerValue2(req.headers, "x-fs-request-id") ?? "unknown";
2843
3007
  const { path } = splitUrl2(req);
@@ -2876,6 +3040,7 @@ function createDevRuntime(options) {
2876
3040
  };
2877
3041
  }
2878
3042
  fs.middleware = middleware;
3043
+ fs.handler = createExpressHandler;
2879
3044
  const devRuntime = {
2880
3045
  fs,
2881
3046
  asPersona: (name) => personaClient.asPersona(name),
@@ -3003,6 +3168,7 @@ var fartherShore = {
3003
3168
  }
3004
3169
  const fs = initFromEnv(options);
3005
3170
  fs.middleware = (mwOptions) => createExpressMiddleware(fs, mwOptions);
3171
+ fs.handler = createExpressHandler;
3006
3172
  return fs;
3007
3173
  }
3008
3174
  };
@@ -3018,7 +3184,6 @@ export {
3018
3184
  FartherShore,
3019
3185
  FartherShoreError,
3020
3186
  FartherShorePermissionError,
3021
- IDENTITY_HEADER_NAMES,
3022
3187
  JwksClient,
3023
3188
  MAX_BODY_BYTES,
3024
3189
  METERING_PAYLOAD_HEADER,
@@ -3034,7 +3199,7 @@ export {
3034
3199
  RUNTIME_ERROR_CODE_TO_ERROR_CODE,
3035
3200
  RUNTIME_HEADER_NAMES,
3036
3201
  RUNTIME_REPLAY_WINDOW_SECONDS,
3037
- RUNTIME_TOKEN_CAPABILITIES,
3202
+ RUNTIME_TOKEN_OPERATIONS,
3038
3203
  RUNTIME_TOKEN_PREFIXES,
3039
3204
  STREAMING_EXEMPT_BODY_HASH,
3040
3205
  ShutdownManager,
@@ -3042,18 +3207,24 @@ export {
3042
3207
  buildHealthReport,
3043
3208
  canonicalizeQuery2 as canonicalizeQuery,
3044
3209
  computeMeteringHeaders,
3210
+ createExpressHandler,
3045
3211
  createExpressMiddleware,
3046
3212
  createUsage,
3213
+ credentialKind,
3214
+ decodeContextClaims,
3047
3215
  fartherShore,
3048
3216
  hasPermission,
3049
3217
  hashBody2 as hashBody,
3050
3218
  initFromEnv2 as initFromEnv,
3219
+ isPortalSession,
3051
3220
  nodeSpawn,
3052
- parsePermissionHeader,
3053
3221
  permissionGrants,
3054
3222
  permissionSatisfies,
3223
+ principalFromContextClaims2 as principalFromContextClaims,
3055
3224
  reportHealth,
3225
+ requireMember,
3056
3226
  requirePermission,
3227
+ requireService,
3057
3228
  runtimeErrorToErrorCode,
3058
3229
  runtimeTokenKind2 as runtimeTokenKind,
3059
3230
  signCanonicalString2 as signCanonicalString,