@farthershore/backend 0.14.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.
@@ -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
- * When true (default), a request is verified only if verification is REQUIRED
26
- * by bootstrap. While verificationRequired=false (pre-keystone) the middleware
27
- * passes requests through WITHOUT attaching a context matching the deploy
28
- * order where the signer is not yet live. Set `always: true` to verify
29
- * regardless (the readiness harness / strict deployments).
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;
@@ -2,6 +2,12 @@ import type { RuntimeMeteringConfig } from "../runtime-types.js";
2
2
  export type MeterOptions = {
3
3
  requestId?: string;
4
4
  routeId?: string;
5
+ /** Subscription to attribute the usage to (billing identity). Pass the
6
+ * verified request context's `signedContext.subscriptionId` when metering
7
+ * inside a request handler. Without it (and without `requestId`, which core
8
+ * can resolve back to the served gateway request), core persists the event
9
+ * UNBILLED and flags it unattributable. */
10
+ subscriptionId?: string;
5
11
  /** Override event_id (idempotency key). Defaults to a random uuid. */
6
12
  eventId?: string;
7
13
  /** Override the timestamp (ISO-8601). Defaults to now. */
@@ -9,7 +15,7 @@ export type MeterOptions = {
9
15
  };
10
16
  export type MeteringClientOptions = {
11
17
  config: RuntimeMeteringConfig;
12
- productId: string;
18
+ businessId: string;
13
19
  backendId: string;
14
20
  /** Core base URL when the config endpoint is a relative path. */
15
21
  coreUrl?: string;
@@ -36,7 +42,7 @@ export type MeteringClientOptions = {
36
42
  export declare class MeteringClient {
37
43
  private readonly config;
38
44
  private readonly endpoint;
39
- private readonly productId;
45
+ private readonly businessId;
40
46
  private readonly backendId;
41
47
  private readonly fetchImpl;
42
48
  private readonly maxRetries;
@@ -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
- /** Max distinct nonces retained. Oldest are evicted first. */
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 (≥ replay window + skew). */
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
- /** The verified X-Fs-Context claims, when the request carried a valid token. */
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 ({@link parsePermissionHeader} output lives on `context.permissions`).
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
- * FAR-723 FAIL-CLOSED: an ABSENT permission set (`ctx.permissions === undefined`)
69
- * DENIES — the reverse of the former grant-all grace UNLESS the request
70
- * carried a cryptographically VERIFIED context token that simply omits the
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) still grants
78
- * everything and `[]` denies.
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,37 +42,31 @@ export type FartherShoreInitOptions = {
41
42
  /** SDK metadata forwarded to bootstrap. */
42
43
  instanceId?: string;
43
44
  /**
44
- * FAR-723 / UA-6 — HS256 secret(s) for verifying the gateway's SIGNED
45
- * `X-Fs-Context` claim. These are the GATEWAY CONTEXT-SIGNING keyring values
46
- * (`CONTEXT_SIGNING_KEYS_JSON` the keys `forward-upstream` stamps the
47
- * header with; supply every live key during rotation try-all). They are
48
- * NOT the product's `contextTokenSecret`, which signs `fsc_` INGRESS tokens
49
- * verified BY the gateway setting that here would reject every valid
50
- * gateway request in `"required"` mode. When present, a VERIFIED context's
51
- * `permissions`/`roles` claims are the AUTHORITATIVE identity source
52
- * (preferred over the transitional unsigned `x-fs-permissions`/`x-fs-roles`
53
- * headers). Defaults to `FS_CONTEXT_SECRETS` (comma-separated) from the env.
54
- *
55
- * NOTE (core-side dependency, FAR-723 publish gate): no bootstrap field
56
- * carries these keys yet, and handing the raw platform keyring to builder
57
- * backends is NOT the end-state (it would allow cross-product context
58
- * forgery). The distribution mechanism — per-product 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
- * FAR-723 / UA-6 `"preferred"` (default): a missing/unverifiable signed
67
- * context falls back to the transitional unsigned headers.
68
- * `"required"`: a missing or invalid signed context REJECTS the request
69
- * (fail-closed), including when the context-secret keyring is empty or
70
- * unconfigured. Defaults to `FS_CONTEXT_VERIFICATION` from the env, else
71
- * `"preferred"`. The unsigned fallback is transitional and is removed after
72
- * the backend-v* publish gate.
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
- contextVerification?: "preferred" | "required";
69
+ nonceStore?: NonceStore;
75
70
  };
76
71
  export declare const SDK_VERSION: string;
77
72
  export declare const CONTRACTS_FP: string;
@@ -88,10 +83,8 @@ export declare class FartherShore {
88
83
  private readonly coreUrl;
89
84
  private readonly instanceId?;
90
85
  private readonly tunnelOptions;
91
- /** FAR-723 HS256 secret(s) for verifying the signed X-Fs-Context claim. */
86
+ /** OPTIONAL HS256 secret(s) defense-in-depth over the cv=2 X-Fs-Context. */
92
87
  private readonly contextSecrets;
93
- /** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
94
- private readonly contextVerification;
95
88
  private readonly nonceCache;
96
89
  private readonly shutdownManager;
97
90
  private jwks;
@@ -0,0 +1,25 @@
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;
@@ -1,33 +1,110 @@
1
1
  import { FartherShoreError } from "./errors.js";
2
- /** The signed context payload (claim-format cv=1). */
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 1 (and legacy tokens
5
- * without cv, which predate UA-6 and carry no permissions). */
6
- cv?: number;
7
- orgId: string;
8
- actor: {
9
- type: string;
10
- id: string | null;
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
- productId: string;
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
- /** UA-6 the unified-authz permission claim (absent when unminted). */
19
- permissions?: string[];
20
- /** UA-6 the bound role keys (absent when unminted). */
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
- * Verify an `X-Fs-Context` token against one or more HS256 secrets (try-all
25
- * for keyring rotation). Returns the typed payload on success, `null` on any
26
- * failure (malformed, wrong alg, bad signature, unparseable payload) the
27
- * caller decides whether absence/invalidity is fatal (`required`) or falls
28
- * back to the transitional unsigned headers (`preferred`).
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
- /** Thrown by verifyRequest when `contextVerification: "required"` and the
32
- * signed context is missing or fails verification. */
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 { NonceCache } from "./nonceCache.js";
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;
@@ -23,7 +23,7 @@ export type HeadersLike = Headers | Record<string, string | string[] | undefined
23
23
  /** The verified request context attached to req.fartherShore. */
24
24
  export type FartherShoreRequestContext = {
25
25
  requestId: string;
26
- productId: string;
26
+ businessId: string;
27
27
  backendId: string;
28
28
  routeId: string;
29
29
  policyVersion: string;
@@ -35,28 +35,39 @@ export type FartherShoreRequestContext = {
35
35
  meters?: string[];
36
36
  features?: Record<string, unknown>;
37
37
  /**
38
- * Managed-RBAC permissions the gateway resolved for the acting user —
39
- * preferred from the VERIFIED signed `X-Fs-Context` claim, else the UNSIGNED
40
- * `x-fs-permissions` identity header (both trusted transitively on a verified
41
- * request see permissions.ts). `undefined` when NO permission source was
42
- * present FAR-723 carrier-level DENY; `[]` for an authenticated user with
43
- * no grants. Read via {@link hasPermission} / {@link requirePermission}.
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
- /** UA-6 — the VERIFIED signed context payload, when context secrets are
47
- * configured and the X-Fs-Context token verified. Its permissions/roles
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 user holds (display/audit only). */
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: NonceCache;
58
- /** Expected product id (from bootstrap). When set, must match the signed claim. */
59
- productId?: string;
68
+ nonceCache: NonceStore;
69
+ /** Expected business id (from bootstrap). When set, must match the signed claim. */
70
+ businessId?: string;
60
71
  /** Expected backend id (from bootstrap). When set, must match. */
61
72
  backendId?: string;
62
73
  /**
@@ -70,18 +81,17 @@ export type VerifyRequestDeps = {
70
81
  /** Injectable clock (seconds since epoch). */
71
82
  nowSeconds?: () => number;
72
83
  /**
73
- * UA-6 HS256 secret(s) for verifying the gateway's SIGNED `X-Fs-Context`
74
- * claim (multiple = keyring rotation, try-all). When provided, a VERIFIED
75
- * context's `permissions`/`roles` claims are preferred over the
76
- * transitional unsigned identity headers.
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,18 +73,19 @@ 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", "product-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: "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.";
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.";
84
- readonly "product-id": "Product id the request is routed to.";
84
+ readonly "business-id": "Business id the request is routed to.";
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", "product-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";
@@ -108,7 +109,7 @@ export declare const RUNTIME_HEADERS: {
108
109
  readonly keyId: "x-fs-key-id";
109
110
  readonly requestId: "x-fs-request-id";
110
111
  readonly timestamp: "x-fs-timestamp";
111
- readonly productId: "x-fs-product-id";
112
+ readonly businessId: "x-fs-business-id";
112
113
  readonly backendId: "x-fs-backend-id";
113
114
  readonly routeId: "x-fs-route-id";
114
115
  readonly policyVersion: "x-fs-policy-version";
@@ -137,6 +138,8 @@ 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";
140
143
  };
141
144
  export type RuntimeErrorCode = (typeof RUNTIME_ERROR_CODES)[keyof typeof RUNTIME_ERROR_CODES];
142
145
  export declare const RUNTIME_METERING_CONTRACT: {
@@ -145,7 +148,7 @@ export declare const RUNTIME_METERING_CONTRACT: {
145
148
  readonly credential: "reusable-bearer";
146
149
  readonly event: {
147
150
  readonly event_id: "string";
148
- readonly product_id: "string";
151
+ readonly business_id: "string";
149
152
  readonly backend_id: "string";
150
153
  readonly route_id: "string?";
151
154
  readonly request_id: "string?";