@farthershore/backend 0.9.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 +88 -46
- package/dist/generated/runtime-contract.js +2 -1
- package/dist/index.js +160 -4
- package/dist/types/core/permissions.d.ts +72 -0
- package/dist/types/core/verifyContext.d.ts +33 -0
- package/dist/types/core/verifyRequest.d.ts +29 -0
- package/dist/types/generated/runtime-contract.d.ts +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/runtime-types.d.ts +5 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
# @farthershore/backend
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
The runtime SDK for your own backend. When you run a software product on Farther
|
|
4
|
+
Shore with a bring-your-own-backend, the platform's edge gateway sits in front of
|
|
5
|
+
your service. This package lets your backend **trust the gateway** (verify that
|
|
6
|
+
each request really came from it) and **report usage** back for metering and
|
|
7
|
+
billing — from a single token, `FS_RUNTIME_TOKEN`.
|
|
8
|
+
|
|
9
|
+
Install one package, set one environment variable, and you get fail-closed
|
|
10
|
+
gateway-to-upstream request verification, response-bound usage reporting, and
|
|
11
|
+
graceful lifecycle (health + shutdown). Everything else — your product, backend,
|
|
12
|
+
and environment ids, the verification keys, and the metering endpoint — is
|
|
13
|
+
fetched automatically from the token at startup.
|
|
14
|
+
|
|
15
|
+
> **Status: `0.11.0`.** Pre-1.0: minor releases may include breaking changes, so
|
|
16
|
+
> pin this package to an exact version (or a patch-only range) and upgrade
|
|
17
|
+
> deliberately.
|
|
10
18
|
|
|
11
19
|
## Install
|
|
12
20
|
|
|
@@ -14,7 +22,10 @@ gateway-to-upstream request verification plus response-bound usage reporting.
|
|
|
14
22
|
npm install @farthershore/backend
|
|
15
23
|
```
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
Requires Node 22+. The Express adapter has an optional `express` peer dependency
|
|
26
|
+
(v4 or v5); the core verification primitive is framework-neutral.
|
|
27
|
+
|
|
28
|
+
## Quick start (any Fetch-compatible handler)
|
|
18
29
|
|
|
19
30
|
```ts
|
|
20
31
|
import { fartherShore, withUsage } from "@farthershore/backend";
|
|
@@ -25,6 +36,8 @@ export async function POST(request: Request) {
|
|
|
25
36
|
const url = new URL(request.url);
|
|
26
37
|
const body = new Uint8Array(await request.clone().arrayBuffer());
|
|
27
38
|
|
|
39
|
+
// Fail-closed: throws a FartherShoreError if the request is not a genuine,
|
|
40
|
+
// unmodified request signed by the gateway.
|
|
28
41
|
await fs.verifyRequest({
|
|
29
42
|
method: request.method,
|
|
30
43
|
path: url.pathname,
|
|
@@ -34,13 +47,15 @@ export async function POST(request: Request) {
|
|
|
34
47
|
});
|
|
35
48
|
|
|
36
49
|
const result = await runWorkflow(await request.json());
|
|
50
|
+
|
|
51
|
+
// Report usage on the way out — no extra network call.
|
|
37
52
|
return withUsage(request, Response.json(result), {
|
|
38
53
|
tokens_used: result.tokensUsed,
|
|
39
54
|
});
|
|
40
55
|
}
|
|
41
56
|
```
|
|
42
57
|
|
|
43
|
-
## Express
|
|
58
|
+
## Quick start (Express)
|
|
44
59
|
|
|
45
60
|
```ts
|
|
46
61
|
import { fartherShore } from "@farthershore/backend";
|
|
@@ -60,31 +75,37 @@ process.on("SIGTERM", () => void fs.shutdown());
|
|
|
60
75
|
|
|
61
76
|
## What `initFromEnv()` derives
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
You configure exactly one thing: `FS_RUNTIME_TOKEN` (mint it for your backend
|
|
79
|
+
with the Farther Shore CLI or dashboard). Everything else — product / backend /
|
|
80
|
+
environment ids, the JWKS url used to verify signatures, the metering endpoint
|
|
81
|
+
and credential, and verification settings — is fetched from the platform at
|
|
82
|
+
startup and cached in memory. The token is validated eagerly, so a
|
|
83
|
+
missing or malformed token fails fast.
|
|
67
84
|
|
|
68
|
-
|
|
85
|
+
You can override the core URL via `FS_CORE_URL` (or pass options to
|
|
86
|
+
`initFromEnv()`), but in normal use no other configuration is needed.
|
|
87
|
+
|
|
88
|
+
## Request verification (fail-closed, always)
|
|
69
89
|
|
|
70
90
|
`fs.middleware()` (Express) and the framework-neutral
|
|
71
|
-
`fs.verifyRequest({ method, path, query, headers, body })` recompute
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
`fs.verifyRequest({ method, path, query, headers, body })` recompute a canonical
|
|
92
|
+
signing string from the actual request and verify the gateway's Ed25519 signature
|
|
93
|
+
against a JWKS-resolved public key. The plaintext `X-FS-*` headers are
|
|
94
|
+
**untrusted** — identity comes only from a signature whose claims match the real
|
|
95
|
+
request, so a forged or replayed request cannot impersonate the gateway.
|
|
76
96
|
|
|
77
97
|
Every failure (missing / malformed / bad-signature / stale / clock-skew /
|
|
78
|
-
wrong-route / body-hash-mismatch / replayed-nonce / unknown-
|
|
79
|
-
|
|
80
|
-
oversized bodies). There is no fail-open
|
|
98
|
+
wrong-route / body-hash-mismatch / replayed-nonce / unknown-key /
|
|
99
|
+
keys-unavailable) throws a typed `FartherShoreError` that maps to **HTTP 401**
|
|
100
|
+
(413 for oversized bodies). There is no fail-open path.
|
|
81
101
|
|
|
82
|
-
## Response-bound
|
|
102
|
+
## Response-bound usage reporting
|
|
83
103
|
|
|
84
|
-
Use `withUsage()` or `createUsage()` when
|
|
85
|
-
|
|
86
|
-
usage into internal response headers, and the
|
|
87
|
-
strips those headers before
|
|
104
|
+
Use `withUsage()` (or the builder-style `createUsage()`) when you know the usage
|
|
105
|
+
for a request while you are returning the response. These helpers make **no
|
|
106
|
+
network call** — they sign the usage into internal response headers, and the
|
|
107
|
+
gateway verifies, settles, and strips those headers before your subscriber sees
|
|
108
|
+
the response.
|
|
88
109
|
|
|
89
110
|
```ts
|
|
90
111
|
import { withUsage } from "@farthershore/backend";
|
|
@@ -103,27 +124,48 @@ export async function POST(request: Request) {
|
|
|
103
124
|
}
|
|
104
125
|
```
|
|
105
126
|
|
|
106
|
-
`measureContext` is free-form pricing/analytics context persisted with the
|
|
107
|
-
event.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
127
|
+
- `measureContext` is free-form pricing/analytics context persisted with the
|
|
128
|
+
usage event.
|
|
129
|
+
- `creditUnitsConsumed` is a numeric map for credit-wallet style products; keys
|
|
130
|
+
and values are validated locally before signing.
|
|
131
|
+
|
|
132
|
+
The meter keys you report (e.g. `tokens_used`) must match meters declared in your
|
|
133
|
+
product. Request-count style limits are enforced by the gateway and need no
|
|
134
|
+
backend code.
|
|
135
|
+
|
|
136
|
+
## Async / background usage
|
|
137
|
+
|
|
138
|
+
Use `fs.meter(meter, qty, { requestId, routeId })` only for usage that is **not**
|
|
139
|
+
tied to a gateway response — background jobs, deferred billing, batch work. It
|
|
140
|
+
enqueues an idempotent event and POSTs it to the platform's metering endpoint.
|
|
141
|
+
Delivery is at-least-once; the event idempotency key keeps ingestion safe.
|
|
142
|
+
Background usage is tallied and billed after the cycle, not enforced in
|
|
143
|
+
real time.
|
|
144
|
+
|
|
145
|
+
## Lifecycle
|
|
146
|
+
|
|
147
|
+
- `fs.health()` returns the current local health report (token present, bootstrap
|
|
148
|
+
loaded, verification + metering status).
|
|
149
|
+
- `fs.shutdown()` flushes any buffered metering and sends a `stopping` heartbeat.
|
|
150
|
+
Call it on `SIGTERM` / `SIGINT` for graceful shutdown.
|
|
112
151
|
|
|
113
|
-
##
|
|
152
|
+
## Key exports
|
|
114
153
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
154
|
+
| Export | Purpose |
|
|
155
|
+
| ------------------------------------ | ---------------------------------------------------- |
|
|
156
|
+
| `fartherShore.initFromEnv()` | Create the runtime instance from `FS_RUNTIME_TOKEN`. |
|
|
157
|
+
| `fs.middleware()` | Express fail-closed verify → `req.fartherShore`. |
|
|
158
|
+
| `fs.verifyRequest({...})` | Framework-neutral request verification. |
|
|
159
|
+
| `withUsage()` / `createUsage()` | Response-bound usage reporting (no network call). |
|
|
160
|
+
| `fs.meter(meter, qty, opts)` | Async/background usage event. |
|
|
161
|
+
| `fs.health()` / `fs.shutdown()` | Health report and graceful shutdown. |
|
|
162
|
+
| `FartherShoreError`, `MeteringError` | Typed errors. |
|
|
120
163
|
|
|
121
|
-
|
|
122
|
-
|
|
164
|
+
A subpath export, `@farthershore/backend/express`, exposes the Express adapter
|
|
165
|
+
types directly if you prefer to wire the middleware yourself.
|
|
123
166
|
|
|
124
|
-
|
|
125
|
-
require no upstream code.
|
|
167
|
+
## Learn more
|
|
126
168
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
169
|
+
- Platform documentation: https://docs.farthershore.com
|
|
170
|
+
- Provisioning a backend and minting a runtime token is done through the Farther
|
|
171
|
+
Shore CLI or dashboard.
|
|
@@ -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",
|
|
@@ -309,6 +312,10 @@ var RUNTIME_HEADER_NAMES = {
|
|
|
309
312
|
policyVersion: "x-fs-policy-version",
|
|
310
313
|
bodyHash: "x-fs-body-hash"
|
|
311
314
|
};
|
|
315
|
+
var RUNTIME_IDENTITY_HEADER_NAMES = {
|
|
316
|
+
permissions: "x-fs-permissions",
|
|
317
|
+
roles: "x-fs-roles"
|
|
318
|
+
};
|
|
312
319
|
var RUNTIME_CLOCK_SKEW_SECONDS = 5;
|
|
313
320
|
var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
|
|
314
321
|
var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
@@ -1273,6 +1280,121 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
|
|
|
1273
1280
|
return `${root}${sep}${normalized}`;
|
|
1274
1281
|
}
|
|
1275
1282
|
|
|
1283
|
+
// src/core/permissions.ts
|
|
1284
|
+
var WILDCARD = "*";
|
|
1285
|
+
var FartherShorePermissionError = class extends Error {
|
|
1286
|
+
code = "permission_denied";
|
|
1287
|
+
status = 403;
|
|
1288
|
+
/** The permission key that was required but not held. */
|
|
1289
|
+
requiredPermission;
|
|
1290
|
+
constructor(requiredPermission, message) {
|
|
1291
|
+
super(message ?? `missing required permission: ${requiredPermission}`);
|
|
1292
|
+
this.name = "FartherShorePermissionError";
|
|
1293
|
+
this.requiredPermission = requiredPermission;
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
function parsePermissionHeader(raw) {
|
|
1297
|
+
if (raw === null || raw === void 0) return void 0;
|
|
1298
|
+
return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1299
|
+
}
|
|
1300
|
+
function permissionGrants(permissions, key2) {
|
|
1301
|
+
if (permissions === void 0) return true;
|
|
1302
|
+
if (permissions.includes(WILDCARD)) return true;
|
|
1303
|
+
return permissions.includes(key2);
|
|
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
|
+
}
|
|
1316
|
+
function hasPermission(ctx, key2) {
|
|
1317
|
+
return permissionSatisfies(key2, ctx.permissions);
|
|
1318
|
+
}
|
|
1319
|
+
function requirePermission(ctx, key2) {
|
|
1320
|
+
if (!hasPermission(ctx, key2)) {
|
|
1321
|
+
throw new FartherShorePermissionError(key2);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
var IDENTITY_HEADER_NAMES = RUNTIME_IDENTITY_HEADER_NAMES;
|
|
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
|
+
|
|
1276
1398
|
// src/core/verifyRequest.ts
|
|
1277
1399
|
async function verifyRequest(input, deps) {
|
|
1278
1400
|
const h = headerGetter(input.headers);
|
|
@@ -1380,6 +1502,29 @@ async function verifyRequest(input, deps) {
|
|
|
1380
1502
|
"x-fs-request-id has already been seen (replay)"
|
|
1381
1503
|
);
|
|
1382
1504
|
}
|
|
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
|
+
}
|
|
1383
1528
|
return {
|
|
1384
1529
|
requestId,
|
|
1385
1530
|
productId: signedProductId,
|
|
@@ -1387,7 +1532,10 @@ async function verifyRequest(input, deps) {
|
|
|
1387
1532
|
routeId: signedRouteId,
|
|
1388
1533
|
policyVersion,
|
|
1389
1534
|
timestamp,
|
|
1390
|
-
bodyHash: computedBodyHash
|
|
1535
|
+
bodyHash: computedBodyHash,
|
|
1536
|
+
...permissions !== void 0 ? { permissions } : {},
|
|
1537
|
+
...roles !== void 0 ? { roles } : {},
|
|
1538
|
+
...signedContext ? { signedContext } : {}
|
|
1391
1539
|
};
|
|
1392
1540
|
}
|
|
1393
1541
|
async function computeBodyHash(input) {
|
|
@@ -1422,8 +1570,8 @@ function headerGetter(headers) {
|
|
|
1422
1570
|
|
|
1423
1571
|
// src/core/runtime.ts
|
|
1424
1572
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1425
|
-
var SDK_VERSION = "0.
|
|
1426
|
-
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";
|
|
1427
1575
|
var FartherShore = class {
|
|
1428
1576
|
bootstrapClient;
|
|
1429
1577
|
fetchImpl;
|
|
@@ -1893,6 +2041,8 @@ export {
|
|
|
1893
2041
|
FS_RUNTIME_TOKEN_ENV,
|
|
1894
2042
|
FartherShore,
|
|
1895
2043
|
FartherShoreError,
|
|
2044
|
+
FartherShorePermissionError,
|
|
2045
|
+
IDENTITY_HEADER_NAMES,
|
|
1896
2046
|
JwksClient,
|
|
1897
2047
|
MAX_BODY_BYTES,
|
|
1898
2048
|
METERING_PAYLOAD_HEADER,
|
|
@@ -1917,15 +2067,21 @@ export {
|
|
|
1917
2067
|
createExpressMiddleware,
|
|
1918
2068
|
createUsage,
|
|
1919
2069
|
fartherShore,
|
|
2070
|
+
hasPermission,
|
|
1920
2071
|
hashBody2 as hashBody,
|
|
1921
2072
|
initFromEnv2 as initFromEnv,
|
|
1922
2073
|
nodeSpawn,
|
|
2074
|
+
parsePermissionHeader,
|
|
2075
|
+
permissionGrants,
|
|
2076
|
+
permissionSatisfies,
|
|
1923
2077
|
reportHealth,
|
|
2078
|
+
requirePermission,
|
|
1924
2079
|
runtimeErrorToErrorCode,
|
|
1925
2080
|
runtimeTokenKind2 as runtimeTokenKind,
|
|
1926
2081
|
signCanonicalString2 as signCanonicalString,
|
|
1927
2082
|
statusForCode,
|
|
1928
2083
|
verifyCanonicalSignature2 as verifyCanonicalSignature,
|
|
2084
|
+
verifyContext,
|
|
1929
2085
|
verifyRequest,
|
|
1930
2086
|
withUsage
|
|
1931
2087
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by {@link requirePermission} when the acting user lacks a permission.
|
|
3
|
+
* Distinct from {@link FartherShoreError} (which models signing/verification
|
|
4
|
+
* failures) — authorization is a separate concern from request verification,
|
|
5
|
+
* and its 403 status is not part of the runtime verification contract.
|
|
6
|
+
*/
|
|
7
|
+
export declare class FartherShorePermissionError extends Error {
|
|
8
|
+
readonly code = "permission_denied";
|
|
9
|
+
readonly status = 403;
|
|
10
|
+
/** The permission key that was required but not held. */
|
|
11
|
+
readonly requiredPermission: string;
|
|
12
|
+
constructor(requiredPermission: string, message?: string);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 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.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parsePermissionHeader(raw: string | null | undefined): string[] | undefined;
|
|
21
|
+
/**
|
|
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.
|
|
26
|
+
*
|
|
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.
|
|
35
|
+
*/
|
|
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;
|
|
51
|
+
/** The subset of a verified context these helpers read. */
|
|
52
|
+
export interface PermissionCarrier {
|
|
53
|
+
permissions?: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* True when the acting user holds `key`. Call only with a verified request
|
|
57
|
+
* context ({@link parsePermissionHeader} output lives on `context.permissions`).
|
|
58
|
+
* 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).
|
|
61
|
+
*/
|
|
62
|
+
export declare function hasPermission(ctx: PermissionCarrier, key: string): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Assert the acting user holds `key`, throwing {@link FartherShorePermissionError}
|
|
65
|
+
* (403) otherwise. Same trust model as {@link hasPermission}.
|
|
66
|
+
*/
|
|
67
|
+
export declare function requirePermission(ctx: PermissionCarrier, key: string): void;
|
|
68
|
+
/** Re-exported for callers that read the header name directly. */
|
|
69
|
+
export declare const IDENTITY_HEADER_NAMES: {
|
|
70
|
+
readonly permissions: "x-fs-permissions";
|
|
71
|
+
readonly roles: "x-fs-roles";
|
|
72
|
+
};
|
|
@@ -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. */
|
|
@@ -32,6 +33,20 @@ export type FartherShoreRequestContext = {
|
|
|
32
33
|
customerId?: string;
|
|
33
34
|
meters?: string[];
|
|
34
35
|
features?: Record<string, unknown>;
|
|
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}.
|
|
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;
|
|
48
|
+
/** Managed-RBAC role keys the acting user holds (display/audit only). */
|
|
49
|
+
roles?: string[];
|
|
35
50
|
};
|
|
36
51
|
export type VerifyRequestDeps = {
|
|
37
52
|
jwks: JwksClient;
|
|
@@ -50,5 +65,19 @@ export type VerifyRequestDeps = {
|
|
|
50
65
|
replayWindowSeconds?: number;
|
|
51
66
|
/** Injectable clock (seconds since epoch). */
|
|
52
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";
|
|
53
82
|
};
|
|
54
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,6 +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 { 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";
|
|
7
10
|
export { JwksClient, type Jwk, type JwksClientOptions } from "./core/jwks.js";
|
|
8
11
|
export { NonceCache, type NonceCacheOptions } from "./core/nonceCache.js";
|
|
9
12
|
export { BootstrapClient, type BootstrapClientOptions, } from "./core/bootstrap.js";
|
|
@@ -133,6 +133,11 @@ export declare const RUNTIME_HEADER_NAMES: {
|
|
|
133
133
|
readonly bodyHash: "x-fs-body-hash";
|
|
134
134
|
};
|
|
135
135
|
export type RuntimeHeaderName = (typeof RUNTIME_HEADER_NAMES)[keyof typeof RUNTIME_HEADER_NAMES];
|
|
136
|
+
export declare const RUNTIME_IDENTITY_HEADER_NAMES: {
|
|
137
|
+
readonly permissions: "x-fs-permissions";
|
|
138
|
+
readonly roles: "x-fs-roles";
|
|
139
|
+
};
|
|
140
|
+
export type RuntimeIdentityHeaderName = (typeof RUNTIME_IDENTITY_HEADER_NAMES)[keyof typeof RUNTIME_IDENTITY_HEADER_NAMES];
|
|
136
141
|
/** Mirrors SERVICE_JWT_CLOCK_SKEW_SECONDS — the per-request signer reuses the
|
|
137
142
|
* same Ed25519/JWKS infra so the skew allowance is kept identical. */
|
|
138
143
|
export declare const RUNTIME_CLOCK_SKEW_SECONDS = 5;
|
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",
|
|
@@ -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-x64": "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"
|