@farthershore/backend 0.9.0 → 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 CHANGED
@@ -1,12 +1,20 @@
1
1
  # @farthershore/backend
2
2
 
3
- Runtime metering and gateway-verification SDK for builder upstreams. Install one
4
- package, set one token (`FS_RUNTIME_TOKEN`), and Farther Shore handles signed
5
- gateway-to-upstream request verification plus response-bound usage reporting.
6
-
7
- > **Status: `0.8.2`.** Versions independently from
8
- > `@farthershore/farthershore-js` and `@farthershore/business`. Pre-1.0: minor
9
- > bumps may break, so pin this package exactly or use a patch-only range.
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
- ## Quick start (Fetch-compatible handlers)
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 verification
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
- The builder configures exactly one thing: `FS_RUNTIME_TOKEN`. Everything else
64
- product/upstream/environment ids, the JWKS url, the metering endpoint and
65
- credential, verification config, transport is fetched from
66
- `POST /v1/runtime/bootstrap` and cached in memory (refreshed lazily).
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
- ## Verification (fail-closed, always)
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 the
72
- [canonical signing string](../../docs/superpowers/specs/metering-runtime-spec.md)
73
- from the actual request and verify the gateway's Ed25519 signature against a
74
- JWKS-resolved public key. The plaintext `X-FS-*` headers are **untrusted**
75
- identity comes only from a signature whose claims match the real request.
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-kid /
79
- jwks-unavailable) returns a typed `FartherShoreError` **HTTP 401** (413 for
80
- oversized bodies). There is no fail-open branch.
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 metering
102
+ ## Response-bound usage reporting
83
103
 
84
- Use `withUsage()` or `createUsage()` when usage is known while returning a
85
- gateway-handled response. These helpers make no network call. They sign dynamic
86
- usage into internal response headers, and the gateway verifies, settles, and
87
- strips those headers before the subscriber receives the response.
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 usage
107
- event. `creditUnitsConsumed` is a numeric map for credit-wallet style products;
108
- keys and values are validated locally before signing. The gateway accepts actual
109
- request-bound usage only from the `x-fs-metering` response-header contract signed
110
- with `FS_RUNTIME_TOKEN`; retired `x-fs-usage` actual-report headers are stripped
111
- and ignored.
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
- ## Async/background metering
152
+ ## Key exports
114
153
 
115
- Use `fs.meter(meter, qty, { requestId, routeId })` only for async/background
116
- usage that is not tied to a gateway response. It enqueues an idempotent event and
117
- POSTs it to `/v1/metering/events` with a reusable bearer credential. Delivery is
118
- at-least-once; the `event_id` idempotency key keeps core's ingest safe. Values
119
- are tallied/billed post-cycle, not real-time enforced.
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
- The meter key is not hardcoded by the SDK. It must match a dynamic meter declared
122
- in the product contract, such as `tokens_used` in the Product SDK examples.
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
- Product route defaults such as request counts remain platform-managed and
125
- require no upstream code.
167
+ ## Learn more
126
168
 
127
- The signing primitives and contract constants are re-exported from
128
- `@farthershore/contracts/runtime`, the language-neutral source of truth every
129
- SDK (TypeScript first; Go/Python/Java/Rust later) implements.
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";
@@ -1273,6 +1277,38 @@ function resolvePackageBinary(require2, pkg, manifestPath) {
1273
1277
  return `${root}${sep}${normalized}`;
1274
1278
  }
1275
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
+
1276
1312
  // src/core/verifyRequest.ts
1277
1313
  async function verifyRequest(input, deps) {
1278
1314
  const h = headerGetter(input.headers);
@@ -1380,6 +1416,10 @@ async function verifyRequest(input, deps) {
1380
1416
  "x-fs-request-id has already been seen (replay)"
1381
1417
  );
1382
1418
  }
1419
+ const permissions = parsePermissionHeader(
1420
+ h(RUNTIME_IDENTITY_HEADER_NAMES.permissions)
1421
+ );
1422
+ const roles = parsePermissionHeader(h(RUNTIME_IDENTITY_HEADER_NAMES.roles));
1383
1423
  return {
1384
1424
  requestId,
1385
1425
  productId: signedProductId,
@@ -1387,7 +1427,9 @@ async function verifyRequest(input, deps) {
1387
1427
  routeId: signedRouteId,
1388
1428
  policyVersion,
1389
1429
  timestamp,
1390
- bodyHash: computedBodyHash
1430
+ bodyHash: computedBodyHash,
1431
+ ...permissions !== void 0 ? { permissions } : {},
1432
+ ...roles !== void 0 ? { roles } : {}
1391
1433
  };
1392
1434
  }
1393
1435
  async function computeBodyHash(input) {
@@ -1422,8 +1464,8 @@ function headerGetter(headers) {
1422
1464
 
1423
1465
  // src/core/runtime.ts
1424
1466
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1425
- var SDK_VERSION = "0.9.0".length > 0 ? "0.9.0" : "0.0.0-dev";
1426
- var CONTRACTS_FP = "bd767c2d91739744".length > 0 ? "bd767c2d91739744" : "0000000000000000";
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";
1427
1469
  var FartherShore = class {
1428
1470
  bootstrapClient;
1429
1471
  fetchImpl;
@@ -1893,6 +1935,8 @@ export {
1893
1935
  FS_RUNTIME_TOKEN_ENV,
1894
1936
  FartherShore,
1895
1937
  FartherShoreError,
1938
+ FartherShorePermissionError,
1939
+ IDENTITY_HEADER_NAMES,
1896
1940
  JwksClient,
1897
1941
  MAX_BODY_BYTES,
1898
1942
  METERING_PAYLOAD_HEADER,
@@ -1917,10 +1961,14 @@ export {
1917
1961
  createExpressMiddleware,
1918
1962
  createUsage,
1919
1963
  fartherShore,
1964
+ hasPermission,
1920
1965
  hashBody2 as hashBody,
1921
1966
  initFromEnv2 as initFromEnv,
1922
1967
  nodeSpawn,
1968
+ parsePermissionHeader,
1969
+ permissionGrants,
1923
1970
  reportHealth,
1971
+ requirePermission,
1924
1972
  runtimeErrorToErrorCode,
1925
1973
  runtimeTokenKind2 as runtimeTokenKind,
1926
1974
  signCanonicalString2 as signCanonicalString,
@@ -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
+ };
@@ -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;
@@ -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";
@@ -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.9.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"