@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 +83 -0
- package/README.md +1 -1
- package/dist/adapters/express.js +66 -2
- package/dist/generated/runtime-contract.js +11 -5
- package/dist/index.js +325 -154
- package/dist/testing/index.js +272 -142
- package/dist/types/adapters/express.d.ts +55 -5
- package/dist/types/core/errors.d.ts +4 -1
- package/dist/types/core/nonceCache.d.ts +25 -4
- package/dist/types/core/permissions.d.ts +14 -25
- package/dist/types/core/runtime.d.ts +23 -31
- package/dist/types/core/subject.d.ts +45 -0
- package/dist/types/core/verifyContext.d.ts +96 -19
- package/dist/types/core/verifyRequest.d.ts +34 -24
- package/dist/types/generated/runtime-contract.d.ts +7 -3
- package/dist/types/index.d.ts +17 -8
- package/dist/types/response-metering.d.ts +7 -0
- package/dist/types/runtime-signing.d.ts +7 -0
- package/dist/types/runtime-types.d.ts +12 -11
- package/dist/types/testing/devRuntime.d.ts +3 -1
- package/dist/types/testing/signers.d.ts +12 -4
- package/package.json +5 -4
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
|
+
> **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
|
|
package/dist/adapters/express.js
CHANGED
|
@@ -18,9 +18,24 @@ var FartherShoreError = class extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
};
|
|
20
20
|
function statusForCode(code) {
|
|
21
|
-
|
|
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
|
-
|
|
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: "
|
|
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",
|