@farthershore/backend 0.15.0 → 0.17.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,83 @@
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.17.0]
8
+
9
+ ### Changed — BREAKING
10
+
11
+ - `RUNTIME_TOKEN_CAPABILITIES` is renamed `RUNTIME_TOKEN_OPERATIONS`, and the
12
+ type `RuntimeTokenCapability` is renamed `RuntimeTokenOperation`. The values
13
+ are operations, not capabilities, and the old names contradicted the runtime
14
+ contract they mirror. Update imports; no behaviour change.
15
+
16
+ ### Added
17
+
18
+ - `credentialKind()` and `isPortalSession()` — route-surface credential-kind
19
+ derivation from the signed principal. No new claim is required; both are
20
+ derived from what the gateway already signs.
21
+
22
+ ## [0.16.0]
23
+
24
+ Consumer-principal runtime. Every verified request now carries a typed
25
+ principal, request verification binds the signed context into the signature, and
26
+ the verified context is the **single, guaranteed, non-optional** source of
27
+ identity — verification is strict by default and inbound `x-fs-*` headers are
28
+ stripped before a handler runs.
29
+
30
+ ### Breaking
31
+
32
+ - The Ed25519 request-signature canonical string now includes `context-hash`
33
+ (SHA-256 of the signed `X-Fs-Context`) as a bound field. The platform gateway
34
+ signs with it, so a backend on an older SDK verifies against the previous
35
+ canonical string and **rejects every request**. Upgrade this SDK in lockstep
36
+ with the platform deploy; a mismatch fails loud with a generic `bad_signature`
37
+ rejection (the recomputed canonical string no longer matches the signature),
38
+ not silently.
39
+ - `verifyContext` accepts context-token version `cv: 2` only (RFC-9068/8693-style
40
+ `sub` / `client_id` / `act` claims); `cv: 1` tokens are rejected.
41
+ - `verifyRequest` is fail-closed: the `contextVerification` option and the
42
+ unsigned `x-fs-permissions` / `x-fs-roles` header fallback are removed. A
43
+ presented `X-Fs-Context` that is malformed or not `cv: 2` is a `401`.
44
+ - **`ctx.principal` is derived from the Ed25519-vouched context — no HS256
45
+ context secret required.** The `X-Fs-Context` token's SHA-256 is bound into the
46
+ verified request signature, so once the request signature checks out the token
47
+ content is gateway-vouched and the principal is produced regardless of whether
48
+ a context secret is configured (with request signing on by default, that means
49
+ every identity-bearing request resolves a principal). The `contextSecrets` /
50
+ `FS_CONTEXT_SECRETS` HS256 keyring is now **optional defense-in-depth**: when
51
+ configured, a presented token must ALSO pass HS256 or the request is a `401`;
52
+ when unset, the request-signature binding is sufficient. A request the gateway
53
+ signed with no context is a legitimate identity-less request (no principal),
54
+ not a rejection.
55
+ - **`fs.middleware()` is STRICT BY DEFAULT.** The `always` option now defaults to
56
+ `true`: every request is verified fail-closed and its verified context becomes
57
+ a **guaranteed, non-optional** presence on `req.fartherShore`. The old
58
+ pre-keystone default (pass through until bootstrap requires verification) is
59
+ gone — pass `always: false` to explicitly defer to bootstrap's
60
+ `verification.required` flag (an advanced escape hatch for a backend that
61
+ consumes no identity).
62
+ - **Identity is single-sourced from the verified context.** After verification,
63
+ the middleware **STRIPS every inbound `x-fs-*` header** from the request
64
+ (`req.headers` and `req.rawHeaders`) before the handler runs, so a spoofable
65
+ plaintext identity/metering header (e.g. a direct caller's `x-fs-org-id` /
66
+ `x-fs-actor-*` / `x-fs-permissions`) is physically unreadable by a handler —
67
+ the verified `req.fartherShore` context is the only identity that remains.
68
+
69
+ ### Added
70
+
71
+ - `ctx.principal` — the verified consumer principal
72
+ (`{ org, subject: member | service }`) on every request context.
73
+ - `requireMember(ctx)` / `requireService(ctx)` — narrow-or-throw helpers for
74
+ subject-gated handlers.
75
+ - `fs.handler((ctx, req, res) => …)` / `createExpressHandler` — wrap a route
76
+ handler so it runs only with a GUARANTEED verified context: `ctx` is a
77
+ non-optional `FartherShoreRequestContext` (read `ctx.principal` /
78
+ `requireMember(ctx)` with no optional-chaining), else it fails closed with a
79
+ `401`. A thrown `FartherShoreError` / `FartherShorePermissionError` is mapped
80
+ to its typed status.
81
+ - `MeteringOptions.requestId` — thread the verified `ctx.requestId` into
82
+ `withUsage()` / `createUsage()`. The inbound `x-fs-request-id` header is now
83
+ 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.17.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
 
@@ -18,9 +18,24 @@ var FartherShoreError = class extends Error {
18
18
  }
19
19
  };
20
20
  function statusForCode(code) {
21
- return code === "body_too_large" ? 413 : 401;
21
+ if (code === "body_too_large") return 413;
22
+ if (code === "surface_not_allowed") return 403;
23
+ return 401;
22
24
  }
23
25
 
26
+ // src/core/permissions.ts
27
+ var FartherShorePermissionError = class extends Error {
28
+ code = "permission_denied";
29
+ status = 403;
30
+ /** The permission key that was required but not held. */
31
+ requiredPermission;
32
+ constructor(requiredPermission, message) {
33
+ super(message ?? `missing required permission: ${requiredPermission}`);
34
+ this.name = "FartherShorePermissionError";
35
+ this.requiredPermission = requiredPermission;
36
+ }
37
+ };
38
+
24
39
  // src/generated/runtime-contract.ts
25
40
  var RUNTIME_BODY_HASH_CONTRACT = {
26
41
  algorithm: "SHA-256",
@@ -48,7 +63,9 @@ function createExpressMiddleware(fs, options = {}) {
48
63
  }
49
64
  async function runMiddleware(fs, options, req, res, next) {
50
65
  try {
51
- if (!options.always && !await fs.verificationRequired()) {
66
+ const strict = options.always ?? true;
67
+ if (!strict && !await fs.verificationRequired()) {
68
+ stripFartherShoreHeaders(req);
52
69
  next();
53
70
  return;
54
71
  }
@@ -65,6 +82,7 @@ async function runMiddleware(fs, options, req, res, next) {
65
82
  streamingExempt
66
83
  });
67
84
  req.fartherShore = ctx;
85
+ stripFartherShoreHeaders(req);
68
86
  next();
69
87
  } catch (error) {
70
88
  fail(res, error);
@@ -77,6 +95,51 @@ function fail(res, error) {
77
95
  }
78
96
  res.status(401).json({ error: "bad_signature" });
79
97
  }
98
+ function stripFartherShoreHeaders(req) {
99
+ const headers = req.headers;
100
+ for (const name of Object.keys(headers)) {
101
+ if (name.toLowerCase().startsWith("x-fs-")) {
102
+ delete headers[name];
103
+ }
104
+ }
105
+ const withRaw = req;
106
+ const raw = withRaw.rawHeaders;
107
+ if (Array.isArray(raw)) {
108
+ const cleaned = [];
109
+ for (let i = 0; i < raw.length; i += 2) {
110
+ const key = raw[i];
111
+ const value = raw[i + 1];
112
+ if (typeof key !== "string" || value === void 0) continue;
113
+ if (key.toLowerCase().startsWith("x-fs-")) continue;
114
+ cleaned.push(key, value);
115
+ }
116
+ withRaw.rawHeaders = cleaned;
117
+ }
118
+ }
119
+ function createExpressHandler(handler) {
120
+ return (req, res, next) => {
121
+ const ctx = req.fartherShore;
122
+ if (!ctx) {
123
+ res.status(401).json({ error: "context_unverified" });
124
+ return;
125
+ }
126
+ if (!ctx.principal) {
127
+ res.status(401).json({ error: "principal_required" });
128
+ return;
129
+ }
130
+ const verified = ctx;
131
+ void Promise.resolve().then(
132
+ () => handler(verified, req, res, next)
133
+ ).catch((error) => failHandler(res, next, error));
134
+ };
135
+ }
136
+ function failHandler(res, next, error) {
137
+ if (error instanceof FartherShoreError || error instanceof FartherShorePermissionError) {
138
+ res.status(error.status).json({ error: error.code });
139
+ return;
140
+ }
141
+ next(error);
142
+ }
80
143
  function splitUrl(req) {
81
144
  const raw = req.originalUrl ?? req.url ?? req.path ?? "/";
82
145
  const qIndex = raw.indexOf("?");
@@ -103,5 +166,6 @@ function headerValue(headers, name) {
103
166
  return value;
104
167
  }
105
168
  export {
169
+ createExpressHandler,
106
170
  createExpressMiddleware
107
171
  };
@@ -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,10 @@ 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",
170
+ surfaceNotAllowed: "surface_not_allowed"
165
171
  };
166
172
  var RUNTIME_METERING_CONTRACT = {
167
173
  endpoint: "/v1/metering/events",