@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 +1 -1
- package/dist/generated/runtime-contract.js +2 -1
- package/dist/index.js +147 -10
- package/dist/types/core/permissions.d.ts +48 -15
- package/dist/types/core/runtime.d.ts +36 -0
- package/dist/types/core/verifyContext.d.ts +33 -0
- package/dist/types/core/verifyRequest.d.ts +25 -5
- package/dist/types/generated/runtime-contract.d.ts +1 -0
- package/dist/types/index.d.ts +3 -1
- package/package.json +4 -4
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.
|
|
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
|
-
|
|
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
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
const
|
|
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.
|
|
1468
|
-
var CONTRACTS_FP = "
|
|
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 (
|
|
17
|
-
* for a present-but-empty header
|
|
18
|
-
* grants). Whitespace-trimmed; empty
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
37
|
-
* the
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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: {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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.
|
|
56
|
+
"@farthershore/contracts": "0.62.0"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=22"
|