@farthershore/backend 0.17.0 → 0.19.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 CHANGED
@@ -4,6 +4,42 @@ All notable changes to the runtime backend SDK are documented here. This SDK
4
4
  versions independently from the frontend and business SDKs. Pre-1.0: minor
5
5
  versions may include breaking changes.
6
6
 
7
+ ## [0.19.0]
8
+
9
+ ### Added
10
+
11
+ - One runtime token can now serve EVERY environment of a business. Bootstrap
12
+ reports `backendIds` — every backend the token may serve — and the SDK checks
13
+ the gateway's signed backend id for MEMBERSHIP of that set instead of equality
14
+ with a single id.
15
+
16
+ A process holds exactly one `FS_RUNTIME_TOKEN`, so an environment-scoped token
17
+ forced a SEPARATE deployment per environment: pointing a backend at a preview
18
+ environment meant repointing, and breaking, production. A business-scoped
19
+ token (`farthershore backend tokens create <biz>`, no `--env`) now serves them
20
+ all from one deployment; `--env <name>` still pins a token to one environment
21
+ when you want that guarantee.
22
+
23
+ Still fail-closed and still bounded by the business — this widens across
24
+ ENVIRONMENTS, never across businesses. Additive: an older core omits
25
+ `backendIds` and the single-id equality check still applies.
26
+
27
+ ## [0.18.0]
28
+
29
+ ### Added
30
+
31
+ - `createExpressMiddleware` accepts `onVerificationError`, called when
32
+ verification REJECTS a request with the diagnostic detail the response body
33
+ deliberately withholds (`{ code, message, status, method, path }`).
34
+
35
+ The wire body stays `{ error: <code> }` — several distinct causes share one
36
+ code (`route_mismatch` covers a business mismatch, a backend mismatch, AND an
37
+ unserved route id) and the specifics must not leak to an unauthenticated
38
+ caller. Previously they were discarded entirely, leaving the operator no way
39
+ to tell which cause fired even in their own logs. Defaults to a one-line
40
+ `console.warn`; pass a function to route it into a structured logger, or
41
+ `() => {}` to silence it. A throwing reporter cannot turn a 401 into a 500.
42
+
7
43
  ## [0.17.0]
8
44
 
9
45
  ### Changed — BREAKING
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.17.0`.** Pre-1.0: minor releases may include breaking changes, so
15
+ > **Status: `0.19.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
 
@@ -85,15 +85,27 @@ async function runMiddleware(fs, options, req, res, next) {
85
85
  stripFartherShoreHeaders(req);
86
86
  next();
87
87
  } catch (error) {
88
- fail(res, error);
88
+ fail(res, error, options, req);
89
89
  }
90
90
  }
91
- function fail(res, error) {
92
- if (error instanceof FartherShoreError) {
93
- res.status(error.status).json({ error: error.code });
94
- return;
91
+ function fail(res, error, options, req) {
92
+ const code = error instanceof FartherShoreError ? error.code : "bad_signature";
93
+ const status = error instanceof FartherShoreError ? error.status : 401;
94
+ const message = error instanceof Error && error.message ? error.message : code;
95
+ const report = options.onVerificationError ?? ((d) => {
96
+ console.warn(`[farthershore] request rejected (${d.code}): ${d.message}`);
97
+ });
98
+ try {
99
+ report({
100
+ code,
101
+ message,
102
+ status,
103
+ method: req.method ?? "",
104
+ path: req.url ?? ""
105
+ });
106
+ } catch {
95
107
  }
96
- res.status(401).json({ error: "bad_signature" });
108
+ res.status(status).json({ error: code });
97
109
  }
98
110
  function stripFartherShoreHeaders(req) {
99
111
  const headers = req.headers;
package/dist/index.js CHANGED
@@ -1781,7 +1781,14 @@ async function verifyRequest(input, deps) {
1781
1781
  "signed business-id does not match this backend's business"
1782
1782
  );
1783
1783
  }
1784
- if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
1784
+ if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
1785
+ if (!deps.backendIds.has(signedBackendId)) {
1786
+ throw new FartherShoreError(
1787
+ "route_mismatch",
1788
+ "signed backend-id is not one this deployment serves"
1789
+ );
1790
+ }
1791
+ } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
1785
1792
  throw new FartherShoreError(
1786
1793
  "route_mismatch",
1787
1794
  "signed backend-id does not match this backend"
@@ -1918,7 +1925,7 @@ function headerGetter(headers) {
1918
1925
 
1919
1926
  // src/core/runtime.ts
1920
1927
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1921
- var SDK_VERSION = "0.17.0".length > 0 ? "0.17.0" : "0.0.0-dev";
1928
+ var SDK_VERSION = "0.19.0".length > 0 ? "0.19.0" : "0.0.0-dev";
1922
1929
  var FartherShore = class {
1923
1930
  bootstrapClient;
1924
1931
  fetchImpl;
@@ -2066,11 +2073,13 @@ var FartherShore = class {
2066
2073
  );
2067
2074
  }
2068
2075
  const knownRouteIds = new Set(config.routes.map((r) => r.id));
2076
+ const backendIds = config.backendIds?.length ? new Set(config.backendIds) : void 0;
2069
2077
  const context = await verifyRequest(input, {
2070
2078
  jwks: this.jwks,
2071
2079
  nonceCache: this.nonceCache,
2072
2080
  businessId: config.business.id,
2073
2081
  backendId: config.backend.id,
2082
+ ...backendIds ? { backendIds } : {},
2074
2083
  knownRouteIds,
2075
2084
  clockSkewSeconds: config.verification.clockSkewSeconds,
2076
2085
  replayWindowSeconds: config.verification.replayWindowSeconds,
@@ -2276,15 +2285,27 @@ async function runMiddleware(fs, options, req, res, next) {
2276
2285
  stripFartherShoreHeaders(req);
2277
2286
  next();
2278
2287
  } catch (error) {
2279
- fail(res, error);
2288
+ fail(res, error, options, req);
2280
2289
  }
2281
2290
  }
2282
- function fail(res, error) {
2283
- if (error instanceof FartherShoreError) {
2284
- res.status(error.status).json({ error: error.code });
2285
- return;
2291
+ function fail(res, error, options, req) {
2292
+ const code = error instanceof FartherShoreError ? error.code : "bad_signature";
2293
+ const status = error instanceof FartherShoreError ? error.status : 401;
2294
+ const message = error instanceof Error && error.message ? error.message : code;
2295
+ const report = options.onVerificationError ?? ((d) => {
2296
+ console.warn(`[farthershore] request rejected (${d.code}): ${d.message}`);
2297
+ });
2298
+ try {
2299
+ report({
2300
+ code,
2301
+ message,
2302
+ status,
2303
+ method: req.method ?? "",
2304
+ path: req.url ?? ""
2305
+ });
2306
+ } catch {
2286
2307
  }
2287
- res.status(401).json({ error: "bad_signature" });
2308
+ res.status(status).json({ error: code });
2288
2309
  }
2289
2310
  function stripFartherShoreHeaders(req) {
2290
2311
  const headers = req.headers;
@@ -2012,7 +2012,14 @@ async function verifyRequest(input, deps) {
2012
2012
  "signed business-id does not match this backend's business"
2013
2013
  );
2014
2014
  }
2015
- if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
2015
+ if (deps.backendIds !== void 0 && deps.backendIds.size > 0) {
2016
+ if (!deps.backendIds.has(signedBackendId)) {
2017
+ throw new FartherShoreError(
2018
+ "route_mismatch",
2019
+ "signed backend-id is not one this deployment serves"
2020
+ );
2021
+ }
2022
+ } else if (deps.backendId !== void 0 && signedBackendId !== deps.backendId) {
2016
2023
  throw new FartherShoreError(
2017
2024
  "route_mismatch",
2018
2025
  "signed backend-id does not match this backend"
@@ -2149,7 +2156,7 @@ function headerGetter(headers) {
2149
2156
 
2150
2157
  // src/core/runtime.ts
2151
2158
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
2152
- var SDK_VERSION = "0.17.0".length > 0 ? "0.17.0" : "0.0.0-dev";
2159
+ var SDK_VERSION = "0.19.0".length > 0 ? "0.19.0" : "0.0.0-dev";
2153
2160
  var FartherShore = class {
2154
2161
  bootstrapClient;
2155
2162
  fetchImpl;
@@ -2297,11 +2304,13 @@ var FartherShore = class {
2297
2304
  );
2298
2305
  }
2299
2306
  const knownRouteIds = new Set(config.routes.map((r) => r.id));
2307
+ const backendIds = config.backendIds?.length ? new Set(config.backendIds) : void 0;
2300
2308
  const context = await verifyRequest(input, {
2301
2309
  jwks: this.jwks,
2302
2310
  nonceCache: this.nonceCache,
2303
2311
  businessId: config.business.id,
2304
2312
  backendId: config.backend.id,
2313
+ ...backendIds ? { backendIds } : {},
2305
2314
  knownRouteIds,
2306
2315
  clockSkewSeconds: config.verification.clockSkewSeconds,
2307
2316
  replayWindowSeconds: config.verification.replayWindowSeconds,
@@ -2502,15 +2511,27 @@ async function runMiddleware(fs, options, req, res, next) {
2502
2511
  stripFartherShoreHeaders(req);
2503
2512
  next();
2504
2513
  } catch (error) {
2505
- fail(res, error);
2514
+ fail(res, error, options, req);
2506
2515
  }
2507
2516
  }
2508
- function fail(res, error) {
2509
- if (error instanceof FartherShoreError) {
2510
- res.status(error.status).json({ error: error.code });
2511
- return;
2517
+ function fail(res, error, options, req) {
2518
+ const code = error instanceof FartherShoreError ? error.code : "bad_signature";
2519
+ const status = error instanceof FartherShoreError ? error.status : 401;
2520
+ const message = error instanceof Error && error.message ? error.message : code;
2521
+ const report = options.onVerificationError ?? ((d) => {
2522
+ console.warn(`[farthershore] request rejected (${d.code}): ${d.message}`);
2523
+ });
2524
+ try {
2525
+ report({
2526
+ code,
2527
+ message,
2528
+ status,
2529
+ method: req.method ?? "",
2530
+ path: req.url ?? ""
2531
+ });
2532
+ } catch {
2512
2533
  }
2513
- res.status(401).json({ error: "bad_signature" });
2534
+ res.status(status).json({ error: code });
2514
2535
  }
2515
2536
  function stripFartherShoreHeaders(req) {
2516
2537
  const headers = req.headers;
@@ -36,6 +36,27 @@ export type MiddlewareOptions = {
36
36
  * consumes no identity; the secure default is strict.
37
37
  */
38
38
  always?: boolean;
39
+ /**
40
+ * Called when verification REJECTS a request, with the diagnostic detail that
41
+ * is deliberately withheld from the response body.
42
+ *
43
+ * The wire response is only `{ error: <code> }` — several distinct causes
44
+ * share one code (`route_mismatch` covers a business mismatch, a backend
45
+ * mismatch, AND an unserved route id), and the specifics must not leak to an
46
+ * unauthenticated caller. But discarding them entirely leaves the operator
47
+ * with no way to tell which cause fired, in their OWN logs, for their OWN
48
+ * server. That is what this hook restores.
49
+ *
50
+ * Defaults to a one-line `console.warn`. Pass a function to route it into a
51
+ * structured logger, or `() => {}` to silence it.
52
+ */
53
+ onVerificationError?: (detail: {
54
+ code: string;
55
+ message: string;
56
+ status: number;
57
+ method: string;
58
+ path: string;
59
+ }) => void;
39
60
  };
40
61
  /**
41
62
  * A verified request context whose {@link ConsumerPrincipal} is GUARANTEED
@@ -68,8 +68,21 @@ export type VerifyRequestDeps = {
68
68
  nonceCache: NonceStore;
69
69
  /** Expected business id (from bootstrap). When set, must match the signed claim. */
70
70
  businessId?: string;
71
- /** Expected backend id (from bootstrap). When set, must match. */
71
+ /** Expected backend id (from bootstrap). When set, must match — unless
72
+ * `backendIds` is provided, which supersedes it with a membership check. */
72
73
  backendId?: string;
74
+ /**
75
+ * Every backend id this deployment may serve (from bootstrap), across all of
76
+ * the business's environments. When provided, the signed backend id must be a
77
+ * MEMBER of this set — superseding the single-id equality check above.
78
+ *
79
+ * A process holds exactly one `FS_RUNTIME_TOKEN`, so pinning to one backend id
80
+ * forced a separate deployment per environment: serving a preview env meant
81
+ * repointing (and breaking) production. A business-scoped token serves them
82
+ * all from one deployment. Still fail-closed, still bound to this business —
83
+ * just no longer bound to a single environment.
84
+ */
85
+ backendIds?: ReadonlySet<string>;
73
86
  /**
74
87
  * Set of route ids this backend serves (from bootstrap). When provided AND
75
88
  * the signed route-id is non-empty, the signed route must be a member —
@@ -233,6 +233,21 @@ export type RuntimeBootstrapResponse = {
233
233
  slug: string;
234
234
  name: string;
235
235
  };
236
+ /**
237
+ * Every backend id this token may serve, across ALL of the business's
238
+ * environments — `backend.id` is always a member.
239
+ *
240
+ * One deployment holds exactly one `FS_RUNTIME_TOKEN`, so an
241
+ * environment-scoped token forced a SEPARATE deployment per environment:
242
+ * serving a preview env meant repointing (and breaking) production. A
243
+ * business-scoped token serves every environment from one deployment, and the
244
+ * SDK checks the gateway's signed backend id for MEMBERSHIP of this set
245
+ * rather than equality with a single id.
246
+ *
247
+ * Optional and additive: an older core omits it and the SDK falls back to the
248
+ * single-id equality check.
249
+ */
250
+ backendIds?: string[];
236
251
  environment: {
237
252
  id: string | null;
238
253
  kind: RuntimeEnvironmentKind;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farthershore/backend",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",
@@ -37,10 +37,10 @@
37
37
  "access": "public"
38
38
  },
39
39
  "optionalDependencies": {
40
- "@farthershore/cloudflared-linux-x64": "0.0.0",
41
40
  "@farthershore/cloudflared-linux-arm64": "0.0.0",
42
- "@farthershore/cloudflared-darwin-x64": "0.0.0",
43
- "@farthershore/cloudflared-darwin-arm64": "0.0.0"
41
+ "@farthershore/cloudflared-linux-x64": "0.0.0",
42
+ "@farthershore/cloudflared-darwin-arm64": "0.0.0",
43
+ "@farthershore/cloudflared-darwin-x64": "0.0.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "express": "^4.0.0 || ^5.0.0"