whisp-js 0.0.0-stage → 0.1.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -2
  3. package/dist/cjs/client.d.ts +164 -0
  4. package/dist/cjs/client.js +460 -0
  5. package/dist/cjs/constants.d.ts +43 -0
  6. package/dist/cjs/constants.js +59 -0
  7. package/dist/cjs/errors.d.ts +96 -0
  8. package/dist/cjs/errors.js +115 -0
  9. package/dist/cjs/index.d.ts +19 -0
  10. package/dist/cjs/index.js +39 -0
  11. package/dist/cjs/listener.d.ts +93 -0
  12. package/dist/cjs/listener.js +468 -0
  13. package/dist/cjs/media.d.ts +40 -0
  14. package/dist/cjs/media.js +181 -0
  15. package/dist/cjs/package.json +3 -0
  16. package/dist/cjs/refs.d.ts +4 -0
  17. package/dist/cjs/refs.js +38 -0
  18. package/dist/cjs/runtime.d.ts +24 -0
  19. package/dist/cjs/runtime.js +93 -0
  20. package/dist/cjs/sse.d.ts +32 -0
  21. package/dist/cjs/sse.js +105 -0
  22. package/dist/cjs/transport.d.ts +61 -0
  23. package/dist/cjs/transport.js +181 -0
  24. package/dist/cjs/types.d.ts +516 -0
  25. package/dist/cjs/types.js +14 -0
  26. package/dist/cjs/version.d.ts +2 -0
  27. package/dist/cjs/version.js +5 -0
  28. package/dist/cjs/webhook.d.ts +27 -0
  29. package/dist/cjs/webhook.js +128 -0
  30. package/dist/esm/client.d.ts +164 -0
  31. package/dist/esm/client.js +456 -0
  32. package/dist/esm/constants.d.ts +43 -0
  33. package/dist/esm/constants.js +56 -0
  34. package/dist/esm/errors.d.ts +96 -0
  35. package/dist/esm/errors.js +107 -0
  36. package/dist/esm/index.d.ts +19 -0
  37. package/dist/esm/index.js +16 -0
  38. package/dist/esm/listener.d.ts +93 -0
  39. package/dist/esm/listener.js +463 -0
  40. package/dist/esm/media.d.ts +40 -0
  41. package/dist/esm/media.js +175 -0
  42. package/dist/esm/refs.d.ts +4 -0
  43. package/dist/esm/refs.js +33 -0
  44. package/dist/esm/runtime.d.ts +24 -0
  45. package/dist/esm/runtime.js +84 -0
  46. package/dist/esm/sse.d.ts +32 -0
  47. package/dist/esm/sse.js +100 -0
  48. package/dist/esm/transport.d.ts +61 -0
  49. package/dist/esm/transport.js +172 -0
  50. package/dist/esm/types.d.ts +516 -0
  51. package/dist/esm/types.js +13 -0
  52. package/dist/esm/version.d.ts +2 -0
  53. package/dist/esm/version.js +2 -0
  54. package/dist/esm/webhook.d.ts +27 -0
  55. package/dist/esm/webhook.js +123 -0
  56. package/package.json +37 -5
@@ -0,0 +1,516 @@
1
+ /**
2
+ * The public API's shapes, written by hand (phase2-design 12.1): the SDK is
3
+ * published on its own and can't depend on the monorepo's packages. Each type
4
+ * here equals `Static<typeof X>` of its schema in `packages/schema/src/public`
5
+ * (packages/sdk/test/types.test.ts checks it at compile time, so `bun
6
+ * typecheck` fails on drift).
7
+ *
8
+ * The API only grows (4.1): a field can be added, never renamed, removed or
9
+ * retyped, and a new value of a union is announced in the changelog first.
10
+ * Times are ISO 8601 with milliseconds; phones are WhatsApp digits without
11
+ * `+` (`972501234567`).
12
+ */
13
+ export type NumberStatus = 'pairing' | 'connecting' | 'connected' | 'disconnected';
14
+ export type DisconnectReason = 'logged_out' | 'replaced' | 'bad_session' | 'locked' | 'temp_banned' | 'banned' | 'client_rejected' | 'unknown' | 'pairing_expired' | 'pairing_failed' | 'unlinked' | 'project_deleted';
15
+ export type RiskLevel = 'healthy' | 'watch' | 'at_risk';
16
+ /** What a risk reason is about: one of the seven signals, or one of WhatsApp's own warnings. */
17
+ export type RiskSignal = 'volume' | 'to_new' | 'reply_rate' | 'failed' | 'newly_linked' | 'disconnects' | 'burst' | 'wa_reachout_timelock' | 'wa_message_cap';
18
+ export type ChatKind = 'direct' | 'group';
19
+ export type LabelColor = 'green' | 'blue' | 'purple' | 'amber' | 'red' | 'grey';
20
+ export type MessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'played' | 'failed';
21
+ export type MediaState = 'pending' | 'ready' | 'too_large' | 'expired' | 'failed';
22
+ export type MediaKind = 'image' | 'video' | 'audio' | 'voice' | 'document' | 'sticker';
23
+ /**
24
+ * `auto`: the reply Whisp sends to a rejected call, when the number is set
25
+ * up to. `system`: an alert Whisp itself sends from an operations number.
26
+ */
27
+ export type SentBy = 'phone' | 'member' | 'api' | 'auto' | 'system';
28
+ export type PresenceState = 'available' | 'unavailable' | 'composing' | 'recording' | 'paused';
29
+ export type ProjectEnvironment = 'production' | 'testing';
30
+ export type UploadKind = 'image' | 'video' | 'document' | 'voice';
31
+ /** How a pool picks its number; `healthiest` is the default. */
32
+ export type SendStrategy = 'healthiest' | 'random' | 'round-robin';
33
+ export type ChatChange = 'unread' | 'done' | 'pinned' | 'assignee' | 'labels' | 'title' | 'ephemeral';
34
+ export type CursorRejection = 'cursor_unknown' | 'cursor_expired';
35
+ /** Every message type but the inbox's own `waiting` stubs and `system` lines. */
36
+ export type MessageType = 'text' | 'image' | 'video' | 'audio' | 'voice' | 'document' | 'sticker' | 'location' | 'contact' | 'poll' | 'unsupported';
37
+ /** PLAN's twelve public event types (6.1). */
38
+ export type PublicEventType = 'message.received' | 'message.sent' | 'message.status' | 'message.reaction' | 'message.deleted' | 'chat.updated' | 'presence.updated' | 'group.updated' | 'group.participants' | 'number.status' | 'number.risk' | 'call.received';
39
+ /** Someone on WhatsApp. `phone` is null for a LID-only contact. */
40
+ export type Actor = {
41
+ phone: string | null;
42
+ lid: string | null;
43
+ name: string | null;
44
+ };
45
+ /**
46
+ * One of the project's WhatsApp numbers. `risk` is null until the number was
47
+ * first linked. `healthScore`: 0 to 100, lower is healthier (the worst of its
48
+ * risk signals: under 50 healthy, 50 to 79 watch, 80 and up at risk), at
49
+ * least 30 until it sent 20 messages in the last 24 hours (new or idle), null
50
+ * until its first evaluation; `healthiest` pools prefer lower scores, in bands
51
+ * of 10. `paused`: sends from the API are refused with 409 NUMBER_PAUSED (a
52
+ * pool skips it). `capReached`: it sent its daily cap in the last 24 hours,
53
+ * so sends are refused with 429 DAILY_CAP_REACHED.
54
+ */
55
+ export type WhatsAppNumber = {
56
+ id: string;
57
+ phone: string;
58
+ name: string;
59
+ status: NumberStatus;
60
+ disconnectReason: DisconnectReason | null;
61
+ disconnectedAt: string | null;
62
+ waName: string | null;
63
+ platform: string | null;
64
+ linkedAt: string | null;
65
+ risk: RiskLevel | null;
66
+ healthScore: number | null;
67
+ paused: boolean;
68
+ capReached: boolean;
69
+ last24h: {
70
+ sent: number;
71
+ received: number;
72
+ };
73
+ };
74
+ export type Label = {
75
+ id: string;
76
+ name: string;
77
+ color: LabelColor;
78
+ };
79
+ export type Assignee = {
80
+ id: string;
81
+ name: string;
82
+ };
83
+ /** A chat of one of the project's numbers. */
84
+ export type Chat = {
85
+ id: string;
86
+ numberId: string;
87
+ /** The Whisp number's phone. */
88
+ number: string;
89
+ kind: ChatKind;
90
+ jid: string;
91
+ /** Null for groups and LID-only contacts. */
92
+ phone: string | null;
93
+ lid: string | null;
94
+ title: string;
95
+ unread: number;
96
+ done: boolean;
97
+ pinned: boolean;
98
+ assignee: Assignee | null;
99
+ labels: Label[];
100
+ participantCount: number | null;
101
+ lastMessageAt: string;
102
+ };
103
+ /** A message's file. Download it with `downloadMedia`, never from `url` with your token attached elsewhere. */
104
+ export type MessageMedia = {
105
+ kind: MediaKind;
106
+ mime: string | null;
107
+ size: number | null;
108
+ fileName: string | null;
109
+ width: number | null;
110
+ height: number | null;
111
+ durationMs: number | null;
112
+ pages: number | null;
113
+ state: MediaState;
114
+ url: string;
115
+ };
116
+ /** What a reply points at; `text` cut to 1024 characters. */
117
+ export type MessageQuoted = {
118
+ id: string | null;
119
+ waId: string;
120
+ fromMe: boolean;
121
+ type: string;
122
+ text: string | null;
123
+ };
124
+ export type MessageChat = {
125
+ kind: ChatKind;
126
+ jid: string;
127
+ phone: string | null;
128
+ title: string | null;
129
+ };
130
+ export type LocationContent = {
131
+ kind: 'location';
132
+ lat: number;
133
+ lng: number;
134
+ name: string | null;
135
+ address: string | null;
136
+ };
137
+ export type ContactCardContent = {
138
+ kind: 'contact';
139
+ name: string;
140
+ phones: string[];
141
+ };
142
+ export type PollContent = {
143
+ kind: 'poll';
144
+ question: string;
145
+ options: string[];
146
+ selectable: number;
147
+ };
148
+ export type MessageContent = LocationContent | ContactCardContent | PollContent;
149
+ export type MessageError = {
150
+ code: string;
151
+ message: string;
152
+ };
153
+ /** One message, inbound or outbound. `from` is `sender.phone`; `to` is null in groups. */
154
+ export type Message = {
155
+ id: string;
156
+ /** WhatsApp's id; null only while an outbound message waits to be sent. */
157
+ waId: string | null;
158
+ /** The Whisp number's phone. */
159
+ number: string;
160
+ numberId: string;
161
+ chatId: string;
162
+ chat: MessageChat;
163
+ fromMe: boolean;
164
+ from: string | null;
165
+ sender: Actor;
166
+ to: string | null;
167
+ type: MessageType;
168
+ /** The body or the caption. */
169
+ text: string | null;
170
+ media: MessageMedia | null;
171
+ quoted: MessageQuoted | null;
172
+ content: MessageContent | null;
173
+ status: MessageStatus;
174
+ error: MessageError | null;
175
+ /** Who sent an outbound message; null for inbound ones. */
176
+ sentBy: SentBy | null;
177
+ /** Your `reference` from the send. */
178
+ reference: string | null;
179
+ at: string;
180
+ editedAt: string | null;
181
+ deletedAt: string | null;
182
+ };
183
+ /** The number an event happened on. */
184
+ export type EventNumber = {
185
+ id: string;
186
+ phone: string;
187
+ };
188
+ export type MessageStatusData = {
189
+ messageId: string;
190
+ waId: string | null;
191
+ chatId: string;
192
+ to: string | null;
193
+ status: 'delivered' | 'read' | 'played' | 'failed';
194
+ previousStatus: MessageStatus;
195
+ error: MessageError | null;
196
+ reference: string | null;
197
+ at: string;
198
+ };
199
+ export type MessageReactionData = {
200
+ messageId: string;
201
+ waId: string | null;
202
+ chatId: string;
203
+ emoji: string;
204
+ removed: boolean;
205
+ fromMe: boolean;
206
+ actor: Actor | null;
207
+ reference: string | null;
208
+ at: string;
209
+ };
210
+ export type MessageDeletedData = {
211
+ messageId: string;
212
+ waId: string | null;
213
+ chatId: string;
214
+ fromMe: boolean;
215
+ deletedBy: Actor | null;
216
+ reference: string | null;
217
+ at: string;
218
+ };
219
+ export type ChatUpdatedData = {
220
+ chat: Chat;
221
+ changes: ChatChange[];
222
+ };
223
+ export type PresenceUpdatedData = {
224
+ chatId: string;
225
+ chat: {
226
+ kind: ChatKind;
227
+ jid: string;
228
+ phone: string | null;
229
+ };
230
+ state: PresenceState;
231
+ participant: Actor | null;
232
+ at: string;
233
+ };
234
+ export type GroupUpdatedData = {
235
+ chatId: string;
236
+ jid: string;
237
+ changes: {
238
+ subject?: string;
239
+ description?: string | null;
240
+ announce?: boolean;
241
+ restrict?: boolean;
242
+ joinApprovalMode?: boolean;
243
+ memberAddMode?: boolean;
244
+ ephemeralDuration?: number | null;
245
+ };
246
+ by: Actor | null;
247
+ at: string;
248
+ };
249
+ export type GroupParticipantsData = {
250
+ chatId: string;
251
+ jid: string;
252
+ action: 'add' | 'remove' | 'promote' | 'demote' | 'modify';
253
+ participants: Actor[];
254
+ by: Actor | null;
255
+ at: string;
256
+ };
257
+ export type NumberStatusData = {
258
+ status: NumberStatus;
259
+ previousStatus: NumberStatus;
260
+ reason: DisconnectReason | null;
261
+ detail: {
262
+ code: number | null;
263
+ until: string | null;
264
+ } | null;
265
+ at: string;
266
+ };
267
+ /** One reason for a risk change: the signal, its level, its value and the English sentence. */
268
+ export type RiskReason = {
269
+ signal: RiskSignal;
270
+ level: RiskLevel;
271
+ value: number | null;
272
+ text: string;
273
+ };
274
+ export type NumberRiskData = {
275
+ risk: RiskLevel;
276
+ previousRisk: RiskLevel;
277
+ /** The first reason's sentence. */
278
+ reason: string;
279
+ /** At most two. */
280
+ reasons: RiskReason[];
281
+ /** Sending is paused on the number after this change: route sends elsewhere. */
282
+ paused: boolean;
283
+ at: string;
284
+ };
285
+ export type CallReceivedData = {
286
+ callId: string;
287
+ from: Actor;
288
+ chatId: string | null;
289
+ video: boolean;
290
+ group: boolean;
291
+ /** Delivered after a reconnect; the call is already over. */
292
+ offline: boolean;
293
+ /** The number rejected it (its "Reject calls" setting). */
294
+ rejected: boolean;
295
+ at: string;
296
+ };
297
+ /** Each type's `data`. */
298
+ export type EventData = {
299
+ 'message.received': Message;
300
+ 'message.sent': Message;
301
+ 'message.status': MessageStatusData;
302
+ 'message.reaction': MessageReactionData;
303
+ 'message.deleted': MessageDeletedData;
304
+ 'chat.updated': ChatUpdatedData;
305
+ 'presence.updated': PresenceUpdatedData;
306
+ 'group.updated': GroupUpdatedData;
307
+ 'group.participants': GroupParticipantsData;
308
+ 'number.status': NumberStatusData;
309
+ 'number.risk': NumberRiskData;
310
+ 'call.received': CallReceivedData;
311
+ };
312
+ /** The envelope: the SSE `data:`, an item of `GET /events` and a webhook body are this same object. */
313
+ export type EventOf<T extends PublicEventType> = {
314
+ /** The event id: the dedupe key. Not the resume cursor. */
315
+ id: string;
316
+ type: T;
317
+ /** When the change committed. */
318
+ createdAt: string;
319
+ projectId: string;
320
+ number: EventNumber;
321
+ data: EventData[T];
322
+ };
323
+ /** One public event, narrowed by `type`. */
324
+ export type PublicEvent = {
325
+ [T in PublicEventType]: EventOf<T>;
326
+ }[PublicEventType];
327
+ /** The test delivery's body: the envelope with `number: null`, sent only to webhooks. */
328
+ export type WebhookTestEvent = {
329
+ id: string;
330
+ type: 'webhook.test';
331
+ createdAt: string;
332
+ projectId: string;
333
+ number: null;
334
+ data: {
335
+ webhookId: string;
336
+ message: string;
337
+ };
338
+ };
339
+ /** What a webhook receives; `verifyWebhook` returns it typed by `type`. */
340
+ export type WebhookEvent = PublicEvent | WebhookTestEvent;
341
+ /** The stream's first event: whether the resume asked for was honoured, and why not. */
342
+ export type StreamReady = {
343
+ resumed: boolean;
344
+ reason?: CursorRejection;
345
+ };
346
+ /** The stream's last event before a deliberate close (`TOKEN_REVOKED`, `SHUTDOWN`). */
347
+ export type StreamError = {
348
+ code: string;
349
+ message: string;
350
+ };
351
+ /** `GET /events`, ascending; `nextAfter` is the cursor to ask with next. */
352
+ export type EventList = {
353
+ events: PublicEvent[];
354
+ nextAfter: string | null;
355
+ };
356
+ /** A file you uploaded (POST /uploads), or an https URL the server downloads. */
357
+ export type MediaInput = {
358
+ uploadId: string;
359
+ } | {
360
+ url: string;
361
+ fileName?: string;
362
+ mime?: string;
363
+ };
364
+ /** `POST /messages`: text, a file or a voice note, from a number, a pool or the whole project. */
365
+ export type SendMessageBody = {
366
+ to: string;
367
+ replyTo?: string;
368
+ reference?: string;
369
+ wait?: boolean | number;
370
+ dailyCap?: number;
371
+ type?: 'text';
372
+ text: string;
373
+ from?: string | string[];
374
+ strategy?: SendStrategy;
375
+ sticky?: boolean;
376
+ } | {
377
+ to: string;
378
+ replyTo?: string;
379
+ reference?: string;
380
+ wait?: boolean | number;
381
+ dailyCap?: number;
382
+ type: 'image' | 'video' | 'document';
383
+ media: MediaInput;
384
+ caption?: string;
385
+ from?: string | string[];
386
+ strategy?: SendStrategy;
387
+ sticky?: boolean;
388
+ } | {
389
+ to: string;
390
+ replyTo?: string;
391
+ reference?: string;
392
+ wait?: boolean | number;
393
+ dailyCap?: number;
394
+ type: 'voice';
395
+ media: MediaInput;
396
+ from?: string | string[];
397
+ strategy?: SendStrategy;
398
+ sticky?: boolean;
399
+ };
400
+ /** `POST /numbers/:number/messages`: the same without the pool fields. */
401
+ export type SendFromNumberBody = {
402
+ to: string;
403
+ replyTo?: string;
404
+ reference?: string;
405
+ wait?: boolean | number;
406
+ dailyCap?: number;
407
+ type?: 'text';
408
+ text: string;
409
+ } | {
410
+ to: string;
411
+ replyTo?: string;
412
+ reference?: string;
413
+ wait?: boolean | number;
414
+ dailyCap?: number;
415
+ type: 'image' | 'video' | 'document';
416
+ media: MediaInput;
417
+ caption?: string;
418
+ } | {
419
+ to: string;
420
+ replyTo?: string;
421
+ reference?: string;
422
+ wait?: boolean | number;
423
+ dailyCap?: number;
424
+ type: 'voice';
425
+ media: MediaInput;
426
+ };
427
+ /** `POST /numbers/:number/messages/media`: files and voice notes only. */
428
+ export type SendMediaFromNumberBody = {
429
+ to: string;
430
+ replyTo?: string;
431
+ reference?: string;
432
+ wait?: boolean | number;
433
+ dailyCap?: number;
434
+ type: 'image' | 'video' | 'document';
435
+ media: MediaInput;
436
+ caption?: string;
437
+ } | {
438
+ to: string;
439
+ replyTo?: string;
440
+ reference?: string;
441
+ wait?: boolean | number;
442
+ dailyCap?: number;
443
+ type: 'voice';
444
+ media: MediaInput;
445
+ };
446
+ /** `GET /project`: the token's project. */
447
+ export type ProjectInfo = {
448
+ project: {
449
+ id: string;
450
+ slug: string;
451
+ name: string;
452
+ environment: ProjectEnvironment;
453
+ defaultNumberId: string | null;
454
+ };
455
+ token: {
456
+ last4: string;
457
+ createdAt: string;
458
+ };
459
+ };
460
+ export type NumberList = {
461
+ numbers: WhatsAppNumber[];
462
+ };
463
+ /** `GET /numbers/:number/check/:phone`: `jid` is the server's form when the phone exists. */
464
+ export type PhoneCheck = {
465
+ phone: string;
466
+ exists: boolean;
467
+ jid: string | null;
468
+ };
469
+ export type MessageList = {
470
+ messages: Message[];
471
+ };
472
+ /** `GET /messages/:messageId/media?redirect=0`. */
473
+ export type MediaLink = {
474
+ url: string;
475
+ expiresAt: string;
476
+ };
477
+ export type ChatListQuery = {
478
+ kind?: ChatKind;
479
+ unread?: boolean;
480
+ done?: boolean;
481
+ cursor?: string;
482
+ limit?: number;
483
+ };
484
+ export type ChatList = {
485
+ chats: Chat[];
486
+ nextCursor: string | null;
487
+ };
488
+ export type ChatMessagesQuery = {
489
+ before?: string;
490
+ after?: string;
491
+ limit?: number;
492
+ };
493
+ export type ChatMessageList = {
494
+ messages: Message[];
495
+ nextCursor: string | null;
496
+ };
497
+ /** `POST /uploads`. */
498
+ export type UploadRequest = {
499
+ kind: UploadKind;
500
+ mime: string;
501
+ size: number;
502
+ name: string;
503
+ };
504
+ /** PUT the bytes to `url` with exactly `headers` before `expiresAt`, then send with `{ media: { uploadId } }`. */
505
+ export type Upload = {
506
+ uploadId: string;
507
+ url: string;
508
+ headers: Record<string, string>;
509
+ expiresAt: string;
510
+ };
511
+ /** Every error the API answers. `code` is stable; `params` carries the values it names. */
512
+ export type ApiError = {
513
+ code: string;
514
+ message: string;
515
+ params?: Record<string, string | number>;
516
+ };
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ /**
3
+ * The public API's shapes, written by hand (phase2-design 12.1): the SDK is
4
+ * published on its own and can't depend on the monorepo's packages. Each type
5
+ * here equals `Static<typeof X>` of its schema in `packages/schema/src/public`
6
+ * (packages/sdk/test/types.test.ts checks it at compile time, so `bun
7
+ * typecheck` fails on drift).
8
+ *
9
+ * The API only grows (4.1): a field can be added, never renamed, removed or
10
+ * retyped, and a new value of a union is announced in the changelog first.
11
+ * Times are ISO 8601 with milliseconds; phones are WhatsApp digits without
12
+ * `+` (`972501234567`).
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
1
+ /** The package's version: sent in the User-Agent. packages/sdk/test/constants.test.ts keeps it equal to package.json's. */
2
+ export declare const SDK_VERSION = "0.1.0";
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SDK_VERSION = void 0;
4
+ /** The package's version: sent in the User-Agent. packages/sdk/test/constants.test.ts keeps it equal to package.json's. */
5
+ exports.SDK_VERSION = '0.1.0';
@@ -0,0 +1,27 @@
1
+ import type { WebhookEvent } from './types.js';
2
+ export interface VerifyWebhookInput {
3
+ /** The raw request body, before any JSON parsing: `await request.text()`, or the Buffer of `express.raw()`. */
4
+ body: string | Uint8Array | ArrayBuffer;
5
+ /** The `X-Whisp-Signature` header (`request.headers.get('x-whisp-signature')`). */
6
+ signature: string | null | undefined;
7
+ /** The endpoint's signing secret (`wsp_whs_…`); several are tried in turn. */
8
+ secret: string | readonly string[] | null | undefined;
9
+ /** How far the signature's time may be from now, in seconds. Default 300. */
10
+ toleranceSeconds?: number;
11
+ /** "Now" in Unix milliseconds, for tests. Default `Date.now()`. */
12
+ now?: number;
13
+ }
14
+ interface ParsedHeader {
15
+ timestamp: number;
16
+ signatures: string[];
17
+ }
18
+ /** `t` and every `v1`; other keys (a later `v2`) are ignored. Null when malformed, or without `t` or `v1`. */
19
+ export declare const parseSignatureHeader: (header: string) => ParsedHeader | null;
20
+ /**
21
+ * The event the request carries, typed by `type` (`webhook.test` included),
22
+ * or throws WhispSignatureError with `reason` 'missing', 'malformed',
23
+ * 'timestamp' or 'mismatch'. A missing or mistyped secret throws a WhispError
24
+ * (SECRET_MISSING, SECRET_INVALID), which is a setup problem, not a bad request.
25
+ */
26
+ export declare const verifyWebhook: (input: VerifyWebhookInput) => Promise<WebhookEvent>;
27
+ export {};
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifyWebhook = exports.parseSignatureHeader = void 0;
4
+ /**
5
+ * `verifyWebhook` (phase2-design 7.3, 12.5): checks `X-Whisp-Signature` on a
6
+ * webhook request and returns the event.
7
+ *
8
+ * The header is `t=<unix seconds>,v1=<hex>[,v1=<hex>]`: one `v1` per secret
9
+ * the endpoint signs with (two during a rotation's 24 h grace, 7.7). Each `v1`
10
+ * is the lowercase hex HMAC-SHA256, keyed with the whole `wsp_whs_…` secret
11
+ * as UTF-8, over `<t>.<raw body>`. A request passes when the header parses,
12
+ * `|now - t|` is within the tolerance (300 s by default) and any `v1` matches.
13
+ *
14
+ * Web Crypto's `subtle.verify` compares in constant time. It is async, so
15
+ * this is. Core's synchronous `verifySignatureHeader` signs the same way for
16
+ * the server; packages/sdk/test/webhook.test.ts holds the two together.
17
+ */
18
+ const constants_ts_1 = require("./constants.js");
19
+ const errors_ts_1 = require("./errors.js");
20
+ const encoder = new TextEncoder();
21
+ const TIMESTAMP = /^\d{1,12}$/;
22
+ const HEX_SIGNATURE = /^[0-9a-f]{64}$/;
23
+ /** `t` and every `v1`; other keys (a later `v2`) are ignored. Null when malformed, or without `t` or `v1`. */
24
+ const parseSignatureHeader = (header) => {
25
+ let timestamp = null;
26
+ const signatures = [];
27
+ for (const part of header.split(',')) {
28
+ const separator = part.indexOf('=');
29
+ if (separator < 1)
30
+ return null;
31
+ const key = part.slice(0, separator).trim();
32
+ const value = part.slice(separator + 1).trim();
33
+ if (key === 't') {
34
+ if (timestamp !== null || !TIMESTAMP.test(value))
35
+ return null;
36
+ timestamp = Number(value);
37
+ }
38
+ else if (key === 'v1') {
39
+ const lower = value.toLowerCase();
40
+ if (!HEX_SIGNATURE.test(lower))
41
+ return null;
42
+ signatures.push(lower);
43
+ }
44
+ }
45
+ return timestamp === null || signatures.length === 0 ? null : { timestamp, signatures };
46
+ };
47
+ exports.parseSignatureHeader = parseSignatureHeader;
48
+ const hexToBytes = (hex) => {
49
+ const out = new Uint8Array(hex.length / 2);
50
+ for (let i = 0; i < out.length; i++)
51
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
52
+ return out;
53
+ };
54
+ const toBytes = (body) => typeof body === 'string' ? encoder.encode(body) : body instanceof Uint8Array ? body : new Uint8Array(body);
55
+ const secretsOf = (secret) => {
56
+ const list = (typeof secret === 'string' ? [secret] : (secret ?? [])).map(value => typeof value === 'string' ? value.trim() : '');
57
+ if (list.length === 0 || list.every(value => value === '')) {
58
+ throw (0, errors_ts_1.localError)('SECRET_MISSING', "verifyWebhook needs the endpoint's signing secret (wsp_whs_…); is its environment variable set?");
59
+ }
60
+ for (const value of list) {
61
+ if (!constants_ts_1.WEBHOOK_SECRET_PATTERN.test(value)) {
62
+ throw (0, errors_ts_1.localError)('SECRET_INVALID', "The secret is not a webhook signing secret (wsp_whs_…). Copy it again from the project's Webhooks tab.");
63
+ }
64
+ }
65
+ return list;
66
+ };
67
+ /**
68
+ * The event the request carries, typed by `type` (`webhook.test` included),
69
+ * or throws WhispSignatureError with `reason` 'missing', 'malformed',
70
+ * 'timestamp' or 'mismatch'. A missing or mistyped secret throws a WhispError
71
+ * (SECRET_MISSING, SECRET_INVALID), which is a setup problem, not a bad request.
72
+ */
73
+ const verifyWebhook = async (input) => {
74
+ const secrets = secretsOf(input.secret);
75
+ const tolerance = input.toleranceSeconds ?? constants_ts_1.SIGNATURE_TOLERANCE_SECONDS;
76
+ if (!Number.isFinite(tolerance) || tolerance < 0) {
77
+ throw (0, errors_ts_1.localError)('OPTIONS_INVALID', 'toleranceSeconds must be a number of seconds, 0 or more');
78
+ }
79
+ const subtle = globalThis.crypto?.subtle;
80
+ if (!subtle)
81
+ throw (0, errors_ts_1.localError)('CRYPTO_UNAVAILABLE', 'verifyWebhook needs Web Crypto (crypto.subtle)');
82
+ const header = typeof input.signature === 'string' ? input.signature : '';
83
+ if (header.trim() === '')
84
+ throw new errors_ts_1.WhispSignatureError('missing');
85
+ const parsed = (0, exports.parseSignatureHeader)(header);
86
+ if (!parsed)
87
+ throw new errors_ts_1.WhispSignatureError('malformed');
88
+ const nowSec = Math.floor((input.now ?? Date.now()) / 1_000);
89
+ if (Math.abs(nowSec - parsed.timestamp) > tolerance)
90
+ throw new errors_ts_1.WhispSignatureError('timestamp');
91
+ const body = toBytes(input.body);
92
+ const prefix = encoder.encode(`${parsed.timestamp}.`);
93
+ const message = new Uint8Array(prefix.length + body.length);
94
+ message.set(prefix);
95
+ message.set(body, prefix.length);
96
+ let matched = false;
97
+ for (const secret of secrets) {
98
+ const key = await subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
99
+ 'verify',
100
+ ]);
101
+ for (const signature of parsed.signatures) {
102
+ if (await subtle.verify('HMAC', key, hexToBytes(signature), message)) {
103
+ matched = true;
104
+ break;
105
+ }
106
+ }
107
+ if (matched)
108
+ break;
109
+ }
110
+ if (!matched)
111
+ throw new errors_ts_1.WhispSignatureError('mismatch');
112
+ let event;
113
+ try {
114
+ event = JSON.parse(typeof input.body === 'string' ? input.body : new TextDecoder().decode(body));
115
+ }
116
+ catch {
117
+ event = undefined;
118
+ }
119
+ const record = event;
120
+ if (typeof record !== 'object' ||
121
+ record === null ||
122
+ typeof record.id !== 'string' ||
123
+ typeof record.type !== 'string') {
124
+ throw new errors_ts_1.WhispSignatureError('malformed', 'The body is signed but is not a Whisp event');
125
+ }
126
+ return event;
127
+ };
128
+ exports.verifyWebhook = verifyWebhook;