@farthershore/backend 0.11.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.11.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
 
package/dist/index.js CHANGED
@@ -1314,6 +1314,9 @@ function permissionSatisfies(required, granted) {
1314
1314
  return false;
1315
1315
  }
1316
1316
  function hasPermission(ctx, key2) {
1317
+ if (ctx.permissions === void 0) {
1318
+ return ctx.signedContext !== void 0;
1319
+ }
1317
1320
  return permissionSatisfies(key2, ctx.permissions);
1318
1321
  }
1319
1322
  function requirePermission(ctx, key2) {
@@ -1505,16 +1508,26 @@ async function verifyRequest(input, deps) {
1505
1508
  let permissions;
1506
1509
  let roles;
1507
1510
  let signedContext = null;
1508
- if (deps.contextSecrets && deps.contextSecrets.length > 0) {
1511
+ const contextSecrets = deps.contextSecrets ?? [];
1512
+ const hasContextKeyring = contextSecrets.length > 0;
1513
+ if (hasContextKeyring) {
1509
1514
  const token = h("x-fs-context");
1510
1515
  if (token) {
1511
- signedContext = await verifyContext(token, deps.contextSecrets);
1516
+ signedContext = await verifyContext(token, contextSecrets);
1512
1517
  if (signedContext === null && deps.contextVerification === "required") {
1513
1518
  throw contextRequiredError("failed verification");
1514
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
+ }
1515
1526
  } else if (deps.contextVerification === "required") {
1516
1527
  throw contextRequiredError("header is missing");
1517
1528
  }
1529
+ } else if (deps.contextVerification === "required") {
1530
+ throw contextRequiredError("keyring is empty");
1518
1531
  }
1519
1532
  if (signedContext) {
1520
1533
  permissions = signedContext.permissions;
@@ -1570,8 +1583,8 @@ function headerGetter(headers) {
1570
1583
 
1571
1584
  // src/core/runtime.ts
1572
1585
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1573
- var SDK_VERSION = "0.11.0".length > 0 ? "0.11.0" : "0.0.0-dev";
1574
- var CONTRACTS_FP = "aae5b92b294c8968".length > 0 ? "aae5b92b294c8968" : "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";
1575
1588
  var FartherShore = class {
1576
1589
  bootstrapClient;
1577
1590
  fetchImpl;
@@ -1581,6 +1594,10 @@ var FartherShore = class {
1581
1594
  coreUrl;
1582
1595
  instanceId;
1583
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;
1584
1601
  nonceCache = new NonceCache();
1585
1602
  shutdownManager = new ShutdownManager();
1586
1603
  jwks = null;
@@ -1598,6 +1615,8 @@ var FartherShore = class {
1598
1615
  this.meteringEnabledOverride = options.metering?.enabled ?? true;
1599
1616
  this.tunnelOptions = options.tunnel ?? {};
1600
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");
1601
1620
  this.bootstrapClient = new BootstrapClient({
1602
1621
  runtimeToken,
1603
1622
  coreUrl,
@@ -1716,7 +1735,13 @@ var FartherShore = class {
1716
1735
  backendId: config.backend.id,
1717
1736
  knownRouteIds,
1718
1737
  clockSkewSeconds: config.verification.clockSkewSeconds,
1719
- 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
1720
1745
  });
1721
1746
  }
1722
1747
  /** Whether verification is required (bootstrap × opt-out). */
@@ -1807,6 +1832,10 @@ function readProcessEnv() {
1807
1832
  const maybeProcess = globalThis.process;
1808
1833
  return maybeProcess?.env ?? {};
1809
1834
  }
1835
+ function parseContextSecrets(raw) {
1836
+ if (!raw) return [];
1837
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1838
+ }
1810
1839
 
1811
1840
  // src/adapters/express.ts
1812
1841
  var STREAMING_CONTENT_TYPES = new Set(
@@ -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,25 +14,26 @@ 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;
37
39
  /**
@@ -39,8 +41,11 @@ export declare function permissionGrants(permissions: readonly string[] | undefi
39
41
  * unified grammar: `"*"` (global), `"<subject>:*"` (subject wildcard), or the
40
42
  * EXACT key. NO verb-class widening (class forms are expanded to concrete verbs
41
43
  * server-side at save time). Superset of {@link permissionGrants} — it adds the
42
- * `<subject>:*` rung and keeps the backend SDK's `undefined → grant-all`
43
- * grace (absent `x-fs-permissions` header full access, today's behavior).
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.
44
49
  *
45
50
  * FAITHFUL COPY of the canonical `permissionSatisfies` in
46
51
  * `@farthershore/contracts` (`authz/verbs.ts`); the published bundle is
@@ -51,13 +56,26 @@ export declare function permissionSatisfies(required: string, granted: readonly
51
56
  /** The subset of a verified context these helpers read. */
52
57
  export interface PermissionCarrier {
53
58
  permissions?: readonly string[];
59
+ /** The verified X-Fs-Context claims, when the request carried a valid token. */
60
+ signedContext?: FartherShoreSignedContext;
54
61
  }
55
62
  /**
56
63
  * True when the acting user holds `key`. Call only with a verified request
57
64
  * context ({@link parsePermissionHeader} output lives on `context.permissions`).
58
65
  * NOTE: this is a convenience for in-handler gating; the edge `permission`
59
- * constraint is the security boundary for route-level access. Uses the unified
60
- * {@link permissionSatisfies} rule (`*` / `<subject>:*` / exact).
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.
61
79
  */
62
80
  export declare function hasPermission(ctx: PermissionCarrier, key: string): boolean;
63
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;
@@ -34,11 +34,12 @@ export type FartherShoreRequestContext = {
34
34
  meters?: string[];
35
35
  features?: Record<string, unknown>;
36
36
  /**
37
- * Managed-RBAC permissions the gateway resolved for the acting user, from
38
- * the UNSIGNED `x-fs-permissions` identity header (trusted transitively on a
39
- * verified request see permissions.ts). `undefined` when the header is
40
- * absent (full-access grace); `[]` for an authenticated user with no grants.
41
- * 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}.
42
43
  */
43
44
  permissions?: string[];
44
45
  /** UA-6 — the VERIFIED signed context payload, when context secrets are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farthershore/backend",
3
- "version": "0.11.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",
@@ -34,8 +34,8 @@
34
34
  "optionalDependencies": {
35
35
  "@farthershore/cloudflared-linux-x64": "0.0.0",
36
36
  "@farthershore/cloudflared-linux-arm64": "0.0.0",
37
- "@farthershore/cloudflared-darwin-x64": "0.0.0",
38
- "@farthershore/cloudflared-darwin-arm64": "0.0.0"
37
+ "@farthershore/cloudflared-darwin-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"