@farthershore/backend 0.14.0 → 0.16.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/CHANGELOG.md ADDED
@@ -0,0 +1,68 @@
1
+ # Changelog — @farthershore/backend
2
+
3
+ All notable changes to the runtime backend SDK are documented here. This SDK
4
+ versions independently from the frontend and business SDKs. Pre-1.0: minor
5
+ versions may include breaking changes.
6
+
7
+ ## [0.16.0]
8
+
9
+ Consumer-principal runtime. Every verified request now carries a typed
10
+ principal, request verification binds the signed context into the signature, and
11
+ the verified context is the **single, guaranteed, non-optional** source of
12
+ identity — verification is strict by default and inbound `x-fs-*` headers are
13
+ stripped before a handler runs.
14
+
15
+ ### Breaking
16
+
17
+ - The Ed25519 request-signature canonical string now includes `context-hash`
18
+ (SHA-256 of the signed `X-Fs-Context`) as a bound field. The platform gateway
19
+ signs with it, so a backend on an older SDK verifies against the previous
20
+ canonical string and **rejects every request**. Upgrade this SDK in lockstep
21
+ with the platform deploy; a mismatch fails loud with a generic `bad_signature`
22
+ rejection (the recomputed canonical string no longer matches the signature),
23
+ not silently.
24
+ - `verifyContext` accepts context-token version `cv: 2` only (RFC-9068/8693-style
25
+ `sub` / `client_id` / `act` claims); `cv: 1` tokens are rejected.
26
+ - `verifyRequest` is fail-closed: the `contextVerification` option and the
27
+ unsigned `x-fs-permissions` / `x-fs-roles` header fallback are removed. A
28
+ presented `X-Fs-Context` that is malformed or not `cv: 2` is a `401`.
29
+ - **`ctx.principal` is derived from the Ed25519-vouched context — no HS256
30
+ context secret required.** The `X-Fs-Context` token's SHA-256 is bound into the
31
+ verified request signature, so once the request signature checks out the token
32
+ content is gateway-vouched and the principal is produced regardless of whether
33
+ a context secret is configured (with request signing on by default, that means
34
+ every identity-bearing request resolves a principal). The `contextSecrets` /
35
+ `FS_CONTEXT_SECRETS` HS256 keyring is now **optional defense-in-depth**: when
36
+ configured, a presented token must ALSO pass HS256 or the request is a `401`;
37
+ when unset, the request-signature binding is sufficient. A request the gateway
38
+ signed with no context is a legitimate identity-less request (no principal),
39
+ not a rejection.
40
+ - **`fs.middleware()` is STRICT BY DEFAULT.** The `always` option now defaults to
41
+ `true`: every request is verified fail-closed and its verified context becomes
42
+ a **guaranteed, non-optional** presence on `req.fartherShore`. The old
43
+ pre-keystone default (pass through until bootstrap requires verification) is
44
+ gone — pass `always: false` to explicitly defer to bootstrap's
45
+ `verification.required` flag (an advanced escape hatch for a backend that
46
+ consumes no identity).
47
+ - **Identity is single-sourced from the verified context.** After verification,
48
+ the middleware **STRIPS every inbound `x-fs-*` header** from the request
49
+ (`req.headers` and `req.rawHeaders`) before the handler runs, so a spoofable
50
+ plaintext identity/metering header (e.g. a direct caller's `x-fs-org-id` /
51
+ `x-fs-actor-*` / `x-fs-permissions`) is physically unreadable by a handler —
52
+ the verified `req.fartherShore` context is the only identity that remains.
53
+
54
+ ### Added
55
+
56
+ - `ctx.principal` — the verified consumer principal
57
+ (`{ org, subject: member | service }`) on every request context.
58
+ - `requireMember(ctx)` / `requireService(ctx)` — narrow-or-throw helpers for
59
+ subject-gated handlers.
60
+ - `fs.handler((ctx, req, res) => …)` / `createExpressHandler` — wrap a route
61
+ handler so it runs only with a GUARANTEED verified context: `ctx` is a
62
+ non-optional `FartherShoreRequestContext` (read `ctx.principal` /
63
+ `requireMember(ctx)` with no optional-chaining), else it fails closed with a
64
+ `401`. A thrown `FartherShoreError` / `FartherShorePermissionError` is mapped
65
+ to its typed status.
66
+ - `MeteringOptions.requestId` — thread the verified `ctx.requestId` into
67
+ `withUsage()` / `createUsage()`. The inbound `x-fs-request-id` header is now
68
+ stripped, so pass the verified id explicitly for dev-mode usage association.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @farthershore/backend
2
2
 
3
- The runtime SDK for your own backend. When you run a software product on Farther
3
+ The runtime SDK for your own backend. When you run a software business on Farther
4
4
  Shore with a bring-your-own-backend, the platform's edge gateway sits in front of
5
5
  your service. This package lets your backend **trust the gateway** (verify that
6
6
  each request really came from it) and **report usage** back for metering and
@@ -8,11 +8,11 @@ billing — from a single token, `FS_RUNTIME_TOKEN`.
8
8
 
9
9
  Install one package, set one environment variable, and you get fail-closed
10
10
  gateway-to-upstream request verification, response-bound usage reporting, and
11
- graceful lifecycle (health + shutdown). Everything else — your product, backend,
11
+ graceful lifecycle (health + shutdown). Everything else — your business, backend,
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.14.0`.** Pre-1.0: minor releases may include breaking changes, so
15
+ > **Status: `0.16.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
 
@@ -76,7 +76,7 @@ process.on("SIGTERM", () => void fs.shutdown());
76
76
  ## What `initFromEnv()` derives
77
77
 
78
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 /
79
+ with the Farther Shore CLI or dashboard). Everything else — business / backend /
80
80
  environment ids, the JWKS url used to verify signatures, the metering endpoint
81
81
  and credential, and verification settings — is fetched from the platform at
82
82
  startup and cached in memory. The token is validated eagerly, so a
@@ -126,11 +126,11 @@ export async function POST(request: Request) {
126
126
 
127
127
  - `measureContext` is free-form pricing/analytics context persisted with the
128
128
  usage event.
129
- - `creditUnitsConsumed` is a numeric map for credit-wallet style products; keys
129
+ - `creditUnitsConsumed` is a numeric map for credit-wallet style businesses; keys
130
130
  and values are validated locally before signing.
131
131
 
132
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
133
+ business. Request-count style limits are enforced by the gateway and need no
134
134
  backend code.
135
135
 
136
136
  ## Async / background usage
@@ -21,6 +21,19 @@ function statusForCode(code) {
21
21
  return code === "body_too_large" ? 413 : 401;
22
22
  }
23
23
 
24
+ // src/core/permissions.ts
25
+ var FartherShorePermissionError = class extends Error {
26
+ code = "permission_denied";
27
+ status = 403;
28
+ /** The permission key that was required but not held. */
29
+ requiredPermission;
30
+ constructor(requiredPermission, message) {
31
+ super(message ?? `missing required permission: ${requiredPermission}`);
32
+ this.name = "FartherShorePermissionError";
33
+ this.requiredPermission = requiredPermission;
34
+ }
35
+ };
36
+
24
37
  // src/generated/runtime-contract.ts
25
38
  var RUNTIME_BODY_HASH_CONTRACT = {
26
39
  algorithm: "SHA-256",
@@ -48,7 +61,9 @@ function createExpressMiddleware(fs, options = {}) {
48
61
  }
49
62
  async function runMiddleware(fs, options, req, res, next) {
50
63
  try {
51
- if (!options.always && !await fs.verificationRequired()) {
64
+ const strict = options.always ?? true;
65
+ if (!strict && !await fs.verificationRequired()) {
66
+ stripFartherShoreHeaders(req);
52
67
  next();
53
68
  return;
54
69
  }
@@ -65,6 +80,7 @@ async function runMiddleware(fs, options, req, res, next) {
65
80
  streamingExempt
66
81
  });
67
82
  req.fartherShore = ctx;
83
+ stripFartherShoreHeaders(req);
68
84
  next();
69
85
  } catch (error) {
70
86
  fail(res, error);
@@ -77,6 +93,51 @@ function fail(res, error) {
77
93
  }
78
94
  res.status(401).json({ error: "bad_signature" });
79
95
  }
96
+ function stripFartherShoreHeaders(req) {
97
+ const headers = req.headers;
98
+ for (const name of Object.keys(headers)) {
99
+ if (name.toLowerCase().startsWith("x-fs-")) {
100
+ delete headers[name];
101
+ }
102
+ }
103
+ const withRaw = req;
104
+ const raw = withRaw.rawHeaders;
105
+ if (Array.isArray(raw)) {
106
+ const cleaned = [];
107
+ for (let i = 0; i < raw.length; i += 2) {
108
+ const key = raw[i];
109
+ const value = raw[i + 1];
110
+ if (typeof key !== "string" || value === void 0) continue;
111
+ if (key.toLowerCase().startsWith("x-fs-")) continue;
112
+ cleaned.push(key, value);
113
+ }
114
+ withRaw.rawHeaders = cleaned;
115
+ }
116
+ }
117
+ function createExpressHandler(handler) {
118
+ return (req, res, next) => {
119
+ const ctx = req.fartherShore;
120
+ if (!ctx) {
121
+ res.status(401).json({ error: "context_unverified" });
122
+ return;
123
+ }
124
+ if (!ctx.principal) {
125
+ res.status(401).json({ error: "principal_required" });
126
+ return;
127
+ }
128
+ const verified = ctx;
129
+ void Promise.resolve().then(
130
+ () => handler(verified, req, res, next)
131
+ ).catch((error) => failHandler(res, next, error));
132
+ };
133
+ }
134
+ function failHandler(res, next, error) {
135
+ if (error instanceof FartherShoreError || error instanceof FartherShorePermissionError) {
136
+ res.status(error.status).json({ error: error.code });
137
+ return;
138
+ }
139
+ next(error);
140
+ }
80
141
  function splitUrl(req) {
81
142
  const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
82
143
  const qIndex = raw.indexOf("?");
@@ -103,5 +164,6 @@ function headerValue(headers, name) {
103
164
  return value;
104
165
  }
105
166
  export {
167
+ createExpressHandler,
106
168
  createExpressMiddleware
107
169
  };
@@ -83,22 +83,24 @@ var RUNTIME_SIGNING_CONTRACT = {
83
83
  "body-hash",
84
84
  "request-id",
85
85
  "timestamp",
86
- "product-id",
86
+ "business-id",
87
87
  "backend-id",
88
88
  "route-id",
89
- "policy-version"
89
+ "policy-version",
90
+ "context-hash"
90
91
  ],
91
92
  fieldRules: {
92
93
  method: "Uppercased HTTP method (e.g. GET, POST).",
93
94
  path: "Request path, percent-encoded as received, no host, no query string. Always begins with '/'.",
94
- query: "Canonical query string: parse pairs, sort by (name, then value) using byte (code-unit) order, re-join name=value pairs with '&'. Names and values are NOT re-encoded (passed through as received). Empty string when there is no query.",
95
+ query: "The request's query string bytes VERBATIM, after the caller has stripped the URL's single leading '?' delimiter. Use the RAW wire query exactly as received \u2014 NO sorting, NO filtering, NO re-stripping a leading '?', NO dropping empty pairs, NO re-encoding. The signature binds to the exact query bytes: any reorder, added/removed/relocated pair, or re-encoding changes the bytes and MUST fail verification. Every SDK verifier MUST read the raw wire query (never a re-parsed/re-serialized form, which could reorder or re-encode and would then fail legitimate requests). Any normalization (sorting by name or value, dropping/moving empty pairs, re-stripping '?') is FORBIDDEN \u2014 each collapses distinct wire queries under one signature. Empty string when there is no query.",
95
96
  "body-hash": "Lowercase hex SHA-256 of the RAW request body bytes. For an empty body, the SHA-256 of zero bytes (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855). Streaming-exempt requests use the literal token 'STREAM'.",
96
97
  "request-id": "Opaque unique request id minted by the gateway (also the replay-cache nonce).",
97
98
  timestamp: "Integer Unix epoch seconds (UTC) at signing time, as a base-10 string with no padding.",
98
- "product-id": "Product id the request is routed to.",
99
+ "business-id": "Business id the request is routed to.",
99
100
  "backend-id": "Backend id the route binds to.",
100
101
  "route-id": "Resolved route id; empty string if the route is unresolved.",
101
- "policy-version": "Tenant artifact / policy version the gateway signed under."
102
+ "policy-version": "Tenant artifact / policy version the gateway signed under.",
103
+ "context-hash": "Lowercase hex SHA-256 of the presented X-Fs-Context JWT string (identity-context binding). For a request with no signed context, the SHA-256 of the empty string (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855). Binds the identity context into the request signature so one verification covers both."
102
104
  }
103
105
  }
104
106
  };
@@ -109,10 +111,11 @@ var RUNTIME_CANONICAL_FIELDS = [
109
111
  "body-hash",
110
112
  "request-id",
111
113
  "timestamp",
112
- "product-id",
114
+ "business-id",
113
115
  "backend-id",
114
116
  "route-id",
115
- "policy-version"
117
+ "policy-version",
118
+ "context-hash"
116
119
  ];
117
120
  var RUNTIME_BODY_HASH_CONTRACT = {
118
121
  algorithm: "SHA-256",
@@ -133,7 +136,7 @@ var RUNTIME_HEADERS = {
133
136
  keyId: "x-fs-key-id",
134
137
  requestId: "x-fs-request-id",
135
138
  timestamp: "x-fs-timestamp",
136
- productId: "x-fs-product-id",
139
+ businessId: "x-fs-business-id",
137
140
  backendId: "x-fs-backend-id",
138
141
  routeId: "x-fs-route-id",
139
142
  policyVersion: "x-fs-policy-version",
@@ -161,7 +164,9 @@ var RUNTIME_ERROR_CODES = {
161
164
  environmentMismatch: "environment_mismatch",
162
165
  missingToken: "missing_token",
163
166
  invalidToken: "invalid_token",
164
- contextUnverified: "context_unverified"
167
+ contextUnverified: "context_unverified",
168
+ memberSubjectRequired: "member_subject_required",
169
+ serviceSubjectRequired: "service_subject_required"
165
170
  };
166
171
  var RUNTIME_METERING_CONTRACT = {
167
172
  endpoint: "/v1/metering/events",
@@ -169,7 +174,7 @@ var RUNTIME_METERING_CONTRACT = {
169
174
  credential: "reusable-bearer",
170
175
  event: {
171
176
  event_id: "string",
172
- product_id: "string",
177
+ business_id: "string",
173
178
  backend_id: "string",
174
179
  route_id: "string?",
175
180
  request_id: "string?",