@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.
@@ -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
+ }