@north-light/comms 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.
@@ -0,0 +1,164 @@
1
+ /** The send API refuses a longer `text`. */
2
+ export const MAX_TEXT_LENGTH = 10_000;
3
+ const OPEN = "\uE000";
4
+ const CLOSE = "\uE001";
5
+ /**
6
+ * Markdown to the plain text a person should read: emphasis, headings, quotes, code fences and
7
+ * HTML are dropped, `[label](url)` becomes `label (url)`, images disappear, lists keep their
8
+ * `-` and `1.` markers and table rows become `a | b`. Text inside code stays exactly as written.
9
+ */
10
+ export function toPlainText(markdown) {
11
+ const kept = [];
12
+ const keep = (text) => {
13
+ kept.push(text);
14
+ return `${OPEN}${kept.length - 1}${CLOSE}`;
15
+ };
16
+ let text = markdown
17
+ .replaceAll("\r\n", "\n")
18
+ .replaceAll(OPEN, "")
19
+ .replaceAll(CLOSE, "");
20
+ // Protected spans first: code, then backslash escapes.
21
+ text = text.replace(/^ {0,3}(`{3,}|~{3,})[^\n]*\n([\s\S]*?)(?:\n {0,3}\1[`~]*[ \t]*(?=\n|$)|$)/gm, (_match, _fence, body) => keep(body));
22
+ text = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, (_m, _t, code) => keep(code.trim()));
23
+ text = text.replace(/\\([\\`*_{}[\]()#+\-.!|~>])/g, (_m, char) => keep(char));
24
+ // Link reference definitions.
25
+ const definitions = new Map();
26
+ text = text.replace(/^ {0,3}\[([^\]]+)\]:[ \t]*<?(\S+?)>?(?:[ \t]+["'(].*["')])?[ \t]*$/gm, (_m, id, url) => {
27
+ definitions.set(id.toLowerCase(), url);
28
+ return "";
29
+ });
30
+ text = text
31
+ .replace(/<!--[\s\S]*?-->/g, "")
32
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
33
+ .replace(/!\[[^\]]*\]\[[^\]]*\]/g, "")
34
+ .replace(/\[([^\]]+)\]\(\s*<?([^)\s>]+)>?(?:\s+["'][^)]*["'])?\s*\)/g, (_m, label, url) => `${label} (${url})`)
35
+ .replace(/\[([^\]]+)\]\[([^\]]*)\]/g, (match, label, id) => {
36
+ const url = definitions.get((id || label).toLowerCase());
37
+ return url ? `${label} (${url})` : match;
38
+ })
39
+ .replace(/<((?:https?|mailto):[^>\s]+)>/g, "$1")
40
+ .replace(/<\/?[a-zA-Z][^>]*>/g, "");
41
+ const lines = text.split("\n").flatMap((line) => {
42
+ if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line))
43
+ return [""];
44
+ // A table's separator row (`|---|---|`) carries no text.
45
+ if (/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(line) &&
46
+ line.includes("|"))
47
+ return [];
48
+ let out = line.replace(/^ {0,3}#{1,6}[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/, "$1");
49
+ out = out.replace(/^(\s*(?:>[ \t]?)+)/, "");
50
+ out = out
51
+ .replace(/^(\s*)[*+-][ \t]+\[[ xX]\][ \t]+/, "$1- ")
52
+ .replace(/^(\s*)[*+][ \t]+/, "$1- ");
53
+ if (/^\s*\|.*\|\s*$/.test(out))
54
+ out = out
55
+ .trim()
56
+ .slice(1, -1)
57
+ .split("|")
58
+ .map((cell) => cell.trim())
59
+ .join(" | ");
60
+ return [out];
61
+ });
62
+ text = lines.join("\n");
63
+ text = text
64
+ .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2")
65
+ .replace(/(?<![*\w])\*(?=\S)([^*\n]*?\S)\*(?![*\w])/g, "$1")
66
+ .replace(/(?<![_\w])_(?=\S)([^_\n]*?\S)_(?![_\w])/g, "$1")
67
+ .replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1");
68
+ return text
69
+ .replace(new RegExp(`${OPEN}(\\d+)${CLOSE}`, "g"), (_m, index) => kept[Number(index)] ?? "")
70
+ .replace(/[ \t]+$/gm, "")
71
+ .replace(/\n{3,}/g, "\n\n")
72
+ .trim();
73
+ }
74
+ /**
75
+ * Splits a long text into parts of at most `maxLength` characters, preferring to break at a
76
+ * blank line, then a line, then a space. Each part is sent as its own message, in order.
77
+ */
78
+ export function splitText(text, maxLength = MAX_TEXT_LENGTH) {
79
+ if (!(maxLength > 1))
80
+ throw new RangeError("maxLength must be greater than 1");
81
+ const parts = [];
82
+ let rest = text.trim();
83
+ while (rest.length > maxLength) {
84
+ let at = rest.lastIndexOf("\n\n", maxLength);
85
+ if (at <= 0)
86
+ at = rest.lastIndexOf("\n", maxLength);
87
+ if (at <= 0)
88
+ at = rest.lastIndexOf(" ", maxLength);
89
+ if (at <= 0) {
90
+ at = maxLength;
91
+ // Never cut a surrogate pair in half.
92
+ if (/[\uD800-\uDBFF]/.test(rest[at - 1] ?? ""))
93
+ at--;
94
+ }
95
+ parts.push(rest.slice(0, at).trimEnd());
96
+ rest = rest.slice(at).trimStart();
97
+ }
98
+ if (rest)
99
+ parts.push(rest);
100
+ return parts;
101
+ }
102
+ /** An agent's markdown reply as the texts to send, in order. Empty markdown gives no texts. */
103
+ export function formatReply(markdown) {
104
+ const plain = toPlainText(markdown);
105
+ return plain ? splitText(plain) : [];
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // Reactions and threaded replies
109
+ // ---------------------------------------------------------------------------
110
+ /** The six iMessage tapbacks by name. */
111
+ export const TAPBACKS = {
112
+ love: "❤️",
113
+ like: "👍",
114
+ dislike: "👎",
115
+ laugh: "😂",
116
+ emphasize: "‼️",
117
+ question: "❓",
118
+ };
119
+ const withoutSelector = (emoji) => emoji.replaceAll("\uFE0F", "");
120
+ /** The tapback name for an emoji (`❤️` → `love`), or `null` when it is not a tapback. */
121
+ export function tapbackName(emoji) {
122
+ const wanted = withoutSelector(emoji);
123
+ for (const [name, value] of Object.entries(TAPBACKS))
124
+ if (withoutSelector(value) === wanted)
125
+ return name;
126
+ return null;
127
+ }
128
+ /** The emoji to send as a reaction, from a tapback name (`love`) or an emoji. Throws for anything else. */
129
+ export function tapbackEmoji(nameOrEmoji) {
130
+ const name = nameOrEmoji.toLowerCase();
131
+ if (name in TAPBACKS)
132
+ return TAPBACKS[name];
133
+ const known = tapbackName(nameOrEmoji);
134
+ if (known)
135
+ return TAPBACKS[known];
136
+ throw new RangeError(`Not a tapback: ${nameOrEmoji} (use one of ${Object.keys(TAPBACKS).join(", ")})`);
137
+ }
138
+ /** What to add to a send so it threads under the message that was received. */
139
+ export function replyTo(received) {
140
+ return { reply_to_message_id: received.message_id };
141
+ }
142
+ /**
143
+ * A received message as one line of text an agent can read: text as written, a reaction as
144
+ * `[reacted love to message <id>]`, an attachment as `[attachment: name (mime)]` followed by
145
+ * its caption, and a threaded message prefixed with `[replying to message <id>] `.
146
+ */
147
+ export function describeReceived(received) {
148
+ const body = describeContent(received.content);
149
+ return received.reply_to_message_id
150
+ ? `[replying to message ${received.reply_to_message_id}] ${body}`
151
+ : body;
152
+ }
153
+ function describeContent(content) {
154
+ switch (content.type) {
155
+ case "text":
156
+ return content.text;
157
+ case "reaction":
158
+ return `[reacted ${tapbackName(content.reaction) ?? content.reaction} to message ${content.target_message_id}]`;
159
+ case "attachment": {
160
+ const label = `[attachment: ${content.name} (${content.mime_type})]`;
161
+ return content.caption ? `${label} ${content.caption}` : label;
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,34 @@
1
+ import type { CommsClient } from "../client.js";
2
+ import type { ContactUserLinkedData, Environment, QueuedMessage, SignInLink, WebhookEvent } from "../types.js";
3
+ /** The text sent with the link. `{url}` is replaced by the link. */
4
+ export declare const DEFAULT_SIGN_IN_TEXT = "Tap to finish signing up: {url}";
5
+ export interface SendSignInLinkOptions {
6
+ environment: Environment;
7
+ /** The message to send; it must contain `{url}`. Default `DEFAULT_SIGN_IN_TEXT`. */
8
+ text?: string;
9
+ /** Where the person lands after signing in; must be on your app's origin host. Default: your app's sign-in start URL. */
10
+ returnTo?: string;
11
+ /** Overrides the key that makes a retry of this call send the text once. */
12
+ idempotencyKey?: string;
13
+ }
14
+ export interface SentSignInLink {
15
+ link: SignInLink;
16
+ message: QueuedMessage;
17
+ }
18
+ /**
19
+ * Creates (or finds) the contact's sign-in link and texts it. Comms refuses with a
20
+ * `CommsError` when the contact is already a user (`already_user`), has an email handle
21
+ * (`no_phone`) or has sent STOP (`opted_out`); handle those before calling if you can.
22
+ * Calling it again while the same link is still valid sends the text only once.
23
+ */
24
+ export declare function sendSignInLink(client: CommsClient, contact: {
25
+ id: string;
26
+ }, options: SendSignInLinkOptions): Promise<SentSignInLink>;
27
+ export type UserLinkedHandler = (data: ContactUserLinkedData, event: Extract<WebhookEvent, {
28
+ type: "contact.user_linked";
29
+ }>) => void | Promise<void>;
30
+ /**
31
+ * Wraps a handler for `contact.user_linked` into a function you call with every verified
32
+ * event. It runs the handler for that event type only and resolves to whether it ran.
33
+ */
34
+ export declare function onUserLinked(handler: UserLinkedHandler): (event: WebhookEvent) => Promise<boolean>;
@@ -0,0 +1,36 @@
1
+ /** The text sent with the link. `{url}` is replaced by the link. */
2
+ export const DEFAULT_SIGN_IN_TEXT = "Tap to finish signing up: {url}";
3
+ /**
4
+ * Creates (or finds) the contact's sign-in link and texts it. Comms refuses with a
5
+ * `CommsError` when the contact is already a user (`already_user`), has an email handle
6
+ * (`no_phone`) or has sent STOP (`opted_out`); handle those before calling if you can.
7
+ * Calling it again while the same link is still valid sends the text only once.
8
+ */
9
+ export async function sendSignInLink(client, contact, options) {
10
+ const template = options.text ?? DEFAULT_SIGN_IN_TEXT;
11
+ if (!template.includes("{url}"))
12
+ throw new TypeError("The sign-in text must contain {url}");
13
+ const link = await client.createSignInLink(contact.id, {
14
+ environment: options.environment,
15
+ ...(options.returnTo === undefined ? {} : { return_to: options.returnTo }),
16
+ });
17
+ const message = await client.sendMessage({
18
+ environment: options.environment,
19
+ to: { contact_id: contact.id },
20
+ text: template.replaceAll("{url}", link.url),
21
+ idempotency_key: options.idempotencyKey ?? `sign-in-link:${contact.id}:${link.expires_at}`,
22
+ });
23
+ return { link, message };
24
+ }
25
+ /**
26
+ * Wraps a handler for `contact.user_linked` into a function you call with every verified
27
+ * event. It runs the handler for that event type only and resolves to whether it ran.
28
+ */
29
+ export function onUserLinked(handler) {
30
+ return async (event) => {
31
+ if (event.type !== "contact.user_linked")
32
+ return false;
33
+ await handler(event.data, event);
34
+ return true;
35
+ };
36
+ }
@@ -0,0 +1,14 @@
1
+ import type { CommsClient } from "../client.js";
2
+ import type { Environment } from "../types.js";
3
+ export interface KeepTypingOptions {
4
+ environment: Environment;
5
+ /** How often the indicator is renewed. Default 5000. */
6
+ intervalMs?: number;
7
+ /** Receives a failed typing call. Default: ignored. */
8
+ onError?: (error: unknown) => void;
9
+ }
10
+ export declare const DEFAULT_TYPING_INTERVAL_MS = 5000;
11
+ /** Turns the indicator on now and keeps it on. The returned function turns it off; call it once the reply is sent. */
12
+ export declare function keepTyping(client: CommsClient, contactId: string, options: KeepTypingOptions): () => Promise<void>;
13
+ /** Runs `work` with the indicator on, and turns it off when `work` settles. */
14
+ export declare function withTyping<T>(client: CommsClient, contactId: string, options: KeepTypingOptions, work: () => Promise<T>): Promise<T>;
@@ -0,0 +1,31 @@
1
+ export const DEFAULT_TYPING_INTERVAL_MS = 5_000;
2
+ /** Turns the indicator on now and keeps it on. The returned function turns it off; call it once the reply is sent. */
3
+ export function keepTyping(client, contactId, options) {
4
+ const intervalMs = options.intervalMs ?? DEFAULT_TYPING_INTERVAL_MS;
5
+ if (!(intervalMs > 0))
6
+ throw new RangeError("intervalMs must be positive");
7
+ const set = (on) => client
8
+ .setTyping(contactId, { environment: options.environment, on })
9
+ .catch((error) => options.onError?.(error));
10
+ let stopped;
11
+ void set(true);
12
+ const timer = setInterval(() => void set(true), intervalMs);
13
+ timer.unref();
14
+ return () => {
15
+ if (!stopped) {
16
+ clearInterval(timer);
17
+ stopped = set(false);
18
+ }
19
+ return stopped;
20
+ };
21
+ }
22
+ /** Runs `work` with the indicator on, and turns it off when `work` settles. */
23
+ export async function withTyping(client, contactId, options, work) {
24
+ const stop = keepTyping(client, contactId, options);
25
+ try {
26
+ return await work();
27
+ }
28
+ finally {
29
+ await stop();
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ export { CommsClient, CommsError, DEFAULT_RESOURCE } from "./client.js";
2
+ export type { CommsClientOptions, PrivateKeyJwk } from "./client.js";
3
+ export { WEBHOOK_TOLERANCE_SECONDS, WebhookVerificationError, sign, verifyWebhook, } from "./webhooks.js";
4
+ export type { VerifyWebhookOptions, WebhookHeaders, WebhookVerificationErrorCode, } from "./webhooks.js";
5
+ export type * from "./types.js";
6
+ export { startFakeServer } from "./fake/server.js";
7
+ export type { FakeServer, FakeServerOptions } from "./fake/server.js";
8
+ export * from "./helpers/signup.js";
9
+ export * from "./helpers/agent-bridge.js";
10
+ export * from "./helpers/batching.js";
11
+ export * from "./helpers/typing.js";
12
+ export * from "./helpers/format.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // Public surface of @north-light/comms.
2
+ export { CommsClient, CommsError, DEFAULT_RESOURCE } from "./client.js";
3
+ export { WEBHOOK_TOLERANCE_SECONDS, WebhookVerificationError, sign, verifyWebhook, } from "./webhooks.js";
4
+ export { startFakeServer } from "./fake/server.js";
5
+ export * from "./helpers/signup.js";
6
+ export * from "./helpers/agent-bridge.js";
7
+ export * from "./helpers/batching.js";
8
+ export * from "./helpers/typing.js";
9
+ export * from "./helpers/format.js";
@@ -0,0 +1,175 @@
1
+ /** The environments an API call can name. The local fake server ignores the field. */
2
+ export type Environment = "test" | "production";
3
+ /** `user:<id>` of a Northlight user. */
4
+ export interface UserRef {
5
+ id: string;
6
+ }
7
+ export type HandleKind = "phone" | "email";
8
+ /** A person who texted the app's numbers, as webhooks carry them. */
9
+ export interface WebhookContact {
10
+ id: string;
11
+ /** The Northlight user behind the handle, or `null` for a non-user. */
12
+ user: UserRef | null;
13
+ handle_kind: HandleKind;
14
+ /** The full E.164 phone number, or the Apple ID email. */
15
+ handle: string;
16
+ }
17
+ export interface TextContent {
18
+ type: "text";
19
+ text: string;
20
+ }
21
+ export interface AttachmentContent {
22
+ type: "attachment";
23
+ /** A Comms-signed GET, valid 24 hours. */
24
+ url: string;
25
+ mime_type: string;
26
+ name: string;
27
+ size_bytes: number;
28
+ caption: string | null;
29
+ }
30
+ export interface ReactionContent {
31
+ type: "reaction";
32
+ /** An emoji; the iMessage tapbacks are ❤️ 👍 👎 😂 ‼️ ❓. */
33
+ reaction: string;
34
+ target_message_id: string;
35
+ }
36
+ export type MessageContent = TextContent | AttachmentContent | ReactionContent;
37
+ export interface MessageReceivedData {
38
+ message_id: string;
39
+ /** The app number the person texted. */
40
+ number: string;
41
+ contact: WebhookContact;
42
+ content: MessageContent;
43
+ reply_to_message_id: string | null;
44
+ received_at: string;
45
+ }
46
+ /** Statuses a `message.status` event reports. */
47
+ export type DeliveryStatus = "sent" | "delivered" | "read" | "failed" | "suppressed_opt_out";
48
+ /** Photon's codes for a `failed` status; any other Photon error code arrives verbatim. */
49
+ export type FailureReason = "dailyLimitExceeded" | "recipientLimitExceeded" | "sendReceiveRatioExceeded" | (string & {});
50
+ export interface MessageStatusData {
51
+ message_id: string;
52
+ contact_id: string;
53
+ status: DeliveryStatus;
54
+ /** Set for `failed`. */
55
+ reason: FailureReason | null;
56
+ }
57
+ export interface ContactIdData {
58
+ contact_id: string;
59
+ }
60
+ export interface ContactUserLinkedData {
61
+ contact_id: string;
62
+ user: UserRef;
63
+ }
64
+ interface WebhookEnvelope<Type extends string, Data> {
65
+ /** Unique per event; deliveries are at least once, so deduplicate on it. */
66
+ id: string;
67
+ type: Type;
68
+ environment: Environment;
69
+ occurred_at: string;
70
+ data: Data;
71
+ }
72
+ export type MessageReceivedEvent = WebhookEnvelope<"message.received", MessageReceivedData>;
73
+ export type MessageStatusEvent = WebhookEnvelope<"message.status", MessageStatusData>;
74
+ export type ContactOptedOutEvent = WebhookEnvelope<"contact.opted_out", ContactIdData>;
75
+ export type ContactOptedInEvent = WebhookEnvelope<"contact.opted_in", ContactIdData>;
76
+ export type ContactUserLinkedEvent = WebhookEnvelope<"contact.user_linked", ContactUserLinkedData>;
77
+ /** The person's account was deleted: the contact id is retired and sends to it answer 404. */
78
+ export type ContactDeletedEvent = WebhookEnvelope<"contact.deleted", ContactIdData>;
79
+ export type WebhookEvent = MessageReceivedEvent | MessageStatusEvent | ContactOptedOutEvent | ContactOptedInEvent | ContactUserLinkedEvent | ContactDeletedEvent;
80
+ export type WebhookEventType = WebhookEvent["type"];
81
+ /** Address a person by contact id or Northlight user id, never by a raw phone number. */
82
+ export type MessageRecipient = {
83
+ contact_id: string;
84
+ } | {
85
+ user_id: string;
86
+ };
87
+ export interface SendMessageFile {
88
+ /** Fetched by Comms; at most 100 MiB. */
89
+ url: string;
90
+ name?: string;
91
+ }
92
+ /** `POST /v1/messages`. `text` or `file` must be present; `text` is at most 10,000 characters. */
93
+ export interface SendMessageRequest {
94
+ environment: Environment;
95
+ to: MessageRecipient;
96
+ text?: string;
97
+ file?: SendMessageFile;
98
+ reply_to_message_id?: string;
99
+ /** The same key for the same app and environment returns the first answer and sends nothing. */
100
+ idempotency_key: string;
101
+ }
102
+ /** `POST /v1/messages/<message_id>/reactions`. */
103
+ export interface SendReactionRequest {
104
+ environment: Environment;
105
+ /** An emoji; the iMessage tapbacks are ❤️ 👍 👎 😂 ‼️ ❓. */
106
+ reaction: string;
107
+ idempotency_key: string;
108
+ }
109
+ /** `202` answer to a send or a reaction; for a reaction, `message_id` is the reaction's own id. */
110
+ export interface QueuedMessage {
111
+ message_id: string;
112
+ status: "queued";
113
+ }
114
+ /** Everything `GET /v1/messages/<id>` can report, including the pre-send `queued`. */
115
+ export type MessageStatus = "queued" | DeliveryStatus;
116
+ export interface MessageRecord {
117
+ message_id: string;
118
+ contact_id: string;
119
+ status: MessageStatus;
120
+ reason: FailureReason | null;
121
+ created_at: string;
122
+ }
123
+ /** `POST /v1/contacts/<id>/typing`. Best effort; never queued, never retried. */
124
+ export interface TypingRequest {
125
+ environment: Environment;
126
+ on: boolean;
127
+ }
128
+ /** `GET /v1/contacts/<id>`. */
129
+ export interface Contact {
130
+ id: string;
131
+ user: UserRef | null;
132
+ handle_kind: HandleKind;
133
+ handle: string;
134
+ opted_out: boolean;
135
+ number: string | null;
136
+ created_at: string;
137
+ }
138
+ /** `POST /v1/contacts/<id>/sign-in-link`. `return_to` must be on the app's `origin_url` host. */
139
+ export interface SignInLinkRequest {
140
+ environment: Environment;
141
+ return_to?: string;
142
+ }
143
+ export interface SignInLink {
144
+ url: string;
145
+ expires_at: string;
146
+ }
147
+ /** `GET /v1/numbers/public`. */
148
+ export interface PublicNumber {
149
+ number: string;
150
+ }
151
+ /** Codes the send API answers with (spec "Send API: app → Comms"). */
152
+ export type SendApiErrorCode = "contact_not_found" | "not_texted_first" | "opted_out" | "not_tester" | "idempotency_conflict" | "message_not_found" | "invalid_target" | "already_user" | "no_phone" | "environment_not_ready" | "invalid_file_url" | "invalid_request" | "unauthorized" | "not_found" | "conflict" | "rate_limited" | "server_error" | "service_unavailable";
153
+ /**
154
+ * Codes the SDK itself raises when no Comms error body arrived: a network failure, a timeout,
155
+ * a response that is not the error envelope, or a failed directory token request (where the
156
+ * directory's OAuth `error`, such as `invalid_client`, is the code).
157
+ */
158
+ export type ClientErrorCode = "network_error" | "timeout" | "unexpected_response" | "token_discovery_failed" | "token_request_failed";
159
+ /** A code Comms or the directory may add later is still a string. */
160
+ export type CommsErrorCode = SendApiErrorCode | ClientErrorCode | (string & {});
161
+ /** The shared error envelope body, `{error: {...}}`, with `origin: "comms"`. */
162
+ export interface ErrorEnvelope {
163
+ error: {
164
+ code: string;
165
+ message: string;
166
+ type?: string;
167
+ origin?: string;
168
+ retryable?: boolean;
169
+ request_id?: string;
170
+ details?: unknown;
171
+ param?: string;
172
+ retry_after_s?: number;
173
+ };
174
+ }
175
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // Wire types of Comms: the app webhook (Comms → app) and the send API (app → Comms).
2
+ // Field names are the wire names, so a body can be passed through without mapping.
3
+ export {};
@@ -0,0 +1,30 @@
1
+ import type { WebhookEvent } from "./types.js";
2
+ /** Deliveries older or newer than this many seconds are refused. */
3
+ export declare const WEBHOOK_TOLERANCE_SECONDS = 300;
4
+ export type WebhookVerificationErrorCode = "missing_headers" | "invalid_timestamp" | "timestamp_too_old" | "timestamp_too_new" | "no_secrets" | "invalid_secret" | "invalid_signature" | "invalid_payload";
5
+ /** Thrown by `verifyWebhook`; `code` says which check failed. */
6
+ export declare class WebhookVerificationError extends Error {
7
+ readonly code: WebhookVerificationErrorCode;
8
+ readonly name = "WebhookVerificationError";
9
+ constructor(code: WebhookVerificationErrorCode, message: string);
10
+ }
11
+ /** Headers as a fetch `Headers`, a Node `IncomingHttpHeaders`, or any plain record. */
12
+ export type WebhookHeaders = Headers | Record<string, string | readonly string[] | undefined>;
13
+ export interface VerifyWebhookOptions {
14
+ /** Tolerance in seconds in both directions. Default 300. */
15
+ toleranceSeconds?: number;
16
+ /** The current time, for tests. Default `Date.now()`. */
17
+ now?: Date | number;
18
+ }
19
+ /**
20
+ * The `webhook-signature` header value: one `v1,<base64 HMAC-SHA256>` per secret, separated by
21
+ * spaces, over `${id}.${timestamp}.${body}`. `timestamp` is Unix seconds.
22
+ */
23
+ export declare function sign(secrets: string | readonly string[], id: string, timestamp: number | string, body: string | Uint8Array): string;
24
+ /**
25
+ * Checks a delivery and returns its parsed event. Pass the raw request body exactly as it
26
+ * arrived (a string or bytes, not re-serialized JSON) and every secret that may have signed
27
+ * it. Throws `WebhookVerificationError` on any failure; respond `400` or `401` and do not
28
+ * process the event.
29
+ */
30
+ export declare function verifyWebhook(headers: WebhookHeaders, rawBody: string | Uint8Array, secrets: string | readonly string[], options?: VerifyWebhookOptions): WebhookEvent;
@@ -0,0 +1,111 @@
1
+ // Standard Webhooks (https://www.standardwebhooks.com/) signing and verification.
2
+ // Comms signs every delivery with each unretired secret of the environment, so during the
3
+ // 24-hour rotation window the `webhook-signature` header carries two signatures; verify with
4
+ // every secret you still trust and any one match accepts the delivery.
5
+ import { createHmac, timingSafeEqual } from "node:crypto";
6
+ /** Deliveries older or newer than this many seconds are refused. */
7
+ export const WEBHOOK_TOLERANCE_SECONDS = 300;
8
+ /** Thrown by `verifyWebhook`; `code` says which check failed. */
9
+ export class WebhookVerificationError extends Error {
10
+ code;
11
+ name = "WebhookVerificationError";
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.code = code;
15
+ }
16
+ }
17
+ const PREFIX = "whsec_";
18
+ function secretKey(secret) {
19
+ const encoded = secret.startsWith(PREFIX)
20
+ ? secret.slice(PREFIX.length)
21
+ : secret;
22
+ const key = Buffer.from(encoded, "base64");
23
+ if (key.length === 0)
24
+ throw new WebhookVerificationError("invalid_secret", "A webhook secret must be `whsec_` followed by base64 key bytes");
25
+ return key;
26
+ }
27
+ function toBytes(body) {
28
+ return typeof body === "string"
29
+ ? Buffer.from(body, "utf8")
30
+ : Buffer.from(body.buffer, body.byteOffset, body.byteLength);
31
+ }
32
+ function hmac(key, id, timestamp, body) {
33
+ return createHmac("sha256", key)
34
+ .update(`${id}.${timestamp}.`)
35
+ .update(body)
36
+ .digest();
37
+ }
38
+ /**
39
+ * The `webhook-signature` header value: one `v1,<base64 HMAC-SHA256>` per secret, separated by
40
+ * spaces, over `${id}.${timestamp}.${body}`. `timestamp` is Unix seconds.
41
+ */
42
+ export function sign(secrets, id, timestamp, body) {
43
+ const list = typeof secrets === "string" ? [secrets] : secrets;
44
+ if (list.length === 0)
45
+ throw new WebhookVerificationError("no_secrets", "At least one secret is required");
46
+ const bytes = toBytes(body);
47
+ const ts = String(timestamp);
48
+ return list
49
+ .map((secret) => `v1,${hmac(secretKey(secret), id, ts, bytes).toString("base64")}`)
50
+ .join(" ");
51
+ }
52
+ function header(headers, name) {
53
+ if (headers instanceof Headers)
54
+ return headers.get(name) ?? undefined;
55
+ for (const [key, value] of Object.entries(headers)) {
56
+ if (key.toLowerCase() !== name)
57
+ continue;
58
+ if (Array.isArray(value))
59
+ return value.join(" ");
60
+ return typeof value === "string" ? value : undefined;
61
+ }
62
+ return undefined;
63
+ }
64
+ /**
65
+ * Checks a delivery and returns its parsed event. Pass the raw request body exactly as it
66
+ * arrived (a string or bytes, not re-serialized JSON) and every secret that may have signed
67
+ * it. Throws `WebhookVerificationError` on any failure; respond `400` or `401` and do not
68
+ * process the event.
69
+ */
70
+ export function verifyWebhook(headers, rawBody, secrets, options = {}) {
71
+ const list = typeof secrets === "string" ? [secrets] : secrets;
72
+ if (list.length === 0)
73
+ throw new WebhookVerificationError("no_secrets", "At least one secret is required");
74
+ const id = header(headers, "webhook-id");
75
+ const timestamp = header(headers, "webhook-timestamp");
76
+ const signatures = header(headers, "webhook-signature");
77
+ if (!id || !timestamp || !signatures)
78
+ throw new WebhookVerificationError("missing_headers", "Missing webhook-id, webhook-timestamp or webhook-signature header");
79
+ if (!/^\d{1,15}$/.test(timestamp))
80
+ throw new WebhookVerificationError("invalid_timestamp", "webhook-timestamp must be Unix seconds");
81
+ const nowMs = options.now instanceof Date
82
+ ? options.now.getTime()
83
+ : (options.now ?? Date.now());
84
+ const skew = Math.floor(nowMs / 1000) - Number(timestamp);
85
+ const tolerance = options.toleranceSeconds ?? WEBHOOK_TOLERANCE_SECONDS;
86
+ if (skew > tolerance)
87
+ throw new WebhookVerificationError("timestamp_too_old", "Webhook timestamp is too old");
88
+ if (skew < -tolerance)
89
+ throw new WebhookVerificationError("timestamp_too_new", "Webhook timestamp is in the future");
90
+ const bytes = toBytes(rawBody);
91
+ const expected = list.map((secret) => hmac(secretKey(secret), id, timestamp, bytes));
92
+ // Every candidate is compared against every expected value with no early exit.
93
+ let matched = false;
94
+ for (const candidate of signatures.split(" ")) {
95
+ const comma = candidate.indexOf(",");
96
+ if (comma < 0 || candidate.slice(0, comma) !== "v1")
97
+ continue;
98
+ const given = Buffer.from(candidate.slice(comma + 1), "base64");
99
+ for (const want of expected)
100
+ if (given.length === want.length && timingSafeEqual(given, want))
101
+ matched = true;
102
+ }
103
+ if (!matched)
104
+ throw new WebhookVerificationError("invalid_signature", "No matching signature found");
105
+ try {
106
+ return JSON.parse(bytes.toString("utf8"));
107
+ }
108
+ catch {
109
+ throw new WebhookVerificationError("invalid_payload", "Webhook body is not valid JSON");
110
+ }
111
+ }