whisp-js 0.0.0-stage → 0.3.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 +739 -2
  3. package/dist/cjs/client.d.ts +213 -0
  4. package/dist/cjs/client.js +570 -0
  5. package/dist/cjs/constants.d.ts +43 -0
  6. package/dist/cjs/constants.js +60 -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 +457 -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 +583 -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 +213 -0
  31. package/dist/esm/client.js +566 -0
  32. package/dist/esm/constants.d.ts +43 -0
  33. package/dist/esm/constants.js +57 -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 +452 -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 +583 -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,123 @@
1
+ /**
2
+ * `verifyWebhook` (phase2-design 7.3, 12.5): checks `X-Whisp-Signature` on a
3
+ * webhook request and returns the event.
4
+ *
5
+ * The header is `t=<unix seconds>,v1=<hex>[,v1=<hex>]`: one `v1` per secret
6
+ * the endpoint signs with (two during a rotation's 24 h grace, 7.7). Each `v1`
7
+ * is the lowercase hex HMAC-SHA256, keyed with the whole `wsp_whs_…` secret
8
+ * as UTF-8, over `<t>.<raw body>`. A request passes when the header parses,
9
+ * `|now - t|` is within the tolerance (300 s by default) and any `v1` matches.
10
+ *
11
+ * Web Crypto's `subtle.verify` compares in constant time. It is async, so
12
+ * this is. Core's synchronous `verifySignatureHeader` signs the same way for
13
+ * the server; packages/sdk/test/webhook.test.ts holds the two together.
14
+ */
15
+ import { SIGNATURE_TOLERANCE_SECONDS, WEBHOOK_SECRET_PATTERN } from './constants.js';
16
+ import { localError, WhispSignatureError } from './errors.js';
17
+ const encoder = new TextEncoder();
18
+ const TIMESTAMP = /^\d{1,12}$/;
19
+ const HEX_SIGNATURE = /^[0-9a-f]{64}$/;
20
+ /** `t` and every `v1`; other keys (a later `v2`) are ignored. Null when malformed, or without `t` or `v1`. */
21
+ export const parseSignatureHeader = (header) => {
22
+ let timestamp = null;
23
+ const signatures = [];
24
+ for (const part of header.split(',')) {
25
+ const separator = part.indexOf('=');
26
+ if (separator < 1)
27
+ return null;
28
+ const key = part.slice(0, separator).trim();
29
+ const value = part.slice(separator + 1).trim();
30
+ if (key === 't') {
31
+ if (timestamp !== null || !TIMESTAMP.test(value))
32
+ return null;
33
+ timestamp = Number(value);
34
+ }
35
+ else if (key === 'v1') {
36
+ const lower = value.toLowerCase();
37
+ if (!HEX_SIGNATURE.test(lower))
38
+ return null;
39
+ signatures.push(lower);
40
+ }
41
+ }
42
+ return timestamp === null || signatures.length === 0 ? null : { timestamp, signatures };
43
+ };
44
+ const hexToBytes = (hex) => {
45
+ const out = new Uint8Array(hex.length / 2);
46
+ for (let i = 0; i < out.length; i++)
47
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
48
+ return out;
49
+ };
50
+ const toBytes = (body) => typeof body === 'string' ? encoder.encode(body) : body instanceof Uint8Array ? body : new Uint8Array(body);
51
+ const secretsOf = (secret) => {
52
+ const list = (typeof secret === 'string' ? [secret] : (secret ?? [])).map(value => typeof value === 'string' ? value.trim() : '');
53
+ if (list.length === 0 || list.every(value => value === '')) {
54
+ throw localError('SECRET_MISSING', "verifyWebhook needs the endpoint's signing secret (wsp_whs_…); is its environment variable set?");
55
+ }
56
+ for (const value of list) {
57
+ if (!WEBHOOK_SECRET_PATTERN.test(value)) {
58
+ throw localError('SECRET_INVALID', "The secret is not a webhook signing secret (wsp_whs_…). Copy it again from the project's Webhooks tab.");
59
+ }
60
+ }
61
+ return list;
62
+ };
63
+ /**
64
+ * The event the request carries, typed by `type` (`webhook.test` included),
65
+ * or throws WhispSignatureError with `reason` 'missing', 'malformed',
66
+ * 'timestamp' or 'mismatch'. A missing or mistyped secret throws a WhispError
67
+ * (SECRET_MISSING, SECRET_INVALID), which is a setup problem, not a bad request.
68
+ */
69
+ export const verifyWebhook = async (input) => {
70
+ const secrets = secretsOf(input.secret);
71
+ const tolerance = input.toleranceSeconds ?? SIGNATURE_TOLERANCE_SECONDS;
72
+ if (!Number.isFinite(tolerance) || tolerance < 0) {
73
+ throw localError('OPTIONS_INVALID', 'toleranceSeconds must be a number of seconds, 0 or more');
74
+ }
75
+ const subtle = globalThis.crypto?.subtle;
76
+ if (!subtle)
77
+ throw localError('CRYPTO_UNAVAILABLE', 'verifyWebhook needs Web Crypto (crypto.subtle)');
78
+ const header = typeof input.signature === 'string' ? input.signature : '';
79
+ if (header.trim() === '')
80
+ throw new WhispSignatureError('missing');
81
+ const parsed = parseSignatureHeader(header);
82
+ if (!parsed)
83
+ throw new WhispSignatureError('malformed');
84
+ const nowSec = Math.floor((input.now ?? Date.now()) / 1_000);
85
+ if (Math.abs(nowSec - parsed.timestamp) > tolerance)
86
+ throw new WhispSignatureError('timestamp');
87
+ const body = toBytes(input.body);
88
+ const prefix = encoder.encode(`${parsed.timestamp}.`);
89
+ const message = new Uint8Array(prefix.length + body.length);
90
+ message.set(prefix);
91
+ message.set(body, prefix.length);
92
+ let matched = false;
93
+ for (const secret of secrets) {
94
+ const key = await subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
95
+ 'verify',
96
+ ]);
97
+ for (const signature of parsed.signatures) {
98
+ if (await subtle.verify('HMAC', key, hexToBytes(signature), message)) {
99
+ matched = true;
100
+ break;
101
+ }
102
+ }
103
+ if (matched)
104
+ break;
105
+ }
106
+ if (!matched)
107
+ throw new WhispSignatureError('mismatch');
108
+ let event;
109
+ try {
110
+ event = JSON.parse(typeof input.body === 'string' ? input.body : new TextDecoder().decode(body));
111
+ }
112
+ catch {
113
+ event = undefined;
114
+ }
115
+ const record = event;
116
+ if (typeof record !== 'object' ||
117
+ record === null ||
118
+ typeof record.id !== 'string' ||
119
+ typeof record.type !== 'string') {
120
+ throw new WhispSignatureError('malformed', 'The body is signed but is not a Whisp event');
121
+ }
122
+ return event;
123
+ };
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
- "name": "whisp-js",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "whisp-js",
3
+ "version": "0.3.0",
4
+ "description": "Send WhatsApp messages from your numbers and listen to their events",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "keywords": [
9
+ "whatsapp",
10
+ "messages",
11
+ "webhooks",
12
+ "sdk"
13
+ ],
14
+ "main": "./dist/cjs/index.js",
15
+ "types": "./dist/esm/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": {
19
+ "types": "./dist/esm/index.d.ts",
20
+ "default": "./dist/esm/index.js"
21
+ },
22
+ "require": {
23
+ "types": "./dist/cjs/index.d.ts",
24
+ "default": "./dist/cjs/index.js"
25
+ }
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "engines": {
33
+ "node": ">=22.12"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }