@farthershore/backend 0.10.0 → 0.11.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.11.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,19 @@ 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
+ return permissionSatisfies(key2, ctx.permissions);
1304
1318
  }
1305
1319
  function requirePermission(ctx, key2) {
1306
1320
  if (!hasPermission(ctx, key2)) {
@@ -1309,6 +1323,78 @@ function requirePermission(ctx, key2) {
1309
1323
  }
1310
1324
  var IDENTITY_HEADER_NAMES = RUNTIME_IDENTITY_HEADER_NAMES;
1311
1325
 
1326
+ // src/core/verifyContext.ts
1327
+ var EXPECTED_JWT_ALG = "HS256";
1328
+ function base64urlDecode(value) {
1329
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
1330
+ const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
1331
+ const bytes = new Uint8Array(binary.length);
1332
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
1333
+ return bytes;
1334
+ }
1335
+ async function importHmacKey(secret) {
1336
+ return crypto.subtle.importKey(
1337
+ "raw",
1338
+ new TextEncoder().encode(secret),
1339
+ { name: "HMAC", hash: "SHA-256" },
1340
+ false,
1341
+ ["verify"]
1342
+ );
1343
+ }
1344
+ async function verifyContext(token, secrets) {
1345
+ const parts = token.split(".");
1346
+ if (parts.length !== 3) return null;
1347
+ const [header, payload, signature] = parts;
1348
+ let headerJson = null;
1349
+ try {
1350
+ headerJson = JSON.parse(
1351
+ new TextDecoder().decode(base64urlDecode(header))
1352
+ );
1353
+ } catch {
1354
+ return null;
1355
+ }
1356
+ if (headerJson?.alg !== EXPECTED_JWT_ALG) return null;
1357
+ const signingInput = new TextEncoder().encode(`${header}.${payload}`);
1358
+ let signatureBytes;
1359
+ try {
1360
+ signatureBytes = base64urlDecode(signature);
1361
+ } catch {
1362
+ return null;
1363
+ }
1364
+ let verified = false;
1365
+ for (const secret of secrets) {
1366
+ try {
1367
+ const key2 = await importHmacKey(secret);
1368
+ if (await crypto.subtle.verify(
1369
+ "HMAC",
1370
+ key2,
1371
+ signatureBytes,
1372
+ signingInput
1373
+ )) {
1374
+ verified = true;
1375
+ break;
1376
+ }
1377
+ } catch {
1378
+ }
1379
+ }
1380
+ if (!verified) return null;
1381
+ try {
1382
+ const parsed = JSON.parse(
1383
+ new TextDecoder().decode(base64urlDecode(payload))
1384
+ );
1385
+ if (typeof parsed !== "object" || parsed === null) return null;
1386
+ return parsed;
1387
+ } catch {
1388
+ return null;
1389
+ }
1390
+ }
1391
+ function contextRequiredError(reason) {
1392
+ return new FartherShoreError(
1393
+ "context_unverified",
1394
+ `X-Fs-Context ${reason} (contextVerification is "required")`
1395
+ );
1396
+ }
1397
+
1312
1398
  // src/core/verifyRequest.ts
1313
1399
  async function verifyRequest(input, deps) {
1314
1400
  const h = headerGetter(input.headers);
@@ -1416,10 +1502,29 @@ async function verifyRequest(input, deps) {
1416
1502
  "x-fs-request-id has already been seen (replay)"
1417
1503
  );
1418
1504
  }
1419
- const permissions = parsePermissionHeader(
1420
- h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
1421
- );
1422
- const roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
1505
+ let permissions;
1506
+ let roles;
1507
+ let signedContext = null;
1508
+ if (deps.contextSecrets && deps.contextSecrets.length > 0) {
1509
+ const token = h("x-fs-context");
1510
+ if (token) {
1511
+ signedContext = await verifyContext(token, deps.contextSecrets);
1512
+ if (signedContext === null && deps.contextVerification === "required") {
1513
+ throw contextRequiredError("failed verification");
1514
+ }
1515
+ } else if (deps.contextVerification === "required") {
1516
+ throw contextRequiredError("header is missing");
1517
+ }
1518
+ }
1519
+ if (signedContext) {
1520
+ permissions = signedContext.permissions;
1521
+ roles = signedContext.roles;
1522
+ } else {
1523
+ permissions = parsePermissionHeader(
1524
+ h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
1525
+ );
1526
+ roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
1527
+ }
1423
1528
  return {
1424
1529
  requestId,
1425
1530
  productId: signedProductId,
@@ -1429,7 +1534,8 @@ async function verifyRequest(input, deps) {
1429
1534
  timestamp,
1430
1535
  bodyHash: computedBodyHash,
1431
1536
  ...permissions !== void 0 ? { permissions } : {},
1432
- ...roles !== void 0 ? { roles } : {}
1537
+ ...roles !== void 0 ? { roles } : {},
1538
+ ...signedContext ? { signedContext } : {}
1433
1539
  };
1434
1540
  }
1435
1541
  async function computeBodyHash(input) {
@@ -1464,8 +1570,8 @@ function headerGetter(headers) {
1464
1570
 
1465
1571
  // src/core/runtime.ts
1466
1572
  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";
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";
1469
1575
  var FartherShore = class {
1470
1576
  bootstrapClient;
1471
1577
  fetchImpl;
@@ -1967,6 +2073,7 @@ export {
1967
2073
  nodeSpawn,
1968
2074
  parsePermissionHeader,
1969
2075
  permissionGrants,
2076
+ permissionSatisfies,
1970
2077
  reportHealth,
1971
2078
  requirePermission,
1972
2079
  runtimeErrorToErrorCode,
@@ -1974,6 +2081,7 @@ export {
1974
2081
  signCanonicalString2 as signCanonicalString,
1975
2082
  statusForCode,
1976
2083
  verifyCanonicalSignature2 as verifyCanonicalSignature,
2084
+ verifyContext,
1977
2085
  verifyRequest,
1978
2086
  withUsage
1979
2087
  };
@@ -34,6 +34,20 @@ export declare function parsePermissionHeader(raw: string | null | undefined): s
34
34
  * shared primitive only covers the defined-array rule.
35
35
  */
36
36
  export declare function permissionGrants(permissions: readonly string[] | undefined, key: string): boolean;
37
+ /**
38
+ * Whether the granted `permissions` satisfy the required `key` under the
39
+ * unified grammar: `"*"` (global), `"<subject>:*"` (subject wildcard), or the
40
+ * EXACT key. NO verb-class widening (class forms are expanded to concrete verbs
41
+ * 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
+ *
45
+ * FAITHFUL COPY of the canonical `permissionSatisfies` in
46
+ * `@farthershore/contracts` (`authz/verbs.ts`); the published bundle is
47
+ * contracts-free, so `permissions-parity.test.ts` asserts agreement over a
48
+ * shared golden table (with the backend's grace rule tested separately).
49
+ */
50
+ export declare function permissionSatisfies(required: string, granted: readonly string[] | undefined): boolean;
37
51
  /** The subset of a verified context these helpers read. */
38
52
  export interface PermissionCarrier {
39
53
  permissions?: readonly string[];
@@ -42,7 +56,8 @@ export interface PermissionCarrier {
42
56
  * True when the acting user holds `key`. Call only with a verified request
43
57
  * context ({@link parsePermissionHeader} output lives on `context.permissions`).
44
58
  * NOTE: this is a convenience for in-handler gating; the edge `permission`
45
- * constraint is the security boundary for route-level access.
59
+ * constraint is the security boundary for route-level access. Uses the unified
60
+ * {@link permissionSatisfies} rule (`*` / `<subject>:*` / exact).
46
61
  */
47
62
  export declare function hasPermission(ctx: PermissionCarrier, key: string): boolean;
48
63
  /**
@@ -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. */
@@ -40,6 +41,10 @@ export type FartherShoreRequestContext = {
40
41
  * Read via {@link hasPermission} / {@link requirePermission}.
41
42
  */
42
43
  permissions?: string[];
44
+ /** UA-6 — the VERIFIED signed context payload, when context secrets are
45
+ * configured and the X-Fs-Context token verified. Its permissions/roles
46
+ * populated the fields above (signed-preferred). */
47
+ signedContext?: FartherShoreSignedContext;
43
48
  /** Managed-RBAC role keys the acting user holds (display/audit only). */
44
49
  roles?: string[];
45
50
  };
@@ -60,5 +65,19 @@ export type VerifyRequestDeps = {
60
65
  replayWindowSeconds?: number;
61
66
  /** Injectable clock (seconds since epoch). */
62
67
  nowSeconds?: () => number;
68
+ /**
69
+ * UA-6 — HS256 secret(s) for verifying the gateway's SIGNED `X-Fs-Context`
70
+ * claim (multiple = keyring rotation, try-all). When provided, a VERIFIED
71
+ * context's `permissions`/`roles` claims are preferred over the
72
+ * transitional unsigned identity headers.
73
+ */
74
+ contextSecrets?: readonly string[];
75
+ /**
76
+ * UA-6 — `"preferred"` (default): a missing/unverifiable signed context
77
+ * falls back to the unsigned headers. `"required"`: missing or invalid
78
+ * signed context REJECTS the request (fail-closed) — set this once your
79
+ * gateway config has context signing enabled.
80
+ */
81
+ contextVerification?: "preferred" | "required";
63
82
  };
64
83
  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.11.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-darwin-arm64": "0.0.0",
36
+ "@farthershore/cloudflared-linux-arm64": "0.0.0",
37
37
  "@farthershore/cloudflared-darwin-x64": "0.0.0",
38
- "@farthershore/cloudflared-linux-arm64": "0.0.0"
38
+ "@farthershore/cloudflared-darwin-arm64": "0.0.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "express": "^4.0.0 || ^5.0.0"