@farthershore/backend 0.20.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.
- package/CHANGELOG.md +103 -0
- package/README.md +239 -91
- package/dist/adapters/express.js +68 -12
- package/dist/generated/runtime-contract.js +21 -236
- package/dist/index.js +519 -417
- package/dist/internal/index.js +587 -0
- package/dist/testing/index.js +614 -302
- package/dist/types/adapters/express.d.ts +32 -3
- package/dist/types/core/permissions.d.ts +25 -13
- package/dist/types/core/post-stream-usage.d.ts +19 -4
- package/dist/types/core/report.d.ts +133 -0
- package/dist/types/core/runtime.d.ts +25 -12
- package/dist/types/core/verifyRequest.d.ts +21 -3
- package/dist/types/generated/runtime-contract.d.ts +14 -189
- package/dist/types/index.d.ts +29 -8
- package/dist/types/internal/index.d.ts +2 -0
- package/dist/types/response-metering.d.ts +29 -39
- package/dist/types/runtime-types.d.ts +16 -1
- package/dist/types/testing/devRuntime.d.ts +11 -2
- package/dist/types/testing/index.d.ts +1 -0
- package/dist/types/testing/usageSink.d.ts +1 -1
- package/dist/types/testing/webhooks.d.ts +30 -0
- package/dist/types/webhooks/index.d.ts +247 -0
- package/dist/types/webhooks/types.d.ts +110 -0
- package/dist/webhooks/index.js +498 -0
- package/package.json +12 -3
- package/dist/types/core/metering.d.ts +0 -68
package/dist/types/index.d.ts
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
import { FartherShore, type FartherShoreInitOptions } from "./core/runtime.js";
|
|
2
|
-
import { type ExpressMiddleware, type ExpressRequestLike, type ExpressResponseLike, type MiddlewareOptions, type VerifiedExpressHandler } from "./adapters/express.js";
|
|
2
|
+
import { type ExpressMiddleware, type ExpressRequestLike, type ExpressResponseLike, type HandlerOptions, type MiddlewareOptions, type VerifiedExpressHandler } from "./adapters/express.js";
|
|
3
|
+
import { type PermissionCarrier } from "./core/permissions.js";
|
|
3
4
|
export { FartherShore } from "./core/runtime.js";
|
|
4
5
|
export type { FartherShoreInitOptions } from "./core/runtime.js";
|
|
5
6
|
export { FartherShoreError, statusForCode } from "./core/errors.js";
|
|
6
|
-
export { verifyRequest, type VerifyRequestInput, type VerifyRequestDeps, type FartherShoreRequestContext, type HeadersLike, } from "./core/verifyRequest.js";
|
|
7
|
+
export { verifyRequest, type VerifyRequestInput, type VerifyRequestDeps, type FartherShoreContext, type FartherShoreRequestContext, type HeadersLike, } from "./core/verifyRequest.js";
|
|
8
|
+
export { MEASUREMENTS_VERSION } from "./core/report.js";
|
|
9
|
+
export type { ReportInput, ReportResult, ReportTransport, Measurement, MeasurementValues, MeasurementDimensions, QuoteInput, QuoteProposal, } from "./core/report.js";
|
|
7
10
|
export { verifyContext, decodeContextClaims, principalFromContextClaims, } from "./core/verifyContext.js";
|
|
8
11
|
export type { FartherShoreSignedContext, ConsumerPrincipal, } from "./core/verifyContext.js";
|
|
9
12
|
export { requireMember, requireService, credentialKind, isPortalSession, type MemberSubject, type ServiceSubject, type PrincipalCarrier, } from "./core/subject.js";
|
|
10
|
-
export { hasPermission, requirePermission,
|
|
13
|
+
export { hasPermission, requirePermission, permissionSatisfies, routePermission, READ_METHODS, FartherShorePermissionError, type PermissionCarrier, } from "./core/permissions.js";
|
|
11
14
|
export { JwksClient, type Jwk, type JwksClientOptions } from "./core/jwks.js";
|
|
12
15
|
export { NonceCache, type NonceCacheOptions, type NonceStore, } from "./core/nonceCache.js";
|
|
13
16
|
export { type ReplayProtectionDiagnostic, type ReplayProtectionMode, } from "./core/replay-protection.js";
|
|
14
17
|
export { BootstrapClient, type BootstrapClientOptions, } from "./core/bootstrap.js";
|
|
15
|
-
export { MeteringClient, type MeteringClientOptions, type MeterOptions, } from "./core/metering.js";
|
|
16
|
-
export { PostStreamUsageClient, type PostStreamUsageClientOptions, type ReportUsageInput, type RequestScopedReportUsageInput, type ReportUsageResult, } from "./core/post-stream-usage.js";
|
|
17
18
|
export { buildHealthReport, reportHealth, type HealthSnapshot, type HealthStatus, type HeartbeatOptions, } from "./core/health.js";
|
|
18
19
|
export { ShutdownManager, type ShutdownHook } from "./core/shutdown.js";
|
|
19
20
|
export { CloudflaredSupervisor, nodeSpawn, REDACTED_TOKEN, type SpawnFn, type SpawnedTunnelProcess, type CloudflaredSupervisorOptions, type TunnelState, type TunnelStatus, } from "./core/tunnel.js";
|
|
20
21
|
export type { FartherShoreTunnelOptions } from "./core/runtime.js";
|
|
21
|
-
export { createExpressMiddleware, createExpressHandler, type ExpressMiddleware, type ExpressRequestLike, type ExpressResponseLike, type ExpressNext, type MiddlewareOptions, type VerifiedExpressHandler, type VerifiedPrincipalContext, } from "./adapters/express.js";
|
|
22
|
+
export { createExpressMiddleware, createExpressHandler, type ExpressMiddleware, type ExpressRequestLike, type ExpressResponseLike, type ExpressNext, type HandlerOptions, type MiddlewareOptions, type VerifiedExpressHandler, type VerifiedPrincipalContext, } from "./adapters/express.js";
|
|
22
23
|
export { FS_RUNTIME_TOKEN_ENV, RUNTIME_TOKEN_PREFIXES, RUNTIME_TOKEN_OPERATIONS, RUNTIME_HEADER_NAMES, RUNTIME_CLOCK_SKEW_SECONDS, RUNTIME_REPLAY_WINDOW_SECONDS, EMPTY_BODY_SHA256, STREAMING_EXEMPT_BODY_HASH, MAX_BODY_BYTES, type RuntimeErrorCode, type RuntimeTokenOperation, type CanonicalSigningInput, type RuntimeBootstrapResponse, type RuntimeMeteringEvent, type RuntimeHealthReport, type TransportMode, RUNTIME_ERROR_CODE_TO_ERROR_CODE, runtimeErrorToErrorCode, type LimitDescriptor, type RuntimeMappedErrorCode, } from "./runtime-types.js";
|
|
23
24
|
export { RUNTIME_ERROR_CODES } from "./generated/runtime-contract.js";
|
|
24
25
|
export { hashBody, buildCanonicalSigningString, canonicalizeQuery, signCanonicalString, verifyCanonicalSignature, runtimeTokenKind, } from "./runtime-signing.js";
|
|
25
|
-
export {
|
|
26
|
+
export { computeMeteringHeaders, MeteringError, METERING_PAYLOAD_HEADER, METERING_SIGNATURE_HEADER, METERING_TOKEN_HEADER, DEFAULT_TOKEN_ENV, type MeteringHeaders, type ComputeMeteringOptions, type ResponseMeteringUsagePayload, } from "./response-metering.js";
|
|
26
27
|
/**
|
|
27
28
|
* The conceptual public entrypoint. `fartherShore.initFromEnv()` mirrors the
|
|
28
29
|
* language-neutral spec. The returned instance is augmented with `middleware()`
|
|
@@ -38,7 +39,27 @@ export type FartherShoreInstance = FartherShore & {
|
|
|
38
39
|
* context is absent, `principal_required` when it is identity-less) before the
|
|
39
40
|
* callback runs. Pairs with the strict `middleware()`.
|
|
40
41
|
*/
|
|
41
|
-
handler
|
|
42
|
+
handler: {
|
|
43
|
+
<Req extends ExpressRequestLike = ExpressRequestLike, Res extends ExpressResponseLike = ExpressResponseLike>(handler: VerifiedExpressHandler<Req, Res>): ExpressMiddleware;
|
|
44
|
+
/**
|
|
45
|
+
* Options-first overload: `fs.handler({ permission: "widgets:write" }, cb)`
|
|
46
|
+
* asserts the permission (fail-closed) before the callback runs,
|
|
47
|
+
* responding `403 permission_denied` otherwise.
|
|
48
|
+
*/
|
|
49
|
+
<Req extends ExpressRequestLike = ExpressRequestLike, Res extends ExpressResponseLike = ExpressResponseLike>(options: HandlerOptions, handler: VerifiedExpressHandler<Req, Res>): ExpressMiddleware;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* In-handler authorization helpers bound to the instance — the SAME shape as
|
|
53
|
+
* the dev runtime's `rt.authz` (which additionally traces each decision), so
|
|
54
|
+
* dev and prod code reads identically. Both key ONLY on `ctx.permissions`
|
|
55
|
+
* with FAIL-CLOSED carrier semantics: an ABSENT permission set DENIES.
|
|
56
|
+
*/
|
|
57
|
+
authz: {
|
|
58
|
+
/** True when the acting user holds `key` (`*` / `<subject>:*` / exact). */
|
|
59
|
+
hasPermission(ctx: PermissionCarrier, key: string): boolean;
|
|
60
|
+
/** Assert `key` is held; throws `FartherShorePermissionError` (403). */
|
|
61
|
+
requirePermission(ctx: PermissionCarrier, key: string): void;
|
|
62
|
+
};
|
|
42
63
|
};
|
|
43
64
|
export declare const fartherShore: {
|
|
44
65
|
/** Derive everything from FS_RUNTIME_TOKEN via bootstrap. */
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { createReportFn, rawDimsUnitsOf, type ReportChannels, type ResponseSink, type ReportFn, } from "../core/report.js";
|
|
2
|
+
export { PostStreamUsageClient, type PostStreamUsageClientOptions, type ReportUsageInput, type ReportUsageResult, } from "../core/post-stream-usage.js";
|
|
@@ -1,18 +1,39 @@
|
|
|
1
|
-
|
|
1
|
+
/** The response-metering fault codes carried by {@link MeteringError}. */
|
|
2
|
+
export declare const RESPONSE_METERING_ERROR_CODES: {
|
|
2
3
|
readonly missingToken: "missing_token";
|
|
3
4
|
readonly invalidMeterKey: "invalid_meter_key";
|
|
4
5
|
readonly invalidMeterValue: "invalid_meter_value";
|
|
6
|
+
readonly invalidQuote: "invalid_quote";
|
|
5
7
|
};
|
|
6
|
-
type ResponseMeteringErrorCode = (typeof RESPONSE_METERING_ERROR_CODES)[keyof typeof RESPONSE_METERING_ERROR_CODES];
|
|
8
|
+
export type ResponseMeteringErrorCode = (typeof RESPONSE_METERING_ERROR_CODES)[keyof typeof RESPONSE_METERING_ERROR_CODES];
|
|
7
9
|
/**
|
|
8
10
|
* The signed response-metering payload. `computeMeteringHeaders` accepts one of
|
|
9
11
|
* these directly, so a non-Express / non-Fetch handler (or a Python/Go backend
|
|
10
|
-
* following the wire recipe) can stamp valid headers
|
|
12
|
+
* following the wire recipe) can stamp valid headers with no JS SDK at all.
|
|
13
|
+
* `ctx.report()` is the JS surface that builds it.
|
|
14
|
+
*
|
|
15
|
+
* `measurements` (with its `measurementsVersion`) is the measurement lane the
|
|
16
|
+
* one reporting verb emits: `{ meter, values, dims }` as declared in the
|
|
17
|
+
* business release. `rawDimsUnits` remains as the flat projection the gateway's
|
|
18
|
+
* existing settlement lane reads.
|
|
11
19
|
*/
|
|
12
20
|
export type ResponseMeteringUsagePayload = {
|
|
13
21
|
method: string;
|
|
14
22
|
path: string;
|
|
15
|
-
rawDimsUnits
|
|
23
|
+
rawDimsUnits?: Record<string, number>;
|
|
24
|
+
/** Schema version of {@link measurements}; currently `1`. */
|
|
25
|
+
measurementsVersion?: number;
|
|
26
|
+
/** Reported measurements — the authoritative rating input. */
|
|
27
|
+
measurements?: {
|
|
28
|
+
meter: string;
|
|
29
|
+
values: Record<string, number>;
|
|
30
|
+
dims?: Record<string, string>;
|
|
31
|
+
}[];
|
|
32
|
+
/** Proposed rate input for a `backendQuoted` pricing rule (core clamps it). */
|
|
33
|
+
quote?: {
|
|
34
|
+
currency: string;
|
|
35
|
+
amountNanos: string;
|
|
36
|
+
};
|
|
16
37
|
measureContext?: Record<string, unknown>;
|
|
17
38
|
creditUnitsConsumed?: Record<string, number>;
|
|
18
39
|
operationKey?: string;
|
|
@@ -43,43 +64,10 @@ export declare const METERING_PAYLOAD_HEADER: "x-fs-metering";
|
|
|
43
64
|
export declare const METERING_SIGNATURE_HEADER: "x-fs-metering-sig";
|
|
44
65
|
export declare const METERING_TOKEN_HEADER: "x-fs-metering-token";
|
|
45
66
|
export declare const DEFAULT_TOKEN_ENV: "FS_RUNTIME_TOKEN";
|
|
46
|
-
export type UsageMap = Record<string, number>;
|
|
47
|
-
export type BillableUsageMap = UsageMap;
|
|
48
|
-
export type MeteringOptions = {
|
|
49
|
-
token?: string;
|
|
50
|
-
env?: Record<string, string | undefined>;
|
|
51
|
-
/**
|
|
52
|
-
* Gateway request id for dev-mode trace/usage association (never signed).
|
|
53
|
-
* Thread the VERIFIED `ctx.requestId` here — the strict middleware strips the
|
|
54
|
-
* inbound `x-fs-request-id` header, so the request object no longer carries it.
|
|
55
|
-
* Falls back to the request's `x-fs-request-id` header when unset.
|
|
56
|
-
*/
|
|
57
|
-
requestId?: string;
|
|
58
|
-
measureContext?: Record<string, unknown>;
|
|
59
|
-
creditUnitsConsumed?: BillableUsageMap;
|
|
60
|
-
/** Gateway-validated operation identity hint. The SDK signs and transports it
|
|
61
|
-
* but never decides billing or policy from it. */
|
|
62
|
-
operationKey?: string;
|
|
63
|
-
/** Gateway-validated policy hint. Advisory identity only; the gateway remains
|
|
64
|
-
* authoritative for customerBillable/provider-cost decisions. */
|
|
65
|
-
usagePolicyId?: string;
|
|
66
|
-
};
|
|
67
|
-
export type UsageWrapOptions = {
|
|
68
|
-
measureContext?: Record<string, unknown>;
|
|
69
|
-
creditUnitsConsumed?: BillableUsageMap;
|
|
70
|
-
operationKey?: string;
|
|
71
|
-
usagePolicyId?: string;
|
|
72
|
-
};
|
|
73
|
-
export type UsageReporter = {
|
|
74
|
-
report(meter: string, value: number): UsageReporter;
|
|
75
|
-
wrap(response: Response, options?: UsageWrapOptions): Promise<Response>;
|
|
76
|
-
};
|
|
77
67
|
export declare class MeteringError extends Error {
|
|
78
68
|
readonly code: ResponseMeteringErrorCode;
|
|
79
69
|
constructor(code: ResponseMeteringErrorCode, message: string);
|
|
80
70
|
}
|
|
81
|
-
export declare function createUsage(request: Request, options?: MeteringOptions): UsageReporter;
|
|
82
|
-
export declare function withUsage(request: Request, response: Response, usage: UsageMap, options?: MeteringOptions): Promise<Response>;
|
|
83
71
|
/**
|
|
84
72
|
* Compute the three response-metering headers for a payload as a plain
|
|
85
73
|
* name→value map, attachable to ANY response mechanism (Fetch `Response`,
|
|
@@ -87,7 +75,9 @@ export declare function withUsage(request: Request, response: Response, usage: U
|
|
|
87
75
|
* primitive: it NEVER throws at request time — if no token is resolvable (or
|
|
88
76
|
* signing fails) it skips stamping, reports the reason (`onSkip` / dev hook /
|
|
89
77
|
* a `console.warn`), and returns `{}` so the builder's endpoint is never broken.
|
|
90
|
-
*
|
|
78
|
+
*
|
|
79
|
+
* This is the framework-neutral wire recipe: `ctx.report()` is the JS surface
|
|
80
|
+
* over it, and a Python/Go backend can stamp identical headers by building the
|
|
81
|
+
* same payload (see `docs/response-metering-wire.md`).
|
|
91
82
|
*/
|
|
92
83
|
export declare function computeMeteringHeaders(payload: ResponseMeteringUsagePayload, options?: ComputeMeteringOptions): Promise<MeteringHeaders>;
|
|
93
|
-
export {};
|
|
@@ -37,7 +37,7 @@ export type LimitClass = "quota" | "rate" | "concurrency" | "capacity" | "spend"
|
|
|
37
37
|
* `LimitReaction`. */
|
|
38
38
|
export type LimitReaction = "none" | "backoff_retry" | "wait_then_retry" | "queue" | "reduce_then_retry" | "fallback" | "upgrade";
|
|
39
39
|
/** Where a limit was decided. Mirrors contracts `LimitOrigin`. */
|
|
40
|
-
export type LimitOrigin = "platform" | "provider";
|
|
40
|
+
export type LimitOrigin = "platform" | "provider" | "subscriber";
|
|
41
41
|
/**
|
|
42
42
|
* F1 — the `_fs` deny envelope a backend stamps on a usage-limit deny body.
|
|
43
43
|
* Structurally identical to the contracts `FsDenyEnvelope`.
|
|
@@ -45,6 +45,7 @@ export type LimitOrigin = "platform" | "provider";
|
|
|
45
45
|
export interface FsDenyEnvelope {
|
|
46
46
|
limitClass: LimitClass;
|
|
47
47
|
scope?: string;
|
|
48
|
+
actorScope?: "member" | "service_account";
|
|
48
49
|
metric?: string;
|
|
49
50
|
reset?: number;
|
|
50
51
|
remaining?: number;
|
|
@@ -75,6 +76,7 @@ export interface FsDenyEnvelope {
|
|
|
75
76
|
export declare const DENY_ENVELOPE_FIELDS: {
|
|
76
77
|
limitClass: true;
|
|
77
78
|
scope: true;
|
|
79
|
+
actorScope: true;
|
|
78
80
|
metric: true;
|
|
79
81
|
reset: true;
|
|
80
82
|
remaining: true;
|
|
@@ -286,6 +288,19 @@ export type RuntimePostStreamUsageEvent = {
|
|
|
286
288
|
meters: Record<string, number>;
|
|
287
289
|
creditUnitsConsumed?: Record<string, number>;
|
|
288
290
|
measureContext?: Record<string, unknown>;
|
|
291
|
+
/** Schema version of {@link measurements}; currently `1`. */
|
|
292
|
+
measurementsVersion?: number;
|
|
293
|
+
/** Measurement lane — `{ meter, values, dims }` as declared in the release. */
|
|
294
|
+
measurements?: {
|
|
295
|
+
meter: string;
|
|
296
|
+
values: Record<string, number>;
|
|
297
|
+
dims?: Record<string, string>;
|
|
298
|
+
}[];
|
|
299
|
+
/** Proposed rate input for a `backendQuoted` pricing rule (core clamps it). */
|
|
300
|
+
quote?: {
|
|
301
|
+
currency: string;
|
|
302
|
+
amountNanos: string;
|
|
303
|
+
};
|
|
289
304
|
signature: string;
|
|
290
305
|
};
|
|
291
306
|
export type RuntimeHealthReport = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FartherShore } from "../core/runtime.js";
|
|
2
|
-
import { type ExpressMiddleware, type ExpressRequestLike, type ExpressResponseLike, type MiddlewareOptions, type VerifiedExpressHandler } from "../adapters/express.js";
|
|
2
|
+
import { type ExpressMiddleware, type ExpressRequestLike, type ExpressResponseLike, type HandlerOptions, type MiddlewareOptions, type VerifiedExpressHandler } from "../adapters/express.js";
|
|
3
3
|
import { type PermissionCarrier } from "../core/permissions.js";
|
|
4
4
|
import { type DevGateway } from "./devGateway.js";
|
|
5
5
|
import { DevUsageSink } from "./usageSink.js";
|
|
@@ -14,7 +14,16 @@ export type TracedCarrier = PermissionCarrier & {
|
|
|
14
14
|
export type FartherShoreDevInstance = FartherShore & {
|
|
15
15
|
middleware(options?: MiddlewareOptions): ExpressMiddleware;
|
|
16
16
|
/** Strict-context handler wrapper (guaranteed non-optional ctx). */
|
|
17
|
-
handler
|
|
17
|
+
handler: {
|
|
18
|
+
<Req extends ExpressRequestLike = ExpressRequestLike, Res extends ExpressResponseLike = ExpressResponseLike>(handler: VerifiedExpressHandler<Req, Res>): ExpressMiddleware;
|
|
19
|
+
/** Options-first overload — declarative `permission` gate. */
|
|
20
|
+
<Req extends ExpressRequestLike = ExpressRequestLike, Res extends ExpressResponseLike = ExpressResponseLike>(options: HandlerOptions, handler: VerifiedExpressHandler<Req, Res>): ExpressMiddleware;
|
|
21
|
+
};
|
|
22
|
+
/** Traced authz helpers — the SAME shape as the prod facade's `fs.authz`. */
|
|
23
|
+
authz: {
|
|
24
|
+
hasPermission(ctx: TracedCarrier, key: string): boolean;
|
|
25
|
+
requirePermission(ctx: TracedCarrier, key: string): void;
|
|
26
|
+
};
|
|
18
27
|
/** The dev harness attached to this runtime. */
|
|
19
28
|
dev: DevRuntime;
|
|
20
29
|
};
|
|
@@ -6,3 +6,4 @@ export { writeDevKeysFile, readDevKeysFile, personaClientFromKeysFile, DEFAULT_K
|
|
|
6
6
|
export { DevUsageSink, type DevUsageEvent } from "./usageSink.js";
|
|
7
7
|
export { DevTraceSink, redactValue, type DevTrace, type DevMode, type VerificationOutcome, type AuthzDecisionEntry, type MeteringTraceEntry, } from "./traceSink.js";
|
|
8
8
|
export { isProductionEnv, assertNotProduction, DevModeInProductionError, } from "./prodGuard.js";
|
|
9
|
+
export { signWebhookForTesting, type SignWebhookForTestingInput, type SignedTestWebhook, } from "./webhooks.js";
|
|
@@ -26,7 +26,7 @@ export type DevUsageEvent = {
|
|
|
26
26
|
/** In-memory, assertable sink for all dev usage. */
|
|
27
27
|
export declare class DevUsageSink {
|
|
28
28
|
readonly events: DevUsageEvent[];
|
|
29
|
-
/** Record a signed response-metering payload (
|
|
29
|
+
/** Record a signed response-metering payload (report() in-band / computeMeteringHeaders). */
|
|
30
30
|
recordResponse(payload: Record<string, unknown>, requestId?: string): void;
|
|
31
31
|
/** Record a background `fs.meter()` event captured by the dev gateway. */
|
|
32
32
|
recordMeterEvent(event: RuntimeMeteringEvent): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { WebhookEnvelope, WebhookEnvelopeType, WebhookEventData } from "../webhooks/types.js";
|
|
2
|
+
export interface SignWebhookForTestingInput<T extends WebhookEnvelopeType> {
|
|
3
|
+
/** The endpoint secret(s) the receiver is configured with (current first). */
|
|
4
|
+
secret: string | readonly string[];
|
|
5
|
+
type: T;
|
|
6
|
+
data: WebhookEventData[T];
|
|
7
|
+
/** Defaults to a random delivery id. */
|
|
8
|
+
id?: string;
|
|
9
|
+
businessId?: string;
|
|
10
|
+
environmentId?: string | null;
|
|
11
|
+
/** Defaults to now (ISO). */
|
|
12
|
+
createdAt?: string;
|
|
13
|
+
/** Signing timestamp (unix seconds); defaults to now. */
|
|
14
|
+
timestamp?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface SignedTestWebhook<T extends WebhookEnvelopeType> {
|
|
17
|
+
envelope: WebhookEnvelope<T>;
|
|
18
|
+
/** The exact bytes to POST. */
|
|
19
|
+
body: string;
|
|
20
|
+
/** `webhook-id` / `webhook-timestamp` / `webhook-signature` / event / content-type. */
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
/** Convenience: a `Request` you can hand straight to `handler.fetch`. */
|
|
23
|
+
request(url?: string): Request;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build + sign one delivery the way the platform does (Standard Webhooks
|
|
27
|
+
* over `${id}.${timestamp}.${body}`). Pass several secrets to emulate the
|
|
28
|
+
* 24 h dual-signing rotation window.
|
|
29
|
+
*/
|
|
30
|
+
export declare function signWebhookForTesting<T extends WebhookEnvelopeType>(input: SignWebhookForTestingInput<T>): SignedTestWebhook<T>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { WEBHOOK_EVENT_HEADER, WEBHOOK_EVENT_NAMES, WEBHOOK_ID_HEADER, WEBHOOK_SECRET_PREFIX, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TEST_EVENT, WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS, type VerifyWebhookInput, type WebhookEnvelope, type WebhookEnvelopeType, type WebhookEventData, type WebhookEventName, type WebhookVerifyFailure, type WebhookVerifyResult } from "./types.js";
|
|
2
|
+
export { WEBHOOK_EVENT_HEADER, WEBHOOK_EVENT_NAMES, WEBHOOK_ID_HEADER, WEBHOOK_SECRET_PREFIX, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TEST_EVENT, WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS, };
|
|
3
|
+
export type { VerifyWebhookInput, WebhookEnvelope, WebhookEnvelopeType, WebhookEventData, WebhookEventName, WebhookVerifyFailure, WebhookVerifyResult, };
|
|
4
|
+
/**
|
|
5
|
+
* Verify one delivery's Standard Webhooks signature + timestamp window. The
|
|
6
|
+
* low-level primitive; `createWebhookHandler` adds dedupe, parsing and routing.
|
|
7
|
+
*/
|
|
8
|
+
export declare function verifyWebhook(input: VerifyWebhookInput): WebhookVerifyResult;
|
|
9
|
+
export declare function isWebhookEnvelopeType(value: unknown): value is WebhookEnvelopeType;
|
|
10
|
+
export type WebhookEventHandler<T extends WebhookEnvelopeType> = (event: WebhookEnvelope<T>) => void | Promise<void>;
|
|
11
|
+
export type WebhookHandlers = {
|
|
12
|
+
[T in WebhookEnvelopeType]?: WebhookEventHandler<T>;
|
|
13
|
+
};
|
|
14
|
+
/** Why a delivery was rejected (401 / 400) — surfaced through `onRejected`. */
|
|
15
|
+
export type WebhookRejection = {
|
|
16
|
+
reason: WebhookVerifyFailure;
|
|
17
|
+
} | {
|
|
18
|
+
reason: "invalid_json";
|
|
19
|
+
} | {
|
|
20
|
+
reason: "invalid_envelope";
|
|
21
|
+
detail: string;
|
|
22
|
+
} | {
|
|
23
|
+
reason: "payload_too_large";
|
|
24
|
+
limitBytes: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Delivery-id store for deduplication. Unlike the request verifier's
|
|
28
|
+
* `NonceStore`, a webhook store leases an id: `claim` before the handler and
|
|
29
|
+
* `settle` after, so a thrown handler releases the id and the platform's retry
|
|
30
|
+
* (same `webhook-id`) re-runs it instead of being acknowledged as a duplicate,
|
|
31
|
+
* while a concurrent retry of an in-flight id never double-runs the handler.
|
|
32
|
+
* See {@link WebhookNonceStore}.
|
|
33
|
+
*/
|
|
34
|
+
export type WebhookClaimOutcome = "fresh" | "in_flight" | "processed" | "saturated";
|
|
35
|
+
/** The result of `claim`. A `token` is present ONLY for a "fresh" claim — it
|
|
36
|
+
* identifies THIS lease so `settle` cannot act on a lease that has since been
|
|
37
|
+
* reclaimed by another delivery (see {@link WebhookNonceStore}). */
|
|
38
|
+
export interface WebhookClaim {
|
|
39
|
+
outcome: WebhookClaimOutcome;
|
|
40
|
+
token?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Delivery-id store protocol — an ATOMIC, OWNERSHIP-FENCED claim/settle lease.
|
|
44
|
+
*
|
|
45
|
+
* The earlier split `has(id)` … handler … `remember(id)` had a check-then-act
|
|
46
|
+
* race: two concurrent deliveries of the SAME `webhook-id` could BOTH observe
|
|
47
|
+
* `has(id) === false` and both run a payment / lifecycle handler. An atomic
|
|
48
|
+
* claim closes that window.
|
|
49
|
+
*
|
|
50
|
+
* `claim(id)` is taken BEFORE the handler:
|
|
51
|
+
* - "fresh" → unseen; the caller HOLDS the lease (identified by the
|
|
52
|
+
* returned `token`) and must settle() with that token.
|
|
53
|
+
* - "in_flight" → another delivery of this id is mid-handler; the caller
|
|
54
|
+
* answers 503 and NEVER runs the handler. The platform
|
|
55
|
+
* retries; that retry sees "processed" once the first wins.
|
|
56
|
+
* - "processed" → already handled to success; answer 200 without re-running.
|
|
57
|
+
* - "saturated" → the store cannot retain the id; the caller runs the
|
|
58
|
+
* handler best-effort WITHOUT a lease (at-least-once holds).
|
|
59
|
+
*
|
|
60
|
+
* `settle(id, token, outcome)` resolves a "fresh" claim, and is a NO-OP unless
|
|
61
|
+
* `token` still identifies the live lease:
|
|
62
|
+
* - "processed" → keep the id so future retries dedupe.
|
|
63
|
+
* - "release" → drop the lease so the platform's retry re-runs the handler.
|
|
64
|
+
*
|
|
65
|
+
* The token fences the case where a handler outlives the in-flight lease TTL:
|
|
66
|
+
* the id becomes reclaimable, a retry re-leases it with a NEW token, and the
|
|
67
|
+
* original handler's late `settle` (with the OLD token) is ignored rather than
|
|
68
|
+
* deleting or overwriting the replacement's lease. (A handler that exceeds the
|
|
69
|
+
* lease TTL still degrades to at-least-once — the receiver dedupes on
|
|
70
|
+
* webhook-id — but never corrupts another delivery's lease.)
|
|
71
|
+
*
|
|
72
|
+
* A shared store (Redis, DB) implements `claim` as an atomic upsert stamping a
|
|
73
|
+
* unique token, a short in-flight lease TTL (so a crashed handler's id becomes
|
|
74
|
+
* reclaimable) and a longer processed TTL of at least `WEBHOOK_DEDUPE_TTL_MS`.
|
|
75
|
+
*/
|
|
76
|
+
export interface WebhookNonceStore {
|
|
77
|
+
claim(id: string): WebhookClaim | Promise<WebhookClaim>;
|
|
78
|
+
settle(id: string, token: string, outcome: "processed" | "release"): void | Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/** Capacity of the default in-memory store (ids retained per TTL window). */
|
|
81
|
+
export declare const WEBHOOK_DEDUPE_MAX_ENTRIES = 250000;
|
|
82
|
+
/**
|
|
83
|
+
* How long an in-flight lease is held before a crashed / hung handler's id
|
|
84
|
+
* becomes reclaimable. Longer than any reasonable handler, shorter than the
|
|
85
|
+
* retry horizon, so a genuinely stuck delivery is retried rather than wedged
|
|
86
|
+
* as a permanent "in_flight".
|
|
87
|
+
*/
|
|
88
|
+
export declare const WEBHOOK_INFLIGHT_LEASE_MS: number;
|
|
89
|
+
/**
|
|
90
|
+
* Default single-instance delivery-id store: an atomic claim/settle lease,
|
|
91
|
+
* TTL-bounded, capacity-bounded, and honest about saturation (returns
|
|
92
|
+
* `"saturated"` rather than lying when it cannot lease a fresh id).
|
|
93
|
+
*/
|
|
94
|
+
export declare class MemoryWebhookNonceStore implements WebhookNonceStore {
|
|
95
|
+
private readonly options;
|
|
96
|
+
private readonly seen;
|
|
97
|
+
private tokenSeq;
|
|
98
|
+
constructor(options?: {
|
|
99
|
+
ttlMs?: number;
|
|
100
|
+
leaseMs?: number;
|
|
101
|
+
maxEntries?: number;
|
|
102
|
+
now?: () => number;
|
|
103
|
+
});
|
|
104
|
+
private horizonFor;
|
|
105
|
+
claim(id: string): WebhookClaim;
|
|
106
|
+
settle(id: string, token: string, outcome: "processed" | "release"): void;
|
|
107
|
+
get size(): number;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* How long a delivery id is remembered. The platform retries a delivery at
|
|
111
|
+
* 30 s, 5 min and 30 min after the first attempt (≈ 35.5 min horizon), so the
|
|
112
|
+
* window must outlast the LAST retry plus clock tolerance — a shorter window
|
|
113
|
+
* would let the final retry re-run a handler whose 2xx the platform never saw.
|
|
114
|
+
*/
|
|
115
|
+
export declare const WEBHOOK_DEDUPE_TTL_MS: number;
|
|
116
|
+
export interface CreateWebhookHandlerOptions {
|
|
117
|
+
/** The endpoint's signing secret (`fswh_…`). */
|
|
118
|
+
secret?: string;
|
|
119
|
+
/** Several accepted secrets (current first) — use while rolling a rotation. */
|
|
120
|
+
secrets?: readonly string[];
|
|
121
|
+
/** Typed per-event handlers. Missing types are acknowledged and ignored. */
|
|
122
|
+
on: WebhookHandlers;
|
|
123
|
+
/**
|
|
124
|
+
* Called for an event `type` this SDK does not know (a newer platform).
|
|
125
|
+
* Always acknowledged 2xx; use this to log or forward.
|
|
126
|
+
*/
|
|
127
|
+
onUnknown?: (event: {
|
|
128
|
+
id: string;
|
|
129
|
+
type: string;
|
|
130
|
+
body: unknown;
|
|
131
|
+
}) => void | Promise<void>;
|
|
132
|
+
/** Called for every rejected delivery (never throws the request). */
|
|
133
|
+
onRejected?: (rejection: WebhookRejection) => void;
|
|
134
|
+
/** Called when a duplicate `webhook-id` is acknowledged without running. */
|
|
135
|
+
onDuplicate?: (event: {
|
|
136
|
+
id: string;
|
|
137
|
+
type: string;
|
|
138
|
+
}) => void;
|
|
139
|
+
/**
|
|
140
|
+
* Delivery-id store for deduplication. Defaults to a bounded in-memory
|
|
141
|
+
* cache (single instance, `WEBHOOK_DEDUPE_TTL_MS`). Provide a shared store
|
|
142
|
+
* (Redis, DB) with an atomic `claim`/`settle` lease when the receiver runs on
|
|
143
|
+
* more than one instance (see {@link WebhookNonceStore}).
|
|
144
|
+
*/
|
|
145
|
+
nonceStore?: WebhookNonceStore;
|
|
146
|
+
/**
|
|
147
|
+
* Largest request body the adapters will buffer (default 1 MiB). Checked
|
|
148
|
+
* against `Content-Length` and enforced on the stream BEFORE the signature
|
|
149
|
+
* is verified, so an unauthenticated caller cannot exhaust memory.
|
|
150
|
+
*/
|
|
151
|
+
maxBodyBytes?: number;
|
|
152
|
+
/** Override for tests; defaults to `Date.now`. */
|
|
153
|
+
now?: () => number;
|
|
154
|
+
}
|
|
155
|
+
/** Default cap on a delivery body. Platform envelopes are a few KiB. */
|
|
156
|
+
export declare const WEBHOOK_MAX_BODY_BYTES: number;
|
|
157
|
+
export type WebhookHandleOutcome = {
|
|
158
|
+
status: 200;
|
|
159
|
+
kind: "handled";
|
|
160
|
+
id: string;
|
|
161
|
+
type: WebhookEnvelopeType;
|
|
162
|
+
} | {
|
|
163
|
+
status: 200;
|
|
164
|
+
kind: "ignored";
|
|
165
|
+
id: string;
|
|
166
|
+
type: WebhookEnvelopeType;
|
|
167
|
+
} | {
|
|
168
|
+
status: 200;
|
|
169
|
+
kind: "unknown_type";
|
|
170
|
+
id: string;
|
|
171
|
+
type: string;
|
|
172
|
+
} | {
|
|
173
|
+
status: 200;
|
|
174
|
+
kind: "duplicate";
|
|
175
|
+
id: string;
|
|
176
|
+
type: string;
|
|
177
|
+
} | {
|
|
178
|
+
status: 400 | 401;
|
|
179
|
+
kind: "rejected";
|
|
180
|
+
rejection: WebhookRejection;
|
|
181
|
+
} | {
|
|
182
|
+
status: 413;
|
|
183
|
+
kind: "rejected";
|
|
184
|
+
rejection: WebhookRejection;
|
|
185
|
+
} | {
|
|
186
|
+
status: 503;
|
|
187
|
+
kind: "store_unavailable";
|
|
188
|
+
id: string;
|
|
189
|
+
type: string;
|
|
190
|
+
} | {
|
|
191
|
+
status: 503;
|
|
192
|
+
kind: "in_flight";
|
|
193
|
+
id: string;
|
|
194
|
+
type: string;
|
|
195
|
+
} | {
|
|
196
|
+
status: 500;
|
|
197
|
+
kind: "handler_error";
|
|
198
|
+
id: string;
|
|
199
|
+
type: string;
|
|
200
|
+
error: unknown;
|
|
201
|
+
};
|
|
202
|
+
export interface WebhookHandleInput {
|
|
203
|
+
/** Raw request body, exactly as received. */
|
|
204
|
+
body: string | Uint8Array;
|
|
205
|
+
headers: Record<string, string | string[] | undefined> | Headers;
|
|
206
|
+
}
|
|
207
|
+
/** Minimal Express-shaped types so we don't hard-depend on @types/express. */
|
|
208
|
+
export type WebhookExpressRequestLike = {
|
|
209
|
+
headers: Record<string, string | string[] | undefined>;
|
|
210
|
+
/** Raw body bytes when captured by a raw parser (`express.raw()`). */
|
|
211
|
+
body?: unknown;
|
|
212
|
+
rawBody?: Buffer | Uint8Array;
|
|
213
|
+
};
|
|
214
|
+
export type WebhookExpressResponseLike = {
|
|
215
|
+
status(code: number): WebhookExpressResponseLike;
|
|
216
|
+
json(body: unknown): unknown;
|
|
217
|
+
};
|
|
218
|
+
export interface WebhookHandler {
|
|
219
|
+
/** Framework-neutral: verify, dedupe, parse, route. Never throws. */
|
|
220
|
+
handle(input: WebhookHandleInput): Promise<WebhookHandleOutcome>;
|
|
221
|
+
/** Fetch-API handler (Next.js route handlers, Hono, Bun, Workers…). */
|
|
222
|
+
fetch(request: Request): Promise<Response>;
|
|
223
|
+
/**
|
|
224
|
+
* Express handler. Mount AFTER a raw-body parser
|
|
225
|
+
* (`express.raw({ type: "application/json" })`) or set `req.rawBody`; a pre-parsed
|
|
226
|
+
* JSON body cannot be verified and is rejected 400.
|
|
227
|
+
*/
|
|
228
|
+
express(): (req: WebhookExpressRequestLike, res: WebhookExpressResponseLike) => Promise<void>;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Read a Fetch request body without ever buffering more than `limit` bytes:
|
|
232
|
+
* a declared `Content-Length` over the cap is refused before any allocation,
|
|
233
|
+
* and a stream that exceeds it mid-flight is abandoned. Returns `null` when
|
|
234
|
+
* the cap is exceeded.
|
|
235
|
+
*/
|
|
236
|
+
export declare function readBoundedRequestBody(request: Request, limit: number): Promise<Uint8Array | null>;
|
|
237
|
+
export declare function createWebhookHandler(options: CreateWebhookHandlerOptions): WebhookHandler;
|
|
238
|
+
export type DataFieldKind = "string" | "number" | "string|null" | "number|null";
|
|
239
|
+
/**
|
|
240
|
+
* Runtime mirror of the typed keys in `WebhookEventData` (types.ts). Kept
|
|
241
|
+
* SDK-local (no zod in the published bundle) and pinned field-by-field to the
|
|
242
|
+
* contracts schemas (`WEBHOOK_EVENT_DATA_SCHEMAS`) by `contracts-drift.test.ts`
|
|
243
|
+
* — a wrong `kind` here (e.g. dropping the `|null` from `amount`) would reject
|
|
244
|
+
* a legitimately-signed delivery, so the pin proves each kind matches the zod
|
|
245
|
+
* field's null/type behavior. Exported solely so that pin can read it.
|
|
246
|
+
*/
|
|
247
|
+
export declare const DATA_FIELD_TYPES: Record<Exclude<WebhookEnvelopeType, "webhook.test">, Record<string, DataFieldKind>>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event names a webhook endpoint can subscribe to (the platform events
|
|
3
|
+
* catalog's webhook-deliverable subset), in catalog order.
|
|
4
|
+
*/
|
|
5
|
+
export declare const WEBHOOK_EVENT_NAMES: readonly ["subscription.created", "subscription.updated", "subscription.canceled", "payment.succeeded", "payment.failed", "entitlement.changed", "usage.threshold_reached"];
|
|
6
|
+
export type WebhookEventName = (typeof WEBHOOK_EVENT_NAMES)[number];
|
|
7
|
+
/** The synthetic event a dashboard / CLI "Send test" produces. */
|
|
8
|
+
export declare const WEBHOOK_TEST_EVENT: "webhook.test";
|
|
9
|
+
/** Every `type` a delivery envelope can carry. */
|
|
10
|
+
export type WebhookEnvelopeType = WebhookEventName | typeof WEBHOOK_TEST_EVENT;
|
|
11
|
+
/**
|
|
12
|
+
* Per-event `data`. The keys listed are what the platform's producers carry
|
|
13
|
+
* today; extra keys pass through untouched (automation rules may attach
|
|
14
|
+
* builder-authored fields), so every shape is open.
|
|
15
|
+
*/
|
|
16
|
+
export interface WebhookEventData {
|
|
17
|
+
"subscription.created": {
|
|
18
|
+
subscriptionId?: string;
|
|
19
|
+
compiledPlanId?: string;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
"subscription.updated": {
|
|
23
|
+
subscriptionId?: string;
|
|
24
|
+
/** Producer-specific change reason, e.g. `plan_changed`, `trial_ending`. */
|
|
25
|
+
reason?: string;
|
|
26
|
+
lifecycle?: string;
|
|
27
|
+
compiledPlanId?: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
};
|
|
30
|
+
"subscription.canceled": {
|
|
31
|
+
subscriptionId?: string;
|
|
32
|
+
reason?: string;
|
|
33
|
+
lifecycle?: string;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
};
|
|
36
|
+
"payment.succeeded": WebhookPaymentData;
|
|
37
|
+
"payment.failed": WebhookPaymentData;
|
|
38
|
+
"entitlement.changed": {
|
|
39
|
+
compiledPlanId?: string;
|
|
40
|
+
lineageId?: string;
|
|
41
|
+
status?: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
"usage.threshold_reached": {
|
|
45
|
+
subscriptionId?: string;
|
|
46
|
+
subscriberId?: string;
|
|
47
|
+
limitId?: string;
|
|
48
|
+
threshold?: number;
|
|
49
|
+
windowStartMs?: number;
|
|
50
|
+
windowEndMs?: number;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
};
|
|
53
|
+
"webhook.test": {
|
|
54
|
+
businessId: string;
|
|
55
|
+
sentAt: string;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export interface WebhookPaymentData {
|
|
60
|
+
subscriptionId?: string;
|
|
61
|
+
invoiceId?: string;
|
|
62
|
+
amount?: number | null;
|
|
63
|
+
currency?: string | null;
|
|
64
|
+
reason?: string;
|
|
65
|
+
[key: string]: unknown;
|
|
66
|
+
}
|
|
67
|
+
/** The JSON body of every delivery. `id` equals the `webhook-id` header. */
|
|
68
|
+
export type WebhookEnvelope<T extends WebhookEnvelopeType = WebhookEnvelopeType> = T extends WebhookEnvelopeType ? {
|
|
69
|
+
id: string;
|
|
70
|
+
type: T;
|
|
71
|
+
/** ISO 8601 — when the event was recorded (stable across retries). */
|
|
72
|
+
createdAt: string;
|
|
73
|
+
businessId: string;
|
|
74
|
+
/** `null` = production. */
|
|
75
|
+
environmentId: string | null;
|
|
76
|
+
data: WebhookEventData[T];
|
|
77
|
+
} : never;
|
|
78
|
+
/** Header names on the wire (Standard Webhooks + our convenience header). */
|
|
79
|
+
export declare const WEBHOOK_ID_HEADER = "webhook-id";
|
|
80
|
+
export declare const WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
81
|
+
export declare const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
82
|
+
export declare const WEBHOOK_EVENT_HEADER = "x-fs-webhook-event";
|
|
83
|
+
/** Accept deliveries within ±this many seconds of the receiver's clock. */
|
|
84
|
+
export declare const WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
85
|
+
/** Prefix every platform-minted signing secret carries. */
|
|
86
|
+
export declare const WEBHOOK_SECRET_PREFIX = "fswh_";
|
|
87
|
+
export type WebhookVerifyFailure = "missing_headers" | "invalid_timestamp" | "timestamp_out_of_tolerance" | "no_matching_signature";
|
|
88
|
+
export type WebhookVerifyResult = {
|
|
89
|
+
ok: true;
|
|
90
|
+
id: string;
|
|
91
|
+
timestamp: number;
|
|
92
|
+
} | {
|
|
93
|
+
ok: false;
|
|
94
|
+
reason: WebhookVerifyFailure;
|
|
95
|
+
};
|
|
96
|
+
export interface VerifyWebhookInput {
|
|
97
|
+
/** Raw request body, exactly as received. */
|
|
98
|
+
body: string | Uint8Array;
|
|
99
|
+
/** Request headers (any case). */
|
|
100
|
+
headers: Record<string, string | string[] | undefined> | Headers;
|
|
101
|
+
/**
|
|
102
|
+
* Accepted signing secrets. Pass `[current, previous]` while you roll a
|
|
103
|
+
* rotated secret on your side; the platform already dual-signs for 24 h.
|
|
104
|
+
*/
|
|
105
|
+
secrets: readonly string[];
|
|
106
|
+
/** Override for tests; defaults to `Date.now()`. */
|
|
107
|
+
now?: () => number;
|
|
108
|
+
/** Override for tests; defaults to ±300 s. */
|
|
109
|
+
toleranceSeconds?: number;
|
|
110
|
+
}
|