@phuzle/relay 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.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/cli.js +583 -0
- package/index.cjs +496 -0
- package/index.d.cts +148 -0
- package/index.d.ts +148 -0
- package/index.js +466 -0
- package/package.json +50 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** Thrown when the plaintext message exceeds 2 KiB. */
|
|
2
|
+
export declare class PayloadTooLargeError extends Error {
|
|
3
|
+
constructor(size: number);
|
|
4
|
+
}
|
|
5
|
+
export type Priority = "min" | "low" | "default" | "high" | "urgent";
|
|
6
|
+
export type ActionRequest = {
|
|
7
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
8
|
+
url: string;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
body?: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* A button on the notification. Either it **sends an HTTP request** from the phone (`request`), or it **opens a link** in the
|
|
14
|
+
* browser (`link`, https only: a dashboard, a runbook, a PR, …). Exactly one of the two.
|
|
15
|
+
*/
|
|
16
|
+
export type Action = {
|
|
17
|
+
id?: string;
|
|
18
|
+
label: string;
|
|
19
|
+
style?: "default" | "destructive";
|
|
20
|
+
confirm?: boolean;
|
|
21
|
+
retry?: boolean;
|
|
22
|
+
} & ({
|
|
23
|
+
request: ActionRequest;
|
|
24
|
+
link?: never;
|
|
25
|
+
} | {
|
|
26
|
+
link: string;
|
|
27
|
+
request?: never;
|
|
28
|
+
});
|
|
29
|
+
export type Message = {
|
|
30
|
+
topic: string;
|
|
31
|
+
title: string;
|
|
32
|
+
body?: string;
|
|
33
|
+
priority?: Priority;
|
|
34
|
+
tags?: string[];
|
|
35
|
+
/** One of the built-in icons (`GLYPHS`): `"rocket"`, `"flame"`, `"bug"`… The phone draws it; nothing is downloaded. */
|
|
36
|
+
icon?: string;
|
|
37
|
+
/** Opens when the notification is tapped (https only). */
|
|
38
|
+
url?: string;
|
|
39
|
+
/**
|
|
40
|
+
* An https image to show with the message. The link is inside the encrypted message; the phone downloads it only if the user
|
|
41
|
+
* turned on "Load images" for the topic, and then the image's server sees that phone's IP address and the time. Max 1000 characters.
|
|
42
|
+
*/
|
|
43
|
+
image?: string;
|
|
44
|
+
/** Notifications with the same group stack together on the phone. */
|
|
45
|
+
group?: string;
|
|
46
|
+
/** Up to 3 buttons: each either calls your HTTPS endpoint straight from the phone (`request`; the request and any secrets in headers are inside the ciphertext) or opens an https `link` in the browser. */
|
|
47
|
+
actions?: Action[];
|
|
48
|
+
/** How long to keep trying while the phone is offline. Default 24 h, max 4 weeks. */
|
|
49
|
+
ttlSeconds?: number;
|
|
50
|
+
/** A newer message with the same key replaces an undelivered older one. */
|
|
51
|
+
collapseKey?: string;
|
|
52
|
+
idempotencyKey?: string;
|
|
53
|
+
/** Hold delivery until this time (up to 30 days ahead). Relay keeps only the sealed message until then. */
|
|
54
|
+
deliverAt?: Date | string;
|
|
55
|
+
/** Shorthand for `deliverAt: now + delaySeconds`. */
|
|
56
|
+
delaySeconds?: number;
|
|
57
|
+
/** Re-send the same message every `everySeconds` (≥ 60), up to `times` more times, until a phone acknowledges it (opens it or presses a button). */
|
|
58
|
+
repeat?: {
|
|
59
|
+
everySeconds: number;
|
|
60
|
+
times: number;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* If nobody acknowledges this message within `afterSeconds`, deliver a message to `topic` (another on-call group). Encrypted for
|
|
64
|
+
* that topic's phones now, so Relay still can't read it; cancelled when this one is acknowledged. Anything not set here
|
|
65
|
+
* (title, body, priority…) is taken from the original message, with priority raised to `urgent` by default.
|
|
66
|
+
*/
|
|
67
|
+
escalate?: {
|
|
68
|
+
topic: string;
|
|
69
|
+
afterSeconds: number;
|
|
70
|
+
title?: string;
|
|
71
|
+
body?: string;
|
|
72
|
+
priority?: Priority;
|
|
73
|
+
repeat?: {
|
|
74
|
+
everySeconds: number;
|
|
75
|
+
times: number;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/** Advanced: only deliver if the message with this id is still unacknowledged at delivery time (what `escalate` uses). */
|
|
79
|
+
unlessAcked?: string;
|
|
80
|
+
};
|
|
81
|
+
export type SendResult = {
|
|
82
|
+
messageId: string;
|
|
83
|
+
accepted: number;
|
|
84
|
+
rejected: {
|
|
85
|
+
deviceId: string;
|
|
86
|
+
reason: string;
|
|
87
|
+
}[];
|
|
88
|
+
missing: string[];
|
|
89
|
+
duplicate?: boolean;
|
|
90
|
+
/** Set when delivery is held until a later time. */
|
|
91
|
+
scheduledFor?: string;
|
|
92
|
+
/** `escalate` was set: the second message's result. */
|
|
93
|
+
escalation?: SendResult;
|
|
94
|
+
};
|
|
95
|
+
/** A failed call to Relay. Branch on `code` (stable), never on `message`. */
|
|
96
|
+
export declare class RelayError extends Error {
|
|
97
|
+
readonly status: number;
|
|
98
|
+
readonly code: string;
|
|
99
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
100
|
+
constructor(message: string, status: number, code: string, retryAfterSeconds?: number | undefined);
|
|
101
|
+
}
|
|
102
|
+
export type RelayOptions = {
|
|
103
|
+
apiKey: string;
|
|
104
|
+
/** Relay's origin (default `https://relay.phuzle.com`; the SDK adds `/v1/…`). Point it at a local, staging or self-hosted Relay; a path prefix is kept. */
|
|
105
|
+
baseUrl?: string;
|
|
106
|
+
fetch?: typeof fetch;
|
|
107
|
+
maxRetries?: number;
|
|
108
|
+
};
|
|
109
|
+
export declare class Relay {
|
|
110
|
+
private readonly opts;
|
|
111
|
+
private readonly base;
|
|
112
|
+
private readonly doFetch;
|
|
113
|
+
private readonly retries;
|
|
114
|
+
private readonly cache;
|
|
115
|
+
/** Trust-on-first-use: a device id must keep the same identity key, or something is swapping keys (crypto-spec §3, T4). */
|
|
116
|
+
private readonly pinned;
|
|
117
|
+
constructor(opts: RelayOptions);
|
|
118
|
+
/** Reads `RELAY_API_KEY` and, optionally, `RELAY_API_URL`; `overrides` win over the environment. */
|
|
119
|
+
static fromEnv(env?: Record<string, string | undefined>, overrides?: Partial<RelayOptions>): Relay;
|
|
120
|
+
send(m: Message): Promise<SendResult>;
|
|
121
|
+
private sendCore;
|
|
122
|
+
private sendOnce;
|
|
123
|
+
/** Recipients for a topic, verified and cached by ETag (a cheap conditional GET on every send keeps the key set fresh). */
|
|
124
|
+
private recipients;
|
|
125
|
+
private request;
|
|
126
|
+
private raw;
|
|
127
|
+
private parse;
|
|
128
|
+
}
|
|
129
|
+
type Plain = {
|
|
130
|
+
ts: number;
|
|
131
|
+
title: string;
|
|
132
|
+
body?: string;
|
|
133
|
+
priority: Priority;
|
|
134
|
+
tags?: string[];
|
|
135
|
+
icon?: string;
|
|
136
|
+
url?: string;
|
|
137
|
+
image?: string;
|
|
138
|
+
group?: string;
|
|
139
|
+
actions?: ((Required<Omit<Action, "link">> & {
|
|
140
|
+
link?: never;
|
|
141
|
+
}) | {
|
|
142
|
+
id: string;
|
|
143
|
+
label: string;
|
|
144
|
+
link: string;
|
|
145
|
+
})[];
|
|
146
|
+
};
|
|
147
|
+
/** Validates and shapes the plaintext (crypto-spec §6). Throws RelayError(code) on anything the phone would reject. */
|
|
148
|
+
export declare function buildPlaintext(m: Message): Omit<Plain, "id">;
|
package/index.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// ../core/src/encoding.ts
|
|
2
|
+
function b64urlEncode(bytes) {
|
|
3
|
+
let s = "";
|
|
4
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
5
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6
|
+
}
|
|
7
|
+
function b64urlDecode(text) {
|
|
8
|
+
const pad = "=".repeat((4 - text.length % 4) % 4);
|
|
9
|
+
const bin = atob(text.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
10
|
+
const out = new Uint8Array(bin.length);
|
|
11
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
var enc = new TextEncoder();
|
|
15
|
+
var utf8 = (s) => enc.encode(s);
|
|
16
|
+
|
|
17
|
+
// ../core/src/crypto/suite.ts
|
|
18
|
+
import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
|
|
19
|
+
import { CipherSuite, HkdfSha256 } from "@hpke/core";
|
|
20
|
+
import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
|
|
21
|
+
var suite = new CipherSuite({
|
|
22
|
+
kem: new DhkemX25519HkdfSha256(),
|
|
23
|
+
kdf: new HkdfSha256(),
|
|
24
|
+
aead: new Chacha20Poly1305()
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ../core/src/crypto/envelope.ts
|
|
28
|
+
var MAX_PLAINTEXT_BYTES = 2048;
|
|
29
|
+
var PayloadTooLargeError = class extends Error {
|
|
30
|
+
constructor(size) {
|
|
31
|
+
super(`Payload is ${size} bytes; limit is ${MAX_PLAINTEXT_BYTES}`);
|
|
32
|
+
this.name = "PayloadTooLargeError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var infoFor = (c) => utf8(`relay/v1\0${c.deviceId}\0${c.messageId}\0${c.topicId}`);
|
|
36
|
+
async function seal(encPub, ctx, plaintext, ekm) {
|
|
37
|
+
const pt = utf8(plaintext);
|
|
38
|
+
if (pt.length > MAX_PLAINTEXT_BYTES)
|
|
39
|
+
throw new PayloadTooLargeError(pt.length);
|
|
40
|
+
const recipientPublicKey = await suite.kem.deserializePublicKey(encPub);
|
|
41
|
+
const sender = await suite.createSenderContext({
|
|
42
|
+
recipientPublicKey,
|
|
43
|
+
info: infoFor(ctx),
|
|
44
|
+
...ekm ? { ekm } : {}
|
|
45
|
+
});
|
|
46
|
+
const ct = new Uint8Array(await sender.seal(pt));
|
|
47
|
+
return {
|
|
48
|
+
deviceId: ctx.deviceId,
|
|
49
|
+
enc: b64urlEncode(new Uint8Array(sender.enc)),
|
|
50
|
+
ct: b64urlEncode(ct)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ../core/src/crypto/keys.ts
|
|
55
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
56
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
57
|
+
function bindingMessage(encPub, idPub) {
|
|
58
|
+
return utf8(`relay-bind-v1
|
|
59
|
+
${b64urlEncode(encPub)}
|
|
60
|
+
${b64urlEncode(idPub)}`);
|
|
61
|
+
}
|
|
62
|
+
function verifyBinding(sig, encPub, idPub) {
|
|
63
|
+
try {
|
|
64
|
+
return ed25519.verify(sig, bindingMessage(encPub, idPub), idPub);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ../core/src/crypto/signing.ts
|
|
71
|
+
import { ed25519 as ed255192 } from "@noble/curves/ed25519";
|
|
72
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2";
|
|
73
|
+
|
|
74
|
+
// ../core/src/lib/glyphs.ts
|
|
75
|
+
var GLYPHS = [
|
|
76
|
+
"activity",
|
|
77
|
+
"alert",
|
|
78
|
+
"battery",
|
|
79
|
+
"bell",
|
|
80
|
+
"bug",
|
|
81
|
+
"calendar",
|
|
82
|
+
"cart",
|
|
83
|
+
"check",
|
|
84
|
+
"clock",
|
|
85
|
+
"cloud",
|
|
86
|
+
"cpu",
|
|
87
|
+
"credit-card",
|
|
88
|
+
"database",
|
|
89
|
+
"dollar",
|
|
90
|
+
"error",
|
|
91
|
+
"flame",
|
|
92
|
+
"git-branch",
|
|
93
|
+
"git-pr",
|
|
94
|
+
"globe",
|
|
95
|
+
"heart",
|
|
96
|
+
"info",
|
|
97
|
+
"key",
|
|
98
|
+
"lock",
|
|
99
|
+
"mail",
|
|
100
|
+
"moon",
|
|
101
|
+
"package",
|
|
102
|
+
"phone",
|
|
103
|
+
"power",
|
|
104
|
+
"rocket",
|
|
105
|
+
"server",
|
|
106
|
+
"shield",
|
|
107
|
+
"star",
|
|
108
|
+
"success",
|
|
109
|
+
"terminal",
|
|
110
|
+
"thermometer",
|
|
111
|
+
"trending-down",
|
|
112
|
+
"trending-up",
|
|
113
|
+
"users",
|
|
114
|
+
"wifi",
|
|
115
|
+
"zap"
|
|
116
|
+
];
|
|
117
|
+
var isGlyph = (v) => typeof v === "string" && GLYPHS.includes(v);
|
|
118
|
+
|
|
119
|
+
// ../core/src/lib/ids.ts
|
|
120
|
+
var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
121
|
+
function ulid(now = Date.now()) {
|
|
122
|
+
let t = now;
|
|
123
|
+
let time = "";
|
|
124
|
+
for (let i = 0; i < 10; i++) {
|
|
125
|
+
time = CROCKFORD[t % 32] + time;
|
|
126
|
+
t = Math.floor(t / 32);
|
|
127
|
+
}
|
|
128
|
+
const r = crypto.getRandomValues(new Uint8Array(16));
|
|
129
|
+
let rand = "";
|
|
130
|
+
for (let i = 0; i < 16; i++) rand += CROCKFORD[r[i] % 32];
|
|
131
|
+
return time + rand;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/index.ts
|
|
135
|
+
var deliverAtIso = (m) => {
|
|
136
|
+
if (m.deliverAt !== void 0 && m.delaySeconds !== void 0)
|
|
137
|
+
throw new RelayError(
|
|
138
|
+
"Use deliverAt or delaySeconds, not both",
|
|
139
|
+
0,
|
|
140
|
+
"invalid_message"
|
|
141
|
+
);
|
|
142
|
+
const d = m.deliverAt !== void 0 ? new Date(m.deliverAt) : m.delaySeconds !== void 0 ? new Date(Date.now() + m.delaySeconds * 1e3) : void 0;
|
|
143
|
+
if (d && Number.isNaN(d.getTime()))
|
|
144
|
+
throw new RelayError("deliverAt is not a valid date", 0, "invalid_message");
|
|
145
|
+
return d?.toISOString();
|
|
146
|
+
};
|
|
147
|
+
var checkRepeat = (r) => {
|
|
148
|
+
if (!r) return;
|
|
149
|
+
if (!Number.isInteger(r.everySeconds) || r.everySeconds < 60 || r.everySeconds > 86400)
|
|
150
|
+
throw new RelayError(
|
|
151
|
+
"repeat.everySeconds must be 60 to 86400",
|
|
152
|
+
0,
|
|
153
|
+
"invalid_message"
|
|
154
|
+
);
|
|
155
|
+
if (!Number.isInteger(r.times) || r.times < 1 || r.times > 20)
|
|
156
|
+
throw new RelayError("repeat.times must be 1 to 20", 0, "invalid_message");
|
|
157
|
+
};
|
|
158
|
+
var RelayError = class extends Error {
|
|
159
|
+
constructor(message, status, code, retryAfterSeconds) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.status = status;
|
|
162
|
+
this.code = code;
|
|
163
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
164
|
+
this.name = "RelayError";
|
|
165
|
+
}
|
|
166
|
+
status;
|
|
167
|
+
code;
|
|
168
|
+
retryAfterSeconds;
|
|
169
|
+
};
|
|
170
|
+
var DEFAULT_URL = "https://relay.phuzle.com";
|
|
171
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
172
|
+
var Relay = class _Relay {
|
|
173
|
+
constructor(opts) {
|
|
174
|
+
this.opts = opts;
|
|
175
|
+
if (!opts.apiKey?.startsWith("rly_"))
|
|
176
|
+
throw new RelayError(
|
|
177
|
+
"apiKey must be a Relay API key (rly_\u2026)",
|
|
178
|
+
0,
|
|
179
|
+
"bad_api_key"
|
|
180
|
+
);
|
|
181
|
+
const base = (opts.baseUrl || DEFAULT_URL).trim();
|
|
182
|
+
if (!/^https?:\/\/[^\s/]+/i.test(base))
|
|
183
|
+
throw new RelayError(
|
|
184
|
+
`baseUrl must be an http(s) URL, got "${base}"`,
|
|
185
|
+
0,
|
|
186
|
+
"bad_base_url"
|
|
187
|
+
);
|
|
188
|
+
this.base = base.replace(/\/+$/, "");
|
|
189
|
+
this.doFetch = opts.fetch ?? fetch;
|
|
190
|
+
this.retries = opts.maxRetries ?? 2;
|
|
191
|
+
}
|
|
192
|
+
opts;
|
|
193
|
+
base;
|
|
194
|
+
doFetch;
|
|
195
|
+
retries;
|
|
196
|
+
cache = /* @__PURE__ */ new Map();
|
|
197
|
+
/** Trust-on-first-use: a device id must keep the same identity key, or something is swapping keys (crypto-spec §3, T4). */
|
|
198
|
+
pinned = /* @__PURE__ */ new Map();
|
|
199
|
+
/** Reads `RELAY_API_KEY` and, optionally, `RELAY_API_URL`; `overrides` win over the environment. */
|
|
200
|
+
static fromEnv(env = process.env, overrides = {}) {
|
|
201
|
+
return new _Relay({
|
|
202
|
+
apiKey: env.RELAY_API_KEY ?? "",
|
|
203
|
+
baseUrl: overrides.baseUrl || env.RELAY_API_URL,
|
|
204
|
+
...overrides.fetch && { fetch: overrides.fetch },
|
|
205
|
+
...overrides.maxRetries !== void 0 && {
|
|
206
|
+
maxRetries: overrides.maxRetries
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
async send(m) {
|
|
211
|
+
checkRepeat(m.repeat);
|
|
212
|
+
checkRepeat(m.escalate?.repeat);
|
|
213
|
+
if (m.escalate && (!Number.isFinite(m.escalate.afterSeconds) || m.escalate.afterSeconds < 30))
|
|
214
|
+
throw new RelayError(
|
|
215
|
+
"escalate.afterSeconds must be at least 30",
|
|
216
|
+
0,
|
|
217
|
+
"invalid_message"
|
|
218
|
+
);
|
|
219
|
+
const result = await this.sendCore(m);
|
|
220
|
+
if (!m.escalate) return result;
|
|
221
|
+
const e = m.escalate;
|
|
222
|
+
const base = new Date(deliverAtIso(m) ?? Date.now());
|
|
223
|
+
result.escalation = await this.sendCore({
|
|
224
|
+
...m,
|
|
225
|
+
topic: e.topic,
|
|
226
|
+
title: e.title ?? m.title,
|
|
227
|
+
body: e.body ?? m.body,
|
|
228
|
+
priority: e.priority ?? "urgent",
|
|
229
|
+
repeat: e.repeat,
|
|
230
|
+
escalate: void 0,
|
|
231
|
+
idempotencyKey: void 0,
|
|
232
|
+
delaySeconds: void 0,
|
|
233
|
+
deliverAt: new Date(base.getTime() + e.afterSeconds * 1e3),
|
|
234
|
+
unlessAcked: result.messageId
|
|
235
|
+
});
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
async sendCore(m) {
|
|
239
|
+
const plaintext = buildPlaintext(m);
|
|
240
|
+
let result = await this.sendOnce(
|
|
241
|
+
m,
|
|
242
|
+
plaintext,
|
|
243
|
+
await this.recipients(m.topic)
|
|
244
|
+
);
|
|
245
|
+
if (result.missing.length > 0) {
|
|
246
|
+
const fresh = await this.recipients(m.topic, true);
|
|
247
|
+
const only = {
|
|
248
|
+
...fresh,
|
|
249
|
+
recipients: fresh.recipients.filter(
|
|
250
|
+
(r) => result.missing.includes(r.deviceId)
|
|
251
|
+
)
|
|
252
|
+
};
|
|
253
|
+
if (only.recipients.length > 0) {
|
|
254
|
+
const extra = await this.sendOnce(
|
|
255
|
+
{ ...m, idempotencyKey: void 0 },
|
|
256
|
+
plaintext,
|
|
257
|
+
only
|
|
258
|
+
);
|
|
259
|
+
result = {
|
|
260
|
+
...result,
|
|
261
|
+
accepted: result.accepted + extra.accepted,
|
|
262
|
+
// `extra.missing` lists devices we deliberately left out of the second request; keep only ones we still failed to reach.
|
|
263
|
+
missing: result.missing.filter(
|
|
264
|
+
(id) => !only.recipients.some((r) => r.deviceId === id) || extra.rejected.some((x) => x.deviceId === id)
|
|
265
|
+
),
|
|
266
|
+
rejected: [...result.rejected, ...extra.rejected]
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
async sendOnce(m, plaintext, set) {
|
|
273
|
+
if (set.recipients.length === 0)
|
|
274
|
+
throw new RelayError(
|
|
275
|
+
`No devices are subscribed to ${m.topic} yet \u2014 pair a phone first.`,
|
|
276
|
+
0,
|
|
277
|
+
"no_recipients"
|
|
278
|
+
);
|
|
279
|
+
const messageId = ulid();
|
|
280
|
+
const body = JSON.stringify({ v: 1, id: messageId, ...plaintext });
|
|
281
|
+
const envelopes = await Promise.all(
|
|
282
|
+
set.recipients.map(
|
|
283
|
+
(r) => seal(
|
|
284
|
+
b64urlDecode(r.encPub),
|
|
285
|
+
{ messageId, topicId: set.topicId, deviceId: r.deviceId },
|
|
286
|
+
body
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
);
|
|
290
|
+
const res = await this.request(
|
|
291
|
+
"POST",
|
|
292
|
+
"/v1/trigger",
|
|
293
|
+
{
|
|
294
|
+
topic: m.topic,
|
|
295
|
+
messageId,
|
|
296
|
+
priority: m.priority ?? "default",
|
|
297
|
+
ttlSeconds: m.ttlSeconds ?? 86400,
|
|
298
|
+
...m.collapseKey ? { collapseKey: m.collapseKey } : {},
|
|
299
|
+
...deliverAtIso(m) ? { deliverAt: deliverAtIso(m) } : {},
|
|
300
|
+
...m.repeat ? { repeat: m.repeat } : {},
|
|
301
|
+
...m.unlessAcked ? { unlessAcked: m.unlessAcked } : {},
|
|
302
|
+
envelopes
|
|
303
|
+
},
|
|
304
|
+
{ "Idempotency-Key": m.idempotencyKey ?? messageId }
|
|
305
|
+
);
|
|
306
|
+
return res;
|
|
307
|
+
}
|
|
308
|
+
/** Recipients for a topic, verified and cached by ETag (a cheap conditional GET on every send keeps the key set fresh). */
|
|
309
|
+
async recipients(topic, bypass = false) {
|
|
310
|
+
const cached = this.cache.get(topic);
|
|
311
|
+
const res = await this.raw(
|
|
312
|
+
"GET",
|
|
313
|
+
`/v1/trigger/recipients?topic=${encodeURIComponent(topic)}`,
|
|
314
|
+
void 0,
|
|
315
|
+
!bypass && cached ? { "If-None-Match": cached.etag } : {}
|
|
316
|
+
);
|
|
317
|
+
if (res.status === 304 && cached) return cached.set;
|
|
318
|
+
const set = await this.parse(res);
|
|
319
|
+
for (const r of set.recipients) {
|
|
320
|
+
if (!verifyBinding(
|
|
321
|
+
b64urlDecode(r.bindSig),
|
|
322
|
+
b64urlDecode(r.encPub),
|
|
323
|
+
b64urlDecode(r.idPub)
|
|
324
|
+
))
|
|
325
|
+
throw new RelayError(
|
|
326
|
+
`Device ${r.deviceId} returned an invalid key binding`,
|
|
327
|
+
0,
|
|
328
|
+
"bad_key_binding"
|
|
329
|
+
);
|
|
330
|
+
const pin = this.pinned.get(r.deviceId);
|
|
331
|
+
if (pin && pin !== r.idPub)
|
|
332
|
+
throw new RelayError(
|
|
333
|
+
`Device ${r.deviceId} changed its identity key`,
|
|
334
|
+
0,
|
|
335
|
+
"key_changed"
|
|
336
|
+
);
|
|
337
|
+
this.pinned.set(r.deviceId, r.idPub);
|
|
338
|
+
}
|
|
339
|
+
const etag = res.headers.get("etag");
|
|
340
|
+
if (etag) this.cache.set(topic, { etag, set });
|
|
341
|
+
return set;
|
|
342
|
+
}
|
|
343
|
+
async request(method, path, body, headers = {}) {
|
|
344
|
+
let attempt = 0;
|
|
345
|
+
for (; ; ) {
|
|
346
|
+
try {
|
|
347
|
+
return await this.parse(await this.raw(method, path, body, headers));
|
|
348
|
+
} catch (e) {
|
|
349
|
+
const transient = e instanceof RelayError ? e.status >= 500 || e.status === 0 && e.code === "network" : false;
|
|
350
|
+
if (!transient || attempt >= this.retries) throw e;
|
|
351
|
+
await sleep(250 * 2 ** attempt++);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async raw(method, path, body, headers = {}) {
|
|
356
|
+
try {
|
|
357
|
+
return await this.doFetch(this.base + path, {
|
|
358
|
+
method,
|
|
359
|
+
headers: {
|
|
360
|
+
authorization: `Bearer ${this.opts.apiKey}`,
|
|
361
|
+
accept: "application/json",
|
|
362
|
+
...body !== void 0 ? { "content-type": "application/json" } : {},
|
|
363
|
+
...headers
|
|
364
|
+
},
|
|
365
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
366
|
+
});
|
|
367
|
+
} catch {
|
|
368
|
+
throw new RelayError("Couldn't reach Relay", 0, "network");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async parse(res) {
|
|
372
|
+
const text = await res.text();
|
|
373
|
+
const json = text ? safeJson(text) : void 0;
|
|
374
|
+
if (res.ok) return json;
|
|
375
|
+
const retry = Number(res.headers.get("retry-after"));
|
|
376
|
+
throw new RelayError(
|
|
377
|
+
json?.error ?? `Relay returned ${res.status}`,
|
|
378
|
+
res.status,
|
|
379
|
+
json?.code ?? `http_${res.status}`,
|
|
380
|
+
Number.isFinite(retry) && retry > 0 ? retry : void 0
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
var safeJson = (t) => {
|
|
385
|
+
try {
|
|
386
|
+
return JSON.parse(t);
|
|
387
|
+
} catch {
|
|
388
|
+
return void 0;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
function buildPlaintext(m) {
|
|
392
|
+
const bad = (msg, code) => new RelayError(msg, 0, code);
|
|
393
|
+
if (!m.title?.trim()) throw bad("A title is required", "invalid_message");
|
|
394
|
+
if (m.title.length > 120)
|
|
395
|
+
throw bad("Title is limited to 120 characters", "invalid_message");
|
|
396
|
+
if ((m.body?.length ?? 0) > 1e3)
|
|
397
|
+
throw bad("Body is limited to 1000 characters", "invalid_message");
|
|
398
|
+
if ((m.tags?.length ?? 0) > 5 || m.tags?.some((t) => t.length > 24))
|
|
399
|
+
throw bad("Up to 5 tags, 24 characters each", "invalid_message");
|
|
400
|
+
if (m.icon !== void 0 && !isGlyph(m.icon))
|
|
401
|
+
throw bad(
|
|
402
|
+
`Unknown icon "${m.icon}". Use one of: ${GLYPHS.join(", ")}`,
|
|
403
|
+
"invalid_message"
|
|
404
|
+
);
|
|
405
|
+
if (m.url && !/^https:\/\//i.test(m.url))
|
|
406
|
+
throw bad("url must be https", "invalid_message");
|
|
407
|
+
if (m.image && (!/^https:\/\//i.test(m.image) || m.image.length > 1e3))
|
|
408
|
+
throw bad(
|
|
409
|
+
"image must be an https link of at most 1000 characters",
|
|
410
|
+
"invalid_message"
|
|
411
|
+
);
|
|
412
|
+
if ((m.actions?.length ?? 0) > 3)
|
|
413
|
+
throw bad("At most 3 actions", "invalid_message");
|
|
414
|
+
for (const raw of m.actions ?? []) {
|
|
415
|
+
const a = raw;
|
|
416
|
+
if (!a.label?.trim() || a.label.length > 30)
|
|
417
|
+
throw bad(
|
|
418
|
+
"Action labels are required and limited to 30 characters",
|
|
419
|
+
"invalid_message"
|
|
420
|
+
);
|
|
421
|
+
if (a.link !== void 0 && a.request !== void 0)
|
|
422
|
+
throw bad(
|
|
423
|
+
`Action "${a.label}": use either request or link, not both`,
|
|
424
|
+
"invalid_message"
|
|
425
|
+
);
|
|
426
|
+
if (a.link !== void 0) {
|
|
427
|
+
if (!/^https:\/\//i.test(a.link))
|
|
428
|
+
throw bad(`Action "${a.label}": link must be https`, "invalid_message");
|
|
429
|
+
if (a.link.length > 1e3)
|
|
430
|
+
throw bad(
|
|
431
|
+
`Action "${a.label}": link is limited to 1000 characters`,
|
|
432
|
+
"invalid_message"
|
|
433
|
+
);
|
|
434
|
+
} else if (!a.request?.url || !/^https:\/\//i.test(a.request.url))
|
|
435
|
+
throw bad(`Action "${a.label}": url must be https`, "invalid_message");
|
|
436
|
+
}
|
|
437
|
+
const actions = m.actions?.map((a, i) => {
|
|
438
|
+
const id = a.id ?? `a${i + 1}`;
|
|
439
|
+
if (a.link !== void 0) return { id, label: a.label, link: a.link };
|
|
440
|
+
return {
|
|
441
|
+
style: "default",
|
|
442
|
+
confirm: a.style === "destructive",
|
|
443
|
+
retry: false,
|
|
444
|
+
...a,
|
|
445
|
+
id
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
return {
|
|
449
|
+
ts: Math.floor(Date.now() / 1e3),
|
|
450
|
+
title: m.title,
|
|
451
|
+
...m.body ? { body: m.body } : {},
|
|
452
|
+
priority: m.priority ?? "default",
|
|
453
|
+
...m.tags?.length ? { tags: m.tags } : {},
|
|
454
|
+
...m.icon ? { icon: m.icon } : {},
|
|
455
|
+
...m.url ? { url: m.url } : {},
|
|
456
|
+
...m.image ? { image: m.image } : {},
|
|
457
|
+
...m.group ? { group: m.group } : {},
|
|
458
|
+
...actions ? { actions } : {}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
export {
|
|
462
|
+
PayloadTooLargeError,
|
|
463
|
+
Relay,
|
|
464
|
+
RelayError,
|
|
465
|
+
buildPlaintext
|
|
466
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@phuzle/relay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Send end-to-end encrypted push notifications through Relay: Node/Bun SDK and `relay` CLI. The message is encrypted on your machine; Relay only routes ciphertext.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"push",
|
|
7
|
+
"notifications",
|
|
8
|
+
"relay",
|
|
9
|
+
"fcm",
|
|
10
|
+
"e2ee",
|
|
11
|
+
"hpke",
|
|
12
|
+
"webhook"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://docs.phuzle.com/relay",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Phuzle Labs",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "./index.cjs",
|
|
19
|
+
"types": "./index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": {
|
|
23
|
+
"types": "./index.d.ts",
|
|
24
|
+
"default": "./index.js"
|
|
25
|
+
},
|
|
26
|
+
"require": {
|
|
27
|
+
"types": "./index.d.cts",
|
|
28
|
+
"default": "./index.cjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"relay": "cli.js"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@hpke/core": "^1.7.0",
|
|
42
|
+
"@hpke/dhkem-x25519": "^1.7.0",
|
|
43
|
+
"@hpke/chacha20poly1305": "^1.7.0",
|
|
44
|
+
"@noble/curves": "^1.9.0",
|
|
45
|
+
"@noble/hashes": "^1.8.0"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|