@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.
- package/CHANGELOG.md +36 -0
- package/README.md +29 -1
- package/dist/index.js +341 -71
- package/dist/testing/index.js +341 -71
- package/dist/types/core/bootstrap.d.ts +8 -0
- package/dist/types/core/deadline.d.ts +80 -0
- package/dist/types/core/jwks.d.ts +42 -7
- package/dist/types/core/post-stream-usage.d.ts +4 -0
- package/dist/types/core/replay-protection.d.ts +28 -0
- package/dist/types/core/runtime.d.ts +19 -8
- package/dist/types/index.d.ts +1 -0
- package/package.json +11 -11
|
@@ -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.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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. */
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
55
|
-
"esbuild": "^0.
|
|
56
|
-
"eslint": "^9.39.
|
|
57
|
-
"eslint-plugin-sonarjs": "^4.0
|
|
58
|
-
"typescript": "^6.0.
|
|
59
|
-
"typescript-eslint": "^8.
|
|
60
|
-
"vitest": "^4.1.
|
|
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": {
|