@farthershore/backend 0.15.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
@@ -12,7 +12,7 @@ graceful lifecycle (health + shutdown). Everything else — your business, backe
12
12
  and environment ids, the verification keys, and the metering endpoint — is
13
13
  fetched automatically from the token at startup.
14
14
 
15
- > **Status: `0.15.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
 
@@ -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
  };
@@ -86,19 +86,21 @@ var RUNTIME_SIGNING_CONTRACT = {
86
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
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
  };
@@ -112,7 +114,8 @@ var RUNTIME_CANONICAL_FIELDS = [
112
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",
@@ -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",