@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 +1 -1
- package/dist/generated/runtime-contract.js +2 -1
- package/dist/index.js +117 -9
- package/dist/types/core/permissions.d.ts +16 -1
- package/dist/types/core/verifyContext.d.ts +33 -0
- package/dist/types/core/verifyRequest.d.ts +19 -0
- package/dist/types/generated/runtime-contract.d.ts +1 -0
- package/dist/types/index.d.ts +3 -1
- package/package.json +3 -3
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.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
|
|
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
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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.
|
|
1468
|
-
var CONTRACTS_FP = "
|
|
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: {
|
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.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-
|
|
36
|
+
"@farthershore/cloudflared-linux-arm64": "0.0.0",
|
|
37
37
|
"@farthershore/cloudflared-darwin-x64": "0.0.0",
|
|
38
|
-
"@farthershore/cloudflared-
|
|
38
|
+
"@farthershore/cloudflared-darwin-arm64": "0.0.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"express": "^4.0.0 || ^5.0.0"
|