@farthershore/backend 0.19.0 → 0.21.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,6 +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
+ import type { ConsumerPrincipal, FartherShoreSignedContext } from "../core/verifyContext.js";
4
4
  /** Minimal Express-shaped types so we don't hard-depend on @types/express. */
5
5
  export type ExpressRequestLike = {
6
6
  method: string;
@@ -18,6 +18,13 @@ export type ExpressResponseLike = {
18
18
  status(code: number): ExpressResponseLike;
19
19
  json(body: unknown): unknown;
20
20
  setHeader(name: string, value: string): void;
21
+ /**
22
+ * Node's `ServerResponse.headersSent`. This is what makes the reporting verb's
23
+ * transport choice AUTOMATIC: while it is false the measurement rides signed
24
+ * response headers (no network call); once the response is on the wire
25
+ * `ctx.report()` transparently switches to the post-stream channel.
26
+ */
27
+ headersSent?: boolean;
21
28
  };
22
29
  export type ExpressNext = (err?: unknown) => void;
23
30
  export type ExpressMiddleware = (req: ExpressRequestLike, res: ExpressResponseLike, next: ExpressNext) => void;
@@ -69,14 +76,27 @@ export type MiddlewareOptions = {
69
76
  */
70
77
  export type VerifiedPrincipalContext = FartherShoreRequestContext & {
71
78
  principal: ConsumerPrincipal;
79
+ signedContext: FartherShoreSignedContext;
72
80
  };
73
81
  /**
74
82
  * A route handler that runs only with a GUARANTEED verified PRINCIPAL. The first
75
- * argument is the {@link VerifiedPrincipalContext} — read `ctx.principal` (and
76
- * narrow with `requireMember`/`requireService`) without any optional-chaining.
83
+ * argument is the {@link VerifiedPrincipalContext} — read `ctx.principal` and
84
+ * `ctx.signedContext` (and narrow with `requireMember`/`requireService`)
85
+ * without any optional-chaining.
77
86
  * See {@link createExpressHandler}.
78
87
  */
79
88
  export type VerifiedExpressHandler<Req extends ExpressRequestLike = ExpressRequestLike, Res extends ExpressResponseLike = ExpressResponseLike> = (ctx: VerifiedPrincipalContext, req: Req, res: Res, next: ExpressNext) => void | Promise<void>;
89
+ /** Options for the `fs.handler(options, cb)` overload. */
90
+ export type HandlerOptions = {
91
+ /**
92
+ * Permission key the verified principal must hold (unified grammar:
93
+ * `*` / `<subject>:*` / exact — custom strings work). Checked with the
94
+ * FAIL-CLOSED carrier gate (`requirePermission`): an ABSENT permission set
95
+ * denies. On failure the wrapper responds `403 { error: "permission_denied" }`
96
+ * before the callback runs.
97
+ */
98
+ permission?: string;
99
+ };
80
100
  /**
81
101
  * Build the Express middleware. Captures raw body bytes, calls verifyRequest,
82
102
  * and fail-closes on any error.
@@ -106,3 +126,12 @@ export declare function createExpressMiddleware(fs: FartherShore, options?: Midd
106
126
  * non-optional guarantee AND Express compatibility.
107
127
  */
108
128
  export declare function createExpressHandler<Req extends ExpressRequestLike = ExpressRequestLike, Res extends ExpressResponseLike = ExpressResponseLike>(handler: VerifiedExpressHandler<Req, Res>): ExpressMiddleware;
129
+ /**
130
+ * Options-first overload: `fs.handler({ permission: "widgets:write" }, cb)`
131
+ * asserts the verified principal holds `permission` (via the fail-closed
132
+ * carrier gate — an absent permission set DENIES) BEFORE the callback runs,
133
+ * responding `403 permission_denied` otherwise. The key is checked with the
134
+ * unified grammar (`*` / `<subject>:*` / exact); derive route-shaped keys with
135
+ * `routePermission(subject, method)`.
136
+ */
137
+ export declare function createExpressHandler<Req extends ExpressRequestLike = ExpressRequestLike, Res extends ExpressResponseLike = ExpressResponseLike>(options: HandlerOptions, handler: VerifiedExpressHandler<Req, Res>): ExpressMiddleware;
@@ -12,6 +12,11 @@ export type BootstrapClientOptions = {
12
12
  now?: () => number;
13
13
  /** Minimum seconds between refreshes regardless of server hint. */
14
14
  minRefreshSeconds?: number;
15
+ /**
16
+ * Maximum age for cached bootstrap authorization metadata during transient
17
+ * refresh failures. Defaults to 5 minutes.
18
+ */
19
+ maxStaleSeconds?: number;
15
20
  };
16
21
  /**
17
22
  * Caches the bootstrap response and refreshes it lazily. `get()` returns the
@@ -24,6 +29,7 @@ export declare class BootstrapClient {
24
29
  private readonly fetchImpl;
25
30
  private readonly now;
26
31
  private readonly minRefreshSeconds;
32
+ private readonly maxStaleMs;
27
33
  private cached;
28
34
  private fetchedAt;
29
35
  private refreshAfterMs;
@@ -36,5 +42,7 @@ export declare class BootstrapClient {
36
42
  /** Last cached value without triggering a refresh (null until bootstrapped). */
37
43
  peek(): RuntimeBootstrapResponse | null;
38
44
  private isStale;
45
+ private isHardStale;
46
+ private cachedOrThrowOnHardStale;
39
47
  private doBootstrap;
40
48
  }
@@ -0,0 +1,80 @@
1
+ import { FartherShoreError } from "./errors.js";
2
+ /**
3
+ * Per-operation deadlines. Verification-path calls are tighter than reporting
4
+ * calls: JWKS sits in front of every inbound request, while a metering flush is
5
+ * a background economic report that is retried anyway.
6
+ */
7
+ export declare const DEADLINE_MS: {
8
+ /** Boot-blocking; generous because it runs once and gates startup. */
9
+ readonly bootstrap: 10000;
10
+ /** On the inbound verification path — must not hold a request open. */
11
+ readonly jwks: 5000;
12
+ /** Background economic report, retried by the caller. */
13
+ readonly metering: 10000;
14
+ /** Background attested usage callback. */
15
+ readonly postStreamUsage: 10000;
16
+ /** Best-effort heartbeat; never blocks anything. */
17
+ readonly health: 5000;
18
+ /** Boot-time route drift report; fail-open at the caller. */
19
+ readonly report: 10000;
20
+ };
21
+ export type DeadlineOperation = keyof typeof DEADLINE_MS;
22
+ /**
23
+ * Byte cap for response bodies the SDK parses. Core's JWKS and bootstrap
24
+ * documents are kilobytes; a megabyte is orders of magnitude of headroom while
25
+ * still refusing an unbounded stream.
26
+ */
27
+ export declare const MAX_RESPONSE_BYTES = 1048576;
28
+ /**
29
+ * True when `cause` is this module's deadline firing (as opposed to a caller
30
+ * cancellation or a transport error). `AbortSignal.timeout` rejects with a
31
+ * `TimeoutError` DOMException; we also accept our own typed marker.
32
+ */
33
+ export declare function isDeadlineExceeded(cause: unknown): boolean;
34
+ /** Raised when a bounded read exceeds {@link MAX_RESPONSE_BYTES}. */
35
+ export declare class ResponseTooLargeError extends Error {
36
+ constructor(limit: number);
37
+ }
38
+ /** Marker for a deadline that fired, preserved across helper boundaries. */
39
+ export declare class DeadlineExceededError extends Error {
40
+ readonly operation: DeadlineOperation;
41
+ constructor(operation: DeadlineOperation, timeoutMs: number);
42
+ }
43
+ export type DeadlineOptions = {
44
+ /** Host cancellation to compose with the SDK deadline. */
45
+ callerSignal?: AbortSignal;
46
+ /**
47
+ * Override the operation default. For callers with a tighter budget than the
48
+ * SDK default (and for tests, which cannot wait out a 10s deadline).
49
+ */
50
+ timeoutMs?: number;
51
+ };
52
+ /**
53
+ * Compose the SDK deadline for `operation` with an optional caller signal.
54
+ * Either aborting aborts the fetch, so host cancellation still works and the
55
+ * SDK can never outlive its own timeout.
56
+ */
57
+ export declare function deadlineSignal(operation: DeadlineOperation, options?: DeadlineOptions): AbortSignal;
58
+ /**
59
+ * `fetch` with a mandatory deadline. Every outbound SDK call goes through here;
60
+ * the CI guard (`fetch-timeouts:check:backend-sdk`) fails the build on a raw
61
+ * `fetch(` added anywhere else in the package.
62
+ *
63
+ * Rethrows a {@link DeadlineExceededError} when OUR timeout fired, so the
64
+ * failure is classifiable even though `AbortSignal.any` erases which input
65
+ * aborted. A caller-driven abort propagates unchanged.
66
+ */
67
+ export declare function fetchWithDeadline(fetchImpl: typeof fetch, input: string, init: RequestInit, operation: DeadlineOperation, options?: DeadlineOptions): Promise<Response>;
68
+ /**
69
+ * Read a response body as text, cancelling the stream once `limit` bytes have
70
+ * arrived. Returns the decoded text; throws {@link ResponseTooLargeError} when
71
+ * the cap is crossed so a caller never sees a silently truncated document.
72
+ */
73
+ export declare function readBoundedText(response: Response, limit?: number): Promise<string>;
74
+ /**
75
+ * Bounded JSON read. Same cap as {@link readBoundedText}; a body that is not
76
+ * valid JSON raises the underlying SyntaxError for the caller to classify.
77
+ */
78
+ export declare function readBoundedJson<T>(response: Response, limit?: number): Promise<T>;
79
+ /** Wrap a deadline failure in the SDK's typed error with a stable code. */
80
+ export declare function deadlineError(operation: DeadlineOperation, cause: unknown): FartherShoreError;
@@ -1,28 +1,53 @@
1
1
  export type Jwk = JsonWebKey & {
2
2
  kid?: string;
3
3
  };
4
+ /** Freshness of the key set backing a verification decision. */
5
+ export type JwksCacheState = "fresh" | "soft_stale" | "hard_stale" | "cold";
6
+ export type JwksObservation = {
7
+ state: JwksCacheState;
8
+ /** Age of the cached key set in ms (0 when cold). */
9
+ ageMs: number;
10
+ /** The `kid` being resolved, when the observation is tied to one. */
11
+ kid?: string;
12
+ };
13
+ /**
14
+ * Observability hook. Fires on every decision that depends on cache freshness,
15
+ * so an operator can alarm on `soft_stale` (Core is failing) long before
16
+ * `hard_stale` (requests are now being rejected).
17
+ */
18
+ export type JwksObserver = (observation: JwksObservation) => void;
4
19
  export type JwksClientOptions = {
5
20
  jwksUrl: string;
6
21
  /** Injectable fetch (tests). Defaults to globalThis.fetch. */
7
22
  fetchImpl?: typeof fetch;
8
23
  /** How long a successful fetch stays fresh before a background refresh. */
9
24
  cacheTtlMs?: number;
25
+ /**
26
+ * Hard ceiling on serving a key set whose refresh is failing. Past this age
27
+ * the client FAILS CLOSED rather than vouching for keys it can no longer
28
+ * confirm. Must be ≥ cacheTtlMs.
29
+ */
30
+ hardStaleMs?: number;
10
31
  /** How long an unknown-kid result is negatively cached (avoids hammering). */
11
32
  negativeCacheMs?: number;
12
33
  /** Injectable clock (tests). */
13
34
  now?: () => number;
35
+ /** Freshness//staleness observations for metrics. */
36
+ onObservation?: JwksObserver;
14
37
  };
15
38
  /**
16
- * Caching JWKS client. Holds the last successful key set indefinitely as a
17
- * warm fallback (stale-while-revalidate) and only fails closed when it has
18
- * NEVER successfully fetched (cold cache).
39
+ * Caching JWKS client. Serves the last successful key set as a warm fallback
40
+ * while a refresh is failing, but only until {@link JwksClientOptions.hardStaleMs};
41
+ * fails closed on a cold cache and past the hard-stale ceiling.
19
42
  */
20
43
  export declare class JwksClient {
21
44
  private readonly jwksUrl;
22
45
  private readonly fetchImpl;
23
46
  private readonly cacheTtlMs;
47
+ private readonly hardStaleMs;
24
48
  private readonly negativeCacheMs;
25
49
  private readonly now;
50
+ private readonly onObservation;
26
51
  private keysByKid;
27
52
  private fetchedAt;
28
53
  private hasFetchedOnce;
@@ -31,18 +56,28 @@ export declare class JwksClient {
31
56
  constructor(options: JwksClientOptions);
32
57
  /**
33
58
  * Resolve a public JWK for `kid`, fail-closed. Throws FartherShoreError with
34
- * `jwks_unavailable` (cold cache + fetch failed) or `unknown_key_id`.
59
+ * `jwks_unavailable` (cold cache, or a hard-stale cache whose refresh is
60
+ * failing) or `unknown_key_id`.
35
61
  */
36
62
  getKey(kid: string): Promise<Jwk>;
37
63
  /** Record a confirmed-missing kid, evicting the oldest if at capacity. */
38
64
  private rememberMissingKid;
65
+ private ageMs;
39
66
  private isStale;
67
+ private isHardStale;
68
+ /** Current freshness of the cached key set. */
69
+ private cacheState;
70
+ private observe;
71
+ /** Fail closed when the cached key set is past the hard-stale ceiling. */
72
+ private assertWithinHardStale;
40
73
  /** Single-flight refresh: concurrent callers share one fetch. */
41
74
  private refresh;
42
75
  private doFetch;
43
76
  /**
44
- * Stale-while-revalidate: with a warm cache, swallow the refresh failure and
45
- * keep serving the last-known keys. With a COLD cache, fail closed.
77
+ * BOUNDED stale-while-revalidate. A COLD cache fails closed. A warm cache
78
+ * inside the soft window swallows the failure and keeps serving. Past the
79
+ * hard-stale ceiling it fails closed too — availability is worth a bounded
80
+ * window of degraded trust, not an unbounded one.
46
81
  */
47
- private failOnColdCache;
82
+ private handleRefreshFailure;
48
83
  }
@@ -13,21 +13,33 @@ export declare class FartherShorePermissionError extends Error {
13
13
  constructor(requiredPermission: string, message?: string);
14
14
  }
15
15
  /**
16
- * Pure grant check a FAITHFUL COPY of the canonical `permissionGrants` in
17
- * `@farthershore/contracts` (`rbac.ts`). Over a DEFINED array: a `"*"` entry
18
- * grants everything, otherwise the key must be an exact member. Its
19
- * `undefined → true` codomain is the PRIMITIVE's contract (kept byte-identical
20
- * to contracts for parity); it is NOT the SDK's carrier policy. Under FAR-723
21
- * the carrier gate {@link hasPermission} DENIES an absent permission set before
22
- * this primitive is consulted, so absence never grants at the boundary.
16
+ * HTTP verbs that classify as `:read`. Everything else including the
17
+ * route-catalog wildcard `*` classifies as `:write`.
23
18
  *
24
- * The published SDK surface must stay contracts-free, so it can't import the
25
- * canonical primitive. A TEST-ONLY parity guard (`permissions-parity.test.ts`,
26
- * which CAN import contracts as a devDependency) asserts this copy agrees with
27
- * the canonical primitive across a shared golden vector table, so the copy
28
- * can't silently drift.
19
+ * FAITHFUL COPY of the canonical `READ_METHODS` in the permissions kernel
20
+ * (`@farthershore/authz/grammar/route`, re-exported by
21
+ * `@farthershore/contracts/rbac`); the published bundle is contracts-free, so
22
+ * `permissions-parity.test.ts` asserts agreement and the full corpus replay
23
+ * lives in packages/authz/test-node/sdk-copy-parity.test.ts.
29
24
  */
30
- export declare function permissionGrants(permissions: readonly string[] | undefined, key: string): boolean;
25
+ export declare const READ_METHODS: ReadonlySet<string>;
26
+ /**
27
+ * Derive the permission string a credential must hold to call a route:
28
+ * `<subject>:read` for safe verbs (GET / HEAD / OPTIONS, any casing),
29
+ * `<subject>:write` for every other method INCLUDING the route-catalog
30
+ * wildcard `*`. Use it to build in-handler permission keys from the same
31
+ * grammar the edge `permission` constraint enforces — never re-spell the
32
+ * `:read`/`:write` suffix locally.
33
+ *
34
+ * FAITHFUL COPY of the canonical `routePermission` in the permissions kernel
35
+ * (`@farthershore/authz/grammar/route`, re-exported by
36
+ * `@farthershore/contracts/rbac`); parity asserted as for {@link READ_METHODS}.
37
+ *
38
+ * @param subject the matched route's permission subject (used verbatim)
39
+ * @param method the REQUEST method (case-insensitive) or a route-catalog
40
+ * method entry (`*` → `:write`)
41
+ */
42
+ export declare function routePermission(subject: string, method: string): string;
31
43
  /**
32
44
  * Whether the granted `permissions` satisfy the required `key` under the
33
45
  * unified grammar: `"*"` (global), `"<subject>:*"` (subject wildcard), or the
@@ -5,10 +5,23 @@ export type ReportUsageInput = {
5
5
  meters: Record<string, number>;
6
6
  creditUnitsConsumed?: Record<string, number>;
7
7
  measureContext?: Record<string, unknown>;
8
- };
9
- export type RequestScopedReportUsageInput = Omit<ReportUsageInput, "requestId" | "subscriptionId"> & {
10
- requestId?: string;
11
- subscriptionId?: string;
8
+ /** Schema version of {@link measurements}; currently `1`. */
9
+ measurementsVersion?: number;
10
+ /**
11
+ * The measurement lane (`{ meter, values, dims }`) — the authoritative rating
12
+ * input `ctx.report()` emits. Additive over `meters`, which remains the flat
13
+ * projection core's existing settlement path reads.
14
+ */
15
+ measurements?: {
16
+ meter: string;
17
+ values: Record<string, number>;
18
+ dims?: Record<string, string>;
19
+ }[];
20
+ /** Proposed rate input for a `backendQuoted` pricing rule (core clamps it). */
21
+ quote?: {
22
+ currency: string;
23
+ amountNanos: string;
24
+ };
12
25
  };
13
26
  export type ReportUsageResult = {
14
27
  ok: true;
@@ -26,6 +39,8 @@ export type PostStreamUsageClientOptions = {
26
39
  sleep?: (delayMs: number) => Promise<void>;
27
40
  /** Backoff after each request-not-found response. */
28
41
  retryDelaysMs?: readonly number[];
42
+ /** Upper bound for Retry-After sleeps; defaults to 10 seconds. */
43
+ maxRetryDelayMs?: number;
29
44
  };
30
45
  /**
31
46
  * Best-effort, attested post-stream billing reporter. The callback is
@@ -40,6 +55,10 @@ export declare class PostStreamUsageClient {
40
55
  private readonly logger;
41
56
  private readonly sleep;
42
57
  private readonly retryDelaysMs;
58
+ private readonly maxRetryDelayMs;
43
59
  constructor(options: PostStreamUsageClientOptions);
44
60
  reportUsage(input: ReportUsageInput): Promise<ReportUsageResult>;
61
+ /** Enforce the token's meter scope + per-event bounds on the measurement lane. */
62
+ private validateMeasurements;
63
+ private retryDelayForAttempt;
45
64
  }
@@ -0,0 +1,28 @@
1
+ import { type NonceStore } from "./nonceCache.js";
2
+ /** How far one-time-use enforcement actually reaches. */
3
+ export type ReplayProtectionMode = "shared" | "single-instance";
4
+ export type ReplayProtectionDiagnostic = {
5
+ mode: ReplayProtectionMode;
6
+ /** True when replay is enforced across every replica, not just this process. */
7
+ crossReplica: boolean;
8
+ };
9
+ export type ResolveReplayProtectionInput = {
10
+ /** Shared store injected by the host, if any. */
11
+ nonceStore?: NonceStore | undefined;
12
+ };
13
+ export type ResolvedReplayProtection = {
14
+ store: NonceStore;
15
+ diagnostic: ReplayProtectionDiagnostic;
16
+ };
17
+ /**
18
+ * Select the replay store and report which mode it is. Never throws — the
19
+ * zero-config default is the in-memory cache, and the time window is what
20
+ * bounds exposure regardless.
21
+ */
22
+ export declare function resolveReplayProtection(input?: ResolveReplayProtectionInput): ResolvedReplayProtection;
23
+ /**
24
+ * Wrap an injected shared store so an OUTAGE fails closed. Only applies when a
25
+ * builder opted into a shared store; the in-memory default cannot fail this
26
+ * way.
27
+ */
28
+ export declare function failClosed(store: NonceStore): NonceStore;
@@ -0,0 +1,133 @@
1
+ import { type ResponseMeteringUsagePayload } from "../response-metering.js";
2
+ /** Observed measurement values, keyed by measure key. */
3
+ export type MeasurementValues = Record<string, number>;
4
+ /** Catalog selectors the measurement was produced under, keyed by dimension. */
5
+ export type MeasurementDimensions = Record<string, string>;
6
+ /**
7
+ * The validated, transmitted quote — a PROPOSED rate input, never a charge.
8
+ * `amountNanos` is a decimal integer string of nanodollars **per unit of the
9
+ * entry's measure** (the platform's money unit is the nanodollar); core
10
+ * multiplies it by the measured quantity, clamps it into the pricing policy's
11
+ * declared per-unit `{min,max}`, and flags out-of-range proposals for dispute.
12
+ * Never send a total.
13
+ */
14
+ export type QuoteProposal = {
15
+ currency: string;
16
+ amountNanos: string;
17
+ };
18
+ /**
19
+ * Authoring shape of {@link QuoteProposal}. `amountNanos` (nanodollars PER
20
+ * UNIT of the entry's measure) accepts a number, bigint, or decimal integer
21
+ * string; anything else is rejected.
22
+ */
23
+ export type QuoteInput = {
24
+ currency: string;
25
+ amountNanos: number | bigint | string;
26
+ };
27
+ /** One measurement report. The complete argument surface of the verb. */
28
+ export type ReportInput = {
29
+ /** Meter key as declared in the business release (plain string at the wire). */
30
+ meter: string;
31
+ /** Observed values keyed by measure key, e.g. `{ input_tokens: 1200 }`. */
32
+ values: MeasurementValues;
33
+ /** Catalog selectors, e.g. `{ model: "acme-4", cache_status: "hit" }`. */
34
+ dims?: MeasurementDimensions;
35
+ /**
36
+ * OPTIONAL money proposal for a `backendQuoted` pricing rule: a PER-UNIT
37
+ * rate in nanodollars (multiplied by the measured quantity — never a
38
+ * total). Opaque at the authoring boundary (a job result carries it through
39
+ * untyped); validated against {@link QuoteInput} here and transmitted as a
40
+ * {@link QuoteProposal}. Applies to every backend-quoted component of this
41
+ * report; ignored by rules that are not backend-quoted.
42
+ */
43
+ quote?: unknown;
44
+ };
45
+ /** Which channel actually carried the measurement. */
46
+ export type ReportTransport = "in_band" | "post_stream";
47
+ /**
48
+ * Delivery outcome. Validation faults THROW (a malformed report is a builder
49
+ * bug worth surfacing); delivery faults resolve `ok: false` so a metering
50
+ * hiccup never breaks the builder's endpoint. A served request may own only
51
+ * one post-stream callback identity, so later calls fail explicitly.
52
+ */
53
+ export type ReportResult = {
54
+ ok: true;
55
+ transport: ReportTransport;
56
+ } | {
57
+ ok: false;
58
+ transport: ReportTransport;
59
+ reason: string;
60
+ };
61
+ /** The wire shape of one validated measurement. */
62
+ export type Measurement = {
63
+ meter: string;
64
+ values: MeasurementValues;
65
+ dims?: MeasurementDimensions;
66
+ };
67
+ /** Version of the `measurements` payload lane (additive over `rawDimsUnits`). */
68
+ export declare const MEASUREMENTS_VERSION = 1;
69
+ /** The transport seam a host adapter supplies for the in-band lane. */
70
+ export type ResponseSink = {
71
+ /** True while headers can still be stamped onto the outgoing response. */
72
+ canStampHeaders(): boolean;
73
+ /** Stamp the signed metering headers onto the outgoing response. */
74
+ stampHeaders(headers: Record<string, string>): void;
75
+ };
76
+ /** Everything `report()` needs that only the runtime can provide. */
77
+ export type ReportChannels = {
78
+ /** Sign the payload into `x-fs-metering*` headers (may resolve `{}`). */
79
+ computeHeaders(payload: ResponseMeteringUsagePayload): Promise<Record<string, string>>;
80
+ /** Deliver over the attested post-stream channel. */
81
+ postStream(input: {
82
+ measurements: Measurement[];
83
+ quote?: QuoteProposal;
84
+ }): Promise<{
85
+ ok: boolean;
86
+ reason?: string;
87
+ }>;
88
+ /** Method/path of the served request, for the in-band payload binding. */
89
+ request?: {
90
+ method: string;
91
+ path: string;
92
+ };
93
+ /** Host-supplied response seam; absent ⇒ the post-stream lane is used. */
94
+ responseSink?: ResponseSink;
95
+ };
96
+ /**
97
+ * `report()` accepts one measurement or an ARRAY of measurements. The array
98
+ * form is the sanctioned way to report several meters after the response is
99
+ * sent: one served request owns exactly ONE post-stream callback identity, so
100
+ * sequential awaited single reports after the first flush cannot be delivered
101
+ * — a batch rides the single callback atomically. All entries of a batch
102
+ * share one quote (at most one distinct quote may be supplied).
103
+ */
104
+ export type ReportFn = (input: ReportInput | readonly ReportInput[]) => Promise<ReportResult>;
105
+ /**
106
+ * Build the bound `report()` verb for a verified context. The returned function
107
+ * validates, picks its own transport, and never asks the caller for identity.
108
+ */
109
+ export declare function createReportFn(channels: ReportChannels): ReportFn;
110
+ /**
111
+ * The `report()` stub attached to a context produced by the BARE
112
+ * `verifyRequest()` primitive (no runtime, therefore no metering channel). It
113
+ * throws an error that names the fix instead of pretending the measurement was
114
+ * delivered.
115
+ */
116
+ export declare function unattachedReport(): ReportFn;
117
+ /**
118
+ * Project a measurement into the flat `rawDimsUnits` lane the gateway's
119
+ * in-band settlement path consumes.
120
+ *
121
+ * PROJECTION RULE (P0-2): the projection keys the METER id, and its scalar is
122
+ * the sum of the measurement's measure values — the meter's structural
123
+ * quantity. The gateway masks `rawDimsUnits` by the matched route's declared
124
+ * METER ids (`maskCostMapByMeters`), so measure-keyed entries would be
125
+ * silently discarded at the edge (UNBILLED). Per-measure detail is NOT lost:
126
+ * the full `{ meter, values, dims }` measurement rides alongside in the
127
+ * versioned `measurements` lane, which is the authoritative rating input.
128
+ */
129
+ export declare function rawDimsUnitsOf(measurement: Measurement): Record<string, number>;
130
+ /** Validate one report input into its wire {@link Measurement}. */
131
+ export declare function validateMeasurement(input: ReportInput): Measurement;
132
+ /** Validate an opaque authored quote into its wire {@link QuoteProposal}. */
133
+ export declare function validateQuote(quote: unknown): QuoteProposal;
@@ -1,8 +1,8 @@
1
1
  import { type RuntimeBootstrapResponse, type RuntimeHealthReport } from "../runtime-types.js";
2
2
  import type { ReconcileResult } from "../reflect/reconcile.js";
3
- import { type MeterOptions } from "./metering.js";
4
- import { type ReportUsageInput, type ReportUsageResult } from "./post-stream-usage.js";
3
+ import { type ResponseSink } from "./report.js";
5
4
  import { type NonceStore } from "./nonceCache.js";
5
+ import { type ReplayProtectionDiagnostic } from "./replay-protection.js";
6
6
  import { type SpawnFn } from "./tunnel.js";
7
7
  import { type FartherShoreRequestContext, type VerifyRequestInput } from "./verifyRequest.js";
8
8
  /** Advanced opt-in tunnel config. The embedded runner is the default DX. */
@@ -21,10 +21,7 @@ export type FartherShoreTunnelOptions = {
21
21
  export type FartherShoreInitOptions = {
22
22
  /** Explicit runtime token. Defaults to process.env.FS_RUNTIME_TOKEN. */
23
23
  runtimeToken?: string;
24
- /**
25
- * Core base URL. Defaults to FS_CORE_URL / FARTHERSHORE_CORE_URL or
26
- * https://core.farthershore.com.
27
- */
24
+ /** Core base URL. Defaults to FS_CORE_URL or https://core.farthershore.com. */
28
25
  coreUrl?: string;
29
26
  /** Env map (tests). Defaults to process.env. */
30
27
  env?: Record<string, string | undefined>;
@@ -57,17 +54,28 @@ export type FartherShoreInitOptions = {
57
54
  */
58
55
  contextSecrets?: readonly string[];
59
56
  /**
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.
57
+ * OPTIONAL shared replay-prevention store. Not required: the signature's
58
+ * ~305s time window is the always-on defense, and the zero-config default is
59
+ * an in-memory per-process cache. Inject a shared atomic store (Redis /
60
+ * Memcached / Cloudflare KV / Durable Object) implementing {@link NonceStore}
61
+ * only if you want one-time-use enforced ACROSS replicas rather than within
62
+ * each one. `checkAndRemember` may be async, and should be TTL-bound to the
63
+ * signature validity window.
64
+ *
65
+ * If you do inject one, an outage of that store fails requests CLOSED — it
66
+ * never degrades to "not a replay".
68
67
  */
69
68
  nonceStore?: NonceStore;
70
69
  };
70
+ /**
71
+ * Host-adapter seams for `verifyRequest`. An adapter that owns the outgoing
72
+ * response (the Express middleware) supplies a {@link ResponseSink} so
73
+ * `ctx.report()` can choose the zero-network in-band header transport while the
74
+ * response is still open. Without one, reports take the post-stream channel.
75
+ */
76
+ export type VerifyRequestHostOptions = {
77
+ responseSink?: ResponseSink;
78
+ };
71
79
  export declare const SDK_VERSION: string;
72
80
  /**
73
81
  * The runtime instance. Lazily bootstraps; holds the JWKS client, nonce cache,
@@ -85,9 +93,9 @@ export declare class FartherShore {
85
93
  /** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
86
94
  private readonly contextSecrets;
87
95
  private readonly nonceCache;
96
+ private readonly replayProtectionDiagnostic;
88
97
  private readonly shutdownManager;
89
98
  private jwks;
90
- private meteringClient;
91
99
  private postStreamUsageClient;
92
100
  private tunnel;
93
101
  private bootstrapped;
@@ -115,7 +123,14 @@ export declare class FartherShore {
115
123
  * Framework-neutral verification primitive. Fail-closed: throws a typed
116
124
  * FartherShoreError on any verification failure. Returns the verified context.
117
125
  */
118
- verifyRequest(input: VerifyRequestInput): Promise<FartherShoreRequestContext>;
126
+ verifyRequest(input: VerifyRequestInput, options?: VerifyRequestHostOptions): Promise<FartherShoreRequestContext>;
127
+ /**
128
+ * Bind the ONE reporting verb to a verified context. Identity comes from the
129
+ * context (`signedContext.subscriptionId` + `requestId`) — never from the
130
+ * caller — so a handler cannot forget it, and a background job that is handed
131
+ * this context keeps reporting against the SAME served identity.
132
+ */
133
+ private buildReportFn;
119
134
  /** Whether verification is required (bootstrap × opt-out). */
120
135
  verificationRequired(): Promise<boolean>;
121
136
  /**
@@ -129,10 +144,19 @@ export declare class FartherShore {
129
144
  * (request verification stays fail-closed regardless — a different axis).
130
145
  */
131
146
  start(): Promise<void>;
132
- /** Record metering usage (billing-only). */
133
- meter(meter: string, qty: number, options?: MeterOptions): Promise<void>;
134
- /** Best-effort attested post-stream usage callback. Never rejects. */
135
- reportUsage(input: ReportUsageInput): Promise<ReportUsageResult>;
147
+ /**
148
+ * PRIVATE transport for the post-stream lane of `ctx.report()`. Never rejects
149
+ * a metering hiccup must not break a builder's endpoint. This is machinery,
150
+ * not surface: the ONE public reporting verb is `ctx.report()`.
151
+ */
152
+ private reportPostStreamUsage;
153
+ /**
154
+ * How far replay protection actually reaches — `"shared"` (enforced across
155
+ * every replica) or `"single-instance"` (this process only). Deployment
156
+ * diagnostic: log it at boot, or assert on it in a smoke test, so the mode is
157
+ * never a surprise. See {@link FartherShoreInitOptions.nonceStore}.
158
+ */
159
+ replayProtection(): ReplayProtectionDiagnostic;
136
160
  /** Current local health report. */
137
161
  health(): RuntimeHealthReport;
138
162
  /** Graceful shutdown: flush metering + send a stopping heartbeat. */