@farthershore/backend 0.10.0 → 0.12.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/README.md CHANGED
@@ -12,7 +12,7 @@ graceful lifecycle (health + shutdown). Everything else — your product, backen
12
12
  and environment ids, the verification keys, and the metering endpoint — is
13
13
  fetched automatically from the token at startup.
14
14
 
15
- > **Status: `0.10.0`.** Pre-1.0: minor releases may include breaking changes, so
15
+ > **Status: `0.12.0`.** Pre-1.0: minor releases may include breaking changes, so
16
16
  > pin this package to an exact version (or a patch-only range) and upgrade
17
17
  > deliberately.
18
18
 
@@ -160,7 +160,8 @@ var RUNTIME_ERROR_CODES = {
160
160
  bodyTooLarge: "body_too_large",
161
161
  environmentMismatch: "environment_mismatch",
162
162
  missingToken: "missing_token",
163
- invalidToken: "invalid_token"
163
+ invalidToken: "invalid_token",
164
+ contextUnverified: "context_unverified"
164
165
  };
165
166
  var RUNTIME_METERING_CONTRACT = {
166
167
  endpoint: "/v1/metering/events",
package/dist/index.js CHANGED
@@ -214,7 +214,8 @@ var RUNTIME_ERROR_CODES = {
214
214
  bodyTooLarge: "body_too_large",
215
215
  environmentMismatch: "environment_mismatch",
216
216
  missingToken: "missing_token",
217
- invalidToken: "invalid_token"
217
+ invalidToken: "invalid_token",
218
+ contextUnverified: "context_unverified"
218
219
  };
219
220
  var RUNTIME_RESPONSE_METERING_CONTRACT = {
220
221
  headers: {
@@ -259,6 +260,8 @@ var RUNTIME_ERROR_CODE_TO_ERROR_CODE = {
259
260
  // Credential / token presentation faults → UNAUTHORIZED (401).
260
261
  [RUNTIME_ERROR_CODES.missingToken]: "UNAUTHORIZED",
261
262
  [RUNTIME_ERROR_CODES.invalidToken]: "UNAUTHORIZED",
263
+ // UA-6 — fail-closed signed-context requirement (mirrors contracts).
264
+ [RUNTIME_ERROR_CODES.contextUnverified]: "UNAUTHORIZED",
262
265
  // Signature / key faults → UNAUTHORIZED (401, fail-closed verification).
263
266
  [RUNTIME_ERROR_CODES.missingSignature]: "UNAUTHORIZED",
264
267
  [RUNTIME_ERROR_CODES.malformedSignature]: "UNAUTHORIZED",
@@ -1299,8 +1302,22 @@ function permissionGrants(permissions, key2) {
1299
1302
  if (permissions.includes(WILDCARD)) return true;
1300
1303
  return permissions.includes(key2);
1301
1304
  }
1305
+ function permissionSatisfies(required, granted) {
1306
+ if (granted === void 0) return true;
1307
+ if (granted.includes(WILDCARD)) return true;
1308
+ if (granted.includes(required)) return true;
1309
+ const idx = required.indexOf(":");
1310
+ if (idx > 0 && idx < required.length - 1) {
1311
+ const subject = required.slice(0, idx);
1312
+ if (granted.includes(`${subject}:${WILDCARD}`)) return true;
1313
+ }
1314
+ return false;
1315
+ }
1302
1316
  function hasPermission(ctx, key2) {
1303
- return permissionGrants(ctx.permissions, key2);
1317
+ if (ctx.permissions === void 0) {
1318
+ return ctx.signedContext !== void 0;
1319
+ }
1320
+ return permissionSatisfies(key2, ctx.permissions);
1304
1321
  }
1305
1322
  function requirePermission(ctx, key2) {
1306
1323
  if (!hasPermission(ctx, key2)) {
@@ -1309,6 +1326,78 @@ function requirePermission(ctx, key2) {
1309
1326
  }
1310
1327
  var IDENTITY_HEADER_NAMES = RUNTIME_IDENTITY_HEADER_NAMES;
1311
1328
 
1329
+ // src/core/verifyContext.ts
1330
+ var EXPECTED_JWT_ALG = "HS256";
1331
+ function base64urlDecode(value) {
1332
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
1333
+ const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
1334
+ const bytes = new Uint8Array(binary.length);
1335
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
1336
+ return bytes;
1337
+ }
1338
+ async function importHmacKey(secret) {
1339
+ return crypto.subtle.importKey(
1340
+ "raw",
1341
+ new TextEncoder().encode(secret),
1342
+ { name: "HMAC", hash: "SHA-256" },
1343
+ false,
1344
+ ["verify"]
1345
+ );
1346
+ }
1347
+ async function verifyContext(token, secrets) {
1348
+ const parts = token.split(".");
1349
+ if (parts.length !== 3) return null;
1350
+ const [header, payload, signature] = parts;
1351
+ let headerJson = null;
1352
+ try {
1353
+ headerJson = JSON.parse(
1354
+ new TextDecoder().decode(base64urlDecode(header))
1355
+ );
1356
+ } catch {
1357
+ return null;
1358
+ }
1359
+ if (headerJson?.alg !== EXPECTED_JWT_ALG) return null;
1360
+ const signingInput = new TextEncoder().encode(`${header}.${payload}`);
1361
+ let signatureBytes;
1362
+ try {
1363
+ signatureBytes = base64urlDecode(signature);
1364
+ } catch {
1365
+ return null;
1366
+ }
1367
+ let verified = false;
1368
+ for (const secret of secrets) {
1369
+ try {
1370
+ const key2 = await importHmacKey(secret);
1371
+ if (await crypto.subtle.verify(
1372
+ "HMAC",
1373
+ key2,
1374
+ signatureBytes,
1375
+ signingInput
1376
+ )) {
1377
+ verified = true;
1378
+ break;
1379
+ }
1380
+ } catch {
1381
+ }
1382
+ }
1383
+ if (!verified) return null;
1384
+ try {
1385
+ const parsed = JSON.parse(
1386
+ new TextDecoder().decode(base64urlDecode(payload))
1387
+ );
1388
+ if (typeof parsed !== "object" || parsed === null) return null;
1389
+ return parsed;
1390
+ } catch {
1391
+ return null;
1392
+ }
1393
+ }
1394
+ function contextRequiredError(reason) {
1395
+ return new FartherShoreError(
1396
+ "context_unverified",
1397
+ `X-Fs-Context ${reason} (contextVerification is "required")`
1398
+ );
1399
+ }
1400
+
1312
1401
  // src/core/verifyRequest.ts
1313
1402
  async function verifyRequest(input, deps) {
1314
1403
  const h = headerGetter(input.headers);
@@ -1416,10 +1505,39 @@ async function verifyRequest(input, deps) {
1416
1505
  "x-fs-request-id has already been seen (replay)"
1417
1506
  );
1418
1507
  }
1419
- const permissions = parsePermissionHeader(
1420
- h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
1421
- );
1422
- const roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
1508
+ let permissions;
1509
+ let roles;
1510
+ let signedContext = null;
1511
+ const contextSecrets = deps.contextSecrets ?? [];
1512
+ const hasContextKeyring = contextSecrets.length > 0;
1513
+ if (hasContextKeyring) {
1514
+ const token = h("x-fs-context");
1515
+ if (token) {
1516
+ signedContext = await verifyContext(token, contextSecrets);
1517
+ if (signedContext === null && deps.contextVerification === "required") {
1518
+ throw contextRequiredError("failed verification");
1519
+ }
1520
+ if (signedContext && signedContext.productId !== signedProductId) {
1521
+ throw new FartherShoreError(
1522
+ "context_unverified",
1523
+ "X-Fs-Context was minted for a different product than the signed request"
1524
+ );
1525
+ }
1526
+ } else if (deps.contextVerification === "required") {
1527
+ throw contextRequiredError("header is missing");
1528
+ }
1529
+ } else if (deps.contextVerification === "required") {
1530
+ throw contextRequiredError("keyring is empty");
1531
+ }
1532
+ if (signedContext) {
1533
+ permissions = signedContext.permissions;
1534
+ roles = signedContext.roles;
1535
+ } else {
1536
+ permissions = parsePermissionHeader(
1537
+ h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
1538
+ );
1539
+ roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
1540
+ }
1423
1541
  return {
1424
1542
  requestId,
1425
1543
  productId: signedProductId,
@@ -1429,7 +1547,8 @@ async function verifyRequest(input, deps) {
1429
1547
  timestamp,
1430
1548
  bodyHash: computedBodyHash,
1431
1549
  ...permissions !== void 0 ? { permissions } : {},
1432
- ...roles !== void 0 ? { roles } : {}
1550
+ ...roles !== void 0 ? { roles } : {},
1551
+ ...signedContext ? { signedContext } : {}
1433
1552
  };
1434
1553
  }
1435
1554
  async function computeBodyHash(input) {
@@ -1464,8 +1583,8 @@ function headerGetter(headers) {
1464
1583
 
1465
1584
  // src/core/runtime.ts
1466
1585
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1467
- var SDK_VERSION = "0.10.0".length > 0 ? "0.10.0" : "0.0.0-dev";
1468
- var CONTRACTS_FP = "5ac9937372d11da5".length > 0 ? "5ac9937372d11da5" : "0000000000000000";
1586
+ var SDK_VERSION = "0.12.0".length > 0 ? "0.12.0" : "0.0.0-dev";
1587
+ var CONTRACTS_FP = "220bea90107ed396".length > 0 ? "220bea90107ed396" : "0000000000000000";
1469
1588
  var FartherShore = class {
1470
1589
  bootstrapClient;
1471
1590
  fetchImpl;
@@ -1475,6 +1594,10 @@ var FartherShore = class {
1475
1594
  coreUrl;
1476
1595
  instanceId;
1477
1596
  tunnelOptions;
1597
+ /** FAR-723 — HS256 secret(s) for verifying the signed X-Fs-Context claim. */
1598
+ contextSecrets;
1599
+ /** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
1600
+ contextVerification;
1478
1601
  nonceCache = new NonceCache();
1479
1602
  shutdownManager = new ShutdownManager();
1480
1603
  jwks = null;
@@ -1492,6 +1615,8 @@ var FartherShore = class {
1492
1615
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
1493
1616
  this.tunnelOptions = options.tunnel ?? {};
1494
1617
  this.instanceId = options.instanceId;
1618
+ this.contextSecrets = options.contextSecrets ?? parseContextSecrets(env.FS_CONTEXT_SECRETS);
1619
+ this.contextVerification = options.contextVerification ?? (env.FS_CONTEXT_VERIFICATION === "required" ? "required" : "preferred");
1495
1620
  this.bootstrapClient = new BootstrapClient({
1496
1621
  runtimeToken,
1497
1622
  coreUrl,
@@ -1610,7 +1735,13 @@ var FartherShore = class {
1610
1735
  backendId: config.backend.id,
1611
1736
  knownRouteIds,
1612
1737
  clockSkewSeconds: config.verification.clockSkewSeconds,
1613
- replayWindowSeconds: config.verification.replayWindowSeconds
1738
+ replayWindowSeconds: config.verification.replayWindowSeconds,
1739
+ // FAR-723 — a VERIFIED signed X-Fs-Context is the preferred (or
1740
+ // required) identity source. Required mode must also fail closed when the
1741
+ // keyring is empty; preferred mode preserves the transitional unsigned
1742
+ // fallback until the backend-v* publish gate removes it.
1743
+ contextSecrets: this.contextSecrets,
1744
+ contextVerification: this.contextVerification
1614
1745
  });
1615
1746
  }
1616
1747
  /** Whether verification is required (bootstrap × opt-out). */
@@ -1701,6 +1832,10 @@ function readProcessEnv() {
1701
1832
  const maybeProcess = globalThis.process;
1702
1833
  return maybeProcess?.env ?? {};
1703
1834
  }
1835
+ function parseContextSecrets(raw) {
1836
+ if (!raw) return [];
1837
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1838
+ }
1704
1839
 
1705
1840
  // src/adapters/express.ts
1706
1841
  var STREAMING_CONTENT_TYPES = new Set(
@@ -1967,6 +2102,7 @@ export {
1967
2102
  nodeSpawn,
1968
2103
  parsePermissionHeader,
1969
2104
  permissionGrants,
2105
+ permissionSatisfies,
1970
2106
  reportHealth,
1971
2107
  requirePermission,
1972
2108
  runtimeErrorToErrorCode,
@@ -1974,6 +2110,7 @@ export {
1974
2110
  signCanonicalString2 as signCanonicalString,
1975
2111
  statusForCode,
1976
2112
  verifyCanonicalSignature2 as verifyCanonicalSignature,
2113
+ verifyContext,
1977
2114
  verifyRequest,
1978
2115
  withUsage
1979
2116
  };
@@ -1,3 +1,4 @@
1
+ import type { FartherShoreSignedContext } from "./verifyContext.js";
1
2
  /**
2
3
  * Thrown by {@link requirePermission} when the acting user lacks a permission.
3
4
  * Distinct from {@link FartherShoreError} (which models signing/verification
@@ -13,36 +14,68 @@ export declare class FartherShorePermissionError extends Error {
13
14
  }
14
15
  /**
15
16
  * Parse the comma-joined `x-fs-permissions` header into a permission list.
16
- * Returns `undefined` when the header is absent (full-access grace) and `[]`
17
- * for a present-but-empty header (deny-all — an authenticated user with no
18
- * grants). Whitespace-trimmed; empty segments dropped.
17
+ * Returns `undefined` when the header is absent (no permission source
18
+ * carrier-level DENY under FAR-723) and `[]` for a present-but-empty header
19
+ * (deny-all — an authenticated user with no grants). Whitespace-trimmed; empty
20
+ * segments dropped.
19
21
  */
20
22
  export declare function parsePermissionHeader(raw: string | null | undefined): string[] | undefined;
21
23
  /**
22
- * Pure grant check. `undefined` permissions (no header) grant everything
23
- * this backend SDK's LOCAL policy, parity with the edge treating an absent
24
- * claim as `["*"]`. Over a DEFINED array the rule is the shared core primitive:
25
- * a `"*"` entry grants everything, otherwise the key must be an exact member.
24
+ * Pure grant check a FAITHFUL COPY of the canonical `permissionGrants` in
25
+ * `@farthershore/contracts` (`rbac.ts`). Over a DEFINED array: a `"*"` entry
26
+ * grants everything, otherwise the key must be an exact member. Its
27
+ * `undefined true` codomain is the PRIMITIVE's contract (kept byte-identical
28
+ * to contracts for parity); it is NOT the SDK's carrier policy. Under FAR-723
29
+ * the carrier gate {@link hasPermission} DENIES an absent permission set before
30
+ * this primitive is consulted, so absence never grants at the boundary.
26
31
  *
27
- * The defined-array branch is a FAITHFUL COPY of the canonical
28
- * `permissionGrants` in `@farthershore/contracts` (`rbac.ts`) — the published
29
- * SDK surface must stay contracts-free, so it can't import it. A TEST-ONLY
30
- * parity guard (`permissions-parity.test.ts`, which CAN import contracts as a
31
- * devDependency) asserts this copy agrees with the canonical primitive across a
32
- * shared golden vector table, so the copy can't silently drift. The
33
- * `undefined → grant-all` grace is documented here as the SDK's own policy; the
34
- * shared primitive only covers the defined-array rule.
32
+ * The published SDK surface must stay contracts-free, so it can't import the
33
+ * canonical primitive. A TEST-ONLY parity guard (`permissions-parity.test.ts`,
34
+ * which CAN import contracts as a devDependency) asserts this copy agrees with
35
+ * the canonical primitive across a shared golden vector table, so the copy
36
+ * can't silently drift.
35
37
  */
36
38
  export declare function permissionGrants(permissions: readonly string[] | undefined, key: string): boolean;
39
+ /**
40
+ * Whether the granted `permissions` satisfy the required `key` under the
41
+ * unified grammar: `"*"` (global), `"<subject>:*"` (subject wildcard), or the
42
+ * EXACT key. NO verb-class widening (class forms are expanded to concrete verbs
43
+ * server-side at save time). Superset of {@link permissionGrants} — it adds the
44
+ * `<subject>:*` rung. NOTE: the `granted === undefined → true` codomain here is
45
+ * the PRIMITIVE's contract (kept byte-identical to contracts for parity); it is
46
+ * NOT the SDK's carrier policy. Callers gate through {@link hasPermission},
47
+ * which under FAR-723 DENIES an absent permission set before ever reaching this
48
+ * primitive — so absence never grants at the carrier level.
49
+ *
50
+ * FAITHFUL COPY of the canonical `permissionSatisfies` in
51
+ * `@farthershore/contracts` (`authz/verbs.ts`); the published bundle is
52
+ * contracts-free, so `permissions-parity.test.ts` asserts agreement over a
53
+ * shared golden table (with the backend's grace rule tested separately).
54
+ */
55
+ export declare function permissionSatisfies(required: string, granted: readonly string[] | undefined): boolean;
37
56
  /** The subset of a verified context these helpers read. */
38
57
  export interface PermissionCarrier {
39
58
  permissions?: readonly string[];
59
+ /** The verified X-Fs-Context claims, when the request carried a valid token. */
60
+ signedContext?: FartherShoreSignedContext;
40
61
  }
41
62
  /**
42
63
  * True when the acting user holds `key`. Call only with a verified request
43
64
  * context ({@link parsePermissionHeader} output lives on `context.permissions`).
44
65
  * NOTE: this is a convenience for in-handler gating; the edge `permission`
45
66
  * constraint is the security boundary for route-level access.
67
+ *
68
+ * FAR-723 FAIL-CLOSED: an ABSENT permission set (`ctx.permissions === undefined`)
69
+ * DENIES — the reverse of the former grant-all grace — UNLESS the request
70
+ * carried a cryptographically VERIFIED context token that simply omits the
71
+ * `perms` claim. Core deliberately mints ORG-actor (and unidentified-user-actor)
72
+ * `fsc_` tokens with no `perms` claim — RBAC never applies to them, and the edge
73
+ * `permission` constraint treats a verified claimless token as full access. The
74
+ * carrier mirrors the edge: verified-but-claimless ⇒ grant; no verified context
75
+ * AND no permission set ⇒ no trusted permission source ⇒ deny. A DEFINED array
76
+ * uses the unified {@link permissionSatisfies} rule (`*` / `<subject>:*` /
77
+ * exact), so an explicit `["*"]` (org OWNER / RBAC-disabled) still grants
78
+ * everything and `[]` denies.
46
79
  */
47
80
  export declare function hasPermission(ctx: PermissionCarrier, key: string): boolean;
48
81
  /**
@@ -39,6 +39,38 @@ export type FartherShoreInitOptions = {
39
39
  tunnel?: FartherShoreTunnelOptions;
40
40
  /** SDK metadata forwarded to bootstrap. */
41
41
  instanceId?: string;
42
+ /**
43
+ * FAR-723 / UA-6 — HS256 secret(s) for verifying the gateway's SIGNED
44
+ * `X-Fs-Context` claim. These are the GATEWAY CONTEXT-SIGNING keyring values
45
+ * (`CONTEXT_SIGNING_KEYS_JSON` — the keys `forward-upstream` stamps the
46
+ * header with; supply every live key during rotation — try-all). They are
47
+ * NOT the product's `contextTokenSecret`, which signs `fsc_` INGRESS tokens
48
+ * verified BY the gateway — setting that here would reject every valid
49
+ * gateway request in `"required"` mode. When present, a VERIFIED context's
50
+ * `permissions`/`roles` claims are the AUTHORITATIVE identity source
51
+ * (preferred over the transitional unsigned `x-fs-permissions`/`x-fs-roles`
52
+ * headers). Defaults to `FS_CONTEXT_SECRETS` (comma-separated) from the env.
53
+ *
54
+ * NOTE (core-side dependency, FAR-723 publish gate): no bootstrap field
55
+ * carries these keys yet, and handing the raw platform keyring to builder
56
+ * backends is NOT the end-state (it would allow cross-product context
57
+ * forgery). The distribution mechanism — per-product derived keys or an
58
+ * asymmetric context signature verified via JWKS like the request
59
+ * signature — is decided at the FAR-723 gate before the headers retire;
60
+ * until then this option (or `FS_CONTEXT_SECRETS`) is the manual wiring for
61
+ * platform-operated deployments.
62
+ */
63
+ contextSecrets?: readonly string[];
64
+ /**
65
+ * FAR-723 / UA-6 — `"preferred"` (default): a missing/unverifiable signed
66
+ * context falls back to the transitional unsigned headers.
67
+ * `"required"`: a missing or invalid signed context REJECTS the request
68
+ * (fail-closed), including when the context-secret keyring is empty or
69
+ * unconfigured. Defaults to `FS_CONTEXT_VERIFICATION` from the env, else
70
+ * `"preferred"`. The unsigned fallback is transitional and is removed after
71
+ * the backend-v* publish gate.
72
+ */
73
+ contextVerification?: "preferred" | "required";
42
74
  };
43
75
  export declare const SDK_VERSION: string;
44
76
  export declare const CONTRACTS_FP: string;
@@ -55,6 +87,10 @@ export declare class FartherShore {
55
87
  private readonly coreUrl;
56
88
  private readonly instanceId?;
57
89
  private readonly tunnelOptions;
90
+ /** FAR-723 — HS256 secret(s) for verifying the signed X-Fs-Context claim. */
91
+ private readonly contextSecrets;
92
+ /** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
93
+ private readonly contextVerification;
58
94
  private readonly nonceCache;
59
95
  private readonly shutdownManager;
60
96
  private jwks;
@@ -0,0 +1,33 @@
1
+ import { FartherShoreError } from "./errors.js";
2
+ /** The signed context payload (claim-format cv=1). */
3
+ export type FartherShoreSignedContext = {
4
+ /** Claim-format version. This SDK understands cv 1 (and legacy tokens
5
+ * without cv, which predate UA-6 and carry no permissions). */
6
+ cv?: number;
7
+ orgId: string;
8
+ actor: {
9
+ type: string;
10
+ id: string | null;
11
+ };
12
+ productId: string;
13
+ compiledPlanId: string;
14
+ subscriptionId: string;
15
+ subscriberId: string;
16
+ environmentId: string | null;
17
+ subjectKey: string;
18
+ /** UA-6 — the unified-authz permission claim (absent when unminted). */
19
+ permissions?: string[];
20
+ /** UA-6 — the bound role keys (absent when unminted). */
21
+ roles?: string[];
22
+ };
23
+ /**
24
+ * Verify an `X-Fs-Context` token against one or more HS256 secrets (try-all
25
+ * for keyring rotation). Returns the typed payload on success, `null` on any
26
+ * failure (malformed, wrong alg, bad signature, unparseable payload) — the
27
+ * caller decides whether absence/invalidity is fatal (`required`) or falls
28
+ * back to the transitional unsigned headers (`preferred`).
29
+ */
30
+ export declare function verifyContext(token: string, secrets: readonly string[]): Promise<FartherShoreSignedContext | null>;
31
+ /** Thrown by verifyRequest when `contextVerification: "required"` and the
32
+ * signed context is missing or fails verification. */
33
+ export declare function contextRequiredError(reason: string): FartherShoreError;
@@ -1,3 +1,4 @@
1
+ import { type FartherShoreSignedContext } from "./verifyContext.js";
1
2
  import type { JwksClient } from "./jwks.js";
2
3
  import type { NonceCache } from "./nonceCache.js";
3
4
  /** Per-request input. `headers` keys are matched case-insensitively. */
@@ -33,13 +34,18 @@ export type FartherShoreRequestContext = {
33
34
  meters?: string[];
34
35
  features?: Record<string, unknown>;
35
36
  /**
36
- * Managed-RBAC permissions the gateway resolved for the acting user, from
37
- * the UNSIGNED `x-fs-permissions` identity header (trusted transitively on a
38
- * verified request see permissions.ts). `undefined` when the header is
39
- * absent (full-access grace); `[]` for an authenticated user with no grants.
40
- * Read via {@link hasPermission} / {@link requirePermission}.
37
+ * Managed-RBAC permissions the gateway resolved for the acting user
38
+ * preferred from the VERIFIED signed `X-Fs-Context` claim, else the UNSIGNED
39
+ * `x-fs-permissions` identity header (both trusted transitively on a verified
40
+ * request see permissions.ts). `undefined` when NO permission source was
41
+ * present FAR-723 carrier-level DENY; `[]` for an authenticated user with
42
+ * no grants. Read via {@link hasPermission} / {@link requirePermission}.
41
43
  */
42
44
  permissions?: string[];
45
+ /** UA-6 — the VERIFIED signed context payload, when context secrets are
46
+ * configured and the X-Fs-Context token verified. Its permissions/roles
47
+ * populated the fields above (signed-preferred). */
48
+ signedContext?: FartherShoreSignedContext;
43
49
  /** Managed-RBAC role keys the acting user holds (display/audit only). */
44
50
  roles?: string[];
45
51
  };
@@ -60,5 +66,19 @@ export type VerifyRequestDeps = {
60
66
  replayWindowSeconds?: number;
61
67
  /** Injectable clock (seconds since epoch). */
62
68
  nowSeconds?: () => number;
69
+ /**
70
+ * UA-6 — HS256 secret(s) for verifying the gateway's SIGNED `X-Fs-Context`
71
+ * claim (multiple = keyring rotation, try-all). When provided, a VERIFIED
72
+ * context's `permissions`/`roles` claims are preferred over the
73
+ * transitional unsigned identity headers.
74
+ */
75
+ contextSecrets?: readonly string[];
76
+ /**
77
+ * UA-6 — `"preferred"` (default): a missing/unverifiable signed context
78
+ * falls back to the unsigned headers. `"required"`: missing or invalid
79
+ * signed context REJECTS the request (fail-closed) — set this once your
80
+ * gateway config has context signing enabled.
81
+ */
82
+ contextVerification?: "preferred" | "required";
63
83
  };
64
84
  export declare function verifyRequest(input: VerifyRequestInput, deps: VerifyRequestDeps): Promise<FartherShoreRequestContext>;
@@ -136,6 +136,7 @@ export declare const RUNTIME_ERROR_CODES: {
136
136
  readonly environmentMismatch: "environment_mismatch";
137
137
  readonly missingToken: "missing_token";
138
138
  readonly invalidToken: "invalid_token";
139
+ readonly contextUnverified: "context_unverified";
139
140
  };
140
141
  export type RuntimeErrorCode = (typeof RUNTIME_ERROR_CODES)[keyof typeof RUNTIME_ERROR_CODES];
141
142
  export declare const RUNTIME_METERING_CONTRACT: {
@@ -4,7 +4,9 @@ export { FartherShore } from "./core/runtime.js";
4
4
  export type { FartherShoreInitOptions } from "./core/runtime.js";
5
5
  export { FartherShoreError, statusForCode } from "./core/errors.js";
6
6
  export { verifyRequest, type VerifyRequestInput, type VerifyRequestDeps, type FartherShoreRequestContext, type HeadersLike, } from "./core/verifyRequest.js";
7
- export { hasPermission, requirePermission, permissionGrants, parsePermissionHeader, FartherShorePermissionError, IDENTITY_HEADER_NAMES, type PermissionCarrier, } from "./core/permissions.js";
7
+ export { verifyContext } from "./core/verifyContext.js";
8
+ export type { FartherShoreSignedContext } from "./core/verifyContext.js";
9
+ export { hasPermission, requirePermission, permissionGrants, permissionSatisfies, parsePermissionHeader, FartherShorePermissionError, IDENTITY_HEADER_NAMES, type PermissionCarrier, } from "./core/permissions.js";
8
10
  export { JwksClient, type Jwk, type JwksClientOptions } from "./core/jwks.js";
9
11
  export { NonceCache, type NonceCacheOptions } from "./core/nonceCache.js";
10
12
  export { BootstrapClient, type BootstrapClientOptions, } from "./core/bootstrap.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farthershore/backend",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Farther Shore backend SDK for builder upstreams: signed response usage, fail-closed gateway request verification, health, and lifecycle from FS_RUNTIME_TOKEN",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,9 +33,9 @@
33
33
  },
34
34
  "optionalDependencies": {
35
35
  "@farthershore/cloudflared-linux-x64": "0.0.0",
36
+ "@farthershore/cloudflared-linux-arm64": "0.0.0",
36
37
  "@farthershore/cloudflared-darwin-arm64": "0.0.0",
37
- "@farthershore/cloudflared-darwin-x64": "0.0.0",
38
- "@farthershore/cloudflared-linux-arm64": "0.0.0"
38
+ "@farthershore/cloudflared-darwin-x64": "0.0.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "express": "^4.0.0 || ^5.0.0"
@@ -53,7 +53,7 @@
53
53
  "typescript": "^6.0.2",
54
54
  "typescript-eslint": "^8.59.0",
55
55
  "vitest": "^4.1.6",
56
- "@farthershore/contracts": "0.61.1"
56
+ "@farthershore/contracts": "0.62.0"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=22"