@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 +1 -1
- package/dist/index.js +34 -5
- package/dist/types/core/permissions.d.ts +37 -19
- package/dist/types/core/runtime.d.ts +36 -0
- package/dist/types/core/verifyRequest.d.ts +6 -5
- 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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
1574
|
-
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";
|
|
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 (
|
|
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;
|
|
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
|
|
43
|
-
*
|
|
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.
|
|
60
|
-
*
|
|
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
|
|
38
|
-
* the
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
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-
|
|
38
|
-
"@farthershore/cloudflared-darwin-
|
|
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.
|
|
56
|
+
"@farthershore/contracts": "0.62.0"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=22"
|