@hello-bill/node 1.0.0 → 1.0.1

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +216 -0
  4. package/dist/express/index.d.cts +34 -0
  5. package/dist/express/index.d.ts +34 -0
  6. package/dist/express/index.js +1019 -0
  7. package/dist/express/index.js.map +1 -0
  8. package/dist/express/index.mjs +1016 -0
  9. package/dist/express/index.mjs.map +1 -0
  10. package/dist/hono/index.d.cts +37 -0
  11. package/dist/hono/index.d.ts +37 -0
  12. package/dist/hono/index.js +1036 -0
  13. package/dist/hono/index.js.map +1 -0
  14. package/dist/hono/index.mjs +1033 -0
  15. package/dist/hono/index.mjs.map +1 -0
  16. package/dist/index-BrfG82zs.d.cts +341 -0
  17. package/dist/index-EXetQn1f.d.ts +341 -0
  18. package/dist/index.d.cts +164 -0
  19. package/dist/index.d.ts +164 -0
  20. package/dist/index.js +1178 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/index.mjs +1164 -0
  23. package/dist/index.mjs.map +1 -0
  24. package/dist/koa/index.d.cts +42 -0
  25. package/dist/koa/index.d.ts +42 -0
  26. package/dist/koa/index.js +1042 -0
  27. package/dist/koa/index.js.map +1 -0
  28. package/dist/koa/index.mjs +1039 -0
  29. package/dist/koa/index.mjs.map +1 -0
  30. package/dist/next/index.d.cts +60 -0
  31. package/dist/next/index.d.ts +60 -0
  32. package/dist/next/index.js +1092 -0
  33. package/dist/next/index.js.map +1 -0
  34. package/dist/next/index.mjs +1090 -0
  35. package/dist/next/index.mjs.map +1 -0
  36. package/dist/types/index.d.cts +51 -0
  37. package/dist/types/index.d.ts +51 -0
  38. package/dist/types/index.js +12 -0
  39. package/dist/types/index.js.map +1 -0
  40. package/dist/types/index.mjs +9 -0
  41. package/dist/types/index.mjs.map +1 -0
  42. package/dist/webhook/index.d.cts +2 -0
  43. package/dist/webhook/index.d.ts +2 -0
  44. package/dist/webhook/index.js +342 -0
  45. package/dist/webhook/index.js.map +1 -0
  46. package/dist/webhook/index.mjs +336 -0
  47. package/dist/webhook/index.mjs.map +1 -0
  48. package/dist/webhooks-DPpqf7d2.d.cts +751 -0
  49. package/dist/webhooks-DPpqf7d2.d.ts +751 -0
  50. package/package.json +164 -7
@@ -0,0 +1,336 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+
3
+ // src/webhook/verify.ts
4
+ var WebhookVerificationError = class extends Error {
5
+ constructor(message, code) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = "WebhookVerificationError";
9
+ }
10
+ };
11
+ function parseHeader(signatureHeader) {
12
+ if (!signatureHeader || typeof signatureHeader !== "string") {
13
+ throw new WebhookVerificationError(
14
+ "Missing signature header",
15
+ "signature.missing_header"
16
+ );
17
+ }
18
+ const parts = signatureHeader.split(",").map((p) => p.trim()).filter(Boolean);
19
+ let t;
20
+ const signatures = {};
21
+ for (const part of parts) {
22
+ const eq = part.indexOf("=");
23
+ if (eq === -1) {
24
+ throw new WebhookVerificationError(
25
+ "Malformed signature header element",
26
+ "signature.malformed"
27
+ );
28
+ }
29
+ const key = part.slice(0, eq);
30
+ const value = part.slice(eq + 1);
31
+ if (key === "t") {
32
+ t = Number(value);
33
+ if (!Number.isFinite(t)) {
34
+ throw new WebhookVerificationError(
35
+ "Malformed timestamp in signature header",
36
+ "signature.malformed"
37
+ );
38
+ }
39
+ } else if (key.length > 0 && value.length > 0) {
40
+ signatures[key] = value;
41
+ }
42
+ }
43
+ if (t === void 0) {
44
+ throw new WebhookVerificationError(
45
+ "Missing timestamp in signature header",
46
+ "signature.malformed"
47
+ );
48
+ }
49
+ if (Object.keys(signatures).length === 0) {
50
+ throw new WebhookVerificationError(
51
+ "No signature values in header",
52
+ "signature.malformed"
53
+ );
54
+ }
55
+ return { t, signatures };
56
+ }
57
+ function computeHmacHex(secret, payload) {
58
+ return createHmac("sha256", secret).update(payload).digest("hex");
59
+ }
60
+ function constantTimeHexEqual(a, b) {
61
+ if (a.length !== b.length) return false;
62
+ try {
63
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+ function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
69
+ const tolerance = opts.tolerance ?? 300;
70
+ const supportedVersions = opts.supportedVersions ?? ["v1"];
71
+ const now = opts.now ?? Date.now;
72
+ const { t, signatures } = parseHeader(signatureHeader);
73
+ const nowSeconds = Math.floor(now() / 1e3);
74
+ const skew = Math.abs(nowSeconds - t);
75
+ if (skew > tolerance) {
76
+ throw new WebhookVerificationError(
77
+ `Signature timestamp outside tolerance (${skew}s > ${tolerance}s)`,
78
+ "signature.expired"
79
+ );
80
+ }
81
+ const hasAnyKnownVersion = supportedVersions.some((v) => v in signatures);
82
+ if (!hasAnyKnownVersion) {
83
+ throw new WebhookVerificationError(
84
+ "No supported signature version present in header",
85
+ "signature.unsupported_version"
86
+ );
87
+ }
88
+ const bodyStr = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
89
+ const signingInput = `${t}.${bodyStr}`;
90
+ for (const version of supportedVersions) {
91
+ const provided = signatures[version];
92
+ if (!provided) continue;
93
+ const expected = computeHmacHex(secret, signingInput);
94
+ if (constantTimeHexEqual(expected, provided)) {
95
+ try {
96
+ return JSON.parse(bodyStr);
97
+ } catch {
98
+ throw new WebhookVerificationError(
99
+ "Verified payload is not valid JSON",
100
+ "signature.malformed"
101
+ );
102
+ }
103
+ }
104
+ }
105
+ throw new WebhookVerificationError(
106
+ "Signature does not match expected value",
107
+ "signature.invalid"
108
+ );
109
+ }
110
+ function verifyWebhookSignature(rawBody, signatureHeader, secret, opts = {}) {
111
+ const tolerance = opts.tolerance ?? 300;
112
+ const supportedVersions = opts.supportedVersions ?? ["v1"];
113
+ const now = opts.now ?? Date.now;
114
+ let parsed;
115
+ try {
116
+ parsed = parseHeader(signatureHeader);
117
+ } catch (err) {
118
+ if (err instanceof WebhookVerificationError) return false;
119
+ throw err;
120
+ }
121
+ const { t, signatures } = parsed;
122
+ const nowSeconds = Math.floor(now() / 1e3);
123
+ const skew = Math.abs(nowSeconds - t);
124
+ if (skew > tolerance) return false;
125
+ const hasAnyKnownVersion = supportedVersions.some((v) => v in signatures);
126
+ if (!hasAnyKnownVersion) return false;
127
+ const bodyStr = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
128
+ const signingInput = `${t}.${bodyStr}`;
129
+ for (const version of supportedVersions) {
130
+ const provided = signatures[version];
131
+ if (!provided) continue;
132
+ const expected = computeHmacHex(secret, signingInput);
133
+ if (constantTimeHexEqual(expected, provided)) {
134
+ return true;
135
+ }
136
+ }
137
+ return false;
138
+ }
139
+
140
+ // src/webhook/dedupe-store.ts
141
+ var InMemoryWebhookDedupeStore = class {
142
+ // Optional test injection. Defaults to Date.now.
143
+ constructor(now = () => Date.now()) {
144
+ this.now = now;
145
+ }
146
+ store = /* @__PURE__ */ new Map();
147
+ async has(id) {
148
+ const expiresAt = this.store.get(id);
149
+ if (expiresAt === void 0) return false;
150
+ if (expiresAt <= this.now()) {
151
+ this.store.delete(id);
152
+ return false;
153
+ }
154
+ return true;
155
+ }
156
+ async record(id, ttlSeconds) {
157
+ this.store.set(id, this.now() + ttlSeconds * 1e3);
158
+ }
159
+ };
160
+
161
+ // src/webhook/handler.ts
162
+ function assertNever(x) {
163
+ throw new Error(`Unhandled webhook variant: ${JSON.stringify(x)}`);
164
+ }
165
+ var noopLogger = {
166
+ debug: () => {
167
+ },
168
+ info: () => {
169
+ },
170
+ warn: () => {
171
+ },
172
+ error: () => {
173
+ }
174
+ };
175
+ function withInfoFallback(logger) {
176
+ return {
177
+ debug: logger?.debug?.bind(logger) ?? (() => {
178
+ }),
179
+ info: logger?.info?.bind(logger) ?? (() => {
180
+ }),
181
+ warn: logger?.warn?.bind(logger) ?? (() => {
182
+ }),
183
+ error: logger?.error?.bind(logger) ?? (() => {
184
+ })
185
+ };
186
+ }
187
+ function headerValue(headers, name) {
188
+ const lower = name.toLowerCase();
189
+ for (const [k, v] of Object.entries(headers)) {
190
+ if (k.toLowerCase() === lower) return Array.isArray(v) ? v[0] : v;
191
+ }
192
+ return void 0;
193
+ }
194
+ async function dispatch(event, ctx, handlers) {
195
+ const logUnhandled = (name) => {
196
+ ctx.logger.info("hellobill.webhook.unhandled_variant", {
197
+ event_id: event.id,
198
+ event_type: event.type,
199
+ handler: name
200
+ });
201
+ };
202
+ switch (event.type) {
203
+ case "session.embed_opened":
204
+ if (handlers.onSessionEmbedOpened) await handlers.onSessionEmbedOpened(event, ctx);
205
+ else logUnhandled("onSessionEmbedOpened");
206
+ return;
207
+ case "session.details_confirmed":
208
+ if (handlers.onSessionDetailsConfirmed) await handlers.onSessionDetailsConfirmed(event, ctx);
209
+ else logUnhandled("onSessionDetailsConfirmed");
210
+ return;
211
+ case "session.products_selected":
212
+ if (handlers.onSessionProductsSelected) await handlers.onSessionProductsSelected(event, ctx);
213
+ else logUnhandled("onSessionProductsSelected");
214
+ return;
215
+ case "session.loa_signed":
216
+ if (handlers.onSessionLoaSigned) await handlers.onSessionLoaSigned(event, ctx);
217
+ else logUnhandled("onSessionLoaSigned");
218
+ return;
219
+ case "session.account_created":
220
+ if (handlers.onSessionAccountCreated) await handlers.onSessionAccountCreated(event, ctx);
221
+ else logUnhandled("onSessionAccountCreated");
222
+ return;
223
+ case "session.app_installed":
224
+ if (handlers.onSessionAppInstalled) await handlers.onSessionAppInstalled(event, ctx);
225
+ else logUnhandled("onSessionAppInstalled");
226
+ return;
227
+ case "session.app_deferred":
228
+ if (handlers.onSessionAppDeferred) await handlers.onSessionAppDeferred(event, ctx);
229
+ else logUnhandled("onSessionAppDeferred");
230
+ return;
231
+ case "subscription.changed":
232
+ if (handlers.onSubscriptionChanged) await handlers.onSubscriptionChanged(event, ctx);
233
+ else logUnhandled("onSubscriptionChanged");
234
+ return;
235
+ case "setup_status.changed":
236
+ if (handlers.onSetupStatusChanged) await handlers.onSetupStatusChanged(event, ctx);
237
+ else logUnhandled("onSetupStatusChanged");
238
+ return;
239
+ case "move_out_notification.sent":
240
+ if (handlers.onMoveOutNotificationSent) await handlers.onMoveOutNotificationSent(event, ctx);
241
+ else logUnhandled("onMoveOutNotificationSent");
242
+ return;
243
+ case "move_out_notification.failed":
244
+ if (handlers.onMoveOutNotificationFailed) await handlers.onMoveOutNotificationFailed(event, ctx);
245
+ else logUnhandled("onMoveOutNotificationFailed");
246
+ return;
247
+ case "bank_details.collected":
248
+ if (handlers.onBankDetailsCollected) await handlers.onBankDetailsCollected(event, ctx);
249
+ else logUnhandled("onBankDetailsCollected");
250
+ return;
251
+ default:
252
+ return assertNever(event);
253
+ }
254
+ }
255
+ function createWebhookHandler(opts) {
256
+ if (opts.webhookSecret == null || opts.webhookSecret === "") {
257
+ throw new Error(
258
+ 'HELLOBILL_WEBHOOK_SECRET must be set; see spec \xA713.2 and the @hello-bill/node README "Webhook receiver" section.'
259
+ );
260
+ }
261
+ const now = opts.now ?? (() => Date.now());
262
+ const dedupeStore = opts.dedupeStore ?? new InMemoryWebhookDedupeStore(now);
263
+ const dedupeTtlSeconds = opts.dedupeTtlSeconds ?? 86400;
264
+ const tolerance = opts.tolerance ?? 300;
265
+ const supportedVersions = opts.supportedVersions ?? ["v1"];
266
+ const handlers = opts.handlers ?? {};
267
+ const safeLogger = withInfoFallback(opts.logger ?? noopLogger);
268
+ return async (req, res) => {
269
+ const rawBody = req.rawBody;
270
+ if (!rawBody) {
271
+ safeLogger.error("hellobill.webhook.missing_raw_body", {});
272
+ res.status(500).json({
273
+ error: {
274
+ type: "api_error",
275
+ code: "server.internal",
276
+ message: "Webhook receiver requires raw body \u2014 adapter not wired correctly"
277
+ }
278
+ });
279
+ return;
280
+ }
281
+ const sigHeader = headerValue(req.headers, "x-hellobill-signature");
282
+ if (!sigHeader) {
283
+ res.status(401).json({ error: "invalid_signature" });
284
+ return;
285
+ }
286
+ const valid = verifyWebhookSignature(rawBody, sigHeader, opts.webhookSecret, {
287
+ tolerance,
288
+ supportedVersions,
289
+ now
290
+ });
291
+ if (!valid) {
292
+ res.status(401).json({ error: "invalid_signature" });
293
+ return;
294
+ }
295
+ let event;
296
+ try {
297
+ event = JSON.parse(rawBody.toString("utf8"));
298
+ } catch {
299
+ res.status(400).json({ error: "invalid_payload" });
300
+ return;
301
+ }
302
+ const seen = await dedupeStore.has(event.id);
303
+ if (seen) {
304
+ safeLogger.info("hellobill.webhook.dedup_hit", { event_id: event.id, event_type: event.type });
305
+ res.status(200).json({ received: true, deduped: true });
306
+ return;
307
+ }
308
+ await dedupeStore.record(event.id, dedupeTtlSeconds);
309
+ const deprecation = headerValue(req.headers, "x-hellobill-deprecation") ?? null;
310
+ if (deprecation) {
311
+ safeLogger.warn("hellobill.webhook.deprecation_notice", {
312
+ deprecation,
313
+ event_id: event.id,
314
+ event_type: event.type
315
+ });
316
+ }
317
+ res.status(200).json({ received: true });
318
+ const ctx = {
319
+ // safeLogger guarantees `info` is defined regardless of partner Logger shape.
320
+ logger: safeLogger,
321
+ headers: req.headers,
322
+ deprecation
323
+ };
324
+ void dispatch(event, ctx, handlers).catch((err) => {
325
+ safeLogger.error("hellobill.webhook.handler_error", {
326
+ event_id: event.id,
327
+ event_type: event.type,
328
+ message: err.message
329
+ });
330
+ });
331
+ };
332
+ }
333
+
334
+ export { InMemoryWebhookDedupeStore, WebhookVerificationError, createWebhookHandler, verifyWebhook, verifyWebhookSignature };
335
+ //# sourceMappingURL=index.mjs.map
336
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/webhook/verify.ts","../../src/webhook/dedupe-store.ts","../../src/webhook/handler.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["import { createHmac, timingSafeEqual } from 'node:crypto';\nimport type { WebhookEvent } from '../types/index.js';\n\nexport interface VerifyWebhookOptions {\n /** Replay window in seconds. Default 300. */\n tolerance?: number;\n /** Signature versions to accept. Default ['v1']. Use ['v1', 'v2'] during key rotation. */\n supportedVersions?: ('v1' | 'v2')[];\n /** Override `Date.now()` for tests. */\n now?: () => number;\n}\n\nexport class WebhookVerificationError extends Error {\n constructor(\n message: string,\n public code: string,\n ) {\n super(message);\n this.name = 'WebhookVerificationError';\n }\n}\n\ninterface ParsedHeader {\n t: number;\n signatures: Record<string, string>;\n}\n\nfunction parseHeader(signatureHeader: string): ParsedHeader {\n if (!signatureHeader || typeof signatureHeader !== 'string') {\n throw new WebhookVerificationError(\n 'Missing signature header',\n 'signature.missing_header',\n );\n }\n\n const parts = signatureHeader.split(',').map((p) => p.trim()).filter(Boolean);\n let t: number | undefined;\n const signatures: Record<string, string> = {};\n\n for (const part of parts) {\n const eq = part.indexOf('=');\n if (eq === -1) {\n throw new WebhookVerificationError(\n 'Malformed signature header element',\n 'signature.malformed',\n );\n }\n const key = part.slice(0, eq);\n const value = part.slice(eq + 1);\n if (key === 't') {\n t = Number(value);\n if (!Number.isFinite(t)) {\n throw new WebhookVerificationError(\n 'Malformed timestamp in signature header',\n 'signature.malformed',\n );\n }\n } else if (key.length > 0 && value.length > 0) {\n signatures[key] = value;\n }\n }\n\n if (t === undefined) {\n throw new WebhookVerificationError(\n 'Missing timestamp in signature header',\n 'signature.malformed',\n );\n }\n if (Object.keys(signatures).length === 0) {\n throw new WebhookVerificationError(\n 'No signature values in header',\n 'signature.malformed',\n );\n }\n\n return { t, signatures };\n}\n\nfunction computeHmacHex(secret: string, payload: string): string {\n return createHmac('sha256', secret).update(payload).digest('hex');\n}\n\nfunction constantTimeHexEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n try {\n return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));\n } catch {\n return false;\n }\n}\n\n/**\n * Verifies a HelloBill webhook delivery.\n * Header format: `t=<unix>,v1=<hex>,v2=<hex>`.\n * HMAC computed over `${t}.${rawBody}` (raw bytes — do not re-serialise).\n */\nexport function verifyWebhook(\n rawBody: Buffer | string,\n signatureHeader: string,\n secret: string,\n opts: VerifyWebhookOptions = {},\n): WebhookEvent {\n const tolerance = opts.tolerance ?? 300;\n const supportedVersions = opts.supportedVersions ?? ['v1'];\n const now = opts.now ?? Date.now;\n\n const { t, signatures } = parseHeader(signatureHeader);\n\n const nowSeconds = Math.floor(now() / 1000);\n const skew = Math.abs(nowSeconds - t);\n if (skew > tolerance) {\n throw new WebhookVerificationError(\n `Signature timestamp outside tolerance (${skew}s > ${tolerance}s)`,\n 'signature.expired',\n );\n }\n\n const hasAnyKnownVersion = supportedVersions.some((v) => v in signatures);\n if (!hasAnyKnownVersion) {\n throw new WebhookVerificationError(\n 'No supported signature version present in header',\n 'signature.unsupported_version',\n );\n }\n\n const bodyStr =\n typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');\n const signingInput = `${t}.${bodyStr}`;\n\n for (const version of supportedVersions) {\n const provided = signatures[version];\n if (!provided) continue;\n const expected = computeHmacHex(secret, signingInput);\n if (constantTimeHexEqual(expected, provided)) {\n try {\n return JSON.parse(bodyStr) as WebhookEvent;\n } catch {\n throw new WebhookVerificationError(\n 'Verified payload is not valid JSON',\n 'signature.malformed',\n );\n }\n }\n }\n\n throw new WebhookVerificationError(\n 'Signature does not match expected value',\n 'signature.invalid',\n );\n}\n\n/**\n * Verifies a HelloBill webhook delivery without parsing the payload.\n * Returns `true` only if the timestamp is within tolerance AND at least one\n * supported scheme verifies. Returns `false` for every other case (missing/\n * malformed header, expired timestamp, no supported version, signature\n * mismatch). Never throws on verification failure — callers route on the\n * boolean. Re-throws non-verification errors (e.g. unexpected crypto failures).\n *\n * Unlike the throwing `verifyWebhook`, this function does NOT attempt to parse\n * the body as JSON — signature verification is purely over the raw bytes.\n * Callers that need the parsed event should call `verifyWebhook` directly or\n * parse separately after calling this function.\n *\n * @param rawBody Raw request body bytes (NOT re-serialised JSON).\n * @param signatureHeader Value of `X-HelloBill-Signature`.\n * @param secret Partner-issued webhook secret (env `HELLOBILL_WEBHOOK_SECRET`).\n * @param opts Optional `tolerance` (default 300s), `supportedVersions`\n * (default `['v1']`), `now` (test injection).\n */\nexport function verifyWebhookSignature(\n rawBody: Buffer | string,\n signatureHeader: string,\n secret: string,\n opts: VerifyWebhookOptions = {},\n): boolean {\n const tolerance = opts.tolerance ?? 300;\n const supportedVersions = opts.supportedVersions ?? ['v1'];\n const now = opts.now ?? Date.now;\n\n let parsed: ParsedHeader;\n try {\n parsed = parseHeader(signatureHeader);\n } catch (err) {\n if (err instanceof WebhookVerificationError) return false;\n throw err;\n }\n\n const { t, signatures } = parsed;\n\n const nowSeconds = Math.floor(now() / 1000);\n const skew = Math.abs(nowSeconds - t);\n if (skew > tolerance) return false;\n\n const hasAnyKnownVersion = supportedVersions.some((v) => v in signatures);\n if (!hasAnyKnownVersion) return false;\n\n const bodyStr =\n typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');\n const signingInput = `${t}.${bodyStr}`;\n\n for (const version of supportedVersions) {\n const provided = signatures[version];\n if (!provided) continue;\n const expected = computeHmacHex(secret, signingInput);\n if (constantTimeHexEqual(expected, provided)) {\n return true;\n }\n }\n\n return false;\n}\n","/**\n * Webhook idempotency dedupe store. Keyed on `WebhookEnvelope.id`.\n *\n * Default `InMemoryWebhookDedupeStore` is fine for single-process deployments\n * (and unit tests). Multi-replica deployments MUST inject a shared backend\n * (e.g. Redis SETEX, RDS `INSERT ... ON CONFLICT DO NOTHING`). Per spec §11.1\n * line 2270: \"Use the `id` field to deduplicate.\"\n */\nexport interface WebhookDedupeStore {\n /** Returns true if `id` has been recorded within its TTL. Never throws on miss. */\n has(id: string): Promise<boolean> | boolean;\n /** Records `id` with the given TTL in seconds. Idempotent on repeat calls. */\n record(id: string, ttlSeconds: number): Promise<void> | void;\n}\n\n/** In-memory implementation. NOT durable across process restarts. Tests + single-proc deploys. */\nexport class InMemoryWebhookDedupeStore implements WebhookDedupeStore {\n private readonly store = new Map<string, number>();\n\n // Optional test injection. Defaults to Date.now.\n constructor(private readonly now: () => number = () => Date.now()) {}\n\n async has(id: string): Promise<boolean> {\n const expiresAt = this.store.get(id);\n if (expiresAt === undefined) return false;\n // Inclusive boundary: at exactly expiresAt, treat as expired. Tested via handler test 6.5.\n if (expiresAt <= this.now()) {\n this.store.delete(id);\n return false;\n }\n return true;\n }\n\n async record(id: string, ttlSeconds: number): Promise<void> {\n this.store.set(id, this.now() + ttlSeconds * 1000);\n }\n}\n","import type {\n BankDetailsCollected,\n MoveOutNotificationFailed,\n MoveOutNotificationSent,\n SessionAccountCreated,\n SessionAppDeferred,\n SessionAppInstalled,\n SessionDetailsConfirmed,\n SessionEmbedOpened,\n SessionLoaSigned,\n SessionProductsSelected,\n SetupStatusChanged,\n SubscriptionChanged,\n WebhookEvent,\n} from '../types/index.js';\nimport type { Logger } from '../core/token-manager.js';\nimport { verifyWebhookSignature, type VerifyWebhookOptions } from './verify.js';\nimport {\n InMemoryWebhookDedupeStore,\n type WebhookDedupeStore,\n} from './dedupe-store.js';\nimport type { HellobillRequest, HellobillResponse } from '../core/handler.js';\n\n/** Per-event runtime context passed to every typed handler. */\nexport interface WebhookHandlerContext {\n /**\n * Logger guaranteed to have `info` defined — sourced from `withInfoFallback`\n * at handler construction. Partner handlers may call `ctx.logger.info(...)`\n * without a null-check regardless of what Logger literal was supplied.\n */\n logger: Logger & { info: (msg: string, meta?: Record<string, unknown>) => void };\n headers: Record<string, string | string[] | undefined>;\n deprecation: string | null;\n}\n\n/**\n * Typed handlers — one per `WebhookEvent` variant. All optional; an\n * unprovided variant logs at info (`unhandled_variant`) and the dispatcher\n * still responds 2xx so HelloBill stops retrying.\n *\n * Handlers run async AFTER the route has already responded; throwing\n * from a handler does NOT cause HelloBill to retry (a 2xx was already\n * written). Errors are logged via `opts.logger?.error`.\n */\nexport interface WebhookHandlers {\n onSessionEmbedOpened?(event: SessionEmbedOpened, ctx: WebhookHandlerContext): Promise<void>;\n onSessionDetailsConfirmed?(event: SessionDetailsConfirmed, ctx: WebhookHandlerContext): Promise<void>;\n onSessionProductsSelected?(event: SessionProductsSelected, ctx: WebhookHandlerContext): Promise<void>;\n onSessionLoaSigned?(event: SessionLoaSigned, ctx: WebhookHandlerContext): Promise<void>;\n onSessionAccountCreated?(event: SessionAccountCreated, ctx: WebhookHandlerContext): Promise<void>;\n onSessionAppInstalled?(event: SessionAppInstalled, ctx: WebhookHandlerContext): Promise<void>;\n onSessionAppDeferred?(event: SessionAppDeferred, ctx: WebhookHandlerContext): Promise<void>;\n onSubscriptionChanged?(event: SubscriptionChanged, ctx: WebhookHandlerContext): Promise<void>;\n onSetupStatusChanged?(event: SetupStatusChanged, ctx: WebhookHandlerContext): Promise<void>;\n onMoveOutNotificationSent?(event: MoveOutNotificationSent, ctx: WebhookHandlerContext): Promise<void>;\n onMoveOutNotificationFailed?(event: MoveOutNotificationFailed, ctx: WebhookHandlerContext): Promise<void>;\n onBankDetailsCollected?(event: BankDetailsCollected, ctx: WebhookHandlerContext): Promise<void>;\n}\n\n/** Options for the webhook route. Lives alongside `CreateHellobillHandlerOptions`. */\nexport interface WebhookHandlerOptions {\n /**\n * Partner webhook secret. From `process.env.HELLOBILL_WEBHOOK_SECRET`.\n * MUST be a non-empty string at `createWebhookHandler` call time — the\n * factory throws synchronously on `null`/`undefined`/`''` so that an\n * unset env var is caught immediately rather than silently 401ing every\n * incoming webhook.\n */\n webhookSecret: string;\n /**\n * Optional dedupe-store. Default: in-memory (single-process). Multi-replica\n * deployments MUST supply a shared backend (Redis, RDS, etc.) — see README.\n */\n dedupeStore?: WebhookDedupeStore;\n /**\n * Idempotency window in seconds. Default 86_400 (24h). Per spec §11.1 line 2270.\n */\n dedupeTtlSeconds?: number;\n /** Signature versions accepted. Default `['v1']`. Set to `['v1','v2']` during rotation. */\n supportedVersions?: VerifyWebhookOptions['supportedVersions'];\n /** Timestamp tolerance in seconds. Default 300. */\n tolerance?: number;\n /** Optional logger. Defaults to no-op. */\n logger?: Logger;\n /** Typed handlers — provide as many as you care about; missing ones log \"unhandled\". */\n handlers?: WebhookHandlers;\n /**\n * Test injection for `Date.now()`. Applied to the signature timestamp AND,\n * only when using the default `InMemoryWebhookDedupeStore`, the dedupe TTL\n * clock. Partner-supplied `dedupeStore` instances MUST own their own clock\n * injection — `opts.now` is NOT forwarded to a partner-supplied store.\n */\n now?: () => number;\n}\n\n/** Sentinel — placed on the receiver's `default:` branch to surface compile-time exhaustiveness. */\nfunction assertNever(x: never): never {\n throw new Error(`Unhandled webhook variant: ${JSON.stringify(x)}`);\n}\n\nconst noopLogger: Logger & { info: () => void } = {\n debug: () => {},\n info: () => {},\n warn: () => {},\n error: () => {},\n};\n\n/**\n * Coalesces a partner-supplied Logger literal that may omit the optional `info`\n * method. Without this, `ctx.logger.info(...)` inside `logUnhandled` raises a\n * runtime TypeError when the partner's Logger only provides debug/warn/error.\n */\nfunction withInfoFallback(\n logger: Logger | undefined,\n): Logger & { info: (msg: string, meta?: Record<string, unknown>) => void } {\n return {\n debug: logger?.debug?.bind(logger) ?? (() => {}),\n info: logger?.info?.bind(logger) ?? (() => {}),\n warn: logger?.warn?.bind(logger) ?? (() => {}),\n error: logger?.error?.bind(logger) ?? (() => {}),\n };\n}\n\nfunction headerValue(\n headers: Record<string, string | string[] | undefined>,\n name: string,\n): string | undefined {\n const lower = name.toLowerCase();\n for (const [k, v] of Object.entries(headers)) {\n if (k.toLowerCase() === lower) return Array.isArray(v) ? v[0] : v;\n }\n return undefined;\n}\n\n/**\n * Dispatch a verified-and-deduped event to its typed handler. Exhaustive over\n * the union. Missing handler slots fall through to `logUnhandled` so a\n * partner-only-cares-about-bank-details deployment never 500s on an\n * unrecognised event type.\n */\nasync function dispatch(\n event: WebhookEvent,\n ctx: WebhookHandlerContext,\n handlers: WebhookHandlers,\n): Promise<void> {\n const logUnhandled = (name: string): void => {\n ctx.logger.info('hellobill.webhook.unhandled_variant', {\n event_id: event.id,\n event_type: event.type,\n handler: name,\n });\n };\n\n switch (event.type) {\n case 'session.embed_opened':\n if (handlers.onSessionEmbedOpened) await handlers.onSessionEmbedOpened(event, ctx);\n else logUnhandled('onSessionEmbedOpened');\n return;\n case 'session.details_confirmed':\n if (handlers.onSessionDetailsConfirmed) await handlers.onSessionDetailsConfirmed(event, ctx);\n else logUnhandled('onSessionDetailsConfirmed');\n return;\n case 'session.products_selected':\n if (handlers.onSessionProductsSelected) await handlers.onSessionProductsSelected(event, ctx);\n else logUnhandled('onSessionProductsSelected');\n return;\n case 'session.loa_signed':\n if (handlers.onSessionLoaSigned) await handlers.onSessionLoaSigned(event, ctx);\n else logUnhandled('onSessionLoaSigned');\n return;\n case 'session.account_created':\n if (handlers.onSessionAccountCreated) await handlers.onSessionAccountCreated(event, ctx);\n else logUnhandled('onSessionAccountCreated');\n return;\n case 'session.app_installed':\n if (handlers.onSessionAppInstalled) await handlers.onSessionAppInstalled(event, ctx);\n else logUnhandled('onSessionAppInstalled');\n return;\n case 'session.app_deferred':\n if (handlers.onSessionAppDeferred) await handlers.onSessionAppDeferred(event, ctx);\n else logUnhandled('onSessionAppDeferred');\n return;\n case 'subscription.changed':\n if (handlers.onSubscriptionChanged) await handlers.onSubscriptionChanged(event, ctx);\n else logUnhandled('onSubscriptionChanged');\n return;\n case 'setup_status.changed':\n if (handlers.onSetupStatusChanged) await handlers.onSetupStatusChanged(event, ctx);\n else logUnhandled('onSetupStatusChanged');\n return;\n case 'move_out_notification.sent':\n if (handlers.onMoveOutNotificationSent) await handlers.onMoveOutNotificationSent(event, ctx);\n else logUnhandled('onMoveOutNotificationSent');\n return;\n case 'move_out_notification.failed':\n if (handlers.onMoveOutNotificationFailed) await handlers.onMoveOutNotificationFailed(event, ctx);\n else logUnhandled('onMoveOutNotificationFailed');\n return;\n case 'bank_details.collected':\n if (handlers.onBankDetailsCollected) await handlers.onBankDetailsCollected(event, ctx);\n else logUnhandled('onBankDetailsCollected');\n return;\n default:\n // Exhaustiveness — if Ticket #1 adds a 13th variant, this fails to compile.\n return assertNever(event);\n }\n}\n\n/**\n * Creates the framework-agnostic webhook receive handler. Returns a function\n * matching the same `HellobillRequest`/`HellobillResponse` shape used by the\n * other routes on `HellobillHandlerSet` (see `core/handler.ts`).\n *\n * Throws synchronously if `opts.webhookSecret` is falsy — a missing or\n * empty secret is caught early so that every incoming webhook does not\n * silently 401 with no visible cause.\n *\n * The Express adapter wires this in alongside `session`/`loa`/etc.\n */\nexport function createWebhookHandler(\n opts: WebhookHandlerOptions,\n): (req: HellobillRequest, res: HellobillResponse) => Promise<void> {\n // Runtime guard: `process.env.HELLOBILL_WEBHOOK_SECRET!` bang-assertion satisfies\n // the TypeScript compiler but does not catch the case where the env var is unset at\n // runtime. Without this guard, every webhook would 401 silently because\n // verifyWebhookSignature would always return false for an undefined secret.\n if (opts.webhookSecret == null || opts.webhookSecret === '') {\n throw new Error(\n 'HELLOBILL_WEBHOOK_SECRET must be set; see spec §13.2 and ' +\n 'the @hello-bill/node README \"Webhook receiver\" section.',\n );\n }\n\n const now = opts.now ?? (() => Date.now());\n const dedupeStore = opts.dedupeStore ?? new InMemoryWebhookDedupeStore(now);\n const dedupeTtlSeconds = opts.dedupeTtlSeconds ?? 86_400;\n const tolerance = opts.tolerance ?? 300;\n const supportedVersions = opts.supportedVersions ?? ['v1'];\n const handlers = opts.handlers ?? {};\n // Coalesce partner-supplied Logger that may omit the optional `info` method.\n // Without this, `ctx.logger.info(...)` inside logUnhandled raises a runtime\n // TypeError when the partner Logger only provides debug/warn/error.\n const safeLogger = withInfoFallback(opts.logger ?? noopLogger);\n\n return async (req: HellobillRequest, res: HellobillResponse): Promise<void> => {\n const rawBody = req.rawBody;\n if (!rawBody) {\n safeLogger.error('hellobill.webhook.missing_raw_body', {});\n res.status(500).json({\n error: {\n type: 'api_error',\n code: 'server.internal',\n message: 'Webhook receiver requires raw body — adapter not wired correctly',\n },\n });\n return;\n }\n\n const sigHeader = headerValue(req.headers, 'x-hellobill-signature');\n if (!sigHeader) {\n res.status(401).json({ error: 'invalid_signature' });\n return;\n }\n\n const valid = verifyWebhookSignature(rawBody, sigHeader, opts.webhookSecret, {\n tolerance,\n supportedVersions,\n now,\n });\n if (!valid) {\n res.status(401).json({ error: 'invalid_signature' });\n return;\n }\n\n // Parse AFTER verification — verify operates on raw bytes by contract per spec §11.2.\n let event: WebhookEvent;\n try {\n event = JSON.parse(rawBody.toString('utf8')) as WebhookEvent;\n } catch {\n res.status(400).json({ error: 'invalid_payload' });\n return;\n }\n\n // Dedupe — record-before-dispatch so multi-replica double-delivery cannot\n // double-process. The crash-window caveat (rare loss) is documented and is\n // the at-most-once-after-2xx contract.\n const seen = await dedupeStore.has(event.id);\n if (seen) {\n safeLogger.info('hellobill.webhook.dedup_hit', { event_id: event.id, event_type: event.type });\n res.status(200).json({ received: true, deduped: true });\n return;\n }\n await dedupeStore.record(event.id, dedupeTtlSeconds);\n\n const deprecation = headerValue(req.headers, 'x-hellobill-deprecation') ?? null;\n if (deprecation) {\n safeLogger.warn('hellobill.webhook.deprecation_notice', {\n deprecation,\n event_id: event.id,\n event_type: event.type,\n });\n }\n\n // Respond 2xx FIRST. Per spec §11.1 line 2272: respond within 10s, process async.\n res.status(200).json({ received: true });\n\n const ctx: WebhookHandlerContext = {\n // safeLogger guarantees `info` is defined regardless of partner Logger shape.\n logger: safeLogger,\n headers: req.headers,\n deprecation,\n };\n\n // Fire-and-forget. Errors logged; HelloBill's retry layer is the durability mechanism.\n void dispatch(event, ctx, handlers).catch((err) => {\n safeLogger.error('hellobill.webhook.handler_error', {\n event_id: event.id,\n event_type: event.type,\n message: (err as Error).message,\n });\n });\n };\n}\n"]}