@farthershore/backend 0.19.0 → 0.20.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.
@@ -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
  }
@@ -26,6 +26,8 @@ export type PostStreamUsageClientOptions = {
26
26
  sleep?: (delayMs: number) => Promise<void>;
27
27
  /** Backoff after each request-not-found response. */
28
28
  retryDelaysMs?: readonly number[];
29
+ /** Upper bound for Retry-After sleeps; defaults to 10 seconds. */
30
+ maxRetryDelayMs?: number;
29
31
  };
30
32
  /**
31
33
  * Best-effort, attested post-stream billing reporter. The callback is
@@ -40,6 +42,8 @@ export declare class PostStreamUsageClient {
40
42
  private readonly logger;
41
43
  private readonly sleep;
42
44
  private readonly retryDelaysMs;
45
+ private readonly maxRetryDelayMs;
43
46
  constructor(options: PostStreamUsageClientOptions);
44
47
  reportUsage(input: ReportUsageInput): Promise<ReportUsageResult>;
48
+ private retryDelayForAttempt;
45
49
  }
@@ -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;
@@ -3,6 +3,7 @@ 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
5
  import { type NonceStore } from "./nonceCache.js";
6
+ import { type ReplayProtectionDiagnostic } from "./replay-protection.js";
6
7
  import { type SpawnFn } from "./tunnel.js";
7
8
  import { type FartherShoreRequestContext, type VerifyRequestInput } from "./verifyRequest.js";
8
9
  /** Advanced opt-in tunnel config. The embedded runner is the default DX. */
@@ -57,14 +58,16 @@ export type FartherShoreInitOptions = {
57
58
  */
58
59
  contextSecrets?: readonly string[];
59
60
  /**
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.
61
+ * OPTIONAL shared replay-prevention store. Not required: the signature's
62
+ * ~305s time window is the always-on defense, and the zero-config default is
63
+ * an in-memory per-process cache. Inject a shared atomic store (Redis /
64
+ * Memcached / Cloudflare KV / Durable Object) implementing {@link NonceStore}
65
+ * only if you want one-time-use enforced ACROSS replicas rather than within
66
+ * each one. `checkAndRemember` may be async, and should be TTL-bound to the
67
+ * signature validity window.
68
+ *
69
+ * If you do inject one, an outage of that store fails requests CLOSED — it
70
+ * never degrades to "not a replay".
68
71
  */
69
72
  nonceStore?: NonceStore;
70
73
  };
@@ -85,6 +88,7 @@ export declare class FartherShore {
85
88
  /** OPTIONAL HS256 secret(s) — defense-in-depth over the cv=2 X-Fs-Context. */
86
89
  private readonly contextSecrets;
87
90
  private readonly nonceCache;
91
+ private readonly replayProtectionDiagnostic;
88
92
  private readonly shutdownManager;
89
93
  private jwks;
90
94
  private meteringClient;
@@ -133,6 +137,13 @@ export declare class FartherShore {
133
137
  meter(meter: string, qty: number, options?: MeterOptions): Promise<void>;
134
138
  /** Best-effort attested post-stream usage callback. Never rejects. */
135
139
  reportUsage(input: ReportUsageInput): Promise<ReportUsageResult>;
140
+ /**
141
+ * How far replay protection actually reaches — `"shared"` (enforced across
142
+ * every replica) or `"single-instance"` (this process only). Deployment
143
+ * diagnostic: log it at boot, or assert on it in a smoke test, so the mode is
144
+ * never a surprise. See {@link FartherShoreInitOptions.nonceStore}.
145
+ */
146
+ replayProtection(): ReplayProtectionDiagnostic;
136
147
  /** Current local health report. */
137
148
  health(): RuntimeHealthReport;
138
149
  /** Graceful shutdown: flush metering + send a stopping heartbeat. */
@@ -10,6 +10,7 @@ export { requireMember, requireService, credentialKind, isPortalSession, type Me
10
10
  export { hasPermission, requirePermission, permissionGrants, permissionSatisfies, FartherShorePermissionError, type PermissionCarrier, } from "./core/permissions.js";
11
11
  export { JwksClient, type Jwk, type JwksClientOptions } from "./core/jwks.js";
12
12
  export { NonceCache, type NonceCacheOptions, type NonceStore, } from "./core/nonceCache.js";
13
+ export { type ReplayProtectionDiagnostic, type ReplayProtectionMode, } from "./core/replay-protection.js";
13
14
  export { BootstrapClient, type BootstrapClientOptions, } from "./core/bootstrap.js";
14
15
  export { MeteringClient, type MeteringClientOptions, type MeterOptions, } from "./core/metering.js";
15
16
  export { PostStreamUsageClient, type PostStreamUsageClientOptions, type ReportUsageInput, type RequestScopedReportUsageInput, type ReportUsageResult, } from "./core/post-stream-usage.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farthershore/backend",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Farther Shore backend SDK for builder upstreams: signed response usage, fail-closed gateway request verification, health, and lifecycle from FS_RUNTIME_TOKEN",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -37,10 +37,10 @@
37
37
  "access": "public"
38
38
  },
39
39
  "optionalDependencies": {
40
- "@farthershore/cloudflared-linux-arm64": "0.0.0",
41
- "@farthershore/cloudflared-linux-x64": "0.0.0",
42
40
  "@farthershore/cloudflared-darwin-arm64": "0.0.0",
43
- "@farthershore/cloudflared-darwin-x64": "0.0.0"
41
+ "@farthershore/cloudflared-darwin-x64": "0.0.0",
42
+ "@farthershore/cloudflared-linux-arm64": "0.0.0",
43
+ "@farthershore/cloudflared-linux-x64": "0.0.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "express": "^4.0.0 || ^5.0.0"
@@ -51,13 +51,13 @@
51
51
  }
52
52
  },
53
53
  "devDependencies": {
54
- "@types/node": "^22.19.17",
55
- "esbuild": "^0.27.7",
56
- "eslint": "^9.39.4",
57
- "eslint-plugin-sonarjs": "^4.0.3",
58
- "typescript": "^6.0.2",
59
- "typescript-eslint": "^8.59.0",
60
- "vitest": "^4.1.6",
54
+ "@types/node": "^22.20.1",
55
+ "esbuild": "^0.28.1",
56
+ "eslint": "^9.39.5",
57
+ "eslint-plugin-sonarjs": "^4.2.0",
58
+ "typescript": "^6.0.3",
59
+ "typescript-eslint": "^8.66.0",
60
+ "vitest": "^4.1.10",
61
61
  "@farthershore/contracts": "0.62.0"
62
62
  },
63
63
  "engines": {