@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FartherShore } from "../core/runtime.js";
|
|
2
2
|
import type { FartherShoreRequestContext } from "../core/verifyRequest.js";
|
|
3
|
+
import type { ConsumerPrincipal } from "../core/verifyContext.js";
|
|
3
4
|
/** Minimal Express-shaped types so we don't hard-depend on @types/express. */
|
|
4
5
|
export type ExpressRequestLike = {
|
|
5
6
|
method: string;
|
|
@@ -22,16 +23,65 @@ export type ExpressNext = (err?: unknown) => void;
|
|
|
22
23
|
export type ExpressMiddleware = (req: ExpressRequestLike, res: ExpressResponseLike, next: ExpressNext) => void;
|
|
23
24
|
export type MiddlewareOptions = {
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* STRICT BY DEFAULT (`true`). Every request is verified FAIL-CLOSED and its
|
|
27
|
+
* inbound `x-fs-*` headers are stripped before the handler runs — the verified
|
|
28
|
+
* `req.fartherShore` context is the only identity source. The builder need not
|
|
29
|
+
* pass this: the gateway signs every request, and a missing/invalid signature
|
|
30
|
+
* or context is a `401`.
|
|
31
|
+
*
|
|
32
|
+
* Set `always: false` to instead DEFER to bootstrap's
|
|
33
|
+
* `verification.required` flag — when the backend contract does not require
|
|
34
|
+
* verification, the request passes through WITHOUT a context (and without
|
|
35
|
+
* stripping). This is an advanced escape hatch for a backend that intentionally
|
|
36
|
+
* consumes no identity; the secure default is strict.
|
|
30
37
|
*/
|
|
31
38
|
always?: boolean;
|
|
32
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* A verified request context whose {@link ConsumerPrincipal} is GUARANTEED
|
|
42
|
+
* present — the shape handed to a {@link VerifiedExpressHandler}. The raw
|
|
43
|
+
* `FartherShoreRequestContext.principal` is optional (an identity-less gateway
|
|
44
|
+
* request is legitimate on the `fs.middleware()` path); `fs.handler()` narrows to
|
|
45
|
+
* this type only AFTER rejecting an absent principal with `401 principal_required`,
|
|
46
|
+
* so the callback can read `ctx.principal` (and narrow with
|
|
47
|
+
* `requireMember`/`requireService`) without any optional-chaining.
|
|
48
|
+
*/
|
|
49
|
+
export type VerifiedPrincipalContext = FartherShoreRequestContext & {
|
|
50
|
+
principal: ConsumerPrincipal;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* A route handler that runs only with a GUARANTEED verified PRINCIPAL. The first
|
|
54
|
+
* argument is the {@link VerifiedPrincipalContext} — read `ctx.principal` (and
|
|
55
|
+
* narrow with `requireMember`/`requireService`) without any optional-chaining.
|
|
56
|
+
* See {@link createExpressHandler}.
|
|
57
|
+
*/
|
|
58
|
+
export type VerifiedExpressHandler<Req extends ExpressRequestLike = ExpressRequestLike, Res extends ExpressResponseLike = ExpressResponseLike> = (ctx: VerifiedPrincipalContext, req: Req, res: Res, next: ExpressNext) => void | Promise<void>;
|
|
33
59
|
/**
|
|
34
60
|
* Build the Express middleware. Captures raw body bytes, calls verifyRequest,
|
|
35
61
|
* and fail-closes on any error.
|
|
36
62
|
*/
|
|
37
63
|
export declare function createExpressMiddleware(fs: FartherShore, options?: MiddlewareOptions): ExpressMiddleware;
|
|
64
|
+
/**
|
|
65
|
+
* Wrap a route handler so it runs only with a GUARANTEED verified PRINCIPAL. The
|
|
66
|
+
* strict {@link createExpressMiddleware} already attaches `req.fartherShore`
|
|
67
|
+
* before any handler runs; this closes the type gap by handing the handler a
|
|
68
|
+
* {@link VerifiedPrincipalContext} whose `principal` is NON-OPTIONAL (no
|
|
69
|
+
* optional-chaining to read `ctx.principal`). It enforces that guarantee at
|
|
70
|
+
* runtime with two fail-closed 401s BEFORE the callback runs:
|
|
71
|
+
* - `context_unverified` when there is no verified context at all (handler
|
|
72
|
+
* mounted without the middleware);
|
|
73
|
+
* - `principal_required` when the context is verified but identity-less (a
|
|
74
|
+
* valid gateway request that carried no `X-Fs-Context`) — so the callback is
|
|
75
|
+
* never invoked with an absent `ctx.principal`.
|
|
76
|
+
* A thrown `FartherShoreError` / `FartherShorePermissionError` — e.g. from
|
|
77
|
+
* `requireMember(ctx)` — is mapped to its typed status; any other error is
|
|
78
|
+
* forwarded to `next` for the app's error pipeline.
|
|
79
|
+
*
|
|
80
|
+
* Returns the wide {@link ExpressMiddleware} so it drops straight into
|
|
81
|
+
* `app.post(path, fs.handler(...))`: a handler whose `req` is narrowed to a
|
|
82
|
+
* `{ fartherShore: ... }` request type is a SUBTYPE of Express's `Request` and
|
|
83
|
+
* would fail Express's `RequestHandler` assignability — moving the verified
|
|
84
|
+
* context onto its own `ctx` argument (not the `req` type) is what keeps the
|
|
85
|
+
* non-optional guarantee AND Express compatibility.
|
|
86
|
+
*/
|
|
87
|
+
export declare function createExpressHandler<Req extends ExpressRequestLike = ExpressRequestLike, Res extends ExpressResponseLike = ExpressResponseLike>(handler: VerifiedExpressHandler<Req, Res>): ExpressMiddleware;
|
|
@@ -21,6 +21,9 @@ export declare class FartherShoreError extends Error {
|
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* Map a runtime error code to its fail-closed HTTP status. Oversized bodies are
|
|
24
|
-
*
|
|
24
|
+
* 413; a wrong-credential-surface denial is 403 (the caller IS authenticated,
|
|
25
|
+
* just not on a surface this route admits — mirrors the canonical
|
|
26
|
+
* `surface_not_allowed → FORBIDDEN` mapping in contracts/error-codes.ts);
|
|
27
|
+
* all other verification failures are 401.
|
|
25
28
|
*/
|
|
26
29
|
export declare function statusForCode(code: RuntimeErrorCode): number;
|
|
@@ -1,7 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay-prevention store contract. `checkAndRemember(id)` returns `true` if
|
|
3
|
+
* `id` was already seen (a REPLAY) and `false` on first sight (recording it).
|
|
4
|
+
*
|
|
5
|
+
* The default {@link NonceCache} is IN-MEMORY and PER-PROCESS: it prevents
|
|
6
|
+
* replay against a single instance only. A multi-replica backend where a
|
|
7
|
+
* captured, still-valid signed request is replayed to a DIFFERENT replica needs
|
|
8
|
+
* a SHARED store (Redis/Memcached/Cloudflare KV/Durable Object) — inject one via
|
|
9
|
+
* `FartherShoreInitOptions.nonceStore`. `checkAndRemember` may be async so a
|
|
10
|
+
* network-backed store can be awaited. (Time-bounding still caps the exposure
|
|
11
|
+
* to the signature's ~300s validity window regardless of the store.)
|
|
12
|
+
*/
|
|
13
|
+
export interface NonceStore {
|
|
14
|
+
checkAndRemember(id: string): boolean | Promise<boolean>;
|
|
15
|
+
}
|
|
1
16
|
export type NonceCacheOptions = {
|
|
2
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Max distinct nonces retained. At capacity (after expired nonces are
|
|
19
|
+
* removed) the cache FAILS CLOSED — new nonces are rejected as replays rather
|
|
20
|
+
* than evicting an unexpired one. Raise this for high single-instance
|
|
21
|
+
* throughput, or inject a shared {@link NonceStore}.
|
|
22
|
+
*/
|
|
3
23
|
maxEntries?: number;
|
|
4
|
-
/** TTL after which a nonce is forgotten
|
|
24
|
+
/** TTL after which a nonce is forgotten. Defaults to the signature validity
|
|
25
|
+
* window (replay window + clock skew); a nonce older than that is rejected by
|
|
26
|
+
* the timestamp check anyway. Must be ≥ that window. */
|
|
5
27
|
ttlMs?: number;
|
|
6
28
|
/** Injectable clock (tests). */
|
|
7
29
|
now?: () => number;
|
|
@@ -11,7 +33,7 @@ export type NonceCacheOptions = {
|
|
|
11
33
|
* the first time it sees an id, and true (replay) on any subsequent sighting
|
|
12
34
|
* while the id is still retained.
|
|
13
35
|
*/
|
|
14
|
-
export declare class NonceCache {
|
|
36
|
+
export declare class NonceCache implements NonceStore {
|
|
15
37
|
private readonly maxEntries;
|
|
16
38
|
private readonly ttlMs;
|
|
17
39
|
private readonly now;
|
|
@@ -25,5 +47,4 @@ export declare class NonceCache {
|
|
|
25
47
|
/** Number of retained nonces (test/observability hook). */
|
|
26
48
|
get size(): number;
|
|
27
49
|
private evictExpired;
|
|
28
|
-
private evictOverflow;
|
|
29
50
|
}
|
|
@@ -12,14 +12,6 @@ export declare class FartherShorePermissionError extends Error {
|
|
|
12
12
|
readonly requiredPermission: string;
|
|
13
13
|
constructor(requiredPermission: string, message?: string);
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
16
|
-
* Parse the comma-joined `x-fs-permissions` header into a permission list.
|
|
17
|
-
* Returns `undefined` when the header is absent (no permission source ⇒
|
|
18
|
-
* carrier-level DENY under FAR-723) and `[]` for a present-but-empty header
|
|
19
|
-
* (deny-all — an authenticated user with no grants). Whitespace-trimmed; empty
|
|
20
|
-
* segments dropped.
|
|
21
|
-
*/
|
|
22
|
-
export declare function parsePermissionHeader(raw: string | null | undefined): string[] | undefined;
|
|
23
15
|
/**
|
|
24
16
|
* Pure grant check — a FAITHFUL COPY of the canonical `permissionGrants` in
|
|
25
17
|
* `@farthershore/contracts` (`rbac.ts`). Over a DEFINED array: a `"*"` entry
|
|
@@ -56,26 +48,28 @@ export declare function permissionSatisfies(required: string, granted: readonly
|
|
|
56
48
|
/** The subset of a verified context these helpers read. */
|
|
57
49
|
export interface PermissionCarrier {
|
|
58
50
|
permissions?: readonly string[];
|
|
59
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* The verified X-Fs-Context claims, when the request carried a valid token.
|
|
53
|
+
* NOT consulted for permission decisions — its presence NEVER grants (an
|
|
54
|
+
* absent `permissions` set always denies). Retained only as part of the
|
|
55
|
+
* verified-context shape a carrier is built from.
|
|
56
|
+
*/
|
|
60
57
|
signedContext?: FartherShoreSignedContext;
|
|
61
58
|
}
|
|
62
59
|
/**
|
|
63
60
|
* True when the acting user holds `key`. Call only with a verified request
|
|
64
|
-
* context (
|
|
61
|
+
* context (the verified `X-Fs-Context` permissions claim lives on
|
|
62
|
+
* `context.permissions`).
|
|
65
63
|
* NOTE: this is a convenience for in-handler gating; the edge `permission`
|
|
66
64
|
* constraint is the security boundary for route-level access.
|
|
67
65
|
*
|
|
68
|
-
*
|
|
69
|
-
* DENIES —
|
|
70
|
-
*
|
|
71
|
-
* `perms` claim. Core deliberately mints ORG-actor (and unidentified-user-actor)
|
|
72
|
-
* `fsc_` tokens with no `perms` claim — RBAC never applies to them, and the edge
|
|
73
|
-
* `permission` constraint treats a verified claimless token as full access. The
|
|
74
|
-
* carrier mirrors the edge: verified-but-claimless ⇒ grant; no verified context
|
|
75
|
-
* AND no permission set ⇒ no trusted permission source ⇒ deny. A DEFINED array
|
|
66
|
+
* FAIL-CLOSED: an ABSENT permission set (`ctx.permissions === undefined`)
|
|
67
|
+
* DENIES — absence NEVER means grant-all, and the presence of a verified signed
|
|
68
|
+
* context does NOT change that (its presence is not consulted). A DEFINED array
|
|
76
69
|
* uses the unified {@link permissionSatisfies} rule (`*` / `<subject>:*` /
|
|
77
|
-
* exact), so an explicit `["*"]` (org OWNER / RBAC-disabled)
|
|
78
|
-
*
|
|
70
|
+
* exact), so an explicit `["*"]` (org OWNER / RBAC-disabled) grants everything
|
|
71
|
+
* and `[]` denies. A builder that wants an ungated route simply does not call
|
|
72
|
+
* this / {@link requirePermission}.
|
|
79
73
|
*/
|
|
80
74
|
export declare function hasPermission(ctx: PermissionCarrier, key: string): boolean;
|
|
81
75
|
/**
|
|
@@ -83,8 +77,3 @@ export declare function hasPermission(ctx: PermissionCarrier, key: string): bool
|
|
|
83
77
|
* (403) otherwise. Same trust model as {@link hasPermission}.
|
|
84
78
|
*/
|
|
85
79
|
export declare function requirePermission(ctx: PermissionCarrier, key: string): void;
|
|
86
|
-
/** Re-exported for callers that read the header name directly. */
|
|
87
|
-
export declare const IDENTITY_HEADER_NAMES: {
|
|
88
|
-
readonly permissions: "x-fs-permissions";
|
|
89
|
-
readonly roles: "x-fs-roles";
|
|
90
|
-
};
|
|
@@ -2,6 +2,7 @@ import { type RuntimeBootstrapResponse, type RuntimeHealthReport } from "../runt
|
|
|
2
2
|
import type { ReconcileResult } from "../reflect/reconcile.js";
|
|
3
3
|
import { type MeterOptions } from "./metering.js";
|
|
4
4
|
import { type ReportUsageInput, type ReportUsageResult } from "./post-stream-usage.js";
|
|
5
|
+
import { type NonceStore } from "./nonceCache.js";
|
|
5
6
|
import { type SpawnFn } from "./tunnel.js";
|
|
6
7
|
import { type FartherShoreRequestContext, type VerifyRequestInput } from "./verifyRequest.js";
|
|
7
8
|
/** Advanced opt-in tunnel config. The embedded runner is the default DX. */
|
|
@@ -41,40 +42,33 @@ export type FartherShoreInitOptions = {
|
|
|
41
42
|
/** SDK metadata forwarded to bootstrap. */
|
|
42
43
|
instanceId?: string;
|
|
43
44
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* carries these keys yet, and handing the raw platform keyring to builder
|
|
57
|
-
* backends is NOT the end-state (it would allow cross-business context
|
|
58
|
-
* forgery). The distribution mechanism — per-business derived keys or an
|
|
59
|
-
* asymmetric context signature verified via JWKS like the request
|
|
60
|
-
* signature — is decided at the FAR-723 gate before the headers retire;
|
|
61
|
-
* until then this option (or `FS_CONTEXT_SECRETS`) is the manual wiring for
|
|
62
|
-
* platform-operated deployments.
|
|
45
|
+
* OPTIONAL HS256 secret(s) for the gateway's cv=2 `X-Fs-Context` claim — pure
|
|
46
|
+
* DEFENSE-IN-DEPTH, NOT required to derive identity. The consumer principal is
|
|
47
|
+
* always produced from the `X-Fs-Context` token whose bytes are vouched for by
|
|
48
|
+
* the Ed25519 request signature (the token's SHA-256 is bound into the signed
|
|
49
|
+
* canonical string). These secrets add a second, independent HS256 proof: when
|
|
50
|
+
* configured, a presented token must ALSO pass HS256 or the request is
|
|
51
|
+
* rejected. They are the GATEWAY CONTEXT-SIGNING keyring values
|
|
52
|
+
* (`CONTEXT_SIGNING_KEYS_JSON`; supply every live key during rotation —
|
|
53
|
+
* try-all), NOT the business's `contextTokenSecret` (which signs `fsc_`
|
|
54
|
+
* INGRESS tokens verified BY the gateway — setting that here would reject every
|
|
55
|
+
* valid gateway request). Defaults to `FS_CONTEXT_SECRETS` (comma-separated)
|
|
56
|
+
* from the env. Leaving it unset is the common case.
|
|
63
57
|
*/
|
|
64
58
|
contextSecrets?: readonly string[];
|
|
65
59
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* the
|
|
60
|
+
* OPTIONAL shared replay-prevention store. The default is an IN-MEMORY,
|
|
61
|
+
* PER-PROCESS {@link NonceCache} — it stops replay against a single instance
|
|
62
|
+
* only. A horizontally-scaled backend (multiple replicas / serverless
|
|
63
|
+
* instances) where a captured, still-valid signed request could be replayed
|
|
64
|
+
* to a DIFFERENT replica should inject a SHARED store (Redis / Memcached /
|
|
65
|
+
* Cloudflare KV / Durable Object) implementing {@link NonceStore}. The signed
|
|
66
|
+
* request's ~300s validity window bounds the exposure either way, but a shared
|
|
67
|
+
* store closes the cross-replica gap. `checkAndRemember` may be async.
|
|
73
68
|
*/
|
|
74
|
-
|
|
69
|
+
nonceStore?: NonceStore;
|
|
75
70
|
};
|
|
76
71
|
export declare const SDK_VERSION: string;
|
|
77
|
-
export declare const CONTRACTS_FP: string;
|
|
78
72
|
/**
|
|
79
73
|
* The runtime instance. Lazily bootstraps; holds the JWKS client, nonce cache,
|
|
80
74
|
* metering buffer, and shutdown hooks.
|
|
@@ -88,10 +82,8 @@ export declare class FartherShore {
|
|
|
88
82
|
private readonly coreUrl;
|
|
89
83
|
private readonly instanceId?;
|
|
90
84
|
private readonly tunnelOptions;
|
|
91
|
-
/**
|
|
85
|
+
/** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
|
|
92
86
|
private readonly contextSecrets;
|
|
93
|
-
/** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
|
|
94
|
-
private readonly contextVerification;
|
|
95
87
|
private readonly nonceCache;
|
|
96
88
|
private readonly shutdownManager;
|
|
97
89
|
private jwks;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ConsumerPrincipal } from "./verifyContext.js";
|
|
2
|
+
/** The member arm of {@link ConsumerPrincipal}'s subject. */
|
|
3
|
+
export type MemberSubject = Extract<ConsumerPrincipal["subject"], {
|
|
4
|
+
kind: "member";
|
|
5
|
+
}>;
|
|
6
|
+
/** The service arm of {@link ConsumerPrincipal}'s subject. */
|
|
7
|
+
export type ServiceSubject = Extract<ConsumerPrincipal["subject"], {
|
|
8
|
+
kind: "service";
|
|
9
|
+
}>;
|
|
10
|
+
/** Anything carrying a verified {@link ConsumerPrincipal} (the request context). */
|
|
11
|
+
export type PrincipalCarrier = {
|
|
12
|
+
principal?: ConsumerPrincipal;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Assert the request resolved to a MEMBER subject and return it narrowed. On a
|
|
16
|
+
* gateway-enforced `subject: 'member'` route this never throws; call it in
|
|
17
|
+
* handler code to read `memberId` without a manual discriminant check. Throws
|
|
18
|
+
* `member_subject_required` when the subject is a service (or absent).
|
|
19
|
+
*/
|
|
20
|
+
export declare function requireMember(ctx: PrincipalCarrier): MemberSubject;
|
|
21
|
+
/**
|
|
22
|
+
* Assert the request resolved to a SERVICE subject and return it narrowed.
|
|
23
|
+
* Throws `service_subject_required` when the subject is a member (or absent).
|
|
24
|
+
*/
|
|
25
|
+
export declare function requireService(ctx: PrincipalCarrier): ServiceSubject;
|
|
26
|
+
/**
|
|
27
|
+
* The credential SURFACE behind a verified request, DERIVED from the signed
|
|
28
|
+
* principal (route-surfaces wave) — no new claim, no spoofable header. A member
|
|
29
|
+
* subject carries `via`; a service subject is always key-borne. Returns
|
|
30
|
+
* `undefined` when the request carried no verified principal (identity-less), so
|
|
31
|
+
* a caller can distinguish "not a portal session" from "unknown".
|
|
32
|
+
*
|
|
33
|
+
* - `"portal_session"` ⟺ a member via a browser session (`fsc_`).
|
|
34
|
+
* - `"api_key"` ⟺ a member's personal key OR any service key (`fsk_`).
|
|
35
|
+
*/
|
|
36
|
+
export declare function credentialKind(ctx: PrincipalCarrier): "portal_session" | "api_key" | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* True when the verified request came from the managed portal UI (a member
|
|
39
|
+
* browser session), false when it came from an API key, and `undefined` when
|
|
40
|
+
* there is no verified principal. Convenience over {@link credentialKind} for
|
|
41
|
+
* the common portal-vs-API branch (e.g. richer UI payloads for portal callers).
|
|
42
|
+
* The gateway's `enforce-surface` middleware is the SECURITY boundary; this is
|
|
43
|
+
* for in-handler ergonomics.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isPortalSession(ctx: PrincipalCarrier): boolean | undefined;
|
|
@@ -1,33 +1,110 @@
|
|
|
1
1
|
import { FartherShoreError } from "./errors.js";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* The verified consumer principal derived from the cv=2 signed context — a
|
|
4
|
+
* tenant (`org`) plus exactly one subject. Local copy of the contracts
|
|
5
|
+
* `ConsumerPrincipal` (the SDK never leaks `@farthershore/contracts` into a
|
|
6
|
+
* public type signature; see runtime-signing.ts).
|
|
7
|
+
*/
|
|
8
|
+
export type ConsumerPrincipal = {
|
|
9
|
+
org: {
|
|
10
|
+
id: string;
|
|
11
|
+
};
|
|
12
|
+
subject: {
|
|
13
|
+
kind: "member";
|
|
14
|
+
/** The member's stable internal id (never an IdP subject/email). */
|
|
15
|
+
memberId: string;
|
|
16
|
+
/** How this request's identity was proven. */
|
|
17
|
+
via: "session" | "api_key";
|
|
18
|
+
/** The personal `fsk_` key id when `via === "api_key"` (audit). */
|
|
19
|
+
keyId?: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "service";
|
|
22
|
+
/** The org-owned service account id — the stable service identity. */
|
|
23
|
+
serviceAccountId: string;
|
|
24
|
+
/** The specific service `fsk_` key id (rotates; audit). */
|
|
25
|
+
keyId: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The cv=2 signed-context payload the gateway stamps into `X-Fs-Context`. Local
|
|
30
|
+
* copy of the contracts `ConsumerContextClaims`. Identity rides the RFC 9068 /
|
|
31
|
+
* RFC 8693 claims (`sub`, `client_id`, `act`, `subjectKind`).
|
|
32
|
+
*/
|
|
3
33
|
export type FartherShoreSignedContext = {
|
|
4
|
-
/** Claim-format version. This SDK understands cv
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
34
|
+
/** Claim-format version. This SDK understands ONLY cv=2 (consumer principal). */
|
|
35
|
+
cv: 2;
|
|
36
|
+
/** Resource owner: member id (member) or service account id (service). */
|
|
37
|
+
sub: string;
|
|
38
|
+
/** API key / service credential id, when the call is key-borne. */
|
|
39
|
+
client_id?: string;
|
|
40
|
+
/** Nested actor (on-behalf-of): personal-key traffic sets `{ sub: keyId }`. */
|
|
41
|
+
act?: {
|
|
42
|
+
sub: string;
|
|
11
43
|
};
|
|
12
|
-
|
|
44
|
+
/** The org (tenant) id. */
|
|
45
|
+
org: string;
|
|
46
|
+
businessId: string;
|
|
13
47
|
compiledPlanId: string;
|
|
14
48
|
subscriptionId: string;
|
|
15
49
|
subscriberId: string;
|
|
16
50
|
environmentId: string | null;
|
|
17
51
|
subjectKey: string;
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
/**
|
|
52
|
+
/** Which subject arm this context resolves to. */
|
|
53
|
+
subjectKind: "member" | "service";
|
|
54
|
+
/** The org-owned service account id, for service subjects (mirror of `sub`). */
|
|
55
|
+
serviceAccountId?: string;
|
|
56
|
+
/** The bound role keys (absent when unminted). */
|
|
21
57
|
roles?: string[];
|
|
58
|
+
/** The unified-authz permission claim (absent when unminted). */
|
|
59
|
+
permissions?: string[];
|
|
22
60
|
};
|
|
23
61
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
62
|
+
* Derive the {@link ConsumerPrincipal} from a verified cv=2 payload, FAIL-CLOSED.
|
|
63
|
+
*
|
|
64
|
+
* This is a thin, contracts-free-typed re-export of the SINGLE canonical
|
|
65
|
+
* derivation in `@farthershore/contracts` (`authz/principal.ts`). The SDK keeps
|
|
66
|
+
* its own local {@link ConsumerPrincipal} / {@link FartherShoreSignedContext}
|
|
67
|
+
* *types* (so the published `.d.ts` never leaks the private contracts package),
|
|
68
|
+
* but the derivation LOGIC lives in exactly one place — no drift is possible.
|
|
69
|
+
*
|
|
70
|
+
* Returns `null` — never a partially-populated principal — when any identity /
|
|
71
|
+
* authz field is invalid: an empty/whitespace `sub`/`org`/`businessId`, a
|
|
72
|
+
* missing/unknown `subjectKind`, a `service` subject with no `serviceAccountId`
|
|
73
|
+
* or `keyId`, a delegated `member` with an empty `act.sub`, a `permissions` /
|
|
74
|
+
* `roles` claim that is not an array of non-empty strings, or CONTRADICTORY
|
|
75
|
+
* identity evidence (a `serviceAccountId` that disagrees with `sub`, or a
|
|
76
|
+
* `client_id` that disagrees with `act.sub`). The caller (`verifyRequest`) treats
|
|
77
|
+
* `null` as tamper evidence and fails closed (`context_unverified`).
|
|
78
|
+
*
|
|
79
|
+
* - service: `serviceAccountId = serviceAccountId ?? sub` (the two must agree),
|
|
80
|
+
* `keyId = client_id ?? act.sub ?? sub`.
|
|
81
|
+
* - member: `memberId = sub`; `via = "api_key"` with `keyId = act.sub` when
|
|
82
|
+
* the delegation claim is present, else `via = "session"`.
|
|
83
|
+
*
|
|
84
|
+
* Every derived id is the TRIMMED (canonical) value.
|
|
85
|
+
*/
|
|
86
|
+
export declare function principalFromContextClaims(claims: FartherShoreSignedContext): ConsumerPrincipal | null;
|
|
87
|
+
/**
|
|
88
|
+
* Verify an `X-Fs-Context` token against one or more HS256 secrets (try-all for
|
|
89
|
+
* keyring rotation). Returns the typed cv=2 payload on success, `null` on any
|
|
90
|
+
* failure (malformed, wrong alg, bad signature, unparseable payload, OR a
|
|
91
|
+
* `cv !== 2` claim-format the SDK does not understand). The caller treats a
|
|
92
|
+
* `null` as fatal (fail-closed).
|
|
29
93
|
*/
|
|
30
94
|
export declare function verifyContext(token: string, secrets: readonly string[]): Promise<FartherShoreSignedContext | null>;
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
95
|
+
/**
|
|
96
|
+
* Decode + shape-check a cv=2 context token's PAYLOAD **without** verifying its
|
|
97
|
+
* HS256 signature. Safe to call ONLY after the token's authenticity has been
|
|
98
|
+
* established by another proof — in this SDK the presented `X-Fs-Context`'s
|
|
99
|
+
* SHA-256 (`contextHash`) is bound into the Ed25519-verified request signature,
|
|
100
|
+
* so once that request signature checks out the token bytes are byte-exact the
|
|
101
|
+
* ones the gateway signed and its CONTENT is gateway-vouched (no HS256 secret
|
|
102
|
+
* required). This is the DEFAULT identity path: with request signing on, a
|
|
103
|
+
* verified request always yields the principal even when no HS256 context secret
|
|
104
|
+
* is distributed to the backend. Returns the typed cv=2 claims, or `null` when
|
|
105
|
+
* the token is malformed or is not a cv=2 claim.
|
|
106
|
+
*/
|
|
107
|
+
export declare function decodeContextClaims(token: string): FartherShoreSignedContext | null;
|
|
108
|
+
/** Thrown by verifyRequest when the signed context is missing or fails
|
|
109
|
+
* verification (context signing is REQUIRED once context secrets exist). */
|
|
33
110
|
export declare function contextRequiredError(reason: string): FartherShoreError;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReportUsageResult, RequestScopedReportUsageInput } from "./post-stream-usage.js";
|
|
2
|
-
import { type FartherShoreSignedContext } from "./verifyContext.js";
|
|
2
|
+
import { type ConsumerPrincipal, type FartherShoreSignedContext } from "./verifyContext.js";
|
|
3
3
|
import type { JwksClient } from "./jwks.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { NonceStore } from "./nonceCache.js";
|
|
5
5
|
/** Per-request input. `headers` keys are matched case-insensitively. */
|
|
6
6
|
export type VerifyRequestInput = {
|
|
7
7
|
method: string;
|
|
@@ -35,26 +35,37 @@ export type FartherShoreRequestContext = {
|
|
|
35
35
|
meters?: string[];
|
|
36
36
|
features?: Record<string, unknown>;
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
38
|
+
* The verified CONSUMER PRINCIPAL behind this request (consumer-principal
|
|
39
|
+
* wave, D3) — derived from the cv=2 `X-Fs-Context` whose bytes are vouched for
|
|
40
|
+
* by the Ed25519 request signature (the token hash is bound into the canonical
|
|
41
|
+
* signing string). OPTIONAL on this raw `fs.middleware()` path: a valid gateway
|
|
42
|
+
* request MAY be identity-less (no `X-Fs-Context` — the gateway signs an
|
|
43
|
+
* empty context hash), in which case there is no principal to resolve. This
|
|
44
|
+
* field is typed honestly here; the `fs.handler()` path (createExpressHandler)
|
|
45
|
+
* is where the GUARANTEED non-optional principal lives — it rejects an
|
|
46
|
+
* identity-less request with `401 principal_required` before the callback runs.
|
|
47
|
+
* Use {@link requireMember} to narrow to the member arm on member-only routes.
|
|
48
|
+
*/
|
|
49
|
+
principal?: ConsumerPrincipal;
|
|
50
|
+
/**
|
|
51
|
+
* Managed-RBAC permissions the gateway resolved for the acting subject —
|
|
52
|
+
* sourced ONLY from the VERIFIED signed `X-Fs-Context` claim (the unsigned
|
|
53
|
+
* `x-fs-permissions` fallback is GONE). `undefined` when the claim was absent
|
|
54
|
+
* ⇒ FAR-723 carrier-level DENY; `[]` for an authenticated subject with no
|
|
55
|
+
* grants. Read via {@link hasPermission} / {@link requirePermission}.
|
|
44
56
|
*/
|
|
45
57
|
permissions?: string[];
|
|
46
|
-
/**
|
|
47
|
-
*
|
|
48
|
-
* populated the fields above (signed-preferred). */
|
|
58
|
+
/** The VERIFIED signed cv=2 context payload. Its permissions/roles populated
|
|
59
|
+
* the fields above; its claims derived {@link principal}. */
|
|
49
60
|
signedContext?: FartherShoreSignedContext;
|
|
50
|
-
/** Managed-RBAC role keys the acting
|
|
61
|
+
/** Managed-RBAC role keys the acting subject holds (display/audit only). */
|
|
51
62
|
roles?: string[];
|
|
52
63
|
/** Request-bound post-stream reporter, attached by the runtime facade. */
|
|
53
64
|
reportUsage?: (input: RequestScopedReportUsageInput) => Promise<ReportUsageResult>;
|
|
54
65
|
};
|
|
55
66
|
export type VerifyRequestDeps = {
|
|
56
67
|
jwks: JwksClient;
|
|
57
|
-
nonceCache:
|
|
68
|
+
nonceCache: NonceStore;
|
|
58
69
|
/** Expected business id (from bootstrap). When set, must match the signed claim. */
|
|
59
70
|
businessId?: string;
|
|
60
71
|
/** Expected backend id (from bootstrap). When set, must match. */
|
|
@@ -70,18 +81,17 @@ export type VerifyRequestDeps = {
|
|
|
70
81
|
/** Injectable clock (seconds since epoch). */
|
|
71
82
|
nowSeconds?: () => number;
|
|
72
83
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
84
|
+
* OPTIONAL HS256 secret(s) for the gateway's cv=2 `X-Fs-Context` claim
|
|
85
|
+
* (multiple = keyring rotation, try-all). Consumer-principal wave (D3): these
|
|
86
|
+
* are NOT required to produce the principal. The context token's authenticity
|
|
87
|
+
* comes from the Ed25519 REQUEST signature — the token's SHA-256 is bound into
|
|
88
|
+
* the canonical signing string, so a verified request already vouches for the
|
|
89
|
+
* token's content. Principal derivation therefore happens whenever a token is
|
|
90
|
+
* presented, with or without a configured secret. When these secrets ARE
|
|
91
|
+
* configured they add DEFENSE-IN-DEPTH: a presented token must ALSO pass HS256
|
|
92
|
+
* (a second, independent proof) or the request is rejected. Leaving this empty
|
|
93
|
+
* is the common case (the request-signature binding is sufficient).
|
|
77
94
|
*/
|
|
78
95
|
contextSecrets?: readonly string[];
|
|
79
|
-
/**
|
|
80
|
-
* UA-6 — `"preferred"` (default): a missing/unverifiable signed context
|
|
81
|
-
* falls back to the unsigned headers. `"required"`: missing or invalid
|
|
82
|
-
* signed context REJECTS the request (fail-closed) — set this once your
|
|
83
|
-
* gateway config has context signing enabled.
|
|
84
|
-
*/
|
|
85
|
-
contextVerification?: "preferred" | "required";
|
|
86
96
|
};
|
|
87
97
|
export declare function verifyRequest(input: VerifyRequestInput, deps: VerifyRequestDeps): Promise<FartherShoreRequestContext>;
|
|
@@ -73,11 +73,11 @@ export declare const RUNTIME_SIGNING_CONTRACT: {
|
|
|
73
73
|
readonly keyValueSeparator: ":";
|
|
74
74
|
readonly trailingNewline: false;
|
|
75
75
|
readonly fieldEncoding: "utf-8";
|
|
76
|
-
readonly fields: readonly ["method", "path", "query", "body-hash", "request-id", "timestamp", "business-id", "backend-id", "route-id", "policy-version"];
|
|
76
|
+
readonly fields: readonly ["method", "path", "query", "body-hash", "request-id", "timestamp", "business-id", "backend-id", "route-id", "policy-version", "context-hash"];
|
|
77
77
|
readonly fieldRules: {
|
|
78
78
|
readonly method: "Uppercased HTTP method (e.g. GET, POST).";
|
|
79
79
|
readonly path: "Request path, percent-encoded as received, no host, no query string. Always begins with '/'.";
|
|
80
|
-
readonly query: "
|
|
80
|
+
readonly 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 — 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 — each collapses distinct wire queries under one signature. Empty string when there is no query.";
|
|
81
81
|
readonly "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'.";
|
|
82
82
|
readonly "request-id": "Opaque unique request id minted by the gateway (also the replay-cache nonce).";
|
|
83
83
|
readonly timestamp: "Integer Unix epoch seconds (UTC) at signing time, as a base-10 string with no padding.";
|
|
@@ -85,6 +85,7 @@ export declare const RUNTIME_SIGNING_CONTRACT: {
|
|
|
85
85
|
readonly "backend-id": "Backend id the route binds to.";
|
|
86
86
|
readonly "route-id": "Resolved route id; empty string if the route is unresolved.";
|
|
87
87
|
readonly "policy-version": "Tenant artifact / policy version the gateway signed under.";
|
|
88
|
+
readonly "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.";
|
|
88
89
|
};
|
|
89
90
|
};
|
|
90
91
|
};
|
|
@@ -92,7 +93,7 @@ export declare const RUNTIME_SIGNING_CONTRACT: {
|
|
|
92
93
|
* Ordered list of fields in the canonical signing string. The order here is
|
|
93
94
|
* load-bearing and identical across all language SDKs.
|
|
94
95
|
*/
|
|
95
|
-
export declare const RUNTIME_CANONICAL_FIELDS: readonly ["method", "path", "query", "body-hash", "request-id", "timestamp", "business-id", "backend-id", "route-id", "policy-version"];
|
|
96
|
+
export declare const RUNTIME_CANONICAL_FIELDS: readonly ["method", "path", "query", "body-hash", "request-id", "timestamp", "business-id", "backend-id", "route-id", "policy-version", "context-hash"];
|
|
96
97
|
export declare const RUNTIME_BODY_HASH_CONTRACT: {
|
|
97
98
|
readonly algorithm: "SHA-256";
|
|
98
99
|
readonly encoding: "hex-lower";
|
|
@@ -137,6 +138,9 @@ export declare const RUNTIME_ERROR_CODES: {
|
|
|
137
138
|
readonly missingToken: "missing_token";
|
|
138
139
|
readonly invalidToken: "invalid_token";
|
|
139
140
|
readonly contextUnverified: "context_unverified";
|
|
141
|
+
readonly memberSubjectRequired: "member_subject_required";
|
|
142
|
+
readonly serviceSubjectRequired: "service_subject_required";
|
|
143
|
+
readonly surfaceNotAllowed: "surface_not_allowed";
|
|
140
144
|
};
|
|
141
145
|
export type RuntimeErrorCode = (typeof RUNTIME_ERROR_CODES)[keyof typeof RUNTIME_ERROR_CODES];
|
|
142
146
|
export declare const RUNTIME_METERING_CONTRACT: {
|