@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.
@@ -218,7 +218,10 @@ var RUNTIME_ERROR_CODES = {
218
218
  environmentMismatch: "environment_mismatch",
219
219
  missingToken: "missing_token",
220
220
  invalidToken: "invalid_token",
221
- contextUnverified: "context_unverified"
221
+ contextUnverified: "context_unverified",
222
+ memberSubjectRequired: "member_subject_required",
223
+ serviceSubjectRequired: "service_subject_required",
224
+ surfaceNotAllowed: "surface_not_allowed"
222
225
  };
223
226
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
224
227
  headers: {
@@ -282,7 +285,12 @@ var RUNTIME_ERROR_CODE_TO_ERROR_CODE = {
282
285
  // the canonical code keeps the "dependency down" semantic for callers.
283
286
  [RUNTIME_ERROR_CODES.jwksUnavailable]: "SERVICE_UNAVAILABLE",
284
287
  // The single non-401 (413) — oversized request body.
285
- [RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR"
288
+ [RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR",
289
+ // Consumer-principal wave — route subject-requirement faults → FORBIDDEN (403).
290
+ [RUNTIME_ERROR_CODES.memberSubjectRequired]: "FORBIDDEN",
291
+ [RUNTIME_ERROR_CODES.serviceSubjectRequired]: "FORBIDDEN",
292
+ // A visible route whose surface set excludes the caller → 403.
293
+ [RUNTIME_ERROR_CODES.surfaceNotAllowed]: "FORBIDDEN"
286
294
  };
287
295
  var FS_RUNTIME_TOKEN_ENV = "FS_RUNTIME_TOKEN";
288
296
  var RUNTIME_TOKEN_PREFIXES = {
@@ -300,10 +308,6 @@ var RUNTIME_HEADER_NAMES = {
300
308
  policyVersion: "x-fs-policy-version",
301
309
  bodyHash: "x-fs-body-hash"
302
310
  };
303
- var RUNTIME_IDENTITY_HEADER_NAMES = {
304
- permissions: "x-fs-permissions",
305
- roles: "x-fs-roles"
306
- };
307
311
  var RUNTIME_CLOCK_SKEW_SECONDS = 5;
308
312
  var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
309
313
  var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
@@ -349,31 +353,16 @@ var CANONICAL_SIGNING_FIELDS = [
349
353
  "business-id",
350
354
  "backend-id",
351
355
  "route-id",
352
- "policy-version"
356
+ "policy-version",
357
+ // Consumer-principal wave (D3) — the trailing identity-context binding line.
358
+ // MUST stay last so pre-binding verifiers that stop at `policy-version`
359
+ // fail loud on a bound request rather than silently accepting a prefix.
360
+ "context-hash"
353
361
  ];
354
362
  var CANONICAL_FIELD_SEPARATOR = "\n";
355
363
  var CANONICAL_KV_SEPARATOR = ":";
356
364
  function canonicalizeQuery(query) {
357
- const raw = query.startsWith("?") ? query.slice(1) : query;
358
- if (raw === "")
359
- return "";
360
- const pairs = raw.split("&").filter((p) => p.length > 0);
361
- pairs.sort((a, b) => {
362
- const [an, ...arest] = a.split("=");
363
- const [bn, ...brest] = b.split("=");
364
- if (an < bn)
365
- return -1;
366
- if (an > bn)
367
- return 1;
368
- const av = arest.join("=");
369
- const bv = brest.join("=");
370
- if (av < bv)
371
- return -1;
372
- if (av > bv)
373
- return 1;
374
- return 0;
375
- });
376
- return pairs.join("&");
365
+ return query;
377
366
  }
378
367
  function buildCanonicalSigningString(input) {
379
368
  const values = {
@@ -386,10 +375,14 @@ function buildCanonicalSigningString(input) {
386
375
  "business-id": input.businessId,
387
376
  "backend-id": input.backendId,
388
377
  "route-id": input.routeId,
389
- "policy-version": input.policyVersion
378
+ "policy-version": input.policyVersion,
379
+ "context-hash": input.contextHash
390
380
  };
391
381
  return CANONICAL_SIGNING_FIELDS.map((field) => `${field}${CANONICAL_KV_SEPARATOR}${values[field]}`).join(CANONICAL_FIELD_SEPARATOR);
392
382
  }
383
+ function hashContextToken(token) {
384
+ return hashBody(new TextEncoder().encode(token ?? ""));
385
+ }
393
386
  var ED25519_ALGORITHM = "Ed25519";
394
387
  async function importEd25519PrivateKey(jwk) {
395
388
  return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["sign"]);
@@ -431,6 +424,7 @@ function base64UrlDecode(value) {
431
424
  // src/runtime-signing.ts
432
425
  var hashBody2 = hashBody;
433
426
  var buildCanonicalSigningString2 = buildCanonicalSigningString;
427
+ var hashContextToken2 = hashContextToken;
434
428
  var signCanonicalString2 = signCanonicalString;
435
429
  var verifyCanonicalSignature2 = verifyCanonicalSignature;
436
430
  var runtimeTokenKind2 = runtimeTokenKind;
@@ -453,7 +447,9 @@ var FartherShoreError = class extends Error {
453
447
  }
454
448
  };
455
449
  function statusForCode(code) {
456
- return code === "body_too_large" ? 413 : 401;
450
+ if (code === "body_too_large") return 413;
451
+ if (code === "surface_not_allowed") return 403;
452
+ return 401;
457
453
  }
458
454
 
459
455
  // src/core/jwks.ts
@@ -611,7 +607,10 @@ async function makeSignedRequest(spec = {}) {
611
607
  businessId: spec.businessId ?? "biz_test",
612
608
  backendId: spec.backendId ?? "be_test",
613
609
  routeId: spec.routeId ?? "route_test",
614
- policyVersion: spec.policyVersion ?? "pv_1"
610
+ policyVersion: spec.policyVersion ?? "pv_1",
611
+ // Consumer-principal wave (D3): bind the X-Fs-Context hash into the
612
+ // canonical string (empty context → SHA-256 of the empty string).
613
+ contextHash: await hashContextToken2(spec.contextToken)
615
614
  };
616
615
  const canonical = buildCanonicalSigningString2(claim);
617
616
  const signature = await signCanonicalString2(canonical, privateJwk);
@@ -624,7 +623,8 @@ async function makeSignedRequest(spec = {}) {
624
623
  [RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
625
624
  [RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
626
625
  [RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
627
- [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash
626
+ [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash,
627
+ ...spec.contextToken ? { "x-fs-context": spec.contextToken } : {}
628
628
  };
629
629
  return {
630
630
  input: { method, path, query, body, streamingExempt },
@@ -663,7 +663,7 @@ function base64urlEncodeJson(value) {
663
663
  }
664
664
  async function signContextToken(claim, secret = TEST_CONTEXT_SECRET, kid = TEST_CONTEXT_KID) {
665
665
  const header = base64urlEncodeJson({ alg: "HS256", typ: "JWT", kid });
666
- const payload = base64urlEncodeJson({ cv: 1, ...claim });
666
+ const payload = base64urlEncodeJson({ ...claim });
667
667
  const signingInput = `${header}.${payload}`;
668
668
  const key2 = await crypto.subtle.importKey(
669
669
  "raw",
@@ -743,12 +743,15 @@ function mergeHeaders(initHeaders, signedHeaders) {
743
743
  return headers;
744
744
  }
745
745
  function buildContextClaim(persona, businessId) {
746
+ const memberId = persona.actor?.id ?? `user_${persona.name}`;
746
747
  return {
747
- orgId: persona.orgId ?? "org_dev",
748
- actor: persona.actor ?? { type: "user", id: `user_${persona.name}` },
749
- // Business binding: the retained signed-context productId claim MUST equal
750
- // the signed request businessId or verifyRequest rejects it as tamper evidence.
751
- productId: businessId,
748
+ cv: 2,
749
+ sub: memberId,
750
+ subjectKind: "member",
751
+ org: persona.orgId ?? "org_dev",
752
+ // Business binding: the signed-context businessId claim MUST equal the
753
+ // signed request businessId or verifyRequest rejects it as tamper evidence.
754
+ businessId,
752
755
  compiledPlanId: persona.compiledPlanId ?? "plan_dev",
753
756
  subscriptionId: persona.subscriptionId ?? "sub_dev",
754
757
  subscriberId: persona.subscriberId ?? "subscriber_dev",
@@ -770,6 +773,11 @@ function createPersonaClient(ctx) {
770
773
  }
771
774
  async function buildHeaders(persona, spec) {
772
775
  const method = normalizeMethod(spec.method);
776
+ const contextToken = persona.anonymous ? void 0 : await signContextToken(
777
+ buildContextClaim(persona, ctx.businessId),
778
+ ctx.contextSecret,
779
+ ctx.contextKid
780
+ );
773
781
  const signed = await makeSignedRequest({
774
782
  method,
775
783
  path: spec.path ?? "/",
@@ -781,19 +789,11 @@ function createPersonaClient(ctx) {
781
789
  routeId: spec.routeId ?? "",
782
790
  privateJwk: ctx.keys.privateJwk,
783
791
  kid: ctx.keys.kid,
792
+ ...contextToken ? { contextToken } : {},
784
793
  ...spec.requestId ? { requestId: spec.requestId } : {},
785
794
  ...spec.timestamp !== void 0 ? { timestamp: spec.timestamp } : {}
786
795
  });
787
- const headers = { ...signed.headers };
788
- if (!persona.anonymous) {
789
- const claim = buildContextClaim(persona, ctx.businessId);
790
- headers["x-fs-context"] = await signContextToken(
791
- claim,
792
- ctx.contextSecret,
793
- ctx.contextKid
794
- );
795
- }
796
- return headers;
796
+ return { ...signed.headers };
797
797
  }
798
798
  function asPersona(name) {
799
799
  const persona = resolve(name);
@@ -873,7 +873,7 @@ function createDevGateway(options) {
873
873
  name: "Dev Backend"
874
874
  },
875
875
  environment: { id: null, kind: "test" },
876
- capabilities: ["gateway_verification", "metering", "health"],
876
+ operations: ["gateway_verification", "metering", "health"],
877
877
  verification: {
878
878
  required: options.mode === "simulated",
879
879
  jwksUrl: DEV_JWKS_URL,
@@ -1422,8 +1422,8 @@ function resolveEndpoint2(endpoint, coreUrl) {
1422
1422
  }
1423
1423
 
1424
1424
  // src/core/nonceCache.ts
1425
- var DEFAULT_MAX_ENTRIES = 1e5;
1426
- var DEFAULT_TTL_MS = 6e5;
1425
+ var DEFAULT_MAX_ENTRIES = 25e4;
1426
+ var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
1427
1427
  var NonceCache = class {
1428
1428
  maxEntries;
1429
1429
  ttlMs;
@@ -1447,7 +1447,7 @@ var NonceCache = class {
1447
1447
  this.seen.delete(id);
1448
1448
  }
1449
1449
  this.evictExpired(at);
1450
- this.evictOverflow();
1450
+ if (this.seen.size >= this.maxEntries) return true;
1451
1451
  this.seen.set(id, at);
1452
1452
  return false;
1453
1453
  }
@@ -1461,13 +1461,6 @@ var NonceCache = class {
1461
1461
  this.seen.delete(id);
1462
1462
  }
1463
1463
  }
1464
- evictOverflow() {
1465
- while (this.seen.size >= this.maxEntries) {
1466
- const oldest = this.seen.keys().next().value;
1467
- if (oldest === void 0) break;
1468
- this.seen.delete(oldest);
1469
- }
1470
- }
1471
1464
  };
1472
1465
 
1473
1466
  // src/core/shutdown.ts
@@ -1802,47 +1795,82 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
1802
1795
  return `${root}${sep}${normalized}`;
1803
1796
  }
1804
1797
 
1805
- // src/core/permissions.ts
1806
- var WILDCARD = "*";
1807
- var FartherShorePermissionError = class extends Error {
1808
- code = "permission_denied";
1809
- status = 403;
1810
- /** The permission key that was required but not held. */
1811
- requiredPermission;
1812
- constructor(requiredPermission, message) {
1813
- super(message ?? `missing required permission: ${requiredPermission}`);
1814
- this.name = "FartherShorePermissionError";
1815
- this.requiredPermission = requiredPermission;
1816
- }
1817
- };
1818
- function parsePermissionHeader(raw) {
1819
- if (raw === null || raw === void 0) return void 0;
1820
- return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
1798
+ // ../contracts/dist/authz/principal.js
1799
+ var SUBJECT_KINDS = ["member", "service"];
1800
+ function isSubjectKind(value) {
1801
+ return typeof value === "string" && SUBJECT_KINDS.includes(value);
1821
1802
  }
1822
- function permissionSatisfies(required, granted) {
1823
- if (granted === void 0) return true;
1824
- if (granted.includes(WILDCARD)) return true;
1825
- if (granted.includes(required)) return true;
1826
- const idx = required.indexOf(":");
1827
- if (idx > 0 && idx < required.length - 1) {
1828
- const subject = required.slice(0, idx);
1829
- if (granted.includes(`${subject}:${WILDCARD}`)) return true;
1830
- }
1831
- return false;
1803
+ function isNonEmptyTrimmed(value) {
1804
+ return typeof value === "string" && value.trim().length > 0;
1832
1805
  }
1833
- function hasPermission(ctx, key2) {
1834
- if (ctx.permissions === void 0) {
1835
- return ctx.signedContext !== void 0;
1836
- }
1837
- return permissionSatisfies(key2, ctx.permissions);
1806
+ function normalizeIdentityId(value) {
1807
+ return value.trim();
1838
1808
  }
1839
- function requirePermission(ctx, key2) {
1840
- if (!hasPermission(ctx, key2)) {
1841
- throw new FartherShorePermissionError(key2);
1809
+ function isNonEmptyStringArray(value) {
1810
+ return Array.isArray(value) && value.every((item) => isNonEmptyTrimmed(item));
1811
+ }
1812
+ function principalFromContextClaims(claims) {
1813
+ if (typeof claims !== "object" || claims === null)
1814
+ return null;
1815
+ const { sub, org: orgId, businessId, subjectKind } = claims;
1816
+ if (!isNonEmptyTrimmed(sub) || !isNonEmptyTrimmed(orgId) || !isNonEmptyTrimmed(businessId)) {
1817
+ return null;
1842
1818
  }
1819
+ if (!isSubjectKind(subjectKind))
1820
+ return null;
1821
+ if (claims.permissions !== void 0 && !isNonEmptyStringArray(claims.permissions)) {
1822
+ return null;
1823
+ }
1824
+ if (claims.roles !== void 0 && !isNonEmptyStringArray(claims.roles)) {
1825
+ return null;
1826
+ }
1827
+ const clientId = claims.client_id;
1828
+ const actSub = claims.act?.sub;
1829
+ if (isNonEmptyTrimmed(clientId) && isNonEmptyTrimmed(actSub) && normalizeIdentityId(clientId) !== normalizeIdentityId(actSub)) {
1830
+ return null;
1831
+ }
1832
+ const org = { id: normalizeIdentityId(orgId) };
1833
+ if (subjectKind === "service") {
1834
+ const explicitServiceAccountId = claims.serviceAccountId;
1835
+ if (isNonEmptyTrimmed(explicitServiceAccountId) && normalizeIdentityId(explicitServiceAccountId) !== normalizeIdentityId(sub)) {
1836
+ return null;
1837
+ }
1838
+ const serviceAccountId = normalizeIdentityId(explicitServiceAccountId ?? sub);
1839
+ const keyIdSource = clientId ?? actSub ?? sub;
1840
+ if (!isNonEmptyTrimmed(serviceAccountId) || !isNonEmptyTrimmed(keyIdSource)) {
1841
+ return null;
1842
+ }
1843
+ return {
1844
+ org,
1845
+ subject: {
1846
+ kind: "service",
1847
+ serviceAccountId,
1848
+ keyId: normalizeIdentityId(keyIdSource)
1849
+ }
1850
+ };
1851
+ }
1852
+ const memberId = normalizeIdentityId(sub);
1853
+ const act = claims.act;
1854
+ if (act !== void 0 && act !== null) {
1855
+ if (typeof act !== "object" || !isNonEmptyTrimmed(act.sub))
1856
+ return null;
1857
+ return {
1858
+ org,
1859
+ subject: {
1860
+ kind: "member",
1861
+ memberId,
1862
+ via: "api_key",
1863
+ keyId: normalizeIdentityId(act.sub)
1864
+ }
1865
+ };
1866
+ }
1867
+ return { org, subject: { kind: "member", memberId, via: "session" } };
1843
1868
  }
1844
1869
 
1845
1870
  // src/core/verifyContext.ts
1871
+ function principalFromContextClaims2(claims) {
1872
+ return principalFromContextClaims(claims);
1873
+ }
1846
1874
  var EXPECTED_JWT_ALG = "HS256";
1847
1875
  function base64urlDecode(value) {
1848
1876
  const padded = value.replace(/-/g, "+").replace(/_/g, "/");
@@ -1882,6 +1910,7 @@ async function verifyContext(token, secrets) {
1882
1910
  }
1883
1911
  let verified = false;
1884
1912
  for (const secret of secrets) {
1913
+ if (secret.trim().length === 0) continue;
1885
1914
  try {
1886
1915
  const key2 = await importHmacKey(secret);
1887
1916
  if (await crypto.subtle.verify(
@@ -1897,11 +1926,20 @@ async function verifyContext(token, secrets) {
1897
1926
  }
1898
1927
  }
1899
1928
  if (!verified) return null;
1929
+ return parseContextPayload(payload);
1930
+ }
1931
+ function decodeContextClaims(token) {
1932
+ const parts = token.split(".");
1933
+ if (parts.length !== 3) return null;
1934
+ return parseContextPayload(parts[1]);
1935
+ }
1936
+ function parseContextPayload(payload) {
1900
1937
  try {
1901
1938
  const parsed = JSON.parse(
1902
1939
  new TextDecoder().decode(base64urlDecode(payload))
1903
1940
  );
1904
1941
  if (typeof parsed !== "object" || parsed === null) return null;
1942
+ if (parsed.cv !== 2) return null;
1905
1943
  return parsed;
1906
1944
  } catch {
1907
1945
  return null;
@@ -1910,7 +1948,7 @@ async function verifyContext(token, secrets) {
1910
1948
  function contextRequiredError(reason) {
1911
1949
  return new FartherShoreError(
1912
1950
  "context_unverified",
1913
- `X-Fs-Context ${reason} (contextVerification is "required")`
1951
+ `X-Fs-Context ${reason} \u2014 signed context is required whenever context secrets are configured`
1914
1952
  );
1915
1953
  }
1916
1954
 
@@ -1986,6 +2024,7 @@ async function verifyRequest(input, deps) {
1986
2024
  "signed route-id is not served by this backend"
1987
2025
  );
1988
2026
  }
2027
+ const contextToken = h("x-fs-context") ?? null;
1989
2028
  const canonicalInput = {
1990
2029
  method: input.method,
1991
2030
  path: input.path,
@@ -1996,7 +2035,13 @@ async function verifyRequest(input, deps) {
1996
2035
  businessId: signedBusinessId,
1997
2036
  backendId: signedBackendId,
1998
2037
  routeId: signedRouteId,
1999
- policyVersion
2038
+ policyVersion,
2039
+ // Consumer-principal wave (D3): bind the presented X-Fs-Context hash into
2040
+ // the canonical string in exact lockstep with the gateway signer. A missing
2041
+ // context hashes the empty string, so an identity-less request still
2042
+ // verifies (it is rejected later by the fail-closed context gate when
2043
+ // secrets exist).
2044
+ contextHash: await hashContextToken2(contextToken)
2000
2045
  };
2001
2046
  const canonical = buildCanonicalSigningString2(canonicalInput);
2002
2047
  const publicJwk = await deps.jwks.getKey(kid);
@@ -2015,44 +2060,28 @@ async function verifyRequest(input, deps) {
2015
2060
  "Ed25519 signature verification failed"
2016
2061
  );
2017
2062
  }
2018
- if (deps.nonceCache.checkAndRemember(requestId)) {
2063
+ if (await deps.nonceCache.checkAndRemember(requestId)) {
2019
2064
  throw new FartherShoreError(
2020
2065
  "replayed_nonce",
2021
2066
  "x-fs-request-id has already been seen (replay)"
2022
2067
  );
2023
2068
  }
2024
- let permissions;
2025
- let roles;
2026
- let signedContext = null;
2027
- const contextSecrets = deps.contextSecrets ?? [];
2028
- const hasContextKeyring = contextSecrets.length > 0;
2029
- if (hasContextKeyring) {
2030
- const token = h("x-fs-context");
2031
- if (token) {
2032
- signedContext = await verifyContext(token, contextSecrets);
2033
- if (signedContext === null && deps.contextVerification === "required") {
2034
- throw contextRequiredError("failed verification");
2035
- }
2036
- if (signedContext && signedContext.productId !== signedBusinessId) {
2037
- throw new FartherShoreError(
2038
- "context_unverified",
2039
- "X-Fs-Context was minted for a different business than the signed request"
2040
- );
2041
- }
2042
- } else if (deps.contextVerification === "required") {
2043
- throw contextRequiredError("header is missing");
2044
- }
2045
- } else if (deps.contextVerification === "required") {
2046
- throw contextRequiredError("keyring is empty");
2047
- }
2069
+ const signedContext = await resolveSignedContext(
2070
+ contextToken,
2071
+ deps.contextSecrets ?? [],
2072
+ signedBusinessId
2073
+ );
2074
+ const permissions = signedContext?.permissions;
2075
+ const roles = signedContext?.roles;
2076
+ let principal;
2048
2077
  if (signedContext) {
2049
- permissions = signedContext.permissions;
2050
- roles = signedContext.roles;
2051
- } else {
2052
- permissions = parsePermissionHeader(
2053
- h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
2054
- );
2055
- roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
2078
+ const derived = principalFromContextClaims2(signedContext);
2079
+ if (derived === null) {
2080
+ throw contextRequiredError(
2081
+ "carried an invalid or incomplete consumer principal"
2082
+ );
2083
+ }
2084
+ principal = derived;
2056
2085
  }
2057
2086
  return {
2058
2087
  requestId,
@@ -2062,11 +2091,32 @@ async function verifyRequest(input, deps) {
2062
2091
  policyVersion,
2063
2092
  timestamp,
2064
2093
  bodyHash: computedBodyHash,
2094
+ ...principal ? { principal } : {},
2065
2095
  ...permissions !== void 0 ? { permissions } : {},
2066
2096
  ...roles !== void 0 ? { roles } : {},
2067
2097
  ...signedContext ? { signedContext } : {}
2068
2098
  };
2069
2099
  }
2100
+ async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
2101
+ if (!contextToken) return null;
2102
+ const signedContext = decodeContextClaims(contextToken);
2103
+ if (signedContext === null) {
2104
+ throw contextRequiredError("payload could not be parsed as a cv=2 claim");
2105
+ }
2106
+ if (contextSecrets.length > 0) {
2107
+ const hmacVerified = await verifyContext(contextToken, contextSecrets);
2108
+ if (hmacVerified === null) {
2109
+ throw contextRequiredError("failed HS256 verification");
2110
+ }
2111
+ }
2112
+ if (signedContext.businessId !== signedBusinessId) {
2113
+ throw new FartherShoreError(
2114
+ "context_unverified",
2115
+ "X-Fs-Context was minted for a different business than the signed request"
2116
+ );
2117
+ }
2118
+ return signedContext;
2119
+ }
2070
2120
  async function computeBodyHash(input) {
2071
2121
  if (input.streamingExempt) return STREAMING_EXEMPT_BODY_HASH;
2072
2122
  const body = input.body;
@@ -2099,8 +2149,7 @@ function headerGetter(headers) {
2099
2149
 
2100
2150
  // src/core/runtime.ts
2101
2151
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
2102
- var SDK_VERSION = "0.15.0".length > 0 ? "0.15.0" : "0.0.0-dev";
2103
- var CONTRACTS_FP = "4b6a36b4cb1f0b68".length > 0 ? "4b6a36b4cb1f0b68" : "0000000000000000";
2152
+ var SDK_VERSION = "0.17.0".length > 0 ? "0.17.0" : "0.0.0-dev";
2104
2153
  var FartherShore = class {
2105
2154
  bootstrapClient;
2106
2155
  fetchImpl;
@@ -2110,11 +2159,9 @@ var FartherShore = class {
2110
2159
  coreUrl;
2111
2160
  instanceId;
2112
2161
  tunnelOptions;
2113
- /** FAR-723 HS256 secret(s) for verifying the signed X-Fs-Context claim. */
2162
+ /** OPTIONAL HS256 secret(s) defense-in-depth over the cv=2 X-Fs-Context. */
2114
2163
  contextSecrets;
2115
- /** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
2116
- contextVerification;
2117
- nonceCache = new NonceCache();
2164
+ nonceCache;
2118
2165
  shutdownManager = new ShutdownManager();
2119
2166
  jwks = null;
2120
2167
  meteringClient = null;
@@ -2132,8 +2179,8 @@ var FartherShore = class {
2132
2179
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
2133
2180
  this.tunnelOptions = options.tunnel ?? {};
2134
2181
  this.instanceId = options.instanceId;
2182
+ this.nonceCache = options.nonceStore ?? new NonceCache();
2135
2183
  this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
2136
- this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
2137
2184
  this.bootstrapClient = new BootstrapClient({
2138
2185
  runtimeToken,
2139
2186
  coreUrl,
@@ -2258,12 +2305,10 @@ var FartherShore = class {
2258
2305
  knownRouteIds,
2259
2306
  clockSkewSeconds: config.verification.clockSkewSeconds,
2260
2307
  replayWindowSeconds: config.verification.replayWindowSeconds,
2261
- // FAR-723 a VERIFIED signed X-Fs-Context is the preferred (or
2262
- // required) identity source. Required mode must also fail closed when the
2263
- // keyring is empty; preferred mode preserves the transitional unsigned
2264
- // fallback until the backend-v* publish gate removes it.
2265
- contextSecrets: this.contextSecrets,
2266
- contextVerification: this.contextVerification
2308
+ // Consumer-principal wave (D3): OPTIONAL defense-in-depth. The principal
2309
+ // is derived from the Ed25519-vouched X-Fs-Context regardless; when these
2310
+ // secrets are set a presented token must ALSO pass HS256.
2311
+ contextSecrets: this.contextSecrets
2267
2312
  });
2268
2313
  return {
2269
2314
  ...context,
@@ -2390,6 +2435,40 @@ function parseContextSecrets(raw) {
2390
2435
  return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2391
2436
  }
2392
2437
 
2438
+ // src/core/permissions.ts
2439
+ var WILDCARD = "*";
2440
+ var FartherShorePermissionError = class extends Error {
2441
+ code = "permission_denied";
2442
+ status = 403;
2443
+ /** The permission key that was required but not held. */
2444
+ requiredPermission;
2445
+ constructor(requiredPermission, message) {
2446
+ super(message ?? `missing required permission: ${requiredPermission}`);
2447
+ this.name = "FartherShorePermissionError";
2448
+ this.requiredPermission = requiredPermission;
2449
+ }
2450
+ };
2451
+ function permissionSatisfies(required, granted) {
2452
+ if (granted === void 0) return true;
2453
+ if (granted.includes(WILDCARD)) return true;
2454
+ if (granted.includes(required)) return true;
2455
+ const idx = required.indexOf(":");
2456
+ if (idx > 0 && idx < required.length - 1) {
2457
+ const subject = required.slice(0, idx);
2458
+ if (granted.includes(`${subject}:${WILDCARD}`)) return true;
2459
+ }
2460
+ return false;
2461
+ }
2462
+ function hasPermission(ctx, key2) {
2463
+ if (ctx.permissions === void 0) return false;
2464
+ return permissionSatisfies(key2, ctx.permissions);
2465
+ }
2466
+ function requirePermission(ctx, key2) {
2467
+ if (!hasPermission(ctx, key2)) {
2468
+ throw new FartherShorePermissionError(key2);
2469
+ }
2470
+ }
2471
+
2393
2472
  // src/adapters/express.ts
2394
2473
  var STREAMING_CONTENT_TYPES = new Set(
2395
2474
  RUNTIME_BODY_HASH_CONTRACT.streamingExemptContentTypes
@@ -2401,7 +2480,9 @@ function createExpressMiddleware(fs, options = {}) {
2401
2480
  }
2402
2481
  async function runMiddleware(fs, options, req, res, next) {
2403
2482
  try {
2404
- if (!options.always && !await fs.verificationRequired()) {
2483
+ const strict = options.always ?? true;
2484
+ if (!strict && !await fs.verificationRequired()) {
2485
+ stripFartherShoreHeaders(req);
2405
2486
  next();
2406
2487
  return;
2407
2488
  }
@@ -2418,6 +2499,7 @@ async function runMiddleware(fs, options, req, res, next) {
2418
2499
  streamingExempt
2419
2500
  });
2420
2501
  req.fartherShore = ctx;
2502
+ stripFartherShoreHeaders(req);
2421
2503
  next();
2422
2504
  } catch (error) {
2423
2505
  fail(res, error);
@@ -2430,6 +2512,51 @@ function fail(res, error) {
2430
2512
  }
2431
2513
  res.status(401).json({ error: "bad_signature" });
2432
2514
  }
2515
+ function stripFartherShoreHeaders(req) {
2516
+ const headers = req.headers;
2517
+ for (const name of Object.keys(headers)) {
2518
+ if (name.toLowerCase().startsWith("x-fs-")) {
2519
+ delete headers[name];
2520
+ }
2521
+ }
2522
+ const withRaw = req;
2523
+ const raw = withRaw.rawHeaders;
2524
+ if (Array.isArray(raw)) {
2525
+ const cleaned = [];
2526
+ for (let i = 0; i < raw.length; i += 2) {
2527
+ const key2 = raw[i];
2528
+ const value = raw[i + 1];
2529
+ if (typeof key2 !== "string" || value === void 0) continue;
2530
+ if (key2.toLowerCase().startsWith("x-fs-")) continue;
2531
+ cleaned.push(key2, value);
2532
+ }
2533
+ withRaw.rawHeaders = cleaned;
2534
+ }
2535
+ }
2536
+ function createExpressHandler(handler) {
2537
+ return (req, res, next) => {
2538
+ const ctx = req.fartherShore;
2539
+ if (!ctx) {
2540
+ res.status(401).json({ error: "context_unverified" });
2541
+ return;
2542
+ }
2543
+ if (!ctx.principal) {
2544
+ res.status(401).json({ error: "principal_required" });
2545
+ return;
2546
+ }
2547
+ const verified = ctx;
2548
+ void Promise.resolve().then(
2549
+ () => handler(verified, req, res, next)
2550
+ ).catch((error) => failHandler(res, next, error));
2551
+ };
2552
+ }
2553
+ function failHandler(res, next, error) {
2554
+ if (error instanceof FartherShoreError || error instanceof FartherShorePermissionError) {
2555
+ res.status(error.status).json({ error: error.code });
2556
+ return;
2557
+ }
2558
+ next(error);
2559
+ }
2433
2560
  function splitUrl(req) {
2434
2561
  const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
2435
2562
  const qIndex = raw.indexOf("?");
@@ -2692,8 +2819,9 @@ function createDevRuntime(options) {
2692
2819
  runtimeToken: keys.runtimeToken,
2693
2820
  coreUrl: DEV_CORE_URL,
2694
2821
  fetchImpl: gateway.fetchImpl,
2822
+ // Consumer-principal wave (D3): signed cv=2 context is fail-closed whenever
2823
+ // contextSecrets are set — no per-mode verification toggle.
2695
2824
  contextSecrets: [keys.contextSecret],
2696
- contextVerification: mode === "simulated" ? "required" : "preferred",
2697
2825
  env: {}
2698
2826
  });
2699
2827
  const tracedAuthz = {
@@ -2719,7 +2847,8 @@ function createDevRuntime(options) {
2719
2847
  });
2720
2848
  }
2721
2849
  function middleware(mwOptions) {
2722
- const inner = createExpressMiddleware(fs, mwOptions);
2850
+ const resolved = mode === "passthrough" ? { always: false, ...mwOptions } : mwOptions ?? {};
2851
+ const inner = createExpressMiddleware(fs, resolved);
2723
2852
  return (req, res, next) => {
2724
2853
  const requestId = headerValue2(req.headers, "x-fs-request-id") ?? "unknown";
2725
2854
  const { path } = splitUrl2(req);
@@ -2758,6 +2887,7 @@ function createDevRuntime(options) {
2758
2887
  };
2759
2888
  }
2760
2889
  fs.middleware = middleware;
2890
+ fs.handler = createExpressHandler;
2761
2891
  const devRuntime = {
2762
2892
  fs,
2763
2893
  asPersona: (name) => personaClient.asPersona(name),