honk-me 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to `honk-me` (npm) are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.2.0] - 2026-10-07
8
+
9
+ ### Added
10
+ - `actions`: up to 3 buttons on a message, `{ title, url }` with an `https://`, `mailto:`,
11
+ `tel:` or `sms:` URL (the `Action` type). Validated locally like the server does, with
12
+ errors named `actions[1].url`; empty `actions` are omitted, so messages without buttons are
13
+ sent exactly as before.
14
+
15
+ ## [0.1.1] - 2026-10-06
16
+
17
+ ### Fixed
18
+ - Near the deadline, a retry no longer starts with only a few milliseconds left. Such an
19
+ attempt could only time out, and its `HonkTimeoutError` hid the server's real answer (for
20
+ example `503`). A retry now needs at least 250 ms (or `timeoutMs`, when shorter) before
21
+ `deadlineMs`; otherwise the last error is thrown at once.
22
+
23
+ ### Changed
24
+ - Package author: Honk <accounts@honk-me.app>. The README links the Rust SDK, the n8n node
25
+ and the WordPress plugin.
26
+
7
27
  ## [0.1.0] - 2026-10-04
8
28
 
9
29
  ### Added
package/README.md CHANGED
@@ -114,12 +114,13 @@ honk.send(message, { idempotencyKey?, signal? }) // → Promise<{ id, duplicate,
114
114
  | `occurredAt` | `Date` or RFC 3339 string | informational |
115
115
  | `url` | string | `https://` only, no credentials |
116
116
  | `imageUrl` | string | `https://` only, no credentials or `#fragment`; fetched by the server afterwards |
117
+ | `actions` | `{ title, url }[]` | up to 3 buttons, the first is the primary; see below |
117
118
  | `metadata` | `Record<string, string \| number \| boolean>` | ≤ 16 keys `[A-Za-z0-9_.-]{1,64}`, strings ≤ 512 chars |
118
119
  | `ttlSeconds` | integer | push lifetime 60–86400, default 3600 |
119
120
  | `sourceSequence` | integer | 0 … 2^53-1, needs `groupKey`; a delayed recovery never closes a newer problem |
120
121
 
121
- Fields are camelCase in this SDK and sent with the API's snake_case names. `null`, `undefined`
122
- and empty optional strings are omitted. A resolved promise means Honk **durably stored** the
122
+ Fields are camelCase in this SDK and sent with the API's snake_case names. `null`, `undefined`,
123
+ empty optional strings and an empty `actions` array are omitted. A resolved promise means Honk **durably stored** the
123
124
  message (`202`); it does not mean a push was delivered or read.
124
125
 
125
126
  Helpers (the last argument takes any message field plus `idempotencyKey` and `signal`):
@@ -132,6 +133,33 @@ await honk.problem('db/backup', 'Backup failed', 'pg_dump exited with 1');
132
133
  await honk.recovery('db/backup', 'Backup OK', 'pg_dump finished in 41 s'); // a beep by default
133
134
  ```
134
135
 
136
+ ### Buttons (actions)
137
+
138
+ Up to three buttons on the message, in display order: reply to the customer, call them, open
139
+ the order.
140
+
141
+ ```js
142
+ await honk.send({
143
+ title: 'New request: online shop quote',
144
+ message: 'Emily Carter (Acme) asked for a quote: online shop, 40 products',
145
+ category: 'customers',
146
+ groupKey: 'requests/4812',
147
+ actions: [
148
+ { title: 'Reply', url: 'mailto:emily@example.com?subject=Your%20quote' },
149
+ { title: 'Call Emily', url: 'tel:+15550134' },
150
+ ],
151
+ });
152
+ ```
153
+
154
+ - `title`: 1–40 characters, one line, shown as sent.
155
+ - `url`, at most 2048 bytes without spaces: `https://` (no credentials); `mailto:` with one
156
+ address and optionally `?subject=…&body=…` (percent-encoded with `encodeURIComponent`, no
157
+ other keys); `tel:` with a number (digits, `-` `.` `(` `)`, `+` only first); `sms:` with a
158
+ number and optionally `?body=…`. Other schemes are refused.
159
+ - Honk never opens or fetches them; the app does when you tap one. They appear on the
160
+ message in the app and the web inbox, and on iPhone notifications that show the message
161
+ text. Errors name the button: `actions[1].url`.
162
+
135
163
  ### Options
136
164
 
137
165
  ```js
@@ -155,8 +183,9 @@ new Honk({
155
183
  `duplicate: true`, so a lost response never creates a second message.
156
184
  - Only network errors, timeouts, `429` and `5xx` are retried, with exponential backoff and
157
185
  full jitter (`random(0, min(8 s, 0.5 s·2ⁿ))`), never sooner than the server's `Retry-After`.
158
- - Everything stops at `deadlineMs`: if the next wait would cross it (for example a daily quota
159
- that resets at midnight), the error is thrown at once with `retryAfter`.
186
+ - Everything stops at `deadlineMs`: if the next wait, plus time for one more attempt, would
187
+ cross it (for example a daily quota that resets at midnight), the error is thrown at once
188
+ with `retryAfter`.
160
189
  - `4xx` other than `429` are never retried: fix the request instead.
161
190
  - Short per-attempt timeouts; Node's `fetch` keeps connections alive, so create one `Honk`
162
191
  per process and reuse it.
@@ -216,9 +245,11 @@ workflow publishes to npm with provenance (see `CHANGELOG.md`).
216
245
 
217
246
  ## Links
218
247
 
219
- - [honk-me.app](https://honk-me.app): the Honk inbox (web, iPhone).
248
+ - [honk-me.app](https://honk-me.app): the Honk inbox (web, iPhone, Apple Watch).
220
249
  - Other SDKs: [PHP / Laravel](https://github.com/honk-me/honk-php),
221
250
  [Go + CLI](https://github.com/honk-me/honk-go), [Swift](https://github.com/honk-me/honk-swift),
222
- [Kotlin / Java](https://github.com/honk-me/honk-kotlin).
251
+ [Kotlin / Java](https://github.com/honk-me/honk-kotlin), [Rust](https://github.com/honk-me/honk-rust).
252
+ - No code: [n8n node](https://github.com/honk-me/honk-n8n) (`n8n-nodes-honk`) and the
253
+ [WordPress plugin](https://github.com/honk-me/honk-wordpress).
223
254
 
224
255
  MIT License.
@@ -12,6 +12,9 @@ const DEFAULT_RETRIES = 4;
12
12
  const DEFAULT_DEADLINE_MS = 30_000;
13
13
  const DEFAULT_BACKOFF_BASE_MS = 500;
14
14
  const DEFAULT_BACKOFF_MAX_MS = 8_000;
15
+ // A retry starts only with at least this long left before the deadline (or timeoutMs, when
16
+ // shorter): with less it could only time out, and its timeout would hide the real error.
17
+ const MIN_ATTEMPT_MS = 250;
15
18
  /** Seconds from a Retry-After header (delta-seconds or HTTP date), or undefined. */
16
19
  function parseRetryAfter(value, now = Date.now()) {
17
20
  if (value === null)
@@ -120,7 +123,7 @@ class Honk {
120
123
  throw failure;
121
124
  const jitter = Math.random() * Math.min(this.#backoffMax, this.#backoffBase * 2 ** (attempt - 1));
122
125
  const wait = Math.max(jitter, (failure.retryAfter ?? 0) * 1000);
123
- if (Date.now() + wait >= deadline)
126
+ if (Date.now() + wait + Math.min(this.timeoutMs, MIN_ATTEMPT_MS) > deadline)
124
127
  throw failure;
125
128
  await sleep(wait, signal);
126
129
  }
@@ -2,4 +2,4 @@ export { Honk, VERSION, parseRetryAfter } from './client.js';
2
2
  export { HonkError, HonkValidationError, HonkAuthError, HonkQuotaError, HonkConflictError, HonkNetworkError, HonkTimeoutError, HonkServerError, type ErrorKind, type FieldError, } from './errors.js';
3
3
  export { buildBody, LIMITS } from './validate.js';
4
4
  export { uuidv7 } from './uuid.js';
5
- export { Severity, SEVERITY_ALIASES, normalizeSeverity, SEVERITIES, PRIORITIES, EVENT_TYPES, CATEGORIES, type SeverityAlias, type SeverityInput, type Priority, type EventType, type Category, type MetadataValue, type Message, type Defaults, type HonkOptions, type SendOptions, type HelperOptions, type SendResult, } from './types.js';
5
+ export { Severity, SEVERITY_ALIASES, normalizeSeverity, SEVERITIES, PRIORITIES, EVENT_TYPES, CATEGORIES, type SeverityAlias, type SeverityInput, type Priority, type EventType, type Category, type MetadataValue, type Action, type Message, type Defaults, type HonkOptions, type SendOptions, type HelperOptions, type SendResult, } from './types.js';
@@ -41,6 +41,20 @@ export declare const PRIORITIES: readonly Priority[];
41
41
  export declare const EVENT_TYPES: readonly EventType[];
42
42
  export declare const CATEGORIES: readonly Category[];
43
43
  export type MetadataValue = string | number | boolean;
44
+ /**
45
+ * A button on the message. Honk never opens or fetches the URL; the app opens it when the user
46
+ * taps the button.
47
+ */
48
+ export interface Action {
49
+ /** 1–40 characters (trimmed), one line, shown as sent: `Reply`, `Call Emily`. */
50
+ title: string;
51
+ /**
52
+ * ≤ 2048 bytes, no spaces: `https://` (no credentials), `mailto:` with one address
53
+ * (`?subject=…&body=…` percent-encoded, no other keys), `tel:` or `sms:` with a number
54
+ * (`sms:` also `?body=…`). Other schemes are refused.
55
+ */
56
+ url: string;
57
+ }
44
58
  /**
45
59
  * One event for `POST /v1/messages`. Only `message` is required. Fields are camelCase here and
46
60
  * sent as the snake_case names of the API (`groupKey` → `group_key`). Empty optional strings
@@ -79,6 +93,8 @@ export interface Message {
79
93
  url?: string | null;
80
94
  /** HTTPS image fetched by the server after ingestion. No credentials or fragment. ≤ 2048 bytes. */
81
95
  imageUrl?: string | null;
96
+ /** Up to 3 buttons, in display order (the first is the primary). Empty means none. */
97
+ actions?: readonly Action[] | null;
82
98
  /** ≤ 16 keys matching `[A-Za-z0-9_.-]{1,64}`; values are strings (≤ 512 chars), numbers or booleans. */
83
99
  metadata?: Record<string, MetadataValue> | null;
84
100
  /** Push lifetime, 60–86400 seconds. Default 3600. */
@@ -9,6 +9,8 @@ export declare const LIMITS: {
9
9
  readonly channel: 64;
10
10
  readonly groupKey: 128;
11
11
  readonly urlBytes: 2048;
12
+ readonly actions: 3;
13
+ readonly actionTitle: 40;
12
14
  readonly metadataKeys: 16;
13
15
  readonly metadataValue: 512;
14
16
  readonly ttlMin: 60;
@@ -24,5 +26,11 @@ export declare function checkIdempotencyKey(key: unknown): string;
24
26
  * listing every invalid field.
25
27
  */
26
28
  export declare function buildBody(input: Message, defaults?: Defaults, validate?: boolean): Record<string, unknown>;
29
+ /**
30
+ * The server's check of a (trimmed) action URL, scheme in any case: `https://` with a host and
31
+ * no credentials (as `url`), `mailto:` with one address and an optional `?subject=…&body=…`,
32
+ * `tel:` / `tel://` with a number, `sms:` with a number and an optional `?body=…`.
33
+ */
34
+ export declare function validActionUrl(s: string): boolean;
27
35
  /** Syntactic check matching the server: https, a host, no credentials, no spaces/backslashes, ≤ 2048 bytes. */
28
36
  export declare function validUrl(raw: unknown, image: boolean): boolean;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.byteLength = exports.LIMITS = void 0;
4
4
  exports.checkIdempotencyKey = checkIdempotencyKey;
5
5
  exports.buildBody = buildBody;
6
+ exports.validActionUrl = validActionUrl;
6
7
  exports.validUrl = validUrl;
7
8
  const errors_js_1 = require("./errors.js");
8
9
  const types_js_1 = require("./types.js");
@@ -16,6 +17,8 @@ exports.LIMITS = {
16
17
  channel: 64,
17
18
  groupKey: 128,
18
19
  urlBytes: 2048,
20
+ actions: 3,
21
+ actionTitle: 40,
19
22
  metadataKeys: 16,
20
23
  metadataValue: 512,
21
24
  ttlMin: 60,
@@ -37,6 +40,7 @@ const FIELDS = {
37
40
  occurredAt: 'occurred_at',
38
41
  url: 'url',
39
42
  imageUrl: 'image_url',
43
+ actions: 'actions',
40
44
  metadata: 'metadata',
41
45
  ttlSeconds: 'ttl_seconds',
42
46
  sourceSequence: 'source_sequence',
@@ -48,6 +52,15 @@ const CONTROL_EXCEPT_BREAKS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0
48
52
  const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
49
53
  const METADATA_KEY = /^[A-Za-z0-9_.-]{1,64}$/;
50
54
  const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,128}$/;
55
+ // Unicode White_Space outside the control characters (Go's unicode.IsSpace, as on the server).
56
+ const SPACE = /[ \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
57
+ // The number of a tel: or sms: action: an optional leading +, digits and - . ( ) separators.
58
+ const PHONE_NUMBER = /^\+?[0-9().-]*[0-9][0-9().-]*$/;
59
+ // A single plain mailto: address, once percent-decoded: dot-atom@dot-atom with a dotted domain
60
+ // (or an IPv4 literal).
61
+ const ATOM = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~\\-\\u{80}-\\u{10ffff}]+";
62
+ const MAIL_ADDRESS = new RegExp(`^${ATOM}(\\.${ATOM})*@(${ATOM}(\\.${ATOM})+|\\[[0-9.]*\\.[0-9.]*\\])$`, 'u');
63
+ const BAD_ESCAPE = /%(?![0-9A-Fa-f]{2})/;
51
64
  const encoder = new TextEncoder();
52
65
  const byteLength = (s) => encoder.encode(s).length;
53
66
  exports.byteLength = byteLength;
@@ -100,7 +113,7 @@ function buildBody(input, defaults = {}, validate = true) {
100
113
  const body = {};
101
114
  for (const name of Object.keys(FIELDS)) {
102
115
  const v = value(name);
103
- if (v === undefined)
116
+ if (v === undefined || (name === 'actions' && Array.isArray(v) && v.length === 0))
104
117
  continue;
105
118
  const wire = FIELDS[name];
106
119
  if (name === 'occurredAt') {
@@ -177,6 +190,7 @@ function checkMessage(b, add) {
177
190
  if (b.image_url !== undefined && !validUrl(b.image_url, true)) {
178
191
  add('image_url', 'invalid_format', 'must be an https URL without credentials or fragment, at most 2048 bytes');
179
192
  }
193
+ checkActions(b.actions, add);
180
194
  const md = b.metadata;
181
195
  if (md !== undefined) {
182
196
  if (typeof md !== 'object' || md === null || Array.isArray(md)) {
@@ -210,6 +224,100 @@ function checkMessage(b, add) {
210
224
  add('ttl_seconds', 'out_of_range', `must be an integer between ${exports.LIMITS.ttlMin} and ${exports.LIMITS.ttlMax}`);
211
225
  }
212
226
  }
227
+ function checkActions(v, add) {
228
+ if (v === undefined)
229
+ return;
230
+ const format = 'must be an array of at most 3 { title, url } objects';
231
+ if (!Array.isArray(v))
232
+ return add('actions', 'invalid_format', format);
233
+ if (v.length > exports.LIMITS.actions)
234
+ return add('actions', 'too_long', `at most ${exports.LIMITS.actions} actions`);
235
+ if (v.some((a) => a === null || typeof a !== 'object' || Array.isArray(a)))
236
+ return add('actions', 'invalid_format', format);
237
+ v.forEach((a, i) => {
238
+ const field = `actions[${i}]`;
239
+ const title = typeof a.title === 'string' ? a.title.trim() : a.title;
240
+ if (title === undefined || title === null || title === '')
241
+ add(`${field}.title`, 'required', 'title is required');
242
+ else if (typeof title !== 'string')
243
+ add(`${field}.title`, 'invalid_format', 'must be a string');
244
+ else if (codePoints(title) > exports.LIMITS.actionTitle)
245
+ add(`${field}.title`, 'too_long', `must be at most ${exports.LIMITS.actionTitle} characters`);
246
+ else if (CONTROL.test(title))
247
+ add(`${field}.title`, 'invalid_format', 'must be one line without control characters');
248
+ const url = typeof a.url === 'string' ? a.url.trim() : a.url;
249
+ if (url === undefined || url === null || url === '')
250
+ add(`${field}.url`, 'required', 'url is required');
251
+ else if (typeof url !== 'string')
252
+ add(`${field}.url`, 'invalid_format', 'must be a string');
253
+ else if ((0, exports.byteLength)(url) > exports.LIMITS.urlBytes)
254
+ add(`${field}.url`, 'too_long', `must be at most ${exports.LIMITS.urlBytes} bytes`);
255
+ else if (!validActionUrl(url))
256
+ add(`${field}.url`, 'invalid_format', 'must be an https://, mailto:, tel: or sms: URL without spaces');
257
+ for (const key of Object.keys(a).sort()) {
258
+ if (key !== 'title' && key !== 'url')
259
+ add(`${field}.${key}`, 'not_allowed', 'unknown field (an action has title and url)');
260
+ }
261
+ });
262
+ }
263
+ /**
264
+ * The server's check of a (trimmed) action URL, scheme in any case: `https://` with a host and
265
+ * no credentials (as `url`), `mailto:` with one address and an optional `?subject=…&body=…`,
266
+ * `tel:` / `tel://` with a number, `sms:` with a number and an optional `?body=…`.
267
+ */
268
+ function validActionUrl(s) {
269
+ if (CONTROL.test(s) || SPACE.test(s))
270
+ return false;
271
+ const colon = s.indexOf(':');
272
+ if (colon < 0)
273
+ return false;
274
+ const rest = s.slice(colon + 1);
275
+ const [head, query] = splitOnce(rest, '?');
276
+ switch (s.slice(0, colon).toLowerCase()) {
277
+ case 'https':
278
+ return validUrl(s, false);
279
+ case 'mailto':
280
+ return mailAddress(head) && actionQuery(query, ['subject', 'body']);
281
+ case 'tel':
282
+ return PHONE_NUMBER.test(rest.startsWith('//') ? rest.slice(2) : rest);
283
+ case 'sms':
284
+ return PHONE_NUMBER.test(head) && actionQuery(query, ['body']);
285
+ default:
286
+ return false;
287
+ }
288
+ }
289
+ function splitOnce(s, sep) {
290
+ const i = s.indexOf(sep);
291
+ return i < 0 ? [s, ''] : [s.slice(0, i), s.slice(i + 1)];
292
+ }
293
+ function mailAddress(s) {
294
+ if (BAD_ESCAPE.test(s))
295
+ return false;
296
+ try {
297
+ return MAIL_ADDRESS.test(decodeURIComponent(s));
298
+ }
299
+ catch {
300
+ return false;
301
+ }
302
+ }
303
+ // Valid percent-encoding, no ";" separators and only the allowed keys ("" is no query).
304
+ function actionQuery(q, allowed) {
305
+ for (const pair of q.split('&')) {
306
+ if (pair.includes(';') || BAD_ESCAPE.test(pair))
307
+ return false;
308
+ if (pair === '')
309
+ continue;
310
+ const [key] = splitOnce(pair, '=');
311
+ try {
312
+ if (!allowed.includes(decodeURIComponent(key.replace(/\+/g, ' '))))
313
+ return false;
314
+ }
315
+ catch {
316
+ return false;
317
+ }
318
+ }
319
+ return true;
320
+ }
213
321
  function shortText(b, field, max, add) {
214
322
  const v = b[field];
215
323
  if (v === undefined)
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.0";
1
+ export declare const VERSION = "0.2.0";
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // Generated from package.json by scripts/build.mjs on every build. Do not edit: change
5
5
  // "version" in package.json instead.
6
- exports.VERSION = '0.1.0';
6
+ exports.VERSION = '0.2.0';
@@ -8,6 +8,9 @@ const DEFAULT_RETRIES = 4;
8
8
  const DEFAULT_DEADLINE_MS = 30_000;
9
9
  const DEFAULT_BACKOFF_BASE_MS = 500;
10
10
  const DEFAULT_BACKOFF_MAX_MS = 8_000;
11
+ // A retry starts only with at least this long left before the deadline (or timeoutMs, when
12
+ // shorter): with less it could only time out, and its timeout would hide the real error.
13
+ const MIN_ATTEMPT_MS = 250;
11
14
  /** Seconds from a Retry-After header (delta-seconds or HTTP date), or undefined. */
12
15
  export function parseRetryAfter(value, now = Date.now()) {
13
16
  if (value === null)
@@ -116,7 +119,7 @@ export class Honk {
116
119
  throw failure;
117
120
  const jitter = Math.random() * Math.min(this.#backoffMax, this.#backoffBase * 2 ** (attempt - 1));
118
121
  const wait = Math.max(jitter, (failure.retryAfter ?? 0) * 1000);
119
- if (Date.now() + wait >= deadline)
122
+ if (Date.now() + wait + Math.min(this.timeoutMs, MIN_ATTEMPT_MS) > deadline)
120
123
  throw failure;
121
124
  await sleep(wait, signal);
122
125
  }
@@ -2,4 +2,4 @@ export { Honk, VERSION, parseRetryAfter } from './client.js';
2
2
  export { HonkError, HonkValidationError, HonkAuthError, HonkQuotaError, HonkConflictError, HonkNetworkError, HonkTimeoutError, HonkServerError, type ErrorKind, type FieldError, } from './errors.js';
3
3
  export { buildBody, LIMITS } from './validate.js';
4
4
  export { uuidv7 } from './uuid.js';
5
- export { Severity, SEVERITY_ALIASES, normalizeSeverity, SEVERITIES, PRIORITIES, EVENT_TYPES, CATEGORIES, type SeverityAlias, type SeverityInput, type Priority, type EventType, type Category, type MetadataValue, type Message, type Defaults, type HonkOptions, type SendOptions, type HelperOptions, type SendResult, } from './types.js';
5
+ export { Severity, SEVERITY_ALIASES, normalizeSeverity, SEVERITIES, PRIORITIES, EVENT_TYPES, CATEGORIES, type SeverityAlias, type SeverityInput, type Priority, type EventType, type Category, type MetadataValue, type Action, type Message, type Defaults, type HonkOptions, type SendOptions, type HelperOptions, type SendResult, } from './types.js';
@@ -41,6 +41,20 @@ export declare const PRIORITIES: readonly Priority[];
41
41
  export declare const EVENT_TYPES: readonly EventType[];
42
42
  export declare const CATEGORIES: readonly Category[];
43
43
  export type MetadataValue = string | number | boolean;
44
+ /**
45
+ * A button on the message. Honk never opens or fetches the URL; the app opens it when the user
46
+ * taps the button.
47
+ */
48
+ export interface Action {
49
+ /** 1–40 characters (trimmed), one line, shown as sent: `Reply`, `Call Emily`. */
50
+ title: string;
51
+ /**
52
+ * ≤ 2048 bytes, no spaces: `https://` (no credentials), `mailto:` with one address
53
+ * (`?subject=…&body=…` percent-encoded, no other keys), `tel:` or `sms:` with a number
54
+ * (`sms:` also `?body=…`). Other schemes are refused.
55
+ */
56
+ url: string;
57
+ }
44
58
  /**
45
59
  * One event for `POST /v1/messages`. Only `message` is required. Fields are camelCase here and
46
60
  * sent as the snake_case names of the API (`groupKey` → `group_key`). Empty optional strings
@@ -79,6 +93,8 @@ export interface Message {
79
93
  url?: string | null;
80
94
  /** HTTPS image fetched by the server after ingestion. No credentials or fragment. ≤ 2048 bytes. */
81
95
  imageUrl?: string | null;
96
+ /** Up to 3 buttons, in display order (the first is the primary). Empty means none. */
97
+ actions?: readonly Action[] | null;
82
98
  /** ≤ 16 keys matching `[A-Za-z0-9_.-]{1,64}`; values are strings (≤ 512 chars), numbers or booleans. */
83
99
  metadata?: Record<string, MetadataValue> | null;
84
100
  /** Push lifetime, 60–86400 seconds. Default 3600. */
@@ -9,6 +9,8 @@ export declare const LIMITS: {
9
9
  readonly channel: 64;
10
10
  readonly groupKey: 128;
11
11
  readonly urlBytes: 2048;
12
+ readonly actions: 3;
13
+ readonly actionTitle: 40;
12
14
  readonly metadataKeys: 16;
13
15
  readonly metadataValue: 512;
14
16
  readonly ttlMin: 60;
@@ -24,5 +26,11 @@ export declare function checkIdempotencyKey(key: unknown): string;
24
26
  * listing every invalid field.
25
27
  */
26
28
  export declare function buildBody(input: Message, defaults?: Defaults, validate?: boolean): Record<string, unknown>;
29
+ /**
30
+ * The server's check of a (trimmed) action URL, scheme in any case: `https://` with a host and
31
+ * no credentials (as `url`), `mailto:` with one address and an optional `?subject=…&body=…`,
32
+ * `tel:` / `tel://` with a number, `sms:` with a number and an optional `?body=…`.
33
+ */
34
+ export declare function validActionUrl(s: string): boolean;
27
35
  /** Syntactic check matching the server: https, a host, no credentials, no spaces/backslashes, ≤ 2048 bytes. */
28
36
  export declare function validUrl(raw: unknown, image: boolean): boolean;
@@ -10,6 +10,8 @@ export const LIMITS = {
10
10
  channel: 64,
11
11
  groupKey: 128,
12
12
  urlBytes: 2048,
13
+ actions: 3,
14
+ actionTitle: 40,
13
15
  metadataKeys: 16,
14
16
  metadataValue: 512,
15
17
  ttlMin: 60,
@@ -31,6 +33,7 @@ const FIELDS = {
31
33
  occurredAt: 'occurred_at',
32
34
  url: 'url',
33
35
  imageUrl: 'image_url',
36
+ actions: 'actions',
34
37
  metadata: 'metadata',
35
38
  ttlSeconds: 'ttl_seconds',
36
39
  sourceSequence: 'source_sequence',
@@ -42,6 +45,15 @@ const CONTROL_EXCEPT_BREAKS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0
42
45
  const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
43
46
  const METADATA_KEY = /^[A-Za-z0-9_.-]{1,64}$/;
44
47
  const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,128}$/;
48
+ // Unicode White_Space outside the control characters (Go's unicode.IsSpace, as on the server).
49
+ const SPACE = /[ \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
50
+ // The number of a tel: or sms: action: an optional leading +, digits and - . ( ) separators.
51
+ const PHONE_NUMBER = /^\+?[0-9().-]*[0-9][0-9().-]*$/;
52
+ // A single plain mailto: address, once percent-decoded: dot-atom@dot-atom with a dotted domain
53
+ // (or an IPv4 literal).
54
+ const ATOM = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~\\-\\u{80}-\\u{10ffff}]+";
55
+ const MAIL_ADDRESS = new RegExp(`^${ATOM}(\\.${ATOM})*@(${ATOM}(\\.${ATOM})+|\\[[0-9.]*\\.[0-9.]*\\])$`, 'u');
56
+ const BAD_ESCAPE = /%(?![0-9A-Fa-f]{2})/;
45
57
  const encoder = new TextEncoder();
46
58
  export const byteLength = (s) => encoder.encode(s).length;
47
59
  const codePoints = (s) => {
@@ -93,7 +105,7 @@ export function buildBody(input, defaults = {}, validate = true) {
93
105
  const body = {};
94
106
  for (const name of Object.keys(FIELDS)) {
95
107
  const v = value(name);
96
- if (v === undefined)
108
+ if (v === undefined || (name === 'actions' && Array.isArray(v) && v.length === 0))
97
109
  continue;
98
110
  const wire = FIELDS[name];
99
111
  if (name === 'occurredAt') {
@@ -170,6 +182,7 @@ function checkMessage(b, add) {
170
182
  if (b.image_url !== undefined && !validUrl(b.image_url, true)) {
171
183
  add('image_url', 'invalid_format', 'must be an https URL without credentials or fragment, at most 2048 bytes');
172
184
  }
185
+ checkActions(b.actions, add);
173
186
  const md = b.metadata;
174
187
  if (md !== undefined) {
175
188
  if (typeof md !== 'object' || md === null || Array.isArray(md)) {
@@ -203,6 +216,100 @@ function checkMessage(b, add) {
203
216
  add('ttl_seconds', 'out_of_range', `must be an integer between ${LIMITS.ttlMin} and ${LIMITS.ttlMax}`);
204
217
  }
205
218
  }
219
+ function checkActions(v, add) {
220
+ if (v === undefined)
221
+ return;
222
+ const format = 'must be an array of at most 3 { title, url } objects';
223
+ if (!Array.isArray(v))
224
+ return add('actions', 'invalid_format', format);
225
+ if (v.length > LIMITS.actions)
226
+ return add('actions', 'too_long', `at most ${LIMITS.actions} actions`);
227
+ if (v.some((a) => a === null || typeof a !== 'object' || Array.isArray(a)))
228
+ return add('actions', 'invalid_format', format);
229
+ v.forEach((a, i) => {
230
+ const field = `actions[${i}]`;
231
+ const title = typeof a.title === 'string' ? a.title.trim() : a.title;
232
+ if (title === undefined || title === null || title === '')
233
+ add(`${field}.title`, 'required', 'title is required');
234
+ else if (typeof title !== 'string')
235
+ add(`${field}.title`, 'invalid_format', 'must be a string');
236
+ else if (codePoints(title) > LIMITS.actionTitle)
237
+ add(`${field}.title`, 'too_long', `must be at most ${LIMITS.actionTitle} characters`);
238
+ else if (CONTROL.test(title))
239
+ add(`${field}.title`, 'invalid_format', 'must be one line without control characters');
240
+ const url = typeof a.url === 'string' ? a.url.trim() : a.url;
241
+ if (url === undefined || url === null || url === '')
242
+ add(`${field}.url`, 'required', 'url is required');
243
+ else if (typeof url !== 'string')
244
+ add(`${field}.url`, 'invalid_format', 'must be a string');
245
+ else if (byteLength(url) > LIMITS.urlBytes)
246
+ add(`${field}.url`, 'too_long', `must be at most ${LIMITS.urlBytes} bytes`);
247
+ else if (!validActionUrl(url))
248
+ add(`${field}.url`, 'invalid_format', 'must be an https://, mailto:, tel: or sms: URL without spaces');
249
+ for (const key of Object.keys(a).sort()) {
250
+ if (key !== 'title' && key !== 'url')
251
+ add(`${field}.${key}`, 'not_allowed', 'unknown field (an action has title and url)');
252
+ }
253
+ });
254
+ }
255
+ /**
256
+ * The server's check of a (trimmed) action URL, scheme in any case: `https://` with a host and
257
+ * no credentials (as `url`), `mailto:` with one address and an optional `?subject=…&body=…`,
258
+ * `tel:` / `tel://` with a number, `sms:` with a number and an optional `?body=…`.
259
+ */
260
+ export function validActionUrl(s) {
261
+ if (CONTROL.test(s) || SPACE.test(s))
262
+ return false;
263
+ const colon = s.indexOf(':');
264
+ if (colon < 0)
265
+ return false;
266
+ const rest = s.slice(colon + 1);
267
+ const [head, query] = splitOnce(rest, '?');
268
+ switch (s.slice(0, colon).toLowerCase()) {
269
+ case 'https':
270
+ return validUrl(s, false);
271
+ case 'mailto':
272
+ return mailAddress(head) && actionQuery(query, ['subject', 'body']);
273
+ case 'tel':
274
+ return PHONE_NUMBER.test(rest.startsWith('//') ? rest.slice(2) : rest);
275
+ case 'sms':
276
+ return PHONE_NUMBER.test(head) && actionQuery(query, ['body']);
277
+ default:
278
+ return false;
279
+ }
280
+ }
281
+ function splitOnce(s, sep) {
282
+ const i = s.indexOf(sep);
283
+ return i < 0 ? [s, ''] : [s.slice(0, i), s.slice(i + 1)];
284
+ }
285
+ function mailAddress(s) {
286
+ if (BAD_ESCAPE.test(s))
287
+ return false;
288
+ try {
289
+ return MAIL_ADDRESS.test(decodeURIComponent(s));
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ }
295
+ // Valid percent-encoding, no ";" separators and only the allowed keys ("" is no query).
296
+ function actionQuery(q, allowed) {
297
+ for (const pair of q.split('&')) {
298
+ if (pair.includes(';') || BAD_ESCAPE.test(pair))
299
+ return false;
300
+ if (pair === '')
301
+ continue;
302
+ const [key] = splitOnce(pair, '=');
303
+ try {
304
+ if (!allowed.includes(decodeURIComponent(key.replace(/\+/g, ' '))))
305
+ return false;
306
+ }
307
+ catch {
308
+ return false;
309
+ }
310
+ }
311
+ return true;
312
+ }
206
313
  function shortText(b, field, max, add) {
207
314
  const v = b[field];
208
315
  if (v === undefined)
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.0";
1
+ export declare const VERSION = "0.2.0";
@@ -1,3 +1,3 @@
1
1
  // Generated from package.json by scripts/build.mjs on every build. Do not edit: change
2
2
  // "version" in package.json instead.
3
- export const VERSION = '0.1.0';
3
+ export const VERSION = '0.2.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honk-me",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Official Node.js/TypeScript client for Honk (honk-me.app): send events from apps, scripts and automations to your phone, with retries and idempotency built in.",
5
5
  "keywords": [
6
6
  "honk",
@@ -24,7 +24,11 @@
24
24
  "url": "git+https://github.com/honk-me/honk-node.git"
25
25
  },
26
26
  "license": "MIT",
27
- "author": "Honk contributors",
27
+ "author": {
28
+ "name": "Honk",
29
+ "email": "accounts@honk-me.app",
30
+ "url": "https://honk-me.app"
31
+ },
28
32
  "sideEffects": false,
29
33
  "main": "./dist/cjs/index.js",
30
34
  "module": "./dist/esm/index.js",