honk-me 0.1.1 → 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 +8 -0
- package/README.md +30 -2
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/types.d.ts +16 -0
- package/dist/cjs/validate.d.ts +8 -0
- package/dist/cjs/validate.js +109 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/types.d.ts +16 -0
- package/dist/esm/validate.d.ts +8 -0
- package/dist/esm/validate.js +108 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ 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
|
+
|
|
7
15
|
## [0.1.1] - 2026-10-06
|
|
8
16
|
|
|
9
17
|
### Fixed
|
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
|
-
|
|
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
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -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. */
|
package/dist/cjs/validate.d.ts
CHANGED
|
@@ -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;
|
package/dist/cjs/validate.js
CHANGED
|
@@ -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)
|
package/dist/cjs/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
package/dist/cjs/version.js
CHANGED
|
@@ -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.
|
|
6
|
+
exports.VERSION = '0.2.0';
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -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. */
|
package/dist/esm/validate.d.ts
CHANGED
|
@@ -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;
|
package/dist/esm/validate.js
CHANGED
|
@@ -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)
|
package/dist/esm/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
package/dist/esm/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "honk-me",
|
|
3
|
-
"version": "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",
|