@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.
@@ -218,7 +218,9 @@ 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"
222
224
  };
223
225
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
224
226
  headers: {
@@ -282,7 +284,10 @@ var RUNTIME_ERROR_CODE_TO_ERROR_CODE = {
282
284
  // the canonical code keeps the "dependency down" semantic for callers.
283
285
  [RUNTIME_ERROR_CODES.jwksUnavailable]: "SERVICE_UNAVAILABLE",
284
286
  // The single non-401 (413) — oversized request body.
285
- [RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR"
287
+ [RUNTIME_ERROR_CODES.bodyTooLarge]: "VALIDATION_ERROR",
288
+ // Consumer-principal wave — route subject-requirement faults → FORBIDDEN (403).
289
+ [RUNTIME_ERROR_CODES.memberSubjectRequired]: "FORBIDDEN",
290
+ [RUNTIME_ERROR_CODES.serviceSubjectRequired]: "FORBIDDEN"
286
291
  };
287
292
  var FS_RUNTIME_TOKEN_ENV = "FS_RUNTIME_TOKEN";
288
293
  var RUNTIME_TOKEN_PREFIXES = {
@@ -294,16 +299,12 @@ var RUNTIME_HEADER_NAMES = {
294
299
  keyId: "x-fs-key-id",
295
300
  requestId: "x-fs-request-id",
296
301
  timestamp: "x-fs-timestamp",
297
- productId: "x-fs-product-id",
302
+ businessId: "x-fs-business-id",
298
303
  backendId: "x-fs-backend-id",
299
304
  routeId: "x-fs-route-id",
300
305
  policyVersion: "x-fs-policy-version",
301
306
  bodyHash: "x-fs-body-hash"
302
307
  };
303
- var RUNTIME_IDENTITY_HEADER_NAMES = {
304
- permissions: "x-fs-permissions",
305
- roles: "x-fs-roles"
306
- };
307
308
  var RUNTIME_CLOCK_SKEW_SECONDS = 5;
308
309
  var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
309
310
  var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
@@ -346,34 +347,19 @@ var CANONICAL_SIGNING_FIELDS = [
346
347
  "body-hash",
347
348
  "request-id",
348
349
  "timestamp",
349
- "product-id",
350
+ "business-id",
350
351
  "backend-id",
351
352
  "route-id",
352
- "policy-version"
353
+ "policy-version",
354
+ // Consumer-principal wave (D3) — the trailing identity-context binding line.
355
+ // MUST stay last so pre-binding verifiers that stop at `policy-version`
356
+ // fail loud on a bound request rather than silently accepting a prefix.
357
+ "context-hash"
353
358
  ];
354
359
  var CANONICAL_FIELD_SEPARATOR = "\n";
355
360
  var CANONICAL_KV_SEPARATOR = ":";
356
361
  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("&");
362
+ return query;
377
363
  }
378
364
  function buildCanonicalSigningString(input) {
379
365
  const values = {
@@ -383,13 +369,17 @@ function buildCanonicalSigningString(input) {
383
369
  "body-hash": input.bodyHash,
384
370
  "request-id": input.requestId,
385
371
  timestamp: String(input.timestamp),
386
- "product-id": input.productId,
372
+ "business-id": input.businessId,
387
373
  "backend-id": input.backendId,
388
374
  "route-id": input.routeId,
389
- "policy-version": input.policyVersion
375
+ "policy-version": input.policyVersion,
376
+ "context-hash": input.contextHash
390
377
  };
391
378
  return CANONICAL_SIGNING_FIELDS.map((field) => `${field}${CANONICAL_KV_SEPARATOR}${values[field]}`).join(CANONICAL_FIELD_SEPARATOR);
392
379
  }
380
+ function hashContextToken(token) {
381
+ return hashBody(new TextEncoder().encode(token ?? ""));
382
+ }
393
383
  var ED25519_ALGORITHM = "Ed25519";
394
384
  async function importEd25519PrivateKey(jwk) {
395
385
  return crypto.subtle.importKey("jwk", { ...jwk, alg: void 0 }, { name: ED25519_ALGORITHM }, false, ["sign"]);
@@ -431,6 +421,7 @@ function base64UrlDecode(value) {
431
421
  // src/runtime-signing.ts
432
422
  var hashBody2 = hashBody;
433
423
  var buildCanonicalSigningString2 = buildCanonicalSigningString;
424
+ var hashContextToken2 = hashContextToken;
434
425
  var signCanonicalString2 = signCanonicalString;
435
426
  var verifyCanonicalSignature2 = verifyCanonicalSignature;
436
427
  var runtimeTokenKind2 = runtimeTokenKind;
@@ -608,10 +599,13 @@ async function makeSignedRequest(spec = {}) {
608
599
  bodyHash,
609
600
  requestId: spec.requestId ?? `req_${cryptoRandom()}`,
610
601
  timestamp: spec.timestamp ?? Math.floor(Date.now() / 1e3),
611
- productId: spec.productId ?? "prod_test",
602
+ businessId: spec.businessId ?? "biz_test",
612
603
  backendId: spec.backendId ?? "be_test",
613
604
  routeId: spec.routeId ?? "route_test",
614
- policyVersion: spec.policyVersion ?? "pv_1"
605
+ policyVersion: spec.policyVersion ?? "pv_1",
606
+ // Consumer-principal wave (D3): bind the X-Fs-Context hash into the
607
+ // canonical string (empty context → SHA-256 of the empty string).
608
+ contextHash: await hashContextToken2(spec.contextToken)
615
609
  };
616
610
  const canonical = buildCanonicalSigningString2(claim);
617
611
  const signature = await signCanonicalString2(canonical, privateJwk);
@@ -620,11 +614,12 @@ async function makeSignedRequest(spec = {}) {
620
614
  [RUNTIME_HEADER_NAMES.keyId]: kid,
621
615
  [RUNTIME_HEADER_NAMES.requestId]: claim.requestId,
622
616
  [RUNTIME_HEADER_NAMES.timestamp]: String(claim.timestamp),
623
- [RUNTIME_HEADER_NAMES.productId]: claim.productId,
617
+ [RUNTIME_HEADER_NAMES.businessId]: claim.businessId,
624
618
  [RUNTIME_HEADER_NAMES.backendId]: claim.backendId,
625
619
  [RUNTIME_HEADER_NAMES.routeId]: claim.routeId,
626
620
  [RUNTIME_HEADER_NAMES.policyVersion]: claim.policyVersion,
627
- [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash
621
+ [RUNTIME_HEADER_NAMES.bodyHash]: claim.bodyHash,
622
+ ...spec.contextToken ? { "x-fs-context": spec.contextToken } : {}
628
623
  };
629
624
  return {
630
625
  input: { method, path, query, body, streamingExempt },
@@ -663,7 +658,7 @@ function base64urlEncodeJson(value) {
663
658
  }
664
659
  async function signContextToken(claim, secret = TEST_CONTEXT_SECRET, kid = TEST_CONTEXT_KID) {
665
660
  const header = base64urlEncodeJson({ alg: "HS256", typ: "JWT", kid });
666
- const payload = base64urlEncodeJson({ cv: 1, ...claim });
661
+ const payload = base64urlEncodeJson({ ...claim });
667
662
  const signingInput = `${header}.${payload}`;
668
663
  const key2 = await crypto.subtle.importKey(
669
664
  "raw",
@@ -742,13 +737,16 @@ function mergeHeaders(initHeaders, signedHeaders) {
742
737
  }
743
738
  return headers;
744
739
  }
745
- function buildContextClaim(persona, productId) {
740
+ function buildContextClaim(persona, businessId) {
741
+ const memberId = persona.actor?.id ?? `user_${persona.name}`;
746
742
  return {
747
- orgId: persona.orgId ?? "org_dev",
748
- actor: persona.actor ?? { type: "user", id: `user_${persona.name}` },
749
- // Product binding: the signed context productId MUST equal the signed
750
- // request productId or verifyRequest rejects it as tamper evidence.
751
- productId,
743
+ cv: 2,
744
+ sub: memberId,
745
+ subjectKind: "member",
746
+ org: persona.orgId ?? "org_dev",
747
+ // Business binding: the signed-context businessId claim MUST equal the
748
+ // signed request businessId or verifyRequest rejects it as tamper evidence.
749
+ businessId,
752
750
  compiledPlanId: persona.compiledPlanId ?? "plan_dev",
753
751
  subscriptionId: persona.subscriptionId ?? "sub_dev",
754
752
  subscriberId: persona.subscriberId ?? "subscriber_dev",
@@ -770,30 +768,27 @@ function createPersonaClient(ctx) {
770
768
  }
771
769
  async function buildHeaders(persona, spec) {
772
770
  const method = normalizeMethod(spec.method);
771
+ const contextToken = persona.anonymous ? void 0 : await signContextToken(
772
+ buildContextClaim(persona, ctx.businessId),
773
+ ctx.contextSecret,
774
+ ctx.contextKid
775
+ );
773
776
  const signed = await makeSignedRequest({
774
777
  method,
775
778
  path: spec.path ?? "/",
776
779
  query: spec.query ?? "",
777
780
  body: spec.body ?? null,
778
781
  streamingExempt: spec.streamingExempt ?? false,
779
- productId: ctx.productId,
782
+ businessId: ctx.businessId,
780
783
  backendId: ctx.backendId,
781
784
  routeId: spec.routeId ?? "",
782
785
  privateJwk: ctx.keys.privateJwk,
783
786
  kid: ctx.keys.kid,
787
+ ...contextToken ? { contextToken } : {},
784
788
  ...spec.requestId ? { requestId: spec.requestId } : {},
785
789
  ...spec.timestamp !== void 0 ? { timestamp: spec.timestamp } : {}
786
790
  });
787
- const headers = { ...signed.headers };
788
- if (!persona.anonymous) {
789
- const claim = buildContextClaim(persona, ctx.productId);
790
- headers["x-fs-context"] = await signContextToken(
791
- claim,
792
- ctx.contextSecret,
793
- ctx.contextKid
794
- );
795
- }
796
- return headers;
791
+ return { ...signed.headers };
797
792
  }
798
793
  function asPersona(name) {
799
794
  const persona = resolve(name);
@@ -861,12 +856,12 @@ var DEV_CORE_URL = "https://dev-gateway.farthershore.local";
861
856
  var DEV_JWKS_URL = `${DEV_CORE_URL}/.well-known/jwks.json`;
862
857
  var DEV_METERING_ENDPOINT = `${DEV_CORE_URL}/v1/metering/events`;
863
858
  function createDevGateway(options) {
864
- const productId = options.productId ?? "prod_dev";
859
+ const businessId = options.businessId ?? "biz_dev";
865
860
  const backendId = options.backendId ?? "be_dev";
866
861
  const meterEvents = [];
867
862
  const reportUsageEvents = [];
868
863
  const bootstrap = {
869
- product: { id: productId, slug: options.productSlug ?? "dev-product" },
864
+ business: { id: businessId, slug: options.businessSlug ?? "dev-business" },
870
865
  backend: {
871
866
  id: backendId,
872
867
  slug: options.backendSlug ?? "dev-backend",
@@ -936,7 +931,7 @@ function createDevGateway(options) {
936
931
  bootstrap,
937
932
  meterEvents,
938
933
  reportUsageEvents,
939
- productId,
934
+ businessId,
940
935
  backendId,
941
936
  jwksUrl: DEV_JWKS_URL
942
937
  };
@@ -1136,7 +1131,7 @@ var DEFAULT_MAX_RETRIES = 3;
1136
1131
  var MeteringClient = class {
1137
1132
  config;
1138
1133
  endpoint;
1139
- productId;
1134
+ businessId;
1140
1135
  backendId;
1141
1136
  fetchImpl;
1142
1137
  maxRetries;
@@ -1150,7 +1145,7 @@ var MeteringClient = class {
1150
1145
  constructor(options) {
1151
1146
  this.config = options.config;
1152
1147
  this.endpoint = resolveEndpoint(options.config.endpoint, options.coreUrl);
1153
- this.productId = options.productId;
1148
+ this.businessId = options.businessId;
1154
1149
  this.backendId = options.backendId;
1155
1150
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1156
1151
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
@@ -1213,13 +1208,14 @@ var MeteringClient = class {
1213
1208
  }
1214
1209
  const event = {
1215
1210
  event_id: options.eventId ?? this.newId(),
1216
- product_id: this.productId,
1211
+ business_id: this.businessId,
1217
1212
  backend_id: this.backendId,
1218
1213
  meter,
1219
1214
  qty,
1220
1215
  timestamp: options.timestamp ?? this.now().toISOString(),
1221
1216
  ...options.routeId ? { route_id: options.routeId } : {},
1222
- ...options.requestId ? { request_id: options.requestId } : {}
1217
+ ...options.requestId ? { request_id: options.requestId } : {},
1218
+ ...options.subscriptionId ? { subscription_id: options.subscriptionId } : {}
1223
1219
  };
1224
1220
  this.buffer.push(event);
1225
1221
  await this.flush();
@@ -1421,8 +1417,8 @@ function resolveEndpoint2(endpoint, coreUrl) {
1421
1417
  }
1422
1418
 
1423
1419
  // src/core/nonceCache.ts
1424
- var DEFAULT_MAX_ENTRIES = 1e5;
1425
- var DEFAULT_TTL_MS = 6e5;
1420
+ var DEFAULT_MAX_ENTRIES = 25e4;
1421
+ var DEFAULT_TTL_MS = (RUNTIME_REPLAY_WINDOW_SECONDS + RUNTIME_CLOCK_SKEW_SECONDS) * 1e3;
1426
1422
  var NonceCache = class {
1427
1423
  maxEntries;
1428
1424
  ttlMs;
@@ -1446,7 +1442,7 @@ var NonceCache = class {
1446
1442
  this.seen.delete(id);
1447
1443
  }
1448
1444
  this.evictExpired(at);
1449
- this.evictOverflow();
1445
+ if (this.seen.size >= this.maxEntries) return true;
1450
1446
  this.seen.set(id, at);
1451
1447
  return false;
1452
1448
  }
@@ -1460,13 +1456,6 @@ var NonceCache = class {
1460
1456
  this.seen.delete(id);
1461
1457
  }
1462
1458
  }
1463
- evictOverflow() {
1464
- while (this.seen.size >= this.maxEntries) {
1465
- const oldest = this.seen.keys().next().value;
1466
- if (oldest === void 0) break;
1467
- this.seen.delete(oldest);
1468
- }
1469
- }
1470
1459
  };
1471
1460
 
1472
1461
  // src/core/shutdown.ts
@@ -1801,47 +1790,82 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
1801
1790
  return `${root}${sep}${normalized}`;
1802
1791
  }
1803
1792
 
1804
- // src/core/permissions.ts
1805
- var WILDCARD = "*";
1806
- var FartherShorePermissionError = class extends Error {
1807
- code = "permission_denied";
1808
- status = 403;
1809
- /** The permission key that was required but not held. */
1810
- requiredPermission;
1811
- constructor(requiredPermission, message) {
1812
- super(message ?? `missing required permission: ${requiredPermission}`);
1813
- this.name = "FartherShorePermissionError";
1814
- this.requiredPermission = requiredPermission;
1815
- }
1816
- };
1817
- function parsePermissionHeader(raw) {
1818
- if (raw === null || raw === void 0) return void 0;
1819
- return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
1793
+ // ../contracts/dist/authz/principal.js
1794
+ var SUBJECT_KINDS = ["member", "service"];
1795
+ function isSubjectKind(value) {
1796
+ return typeof value === "string" && SUBJECT_KINDS.includes(value);
1820
1797
  }
1821
- function permissionSatisfies(required, granted) {
1822
- if (granted === void 0) return true;
1823
- if (granted.includes(WILDCARD)) return true;
1824
- if (granted.includes(required)) return true;
1825
- const idx = required.indexOf(":");
1826
- if (idx > 0 && idx < required.length - 1) {
1827
- const subject = required.slice(0, idx);
1828
- if (granted.includes(`${subject}:${WILDCARD}`)) return true;
1829
- }
1830
- return false;
1798
+ function isNonEmptyTrimmed(value) {
1799
+ return typeof value === "string" && value.trim().length > 0;
1831
1800
  }
1832
- function hasPermission(ctx, key2) {
1833
- if (ctx.permissions === void 0) {
1834
- return ctx.signedContext !== void 0;
1835
- }
1836
- return permissionSatisfies(key2, ctx.permissions);
1801
+ function normalizeIdentityId(value) {
1802
+ return value.trim();
1837
1803
  }
1838
- function requirePermission(ctx, key2) {
1839
- if (!hasPermission(ctx, key2)) {
1840
- throw new FartherShorePermissionError(key2);
1804
+ function isNonEmptyStringArray(value) {
1805
+ return Array.isArray(value) && value.every((item) => isNonEmptyTrimmed(item));
1806
+ }
1807
+ function principalFromContextClaims(claims) {
1808
+ if (typeof claims !== "object" || claims === null)
1809
+ return null;
1810
+ const { sub, org: orgId, businessId, subjectKind } = claims;
1811
+ if (!isNonEmptyTrimmed(sub) || !isNonEmptyTrimmed(orgId) || !isNonEmptyTrimmed(businessId)) {
1812
+ return null;
1841
1813
  }
1814
+ if (!isSubjectKind(subjectKind))
1815
+ return null;
1816
+ if (claims.permissions !== void 0 && !isNonEmptyStringArray(claims.permissions)) {
1817
+ return null;
1818
+ }
1819
+ if (claims.roles !== void 0 && !isNonEmptyStringArray(claims.roles)) {
1820
+ return null;
1821
+ }
1822
+ const clientId = claims.client_id;
1823
+ const actSub = claims.act?.sub;
1824
+ if (isNonEmptyTrimmed(clientId) && isNonEmptyTrimmed(actSub) && normalizeIdentityId(clientId) !== normalizeIdentityId(actSub)) {
1825
+ return null;
1826
+ }
1827
+ const org = { id: normalizeIdentityId(orgId) };
1828
+ if (subjectKind === "service") {
1829
+ const explicitServiceAccountId = claims.serviceAccountId;
1830
+ if (isNonEmptyTrimmed(explicitServiceAccountId) && normalizeIdentityId(explicitServiceAccountId) !== normalizeIdentityId(sub)) {
1831
+ return null;
1832
+ }
1833
+ const serviceAccountId = normalizeIdentityId(explicitServiceAccountId ?? sub);
1834
+ const keyIdSource = clientId ?? actSub ?? sub;
1835
+ if (!isNonEmptyTrimmed(serviceAccountId) || !isNonEmptyTrimmed(keyIdSource)) {
1836
+ return null;
1837
+ }
1838
+ return {
1839
+ org,
1840
+ subject: {
1841
+ kind: "service",
1842
+ serviceAccountId,
1843
+ keyId: normalizeIdentityId(keyIdSource)
1844
+ }
1845
+ };
1846
+ }
1847
+ const memberId = normalizeIdentityId(sub);
1848
+ const act = claims.act;
1849
+ if (act !== void 0 && act !== null) {
1850
+ if (typeof act !== "object" || !isNonEmptyTrimmed(act.sub))
1851
+ return null;
1852
+ return {
1853
+ org,
1854
+ subject: {
1855
+ kind: "member",
1856
+ memberId,
1857
+ via: "api_key",
1858
+ keyId: normalizeIdentityId(act.sub)
1859
+ }
1860
+ };
1861
+ }
1862
+ return { org, subject: { kind: "member", memberId, via: "session" } };
1842
1863
  }
1843
1864
 
1844
1865
  // src/core/verifyContext.ts
1866
+ function principalFromContextClaims2(claims) {
1867
+ return principalFromContextClaims(claims);
1868
+ }
1845
1869
  var EXPECTED_JWT_ALG = "HS256";
1846
1870
  function base64urlDecode(value) {
1847
1871
  const padded = value.replace(/-/g, "+").replace(/_/g, "/");
@@ -1881,6 +1905,7 @@ async function verifyContext(token, secrets) {
1881
1905
  }
1882
1906
  let verified = false;
1883
1907
  for (const secret of secrets) {
1908
+ if (secret.trim().length === 0) continue;
1884
1909
  try {
1885
1910
  const key2 = await importHmacKey(secret);
1886
1911
  if (await crypto.subtle.verify(
@@ -1896,11 +1921,20 @@ async function verifyContext(token, secrets) {
1896
1921
  }
1897
1922
  }
1898
1923
  if (!verified) return null;
1924
+ return parseContextPayload(payload);
1925
+ }
1926
+ function decodeContextClaims(token) {
1927
+ const parts = token.split(".");
1928
+ if (parts.length !== 3) return null;
1929
+ return parseContextPayload(parts[1]);
1930
+ }
1931
+ function parseContextPayload(payload) {
1899
1932
  try {
1900
1933
  const parsed = JSON.parse(
1901
1934
  new TextDecoder().decode(base64urlDecode(payload))
1902
1935
  );
1903
1936
  if (typeof parsed !== "object" || parsed === null) return null;
1937
+ if (parsed.cv !== 2) return null;
1904
1938
  return parsed;
1905
1939
  } catch {
1906
1940
  return null;
@@ -1909,7 +1943,7 @@ async function verifyContext(token, secrets) {
1909
1943
  function contextRequiredError(reason) {
1910
1944
  return new FartherShoreError(
1911
1945
  "context_unverified",
1912
- `X-Fs-Context ${reason} (contextVerification is "required")`
1946
+ `X-Fs-Context ${reason} \u2014 signed context is required whenever context secrets are configured`
1913
1947
  );
1914
1948
  }
1915
1949
 
@@ -1926,12 +1960,12 @@ async function verifyRequest(input, deps) {
1926
1960
  const kid = h(RUNTIME_HEADER_NAMES.keyId);
1927
1961
  const requestId = h(RUNTIME_HEADER_NAMES.requestId);
1928
1962
  const timestampRaw = h(RUNTIME_HEADER_NAMES.timestamp);
1929
- const signedProductId = h(RUNTIME_HEADER_NAMES.productId);
1963
+ const signedBusinessId = h(RUNTIME_HEADER_NAMES.businessId);
1930
1964
  const signedBackendId = h(RUNTIME_HEADER_NAMES.backendId);
1931
1965
  const signedRouteId = h(RUNTIME_HEADER_NAMES.routeId) ?? "";
1932
1966
  const policyVersion = h(RUNTIME_HEADER_NAMES.policyVersion);
1933
1967
  const signedBodyHash = h(RUNTIME_HEADER_NAMES.bodyHash);
1934
- if (!kid || !requestId || !timestampRaw || !signedProductId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
1968
+ if (!kid || !requestId || !timestampRaw || !signedBusinessId || !signedBackendId || policyVersion === void 0 || !signedBodyHash) {
1935
1969
  throw new FartherShoreError(
1936
1970
  "malformed_signature",
1937
1971
  "request is missing one or more required x-fs-* headers"
@@ -1967,10 +2001,10 @@ async function verifyRequest(input, deps) {
1967
2001
  "recomputed body hash does not match the signed x-fs-body-hash"
1968
2002
  );
1969
2003
  }
1970
- if (deps.productId !== void 0 && signedProductId !== deps.productId) {
2004
+ if (deps.businessId !== void 0 && signedBusinessId !== deps.businessId) {
1971
2005
  throw new FartherShoreError(
1972
2006
  "route_mismatch",
1973
- "signed product-id does not match this backend's product"
2007
+ "signed business-id does not match this backend's business"
1974
2008
  );
1975
2009
  }
1976
2010
  if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
@@ -1985,6 +2019,7 @@ async function verifyRequest(input, deps) {
1985
2019
  "signed route-id is not served by this backend"
1986
2020
  );
1987
2021
  }
2022
+ const contextToken = h("x-fs-context") ?? null;
1988
2023
  const canonicalInput = {
1989
2024
  method: input.method,
1990
2025
  path: input.path,
@@ -1992,10 +2027,16 @@ async function verifyRequest(input, deps) {
1992
2027
  bodyHash: computedBodyHash,
1993
2028
  requestId,
1994
2029
  timestamp,
1995
- productId: signedProductId,
2030
+ businessId: signedBusinessId,
1996
2031
  backendId: signedBackendId,
1997
2032
  routeId: signedRouteId,
1998
- policyVersion
2033
+ policyVersion,
2034
+ // Consumer-principal wave (D3): bind the presented X-Fs-Context hash into
2035
+ // the canonical string in exact lockstep with the gateway signer. A missing
2036
+ // context hashes the empty string, so an identity-less request still
2037
+ // verifies (it is rejected later by the fail-closed context gate when
2038
+ // secrets exist).
2039
+ contextHash: await hashContextToken2(contextToken)
1999
2040
  };
2000
2041
  const canonical = buildCanonicalSigningString2(canonicalInput);
2001
2042
  const publicJwk = await deps.jwks.getKey(kid);
@@ -2014,58 +2055,63 @@ async function verifyRequest(input, deps) {
2014
2055
  "Ed25519 signature verification failed"
2015
2056
  );
2016
2057
  }
2017
- if (deps.nonceCache.checkAndRemember(requestId)) {
2058
+ if (await deps.nonceCache.checkAndRemember(requestId)) {
2018
2059
  throw new FartherShoreError(
2019
2060
  "replayed_nonce",
2020
2061
  "x-fs-request-id has already been seen (replay)"
2021
2062
  );
2022
2063
  }
2023
- let permissions;
2024
- let roles;
2025
- let signedContext = null;
2026
- const contextSecrets = deps.contextSecrets ?? [];
2027
- const hasContextKeyring = contextSecrets.length > 0;
2028
- if (hasContextKeyring) {
2029
- const token = h("x-fs-context");
2030
- if (token) {
2031
- signedContext = await verifyContext(token, contextSecrets);
2032
- if (signedContext === null && deps.contextVerification === "required") {
2033
- throw contextRequiredError("failed verification");
2034
- }
2035
- if (signedContext && signedContext.productId !== signedProductId) {
2036
- throw new FartherShoreError(
2037
- "context_unverified",
2038
- "X-Fs-Context was minted for a different product than the signed request"
2039
- );
2040
- }
2041
- } else if (deps.contextVerification === "required") {
2042
- throw contextRequiredError("header is missing");
2043
- }
2044
- } else if (deps.contextVerification === "required") {
2045
- throw contextRequiredError("keyring is empty");
2046
- }
2064
+ const signedContext = await resolveSignedContext(
2065
+ contextToken,
2066
+ deps.contextSecrets ?? [],
2067
+ signedBusinessId
2068
+ );
2069
+ const permissions = signedContext?.permissions;
2070
+ const roles = signedContext?.roles;
2071
+ let principal;
2047
2072
  if (signedContext) {
2048
- permissions = signedContext.permissions;
2049
- roles = signedContext.roles;
2050
- } else {
2051
- permissions = parsePermissionHeader(
2052
- h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
2053
- );
2054
- roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
2073
+ const derived = principalFromContextClaims2(signedContext);
2074
+ if (derived === null) {
2075
+ throw contextRequiredError(
2076
+ "carried an invalid or incomplete consumer principal"
2077
+ );
2078
+ }
2079
+ principal = derived;
2055
2080
  }
2056
2081
  return {
2057
2082
  requestId,
2058
- productId: signedProductId,
2083
+ businessId: signedBusinessId,
2059
2084
  backendId: signedBackendId,
2060
2085
  routeId: signedRouteId,
2061
2086
  policyVersion,
2062
2087
  timestamp,
2063
2088
  bodyHash: computedBodyHash,
2089
+ ...principal ? { principal } : {},
2064
2090
  ...permissions !== void 0 ? { permissions } : {},
2065
2091
  ...roles !== void 0 ? { roles } : {},
2066
2092
  ...signedContext ? { signedContext } : {}
2067
2093
  };
2068
2094
  }
2095
+ async function resolveSignedContext(contextToken, contextSecrets, signedBusinessId) {
2096
+ if (!contextToken) return null;
2097
+ const signedContext = decodeContextClaims(contextToken);
2098
+ if (signedContext === null) {
2099
+ throw contextRequiredError("payload could not be parsed as a cv=2 claim");
2100
+ }
2101
+ if (contextSecrets.length > 0) {
2102
+ const hmacVerified = await verifyContext(contextToken, contextSecrets);
2103
+ if (hmacVerified === null) {
2104
+ throw contextRequiredError("failed HS256 verification");
2105
+ }
2106
+ }
2107
+ if (signedContext.businessId !== signedBusinessId) {
2108
+ throw new FartherShoreError(
2109
+ "context_unverified",
2110
+ "X-Fs-Context was minted for a different business than the signed request"
2111
+ );
2112
+ }
2113
+ return signedContext;
2114
+ }
2069
2115
  async function computeBodyHash(input) {
2070
2116
  if (input.streamingExempt) return STREAMING_EXEMPT_BODY_HASH;
2071
2117
  const body = input.body;
@@ -2098,8 +2144,8 @@ function headerGetter(headers) {
2098
2144
 
2099
2145
  // src/core/runtime.ts
2100
2146
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
2101
- var SDK_VERSION = "0.14.0".length > 0 ? "0.14.0" : "0.0.0-dev";
2102
- var CONTRACTS_FP = "1feb5a4a80b447ad".length > 0 ? "1feb5a4a80b447ad" : "0000000000000000";
2147
+ var SDK_VERSION = "0.16.0".length > 0 ? "0.16.0" : "0.0.0-dev";
2148
+ var CONTRACTS_FP = "c3961d4ea07ff178".length > 0 ? "c3961d4ea07ff178" : "0000000000000000";
2103
2149
  var FartherShore = class {
2104
2150
  bootstrapClient;
2105
2151
  fetchImpl;
@@ -2109,11 +2155,9 @@ var FartherShore = class {
2109
2155
  coreUrl;
2110
2156
  instanceId;
2111
2157
  tunnelOptions;
2112
- /** FAR-723 HS256 secret(s) for verifying the signed X-Fs-Context claim. */
2158
+ /** OPTIONAL HS256 secret(s) defense-in-depth over the cv=2 X-Fs-Context. */
2113
2159
  contextSecrets;
2114
- /** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
2115
- contextVerification;
2116
- nonceCache = new NonceCache();
2160
+ nonceCache;
2117
2161
  shutdownManager = new ShutdownManager();
2118
2162
  jwks = null;
2119
2163
  meteringClient = null;
@@ -2131,8 +2175,8 @@ var FartherShore = class {
2131
2175
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
2132
2176
  this.tunnelOptions = options.tunnel ?? {};
2133
2177
  this.instanceId = options.instanceId;
2178
+ this.nonceCache = options.nonceStore ?? new NonceCache();
2134
2179
  this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
2135
- this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
2136
2180
  this.bootstrapClient = new BootstrapClient({
2137
2181
  runtimeToken,
2138
2182
  coreUrl,
@@ -2168,7 +2212,7 @@ var FartherShore = class {
2168
2212
  if (!this.meteringClient && config.metering.enabled) {
2169
2213
  this.meteringClient = new MeteringClient({
2170
2214
  config: config.metering,
2171
- productId: config.product.id,
2215
+ businessId: config.business.id,
2172
2216
  backendId: config.backend.id,
2173
2217
  coreUrl: this.coreUrl,
2174
2218
  fetchImpl: this.fetchImpl
@@ -2252,17 +2296,15 @@ var FartherShore = class {
2252
2296
  const context = await verifyRequest(input, {
2253
2297
  jwks: this.jwks,
2254
2298
  nonceCache: this.nonceCache,
2255
- productId: config.product.id,
2299
+ businessId: config.business.id,
2256
2300
  backendId: config.backend.id,
2257
2301
  knownRouteIds,
2258
2302
  clockSkewSeconds: config.verification.clockSkewSeconds,
2259
2303
  replayWindowSeconds: config.verification.replayWindowSeconds,
2260
- // FAR-723 a VERIFIED signed X-Fs-Context is the preferred (or
2261
- // required) identity source. Required mode must also fail closed when the
2262
- // keyring is empty; preferred mode preserves the transitional unsigned
2263
- // fallback until the backend-v* publish gate removes it.
2264
- contextSecrets: this.contextSecrets,
2265
- contextVerification: this.contextVerification
2304
+ // Consumer-principal wave (D3): OPTIONAL defense-in-depth. The principal
2305
+ // is derived from the Ed25519-vouched X-Fs-Context regardless; when these
2306
+ // secrets are set a presented token must ALSO pass HS256.
2307
+ contextSecrets: this.contextSecrets
2266
2308
  });
2267
2309
  return {
2268
2310
  ...context,
@@ -2389,6 +2431,40 @@ function parseContextSecrets(raw) {
2389
2431
  return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2390
2432
  }
2391
2433
 
2434
+ // src/core/permissions.ts
2435
+ var WILDCARD = "*";
2436
+ var FartherShorePermissionError = class extends Error {
2437
+ code = "permission_denied";
2438
+ status = 403;
2439
+ /** The permission key that was required but not held. */
2440
+ requiredPermission;
2441
+ constructor(requiredPermission, message) {
2442
+ super(message ?? `missing required permission: ${requiredPermission}`);
2443
+ this.name = "FartherShorePermissionError";
2444
+ this.requiredPermission = requiredPermission;
2445
+ }
2446
+ };
2447
+ function permissionSatisfies(required, granted) {
2448
+ if (granted === void 0) return true;
2449
+ if (granted.includes(WILDCARD)) return true;
2450
+ if (granted.includes(required)) return true;
2451
+ const idx = required.indexOf(":");
2452
+ if (idx > 0 && idx < required.length - 1) {
2453
+ const subject = required.slice(0, idx);
2454
+ if (granted.includes(`${subject}:${WILDCARD}`)) return true;
2455
+ }
2456
+ return false;
2457
+ }
2458
+ function hasPermission(ctx, key2) {
2459
+ if (ctx.permissions === void 0) return false;
2460
+ return permissionSatisfies(key2, ctx.permissions);
2461
+ }
2462
+ function requirePermission(ctx, key2) {
2463
+ if (!hasPermission(ctx, key2)) {
2464
+ throw new FartherShorePermissionError(key2);
2465
+ }
2466
+ }
2467
+
2392
2468
  // src/adapters/express.ts
2393
2469
  var STREAMING_CONTENT_TYPES = new Set(
2394
2470
  RUNTIME_BODY_HASH_CONTRACT.streamingExemptContentTypes
@@ -2400,7 +2476,9 @@ function createExpressMiddleware(fs, options = {}) {
2400
2476
  }
2401
2477
  async function runMiddleware(fs, options, req, res, next) {
2402
2478
  try {
2403
- if (!options.always && !await fs.verificationRequired()) {
2479
+ const strict = options.always ?? true;
2480
+ if (!strict && !await fs.verificationRequired()) {
2481
+ stripFartherShoreHeaders(req);
2404
2482
  next();
2405
2483
  return;
2406
2484
  }
@@ -2417,6 +2495,7 @@ async function runMiddleware(fs, options, req, res, next) {
2417
2495
  streamingExempt
2418
2496
  });
2419
2497
  req.fartherShore = ctx;
2498
+ stripFartherShoreHeaders(req);
2420
2499
  next();
2421
2500
  } catch (error) {
2422
2501
  fail(res, error);
@@ -2429,6 +2508,51 @@ function fail(res, error) {
2429
2508
  }
2430
2509
  res.status(401).json({ error: "bad_signature" });
2431
2510
  }
2511
+ function stripFartherShoreHeaders(req) {
2512
+ const headers = req.headers;
2513
+ for (const name of Object.keys(headers)) {
2514
+ if (name.toLowerCase().startsWith("x-fs-")) {
2515
+ delete headers[name];
2516
+ }
2517
+ }
2518
+ const withRaw = req;
2519
+ const raw = withRaw.rawHeaders;
2520
+ if (Array.isArray(raw)) {
2521
+ const cleaned = [];
2522
+ for (let i = 0; i < raw.length; i += 2) {
2523
+ const key2 = raw[i];
2524
+ const value = raw[i + 1];
2525
+ if (typeof key2 !== "string" || value === void 0) continue;
2526
+ if (key2.toLowerCase().startsWith("x-fs-")) continue;
2527
+ cleaned.push(key2, value);
2528
+ }
2529
+ withRaw.rawHeaders = cleaned;
2530
+ }
2531
+ }
2532
+ function createExpressHandler(handler) {
2533
+ return (req, res, next) => {
2534
+ const ctx = req.fartherShore;
2535
+ if (!ctx) {
2536
+ res.status(401).json({ error: "context_unverified" });
2537
+ return;
2538
+ }
2539
+ if (!ctx.principal) {
2540
+ res.status(401).json({ error: "principal_required" });
2541
+ return;
2542
+ }
2543
+ const verified = ctx;
2544
+ void Promise.resolve().then(
2545
+ () => handler(verified, req, res, next)
2546
+ ).catch((error) => failHandler(res, next, error));
2547
+ };
2548
+ }
2549
+ function failHandler(res, next, error) {
2550
+ if (error instanceof FartherShoreError || error instanceof FartherShorePermissionError) {
2551
+ res.status(error.status).json({ error: error.code });
2552
+ return;
2553
+ }
2554
+ next(error);
2555
+ }
2432
2556
  function splitUrl(req) {
2433
2557
  const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
2434
2558
  const qIndex = raw.indexOf("?");
@@ -2623,7 +2747,7 @@ function personaClientFromKeysFile(path = DEFAULT_KEYS_FILE, options = {}) {
2623
2747
  const file = readDevKeysFile(path);
2624
2748
  const client = createPersonaClient({
2625
2749
  keys: file.keys,
2626
- productId: file.productId,
2750
+ businessId: file.businessId,
2627
2751
  backendId: file.backendId,
2628
2752
  contextSecret: file.keys.contextSecret,
2629
2753
  contextKid: file.keys.contextKid,
@@ -2650,7 +2774,7 @@ function createDevRuntime(options) {
2650
2774
  const gateway = createDevGateway({
2651
2775
  mode,
2652
2776
  keys,
2653
- ...options.productId ? { productId: options.productId } : {},
2777
+ ...options.businessId ? { businessId: options.businessId } : {},
2654
2778
  ...options.backendId ? { backendId: options.backendId } : {},
2655
2779
  ...options.routes ? { routeIds: options.routes } : {},
2656
2780
  onMeterEvent: (event) => {
@@ -2679,7 +2803,7 @@ function createDevRuntime(options) {
2679
2803
  const personas = buildPersonaMap(options.personas);
2680
2804
  const personaClient = createPersonaClient({
2681
2805
  keys,
2682
- productId: gateway.productId,
2806
+ businessId: gateway.businessId,
2683
2807
  backendId: gateway.backendId,
2684
2808
  contextSecret: keys.contextSecret,
2685
2809
  contextKid: keys.contextKid,
@@ -2691,8 +2815,9 @@ function createDevRuntime(options) {
2691
2815
  runtimeToken: keys.runtimeToken,
2692
2816
  coreUrl: DEV_CORE_URL,
2693
2817
  fetchImpl: gateway.fetchImpl,
2818
+ // Consumer-principal wave (D3): signed cv=2 context is fail-closed whenever
2819
+ // contextSecrets are set — no per-mode verification toggle.
2694
2820
  contextSecrets: [keys.contextSecret],
2695
- contextVerification: mode === "simulated" ? "required" : "preferred",
2696
2821
  env: {}
2697
2822
  });
2698
2823
  const tracedAuthz = {
@@ -2718,7 +2843,8 @@ function createDevRuntime(options) {
2718
2843
  });
2719
2844
  }
2720
2845
  function middleware(mwOptions) {
2721
- const inner = createExpressMiddleware(fs, mwOptions);
2846
+ const resolved = mode === "passthrough" ? { always: false, ...mwOptions } : mwOptions ?? {};
2847
+ const inner = createExpressMiddleware(fs, resolved);
2722
2848
  return (req, res, next) => {
2723
2849
  const requestId = headerValue2(req.headers, "x-fs-request-id") ?? "unknown";
2724
2850
  const { path } = splitUrl2(req);
@@ -2757,6 +2883,7 @@ function createDevRuntime(options) {
2757
2883
  };
2758
2884
  }
2759
2885
  fs.middleware = middleware;
2886
+ fs.handler = createExpressHandler;
2760
2887
  const devRuntime = {
2761
2888
  fs,
2762
2889
  asPersona: (name) => personaClient.asPersona(name),
@@ -2809,7 +2936,7 @@ function createDevRuntimeFromEnv(env = readProcessEnv3()) {
2809
2936
  version: 1,
2810
2937
  mode,
2811
2938
  keys,
2812
- productId: runtime.gateway.productId,
2939
+ businessId: runtime.gateway.businessId,
2813
2940
  backendId: runtime.gateway.backendId,
2814
2941
  personas: mapToRecord(runtime.personas)
2815
2942
  };
@@ -2822,7 +2949,7 @@ function printBanner(mode, runtime, tracePath) {
2822
2949
  "============================================================",
2823
2950
  " \u26A0 FARTHER SHORE DEV MODE ACTIVE \u2014 NOT FOR PRODUCTION",
2824
2951
  ` mode: ${mode.toUpperCase()}`,
2825
- ` product: ${runtime.gateway.productId}`,
2952
+ ` business: ${runtime.gateway.businessId}`,
2826
2953
  ` backend: ${runtime.gateway.backendId}`,
2827
2954
  ` personas: ${[...runtime.personas.keys()].join(", ")}`,
2828
2955
  ` usage log: ${USAGE_JSONL_PATH}`,