@farthershore/backend 0.8.2 → 0.10.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/index.js +117 -4
- package/dist/types/core/backoff.d.ts +30 -0
- package/dist/types/core/metering.d.ts +13 -0
- package/dist/types/core/permissions.d.ts +57 -0
- package/dist/types/core/tunnel.d.ts +11 -0
- package/dist/types/core/verifyRequest.d.ts +10 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/response-metering.d.ts +11 -2
- package/dist/types/runtime-types.d.ts +70 -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.10.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.
|
package/dist/index.js
CHANGED
|
@@ -309,6 +309,10 @@ var RUNTIME_HEADER_NAMES = {
|
|
|
309
309
|
policyVersion: "x-fs-policy-version",
|
|
310
310
|
bodyHash: "x-fs-body-hash"
|
|
311
311
|
};
|
|
312
|
+
var RUNTIME_IDENTITY_HEADER_NAMES = {
|
|
313
|
+
permissions: "x-fs-permissions",
|
|
314
|
+
roles: "x-fs-roles"
|
|
315
|
+
};
|
|
312
316
|
var RUNTIME_CLOCK_SKEW_SECONDS = 5;
|
|
313
317
|
var RUNTIME_REPLAY_WINDOW_SECONDS = 300;
|
|
314
318
|
var EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
@@ -719,8 +723,37 @@ function stringifyCause(cause) {
|
|
|
719
723
|
return String(cause);
|
|
720
724
|
}
|
|
721
725
|
|
|
726
|
+
// src/core/backoff.ts
|
|
727
|
+
function computeBackoff(attempt, options) {
|
|
728
|
+
const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
|
|
729
|
+
const exponent = Math.max(0, attempt - 1);
|
|
730
|
+
const cap = Math.min(baseMs * 2 ** exponent, maxMs);
|
|
731
|
+
switch (jitter) {
|
|
732
|
+
case "none":
|
|
733
|
+
return cap;
|
|
734
|
+
case "full":
|
|
735
|
+
return random() * cap;
|
|
736
|
+
case "equal":
|
|
737
|
+
default:
|
|
738
|
+
return cap / 2 + random() * (cap / 2);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
722
742
|
// src/core/metering.ts
|
|
723
743
|
var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
744
|
+
var DEFAULT_BASE_DELAY_MS = 200;
|
|
745
|
+
var DEFAULT_MAX_DELAY_MS = 1e4;
|
|
746
|
+
function isTransientStatus(status) {
|
|
747
|
+
return status === 429 || status >= 500;
|
|
748
|
+
}
|
|
749
|
+
function retryAfterMs(headers) {
|
|
750
|
+
const raw = headers.get("retry-after");
|
|
751
|
+
if (raw === null) return null;
|
|
752
|
+
const trimmed = raw.trim();
|
|
753
|
+
if (!/^\d+$/.test(trimmed)) return null;
|
|
754
|
+
const secs = Number(trimmed);
|
|
755
|
+
return Number.isFinite(secs) ? secs * 1e3 : null;
|
|
756
|
+
}
|
|
724
757
|
var DEFAULT_MAX_RETRIES = 3;
|
|
725
758
|
var MeteringClient = class {
|
|
726
759
|
config;
|
|
@@ -729,6 +762,10 @@ var MeteringClient = class {
|
|
|
729
762
|
backendId;
|
|
730
763
|
fetchImpl;
|
|
731
764
|
maxRetries;
|
|
765
|
+
baseDelayMs;
|
|
766
|
+
maxDelayMs;
|
|
767
|
+
sleep;
|
|
768
|
+
random;
|
|
732
769
|
newId;
|
|
733
770
|
now;
|
|
734
771
|
buffer = [];
|
|
@@ -739,6 +776,10 @@ var MeteringClient = class {
|
|
|
739
776
|
this.backendId = options.backendId;
|
|
740
777
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
741
778
|
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
779
|
+
this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
780
|
+
this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
781
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
782
|
+
this.random = options.random ?? Math.random;
|
|
742
783
|
this.newId = options.newId ?? (() => crypto.randomUUID());
|
|
743
784
|
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
744
785
|
}
|
|
@@ -821,6 +862,7 @@ var MeteringClient = class {
|
|
|
821
862
|
}
|
|
822
863
|
async sendWithRetry(event) {
|
|
823
864
|
for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
|
|
865
|
+
let retryAfter = null;
|
|
824
866
|
try {
|
|
825
867
|
const response = await this.fetchImpl(this.endpoint, {
|
|
826
868
|
method: "POST",
|
|
@@ -832,8 +874,18 @@ var MeteringClient = class {
|
|
|
832
874
|
body: JSON.stringify(event)
|
|
833
875
|
});
|
|
834
876
|
if (response.ok) return true;
|
|
877
|
+
if (!isTransientStatus(response.status)) return false;
|
|
878
|
+
retryAfter = retryAfterMs(response.headers);
|
|
835
879
|
} catch {
|
|
836
880
|
}
|
|
881
|
+
const isLast = attempt === this.maxRetries - 1;
|
|
882
|
+
if (isLast) break;
|
|
883
|
+
const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
|
|
884
|
+
baseMs: this.baseDelayMs,
|
|
885
|
+
maxMs: this.maxDelayMs,
|
|
886
|
+
random: this.random
|
|
887
|
+
});
|
|
888
|
+
await this.sleep(delay);
|
|
837
889
|
}
|
|
838
890
|
return false;
|
|
839
891
|
}
|
|
@@ -946,6 +998,8 @@ var CloudflaredSupervisor = class {
|
|
|
946
998
|
failClosed;
|
|
947
999
|
baseBackoffMs;
|
|
948
1000
|
maxBackoffMs;
|
|
1001
|
+
backoffJitter;
|
|
1002
|
+
random;
|
|
949
1003
|
setTimeoutFn;
|
|
950
1004
|
clearTimeoutFn;
|
|
951
1005
|
childEnv;
|
|
@@ -973,6 +1027,8 @@ var CloudflaredSupervisor = class {
|
|
|
973
1027
|
this.failClosed = options.failClosed ?? false;
|
|
974
1028
|
this.baseBackoffMs = options.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
|
|
975
1029
|
this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
1030
|
+
this.backoffJitter = options.backoffJitter ?? "equal";
|
|
1031
|
+
this.random = options.random ?? Math.random;
|
|
976
1032
|
this.setTimeoutFn = options.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
|
|
977
1033
|
this.clearTimeoutFn = options.clearTimeoutFn ?? ((h) => clearTimeout(h));
|
|
978
1034
|
this.childEnv = options.childEnv;
|
|
@@ -1221,6 +1277,38 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
|
|
|
1221
1277
|
return `${root}${sep}${normalized}`;
|
|
1222
1278
|
}
|
|
1223
1279
|
|
|
1280
|
+
// src/core/permissions.ts
|
|
1281
|
+
var WILDCARD = "*";
|
|
1282
|
+
var FartherShorePermissionError = class extends Error {
|
|
1283
|
+
code = "permission_denied";
|
|
1284
|
+
status = 403;
|
|
1285
|
+
/** The permission key that was required but not held. */
|
|
1286
|
+
requiredPermission;
|
|
1287
|
+
constructor(requiredPermission, message) {
|
|
1288
|
+
super(message ?? `missing required permission: ${requiredPermission}`);
|
|
1289
|
+
this.name = "FartherShorePermissionError";
|
|
1290
|
+
this.requiredPermission = requiredPermission;
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
function parsePermissionHeader(raw) {
|
|
1294
|
+
if (raw === null || raw === void 0) return void 0;
|
|
1295
|
+
return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1296
|
+
}
|
|
1297
|
+
function permissionGrants(permissions, key2) {
|
|
1298
|
+
if (permissions === void 0) return true;
|
|
1299
|
+
if (permissions.includes(WILDCARD)) return true;
|
|
1300
|
+
return permissions.includes(key2);
|
|
1301
|
+
}
|
|
1302
|
+
function hasPermission(ctx, key2) {
|
|
1303
|
+
return permissionGrants(ctx.permissions, key2);
|
|
1304
|
+
}
|
|
1305
|
+
function requirePermission(ctx, key2) {
|
|
1306
|
+
if (!hasPermission(ctx, key2)) {
|
|
1307
|
+
throw new FartherShorePermissionError(key2);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
var IDENTITY_HEADER_NAMES = RUNTIME_IDENTITY_HEADER_NAMES;
|
|
1311
|
+
|
|
1224
1312
|
// src/core/verifyRequest.ts
|
|
1225
1313
|
async function verifyRequest(input, deps) {
|
|
1226
1314
|
const h = headerGetter(input.headers);
|
|
@@ -1328,6 +1416,10 @@ async function verifyRequest(input, deps) {
|
|
|
1328
1416
|
"x-fs-request-id has already been seen (replay)"
|
|
1329
1417
|
);
|
|
1330
1418
|
}
|
|
1419
|
+
const permissions = parsePermissionHeader(
|
|
1420
|
+
h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
|
|
1421
|
+
);
|
|
1422
|
+
const roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
|
|
1331
1423
|
return {
|
|
1332
1424
|
requestId,
|
|
1333
1425
|
productId: signedProductId,
|
|
@@ -1335,7 +1427,9 @@ async function verifyRequest(input, deps) {
|
|
|
1335
1427
|
routeId: signedRouteId,
|
|
1336
1428
|
policyVersion,
|
|
1337
1429
|
timestamp,
|
|
1338
|
-
bodyHash: computedBodyHash
|
|
1430
|
+
bodyHash: computedBodyHash,
|
|
1431
|
+
...permissions !== void 0 ? { permissions } : {},
|
|
1432
|
+
...roles !== void 0 ? { roles } : {}
|
|
1339
1433
|
};
|
|
1340
1434
|
}
|
|
1341
1435
|
async function computeBodyHash(input) {
|
|
@@ -1370,8 +1464,8 @@ function headerGetter(headers) {
|
|
|
1370
1464
|
|
|
1371
1465
|
// src/core/runtime.ts
|
|
1372
1466
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1373
|
-
var SDK_VERSION = "0.
|
|
1374
|
-
var CONTRACTS_FP = "
|
|
1467
|
+
var SDK_VERSION = "0.10.0".length > 0 ? "0.10.0" : "0.0.0-dev";
|
|
1468
|
+
var CONTRACTS_FP = "5ac9937372d11da5".length > 0 ? "5ac9937372d11da5" : "0000000000000000";
|
|
1375
1469
|
var FartherShore = class {
|
|
1376
1470
|
bootstrapClient;
|
|
1377
1471
|
fetchImpl;
|
|
@@ -1727,6 +1821,8 @@ function buildPayload(request, usage, options, wrapOptions) {
|
|
|
1727
1821
|
const url = new URL(request.url);
|
|
1728
1822
|
const measureContext = wrapOptions.measureContext ?? options.measureContext;
|
|
1729
1823
|
const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
|
|
1824
|
+
const operationKey = wrapOptions.operationKey ?? options.operationKey;
|
|
1825
|
+
const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
|
|
1730
1826
|
const payload = {
|
|
1731
1827
|
method: request.method.toUpperCase(),
|
|
1732
1828
|
path: url.pathname,
|
|
@@ -1736,7 +1832,9 @@ function buildPayload(request, usage, options, wrapOptions) {
|
|
|
1736
1832
|
creditUnitsConsumed: sortUsage(
|
|
1737
1833
|
validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
|
|
1738
1834
|
)
|
|
1739
|
-
} : {}
|
|
1835
|
+
} : {},
|
|
1836
|
+
...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
|
|
1837
|
+
...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
|
|
1740
1838
|
};
|
|
1741
1839
|
return JSON.stringify(payload);
|
|
1742
1840
|
}
|
|
@@ -1771,6 +1869,15 @@ function assertMeterValue(meter, value) {
|
|
|
1771
1869
|
}
|
|
1772
1870
|
return value;
|
|
1773
1871
|
}
|
|
1872
|
+
function assertIdentifier(value) {
|
|
1873
|
+
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
|
|
1874
|
+
throw new MeteringError(
|
|
1875
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
1876
|
+
`operation and usage policy identifiers must be 1-128 URL-safe characters`
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
return value;
|
|
1880
|
+
}
|
|
1774
1881
|
function resolveToken(options) {
|
|
1775
1882
|
const token = options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV);
|
|
1776
1883
|
if (!token) {
|
|
@@ -1828,6 +1935,8 @@ export {
|
|
|
1828
1935
|
FS_RUNTIME_TOKEN_ENV,
|
|
1829
1936
|
FartherShore,
|
|
1830
1937
|
FartherShoreError,
|
|
1938
|
+
FartherShorePermissionError,
|
|
1939
|
+
IDENTITY_HEADER_NAMES,
|
|
1831
1940
|
JwksClient,
|
|
1832
1941
|
MAX_BODY_BYTES,
|
|
1833
1942
|
METERING_PAYLOAD_HEADER,
|
|
@@ -1852,10 +1961,14 @@ export {
|
|
|
1852
1961
|
createExpressMiddleware,
|
|
1853
1962
|
createUsage,
|
|
1854
1963
|
fartherShore,
|
|
1964
|
+
hasPermission,
|
|
1855
1965
|
hashBody2 as hashBody,
|
|
1856
1966
|
initFromEnv2 as initFromEnv,
|
|
1857
1967
|
nodeSpawn,
|
|
1968
|
+
parsePermissionHeader,
|
|
1969
|
+
permissionGrants,
|
|
1858
1970
|
reportHealth,
|
|
1971
|
+
requirePermission,
|
|
1859
1972
|
runtimeErrorToErrorCode,
|
|
1860
1973
|
runtimeTokenKind2 as runtimeTokenKind,
|
|
1861
1974
|
signCanonicalString2 as signCanonicalString,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** The jitter strategy applied to the capped exponential delay. */
|
|
2
|
+
export type JitterStrategy =
|
|
3
|
+
/** No jitter — the raw capped exponential (deterministic). */
|
|
4
|
+
"none"
|
|
5
|
+
/** Equal jitter — `cap/2 + random()*cap/2` (the DEFAULT). */
|
|
6
|
+
| "equal"
|
|
7
|
+
/** Full jitter — `random()*cap` (max spread, no minimum floor). */
|
|
8
|
+
| "full";
|
|
9
|
+
export interface BackoffOptions {
|
|
10
|
+
/** The base delay for attempt 1 (ms). Doubles each subsequent attempt. */
|
|
11
|
+
baseMs: number;
|
|
12
|
+
/** The delay ceiling (ms) — the exponential is capped here BEFORE jitter. */
|
|
13
|
+
maxMs: number;
|
|
14
|
+
/** The jitter strategy. Defaults to `equal`. */
|
|
15
|
+
jitter?: JitterStrategy;
|
|
16
|
+
/** Injectable uniform random in [0, 1). Defaults to Math.random. */
|
|
17
|
+
random?: () => number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Compute the backoff delay (ms) for `attempt` (1-based: attempt 1 is the first
|
|
21
|
+
* retry/restart). The capped exponential is `min(baseMs * 2^(attempt-1), maxMs)`;
|
|
22
|
+
* the chosen {@link JitterStrategy} (default `equal`) is then applied. The result
|
|
23
|
+
* is always in `[0, maxMs]`.
|
|
24
|
+
*
|
|
25
|
+
* - `none` → the raw capped exponential.
|
|
26
|
+
* - `equal` → `cap/2 + random()*cap/2` — a guaranteed half-cap floor plus a
|
|
27
|
+
* randomized half (the default; avoids lockstep re-collision).
|
|
28
|
+
* - `full` → `random()*cap` — maximum spread, no floor.
|
|
29
|
+
*/
|
|
30
|
+
export declare function computeBackoff(attempt: number, options: BackoffOptions): number;
|
|
@@ -16,6 +16,15 @@ export type MeteringClientOptions = {
|
|
|
16
16
|
fetchImpl?: typeof fetch;
|
|
17
17
|
/** Max retry attempts per flush before re-buffering. */
|
|
18
18
|
maxRetries?: number;
|
|
19
|
+
/** Base for the exponential inter-attempt backoff (ms). Default 200. */
|
|
20
|
+
baseDelayMs?: number;
|
|
21
|
+
/** Ceiling on any single inter-attempt wait (ms) — caps both backoff and a
|
|
22
|
+
* `Retry-After` hint. Default 10000. */
|
|
23
|
+
maxDelayMs?: number;
|
|
24
|
+
/** Injectable delay primitive (tests pass a no-op; default is a timer). */
|
|
25
|
+
sleep?: (ms: number) => Promise<void>;
|
|
26
|
+
/** Injectable uniform random in [0,1) for the backoff jitter (tests pin it). */
|
|
27
|
+
random?: () => number;
|
|
19
28
|
/** Injectable id generator (tests). */
|
|
20
29
|
newId?: () => string;
|
|
21
30
|
now?: () => Date;
|
|
@@ -31,6 +40,10 @@ export declare class MeteringClient {
|
|
|
31
40
|
private readonly backendId;
|
|
32
41
|
private readonly fetchImpl;
|
|
33
42
|
private readonly maxRetries;
|
|
43
|
+
private readonly baseDelayMs;
|
|
44
|
+
private readonly maxDelayMs;
|
|
45
|
+
private readonly sleep;
|
|
46
|
+
private readonly random;
|
|
34
47
|
private readonly newId;
|
|
35
48
|
private readonly now;
|
|
36
49
|
private readonly buffer;
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
/** The subset of a verified context these helpers read. */
|
|
38
|
+
export interface PermissionCarrier {
|
|
39
|
+
permissions?: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* True when the acting user holds `key`. Call only with a verified request
|
|
43
|
+
* context ({@link parsePermissionHeader} output lives on `context.permissions`).
|
|
44
|
+
* NOTE: this is a convenience for in-handler gating; the edge `permission`
|
|
45
|
+
* constraint is the security boundary for route-level access.
|
|
46
|
+
*/
|
|
47
|
+
export declare function hasPermission(ctx: PermissionCarrier, key: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Assert the acting user holds `key`, throwing {@link FartherShorePermissionError}
|
|
50
|
+
* (403) otherwise. Same trust model as {@link hasPermission}.
|
|
51
|
+
*/
|
|
52
|
+
export declare function requirePermission(ctx: PermissionCarrier, key: string): void;
|
|
53
|
+
/** Re-exported for callers that read the header name directly. */
|
|
54
|
+
export declare const IDENTITY_HEADER_NAMES: {
|
|
55
|
+
readonly permissions: "x-fs-permissions";
|
|
56
|
+
readonly roles: "x-fs-roles";
|
|
57
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EventEmitter } from "node:events";
|
|
2
|
+
import type { JitterStrategy } from "./backoff.js";
|
|
2
3
|
/** A line emitter — the subset of a child stdio stream we consume. */
|
|
3
4
|
type StdioStream = Pick<EventEmitter, "on">;
|
|
4
5
|
/**
|
|
@@ -58,6 +59,14 @@ export type CloudflaredSupervisorOptions = {
|
|
|
58
59
|
baseBackoffMs?: number;
|
|
59
60
|
/** Backoff ceiling. */
|
|
60
61
|
maxBackoffMs?: number;
|
|
62
|
+
/** Jitter strategy for the restart backoff. Defaults to `equal` (the shared
|
|
63
|
+
* backoff default) — half the capped exponential is fixed, half randomized,
|
|
64
|
+
* so multiple supervisors don't re-collide in lockstep after a shared
|
|
65
|
+
* outage. Pass `none` for a deterministic schedule. */
|
|
66
|
+
backoffJitter?: JitterStrategy;
|
|
67
|
+
/** Injectable uniform random in [0, 1) for the jitter (tests pin it). Defaults
|
|
68
|
+
* to Math.random. */
|
|
69
|
+
random?: () => number;
|
|
61
70
|
/** Injectable timer (tests use fake timers / a custom scheduler). */
|
|
62
71
|
setTimeoutFn?: (cb: () => void, ms: number) => unknown;
|
|
63
72
|
clearTimeoutFn?: (handle: unknown) => void;
|
|
@@ -86,6 +95,8 @@ export declare class CloudflaredSupervisor {
|
|
|
86
95
|
private readonly failClosed;
|
|
87
96
|
private readonly baseBackoffMs;
|
|
88
97
|
private readonly maxBackoffMs;
|
|
98
|
+
private readonly backoffJitter;
|
|
99
|
+
private readonly random;
|
|
89
100
|
private readonly setTimeoutFn;
|
|
90
101
|
private readonly clearTimeoutFn;
|
|
91
102
|
private readonly childEnv;
|
|
@@ -32,6 +32,16 @@ export type FartherShoreRequestContext = {
|
|
|
32
32
|
customerId?: string;
|
|
33
33
|
meters?: string[];
|
|
34
34
|
features?: Record<string, unknown>;
|
|
35
|
+
/**
|
|
36
|
+
* Managed-RBAC permissions the gateway resolved for the acting user, from
|
|
37
|
+
* the UNSIGNED `x-fs-permissions` identity header (trusted transitively on a
|
|
38
|
+
* verified request — see permissions.ts). `undefined` when the header is
|
|
39
|
+
* absent (full-access grace); `[]` for an authenticated user with no grants.
|
|
40
|
+
* Read via {@link hasPermission} / {@link requirePermission}.
|
|
41
|
+
*/
|
|
42
|
+
permissions?: string[];
|
|
43
|
+
/** Managed-RBAC role keys the acting user holds (display/audit only). */
|
|
44
|
+
roles?: string[];
|
|
35
45
|
};
|
|
36
46
|
export type VerifyRequestDeps = {
|
|
37
47
|
jwks: JwksClient;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { FartherShore } from "./core/runtime.js";
|
|
|
4
4
|
export type { FartherShoreInitOptions } from "./core/runtime.js";
|
|
5
5
|
export { FartherShoreError, statusForCode } from "./core/errors.js";
|
|
6
6
|
export { verifyRequest, type VerifyRequestInput, type VerifyRequestDeps, type FartherShoreRequestContext, type HeadersLike, } from "./core/verifyRequest.js";
|
|
7
|
+
export { hasPermission, requirePermission, permissionGrants, parsePermissionHeader, FartherShorePermissionError, IDENTITY_HEADER_NAMES, type PermissionCarrier, } from "./core/permissions.js";
|
|
7
8
|
export { JwksClient, type Jwk, type JwksClientOptions } from "./core/jwks.js";
|
|
8
9
|
export { NonceCache, type NonceCacheOptions } from "./core/nonceCache.js";
|
|
9
10
|
export { BootstrapClient, type BootstrapClientOptions, } from "./core/bootstrap.js";
|
|
@@ -9,15 +9,24 @@ export declare const METERING_SIGNATURE_HEADER: "x-fs-metering-sig";
|
|
|
9
9
|
export declare const METERING_TOKEN_HEADER: "x-fs-metering-token";
|
|
10
10
|
export declare const DEFAULT_TOKEN_ENV: "FS_RUNTIME_TOKEN";
|
|
11
11
|
export type UsageMap = Record<string, number>;
|
|
12
|
+
export type BillableUsageMap = UsageMap;
|
|
12
13
|
export type MeteringOptions = {
|
|
13
14
|
token?: string;
|
|
14
15
|
env?: Record<string, string | undefined>;
|
|
15
16
|
measureContext?: Record<string, unknown>;
|
|
16
|
-
creditUnitsConsumed?:
|
|
17
|
+
creditUnitsConsumed?: BillableUsageMap;
|
|
18
|
+
/** Gateway-validated operation identity hint. The SDK signs and transports it
|
|
19
|
+
* but never decides billing or policy from it. */
|
|
20
|
+
operationKey?: string;
|
|
21
|
+
/** Gateway-validated policy hint. Advisory identity only; the gateway remains
|
|
22
|
+
* authoritative for customerBillable/provider-cost decisions. */
|
|
23
|
+
usagePolicyId?: string;
|
|
17
24
|
};
|
|
18
25
|
export type UsageWrapOptions = {
|
|
19
26
|
measureContext?: Record<string, unknown>;
|
|
20
|
-
creditUnitsConsumed?:
|
|
27
|
+
creditUnitsConsumed?: BillableUsageMap;
|
|
28
|
+
operationKey?: string;
|
|
29
|
+
usagePolicyId?: string;
|
|
21
30
|
};
|
|
22
31
|
export type UsageReporter = {
|
|
23
32
|
report(meter: string, value: number): UsageReporter;
|
|
@@ -27,6 +27,71 @@ export declare const LIMIT_DESCRIPTOR_FIELDS: {
|
|
|
27
27
|
dimension: true;
|
|
28
28
|
currentCapacity: true;
|
|
29
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* F1 — the closed usage-limit class set. Structurally identical to the contracts
|
|
32
|
+
* `LimitClass`. A backend that surfaces a usage-limit deny carries this so SDKs
|
|
33
|
+
* can branch on the limit's semantic class.
|
|
34
|
+
*/
|
|
35
|
+
export type LimitClass = "quota" | "rate" | "concurrency" | "capacity" | "spend" | "adaptive";
|
|
36
|
+
/** Recommended client reaction to a limit deny. Mirrors contracts
|
|
37
|
+
* `LimitReaction`. */
|
|
38
|
+
export type LimitReaction = "none" | "backoff_retry" | "wait_then_retry" | "queue" | "reduce_then_retry" | "fallback" | "upgrade";
|
|
39
|
+
/** Where a limit was decided. Mirrors contracts `LimitOrigin`. */
|
|
40
|
+
export type LimitOrigin = "platform" | "provider";
|
|
41
|
+
/**
|
|
42
|
+
* F1 — the `_fs` deny envelope a backend stamps on a usage-limit deny body.
|
|
43
|
+
* Structurally identical to the contracts `FsDenyEnvelope`.
|
|
44
|
+
*/
|
|
45
|
+
export interface FsDenyEnvelope {
|
|
46
|
+
limitClass: LimitClass;
|
|
47
|
+
scope?: string;
|
|
48
|
+
metric?: string;
|
|
49
|
+
reset?: number;
|
|
50
|
+
remaining?: number;
|
|
51
|
+
used?: number;
|
|
52
|
+
limit?: number;
|
|
53
|
+
retrySafe: boolean;
|
|
54
|
+
mustModify: boolean;
|
|
55
|
+
providerReason?: string;
|
|
56
|
+
limitOrigin: LimitOrigin;
|
|
57
|
+
userAction?: string;
|
|
58
|
+
devAction?: string;
|
|
59
|
+
requestId: string;
|
|
60
|
+
decisionId: string;
|
|
61
|
+
/** Which exact constraint denied (projects from `LimitDecision.blockingConstraintId`). */
|
|
62
|
+
blockingConstraintId?: string;
|
|
63
|
+
reaction: LimitReaction;
|
|
64
|
+
envelopeVersion: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* F1 — the RUNTIME field set of the SDK-local {@link FsDenyEnvelope} mirror.
|
|
68
|
+
* `satisfies Record<keyof FsDenyEnvelope, true>` makes the compiler reject this
|
|
69
|
+
* if it drifts from the local interface; the drift guard then asserts it
|
|
70
|
+
* deep-equals the canonical contracts `DENY_ENVELOPE_FIELDS` at RUNTIME — so a
|
|
71
|
+
* hand-copy that adds or drops a field fails a test that actually runs.
|
|
72
|
+
* (Contracts-free: a plain local constant, never the published path to
|
|
73
|
+
* contracts.)
|
|
74
|
+
*/
|
|
75
|
+
export declare const DENY_ENVELOPE_FIELDS: {
|
|
76
|
+
limitClass: true;
|
|
77
|
+
scope: true;
|
|
78
|
+
metric: true;
|
|
79
|
+
reset: true;
|
|
80
|
+
remaining: true;
|
|
81
|
+
used: true;
|
|
82
|
+
limit: true;
|
|
83
|
+
retrySafe: true;
|
|
84
|
+
mustModify: true;
|
|
85
|
+
providerReason: true;
|
|
86
|
+
limitOrigin: true;
|
|
87
|
+
userAction: true;
|
|
88
|
+
devAction: true;
|
|
89
|
+
requestId: true;
|
|
90
|
+
decisionId: true;
|
|
91
|
+
blockingConstraintId: true;
|
|
92
|
+
reaction: true;
|
|
93
|
+
envelopeVersion: true;
|
|
94
|
+
};
|
|
30
95
|
/**
|
|
31
96
|
* C-2 — the canonical core `ErrorCode` VALUES this backend can map a
|
|
32
97
|
* `RuntimeErrorCode` onto (the codomain of {@link RUNTIME_ERROR_CODE_TO_ERROR_CODE}).
|
|
@@ -68,6 +133,11 @@ export declare const RUNTIME_HEADER_NAMES: {
|
|
|
68
133
|
readonly bodyHash: "x-fs-body-hash";
|
|
69
134
|
};
|
|
70
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];
|
|
71
141
|
/** Mirrors SERVICE_JWT_CLOCK_SKEW_SECONDS — the per-request signer reuses the
|
|
72
142
|
* same Ed25519/JWKS infra so the skew allowance is kept identical. */
|
|
73
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.10.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",
|
|
37
36
|
"@farthershore/cloudflared-darwin-arm64": "0.0.0",
|
|
38
|
-
"@farthershore/cloudflared-darwin-x64": "0.0.0"
|
|
37
|
+
"@farthershore/cloudflared-darwin-x64": "0.0.0",
|
|
38
|
+
"@farthershore/cloudflared-linux-arm64": "0.0.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"express": "^4.0.0 || ^5.0.0"
|