@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14

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 (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2880 -1667
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +810 -169
  17. package/dist/server.d.ts +810 -169
  18. package/dist/server.js +2848 -1646
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +365 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +335 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +285 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +338 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,576 @@
1
+ import { KoraError } from '@korajs/core'
2
+
3
+ // ============================================================================
4
+ // Webhook Types
5
+ // ============================================================================
6
+
7
+ /**
8
+ * Events that can trigger webhooks.
9
+ */
10
+ const WEBHOOK_EVENTS = [
11
+ 'user.created',
12
+ 'user.updated',
13
+ 'user.deleted',
14
+ 'user.signin',
15
+ 'user.signout',
16
+ 'user.password_changed',
17
+ 'user.email_verified',
18
+ 'mfa.enabled',
19
+ 'mfa.disabled',
20
+ 'session.created',
21
+ 'session.revoked',
22
+ 'org.created',
23
+ 'org.updated',
24
+ 'org.deleted',
25
+ 'org.member_added',
26
+ 'org.member_removed',
27
+ 'org.invitation_created',
28
+ 'org.invitation_accepted',
29
+ ] as const
30
+
31
+ export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]
32
+
33
+ /**
34
+ * A webhook endpoint configuration.
35
+ */
36
+ export interface WebhookEndpoint {
37
+ /** Unique endpoint ID */
38
+ id: string
39
+ /** Destination URL */
40
+ url: string
41
+ /** Events this endpoint subscribes to */
42
+ events: WebhookEvent[]
43
+ /** Signing secret for HMAC verification */
44
+ secret: string
45
+ /** Whether this endpoint is active */
46
+ active: boolean
47
+ /** When this endpoint was created */
48
+ createdAt: number
49
+ /** Custom metadata */
50
+ metadata?: Record<string, unknown>
51
+ }
52
+
53
+ /**
54
+ * A webhook delivery attempt.
55
+ */
56
+ export interface WebhookDelivery {
57
+ /** Unique delivery ID */
58
+ id: string
59
+ /** Endpoint ID */
60
+ endpointId: string
61
+ /** Event that triggered this delivery */
62
+ event: WebhookEvent
63
+ /** Request payload (JSON) */
64
+ payload: string
65
+ /** HTTP response status (null if network error) */
66
+ responseStatus: number | null
67
+ /** Whether the delivery was successful (2xx response) */
68
+ success: boolean
69
+ /** Error message if delivery failed */
70
+ error: string | null
71
+ /** Number of attempts made */
72
+ attempts: number
73
+ /** When the delivery was first attempted */
74
+ createdAt: number
75
+ /** When the delivery was last attempted */
76
+ lastAttemptAt: number
77
+ }
78
+
79
+ /**
80
+ * Payload sent to webhook endpoints.
81
+ */
82
+ export interface WebhookPayload {
83
+ /** Unique event ID */
84
+ id: string
85
+ /** Event type */
86
+ event: WebhookEvent
87
+ /** When the event occurred */
88
+ timestamp: number
89
+ /** Event data */
90
+ data: Record<string, unknown>
91
+ }
92
+
93
+ /**
94
+ * Store for webhook endpoints and deliveries.
95
+ */
96
+ export interface WebhookStore {
97
+ /** Save a webhook endpoint */
98
+ saveEndpoint(endpoint: WebhookEndpoint): Promise<void>
99
+ /** Get an endpoint by ID */
100
+ getEndpoint(id: string): Promise<WebhookEndpoint | null>
101
+ /** List all endpoints */
102
+ listEndpoints(): Promise<WebhookEndpoint[]>
103
+ /** Delete an endpoint */
104
+ deleteEndpoint(id: string): Promise<void>
105
+ /** Save a delivery record */
106
+ saveDelivery(delivery: WebhookDelivery): Promise<void>
107
+ /** List recent deliveries for an endpoint */
108
+ listDeliveries(endpointId: string, limit?: number): Promise<WebhookDelivery[]>
109
+ }
110
+
111
+ // ============================================================================
112
+ // Errors
113
+ // ============================================================================
114
+
115
+ export class WebhookError extends KoraError {
116
+ constructor(message: string, code: string, context?: Record<string, unknown>) {
117
+ super(message, code, context)
118
+ this.name = 'WebhookError'
119
+ }
120
+ }
121
+
122
+ export class WebhookEndpointNotFoundError extends WebhookError {
123
+ constructor(id: string) {
124
+ super(`Webhook endpoint "${id}" not found.`, 'WEBHOOK_ENDPOINT_NOT_FOUND', { id })
125
+ }
126
+ }
127
+
128
+ // ============================================================================
129
+ // InMemoryWebhookStore
130
+ // ============================================================================
131
+
132
+ export class InMemoryWebhookStore implements WebhookStore {
133
+ private readonly endpoints = new Map<string, WebhookEndpoint>()
134
+ private readonly deliveries: WebhookDelivery[] = []
135
+
136
+ async saveEndpoint(endpoint: WebhookEndpoint): Promise<void> {
137
+ this.endpoints.set(endpoint.id, { ...endpoint })
138
+ }
139
+
140
+ async getEndpoint(id: string): Promise<WebhookEndpoint | null> {
141
+ const ep = this.endpoints.get(id)
142
+ return ep ? { ...ep } : null
143
+ }
144
+
145
+ async listEndpoints(): Promise<WebhookEndpoint[]> {
146
+ return [...this.endpoints.values()].map((e) => ({ ...e }))
147
+ }
148
+
149
+ async deleteEndpoint(id: string): Promise<void> {
150
+ this.endpoints.delete(id)
151
+ }
152
+
153
+ async saveDelivery(delivery: WebhookDelivery): Promise<void> {
154
+ const existing = this.deliveries.findIndex((d) => d.id === delivery.id)
155
+ if (existing >= 0) {
156
+ this.deliveries[existing] = { ...delivery }
157
+ } else {
158
+ this.deliveries.push({ ...delivery })
159
+ }
160
+ }
161
+
162
+ async listDeliveries(endpointId: string, limit = 50): Promise<WebhookDelivery[]> {
163
+ return this.deliveries
164
+ .filter((d) => d.endpointId === endpointId)
165
+ .sort((a, b) => b.createdAt - a.createdAt)
166
+ .slice(0, limit)
167
+ .map((d) => ({ ...d }))
168
+ }
169
+ }
170
+
171
+ // ============================================================================
172
+ // WebhookManager
173
+ // ============================================================================
174
+
175
+ const MAX_RETRIES = 3
176
+ const RETRY_DELAYS_MS = [1000, 5000, 30000] // 1s, 5s, 30s
177
+
178
+ /**
179
+ * Manages webhook registrations and event delivery.
180
+ *
181
+ * Sends HTTP POST requests to registered endpoints when auth events occur.
182
+ * Includes HMAC-SHA256 signature verification and retry logic.
183
+ *
184
+ * @example
185
+ * ```typescript
186
+ * const webhooks = new WebhookManager({
187
+ * store: new InMemoryWebhookStore(),
188
+ * })
189
+ *
190
+ * // Register an endpoint
191
+ * const endpoint = await webhooks.register({
192
+ * url: 'https://myapp.com/webhooks/auth',
193
+ * events: ['user.created', 'user.signin'],
194
+ * })
195
+ *
196
+ * // Dispatch an event (usually called internally by auth system)
197
+ * await webhooks.dispatch('user.created', { userId: 'user-123', email: 'alice@example.com' })
198
+ * ```
199
+ */
200
+ export class WebhookManager {
201
+ private readonly store: WebhookStore
202
+ private readonly fetchFn: typeof globalThis.fetch
203
+ private readonly allowPrivateTargets: boolean
204
+ private readonly resolveHost: ((host: string) => Promise<string[]>) | null
205
+
206
+ /**
207
+ * @param config.store - Endpoint and delivery storage
208
+ * @param config.fetch - Custom fetch. A custom fetch (for example through an
209
+ * egress proxy) owns its own network policy, so Kora skips its delivery-time
210
+ * DNS check unless `resolveHost` is also given.
211
+ * @param config.allowPrivateTargets - Development only: allow http:// and
212
+ * loopback/private/link-local targets. Default false.
213
+ * @param config.resolveHost - Resolve a hostname to addresses at delivery time
214
+ * (defaults to node:dns when no custom fetch is given), so a public name that
215
+ * points at a private address is refused (SSRF, DNS rebinding).
216
+ */
217
+ constructor(config: {
218
+ store: WebhookStore
219
+ fetch?: typeof globalThis.fetch
220
+ allowPrivateTargets?: boolean
221
+ resolveHost?: (host: string) => Promise<string[]>
222
+ }) {
223
+ this.store = config.store
224
+ this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis)
225
+ this.allowPrivateTargets = config.allowPrivateTargets === true
226
+ this.resolveHost = config.resolveHost ?? (config.fetch ? null : resolveWithNodeDns)
227
+ }
228
+
229
+ /**
230
+ * Register a new webhook endpoint.
231
+ */
232
+ async register(params: {
233
+ url: string
234
+ events: WebhookEvent[]
235
+ metadata?: Record<string, unknown>
236
+ }): Promise<WebhookEndpoint> {
237
+ assertSafeWebhookUrl(params.url, this.allowPrivateTargets)
238
+ const endpoint: WebhookEndpoint = {
239
+ id: generateId(),
240
+ url: params.url,
241
+ events: [...params.events],
242
+ secret: generateSecret(),
243
+ active: true,
244
+ createdAt: Date.now(),
245
+ metadata: params.metadata,
246
+ }
247
+
248
+ await this.store.saveEndpoint(endpoint)
249
+ return endpoint
250
+ }
251
+
252
+ /**
253
+ * Update a webhook endpoint.
254
+ */
255
+ async update(
256
+ id: string,
257
+ updates: { url?: string; events?: WebhookEvent[]; active?: boolean },
258
+ ): Promise<WebhookEndpoint> {
259
+ const endpoint = await this.store.getEndpoint(id)
260
+ if (!endpoint) {
261
+ throw new WebhookEndpointNotFoundError(id)
262
+ }
263
+
264
+ if (updates.url !== undefined) {
265
+ assertSafeWebhookUrl(updates.url, this.allowPrivateTargets)
266
+ endpoint.url = updates.url
267
+ }
268
+ if (updates.events !== undefined) endpoint.events = [...updates.events]
269
+ if (updates.active !== undefined) endpoint.active = updates.active
270
+
271
+ await this.store.saveEndpoint(endpoint)
272
+ return endpoint
273
+ }
274
+
275
+ /**
276
+ * Delete a webhook endpoint.
277
+ */
278
+ async remove(id: string): Promise<void> {
279
+ await this.store.deleteEndpoint(id)
280
+ }
281
+
282
+ /**
283
+ * List all webhook endpoints.
284
+ */
285
+ async list(): Promise<WebhookEndpoint[]> {
286
+ return this.store.listEndpoints()
287
+ }
288
+
289
+ /**
290
+ * Get a specific endpoint.
291
+ */
292
+ async get(id: string): Promise<WebhookEndpoint> {
293
+ const endpoint = await this.store.getEndpoint(id)
294
+ if (!endpoint) {
295
+ throw new WebhookEndpointNotFoundError(id)
296
+ }
297
+ return endpoint
298
+ }
299
+
300
+ /**
301
+ * Get recent deliveries for an endpoint.
302
+ */
303
+ async getDeliveries(endpointId: string, limit?: number): Promise<WebhookDelivery[]> {
304
+ return this.store.listDeliveries(endpointId, limit)
305
+ }
306
+
307
+ /**
308
+ * Dispatch an event to all matching webhook endpoints.
309
+ * Delivery is best-effort with retries.
310
+ */
311
+ async dispatch(event: WebhookEvent, data: Record<string, unknown>): Promise<void> {
312
+ const endpoints = await this.store.listEndpoints()
313
+ const matching = endpoints.filter((ep) => ep.active && ep.events.includes(event))
314
+
315
+ const payload: WebhookPayload = {
316
+ id: generateId(),
317
+ event,
318
+ timestamp: Date.now(),
319
+ data,
320
+ }
321
+
322
+ const payloadJson = JSON.stringify(payload)
323
+
324
+ await Promise.allSettled(matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event)))
325
+ }
326
+
327
+ // --- Private ---
328
+
329
+ private async deliverToEndpoint(
330
+ endpoint: WebhookEndpoint,
331
+ payloadJson: string,
332
+ event: WebhookEvent,
333
+ ): Promise<void> {
334
+ const delivery: WebhookDelivery = {
335
+ id: generateId(),
336
+ endpointId: endpoint.id,
337
+ event,
338
+ payload: payloadJson,
339
+ responseStatus: null,
340
+ success: false,
341
+ error: null,
342
+ attempts: 0,
343
+ createdAt: Date.now(),
344
+ lastAttemptAt: Date.now(),
345
+ }
346
+
347
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
348
+ delivery.attempts = attempt + 1
349
+ delivery.lastAttemptAt = Date.now()
350
+
351
+ try {
352
+ await this.assertDeliverable(endpoint.url)
353
+ // Sign the timestamp together with the body, so a captured delivery
354
+ // cannot be replayed later (AUTH-14).
355
+ const timestamp = Math.floor(Date.now() / 1000)
356
+ const signature = await signPayload(`${timestamp}.${payloadJson}`, endpoint.secret)
357
+ const response = await this.fetchFn(endpoint.url, {
358
+ method: 'POST',
359
+ headers: {
360
+ 'Content-Type': 'application/json',
361
+ 'X-Webhook-Signature': `t=${timestamp},v1=${signature}`,
362
+ 'X-Webhook-Timestamp': String(timestamp),
363
+ 'X-Webhook-Event': event,
364
+ 'X-Webhook-Delivery': delivery.id,
365
+ },
366
+ body: payloadJson,
367
+ redirect: 'manual',
368
+ })
369
+
370
+ delivery.responseStatus = response.status
371
+ delivery.success = response.ok
372
+
373
+ if (response.ok) {
374
+ await this.store.saveDelivery(delivery)
375
+ return
376
+ }
377
+
378
+ delivery.error = `HTTP ${response.status}`
379
+ } catch (err) {
380
+ delivery.error = err instanceof Error ? err.message : 'Network error'
381
+ }
382
+
383
+ // Wait before retrying (skip wait on last attempt)
384
+ if (attempt < MAX_RETRIES - 1) {
385
+ await delay(RETRY_DELAYS_MS[attempt] ?? 1000)
386
+ }
387
+ }
388
+
389
+ // All retries exhausted
390
+ await this.store.saveDelivery(delivery)
391
+ }
392
+
393
+ private async assertDeliverable(url: string): Promise<void> {
394
+ assertSafeWebhookUrl(url, this.allowPrivateTargets)
395
+ if (this.allowPrivateTargets || !this.resolveHost) return
396
+ const host = new URL(url).hostname
397
+ const addresses = await this.resolveHost(host)
398
+ if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
399
+ throw new WebhookTargetError(url)
400
+ }
401
+ }
402
+ }
403
+
404
+ /** Thrown when a webhook URL points at a non-public target. */
405
+ export class WebhookTargetError extends WebhookError {
406
+ constructor(url: string) {
407
+ super('Webhook URLs must be https and resolve to public addresses.', 'WEBHOOK_TARGET_REFUSED', {
408
+ url,
409
+ })
410
+ this.name = 'WebhookTargetError'
411
+ }
412
+ }
413
+
414
+ function assertSafeWebhookUrl(url: string, allowPrivate: boolean): void {
415
+ let parsed: URL
416
+ try {
417
+ parsed = new URL(url)
418
+ } catch {
419
+ throw new WebhookTargetError(url)
420
+ }
421
+ if (allowPrivate) {
422
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
423
+ throw new WebhookTargetError(url)
424
+ return
425
+ }
426
+ if (parsed.protocol !== 'https:') throw new WebhookTargetError(url)
427
+ const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase()
428
+ if (host === 'localhost' || host.endsWith('.localhost') || isPrivateAddress(host)) {
429
+ throw new WebhookTargetError(url)
430
+ }
431
+ }
432
+
433
+ /**
434
+ * True for loopback, private (RFC 1918 / ULA), link-local (incl. cloud metadata
435
+ * 169.254.169.254), CGNAT, unspecified and IPv4-mapped private addresses.
436
+ * Non-IP strings return false (hostnames are checked after resolution).
437
+ */
438
+ function isPrivateAddress(address: string): boolean {
439
+ let host = address.replace(/^\[|\]$/g, '').toLowerCase()
440
+ // URL() normalizes IPv4-mapped IPv6 to hex groups (::ffff:c0a8:1).
441
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host)
442
+ if (mapped) {
443
+ const hi = Number.parseInt(mapped[1] as string, 16)
444
+ const lo = Number.parseInt(mapped[2] as string, 16)
445
+ host = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`
446
+ }
447
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(
448
+ host.startsWith('::ffff:') ? host.slice(7) : host,
449
+ )
450
+ if (v4) {
451
+ const [a, b] = [Number(v4[1]), Number(v4[2])]
452
+ return (
453
+ a === 0 ||
454
+ a === 10 ||
455
+ a === 127 ||
456
+ (a === 169 && b === 254) ||
457
+ (a === 172 && b >= 16 && b <= 31) ||
458
+ (a === 192 && b === 168) ||
459
+ (a === 100 && b >= 64 && b <= 127) ||
460
+ a >= 224
461
+ )
462
+ }
463
+ if (host.includes(':')) {
464
+ return (
465
+ host === '::' ||
466
+ host === '::1' ||
467
+ host.startsWith('fe8') ||
468
+ host.startsWith('fe9') ||
469
+ host.startsWith('fea') ||
470
+ host.startsWith('feb') ||
471
+ host.startsWith('fc') ||
472
+ host.startsWith('fd') ||
473
+ host.startsWith('ff')
474
+ )
475
+ }
476
+ return false
477
+ }
478
+
479
+ async function resolveWithNodeDns(host: string): Promise<string[]> {
480
+ if (isPrivateAddress(host) || /^[\d.]+$/.test(host) || host.includes(':')) return [host]
481
+ const dns = (await import('node:dns/promises')) as {
482
+ lookup(h: string, o: { all: true }): Promise<Array<{ address: string }>>
483
+ }
484
+ const results = await dns.lookup(host, { all: true })
485
+ return results.map((r) => r.address)
486
+ }
487
+
488
+ // ============================================================================
489
+ // Helpers
490
+ // ============================================================================
491
+
492
+ function generateId(): string {
493
+ const bytes = new Uint8Array(16)
494
+ globalThis.crypto.getRandomValues(bytes)
495
+ let hex = ''
496
+ for (let i = 0; i < bytes.length; i++) {
497
+ hex += bytes[i]?.toString(16).padStart(2, '0')
498
+ }
499
+ return hex
500
+ }
501
+
502
+ function generateSecret(): string {
503
+ const bytes = new Uint8Array(32)
504
+ globalThis.crypto.getRandomValues(bytes)
505
+ let hex = ''
506
+ for (let i = 0; i < bytes.length; i++) {
507
+ hex += bytes[i]?.toString(16).padStart(2, '0')
508
+ }
509
+ return `whsec_${hex}`
510
+ }
511
+
512
+ /**
513
+ * Sign a payload with HMAC-SHA256 for webhook verification.
514
+ */
515
+ async function signPayload(payload: string, secret: string): Promise<string> {
516
+ const encoder = new TextEncoder()
517
+ const key = await globalThis.crypto.subtle.importKey(
518
+ 'raw',
519
+ encoder.encode(secret),
520
+ { name: 'HMAC', hash: 'SHA-256' },
521
+ false,
522
+ ['sign'],
523
+ )
524
+ const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(payload))
525
+ const bytes = new Uint8Array(signature)
526
+ let hex = ''
527
+ for (let i = 0; i < bytes.length; i++) {
528
+ hex += bytes[i]?.toString(16).padStart(2, '0')
529
+ }
530
+ return hex
531
+ }
532
+
533
+ /** Default accepted age of a webhook delivery: 5 minutes. */
534
+ const DEFAULT_SIGNATURE_TOLERANCE_SECONDS = 300
535
+
536
+ /**
537
+ * Verify a webhook payload signature (`X-Webhook-Signature: t=<unix>,v1=<hex>`).
538
+ *
539
+ * The HMAC covers `${t}.${payload}`, and a delivery older (or newer) than
540
+ * `toleranceSeconds` is rejected, so a captured delivery cannot be replayed.
541
+ *
542
+ * @param payload - The raw request body
543
+ * @param signature - The `X-Webhook-Signature` header
544
+ * @param secret - The endpoint secret
545
+ * @param options - `toleranceSeconds` (default 300)
546
+ * @returns True when the signature is valid and fresh
547
+ */
548
+ export async function verifyWebhookSignature(
549
+ payload: string,
550
+ signature: string,
551
+ secret: string,
552
+ options: { toleranceSeconds?: number } = {},
553
+ ): Promise<boolean> {
554
+ const parts = new Map<string, string>()
555
+ for (const piece of signature.split(',')) {
556
+ const index = piece.indexOf('=')
557
+ if (index > 0) parts.set(piece.slice(0, index).trim(), piece.slice(index + 1).trim())
558
+ }
559
+ const timestamp = Number(parts.get('t'))
560
+ const provided = parts.get('v1')
561
+ if (!Number.isInteger(timestamp) || !provided) return false
562
+ const tolerance = options.toleranceSeconds ?? DEFAULT_SIGNATURE_TOLERANCE_SECONDS
563
+ if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) return false
564
+ const expected = await signPayload(`${timestamp}.${payload}`, secret)
565
+ // Timing-safe comparison
566
+ if (expected.length !== provided.length) return false
567
+ let result = 0
568
+ for (let i = 0; i < expected.length; i++) {
569
+ result |= expected.charCodeAt(i) ^ provided.charCodeAt(i)
570
+ }
571
+ return result === 0
572
+ }
573
+
574
+ function delay(ms: number): Promise<void> {
575
+ return new Promise((resolve) => setTimeout(resolve, ms))
576
+ }