@nopeek/chat 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/dist/index.js ADDED
@@ -0,0 +1,1258 @@
1
+ /**
2
+ * @nopeek/chat — NoPeek client SDK.
3
+ *
4
+ * const np = await NoPeek.connect({ apiUrl, sessionToken });
5
+ * const ch = await np.channels.create({ channelTypeKey: "direct", memberIds: ["usr_…"] });
6
+ * await ch.send({ text: "encrypted before it leaves this device" });
7
+ * ch.on("message", (m) => console.log(m.body?.text));
8
+ */
9
+ import { b64, decryptBytes, decryptPayload, deriveBackupKey, encryptBytes, encryptPayload, exportChannelKey, exportRawKeyB64, importRawKeyB64, generateChannelKey, generateContentKey, generateDeviceKeys, importChannelKey, keyPackageData, rsaOaepWrapB64, sha256hex, unwrapChannelKey, wrapChannelKey, } from "./crypto.js";
10
+ class MemoryStore {
11
+ m = new Map();
12
+ get(k) {
13
+ return this.m.get(k) ?? null;
14
+ }
15
+ set(k, v) {
16
+ this.m.set(k, v);
17
+ }
18
+ }
19
+ const defaultStore = () => {
20
+ try {
21
+ if (typeof localStorage !== "undefined") {
22
+ return { get: (k) => localStorage.getItem(k), set: (k, v) => localStorage.setItem(k, v) };
23
+ }
24
+ }
25
+ catch {
26
+ /* SSR / node */
27
+ }
28
+ return new MemoryStore();
29
+ };
30
+ class Emitter {
31
+ listeners = new Map();
32
+ on(event, fn) {
33
+ if (!this.listeners.has(event))
34
+ this.listeners.set(event, new Set());
35
+ this.listeners.get(event).add(fn);
36
+ return () => this.off(event, fn);
37
+ }
38
+ off(event, fn) {
39
+ this.listeners.get(event)?.delete(fn);
40
+ }
41
+ emit(event, ...args) {
42
+ for (const fn of this.listeners.get(event) ?? [])
43
+ fn(...args);
44
+ }
45
+ }
46
+ export class NoPeek extends Emitter {
47
+ apiUrl;
48
+ appId;
49
+ userId;
50
+ deviceId;
51
+ token;
52
+ store;
53
+ keys;
54
+ ws = null;
55
+ reqCounter = 0;
56
+ pending = new Map();
57
+ channelKeys = new Map(); // channelId -> AES key
58
+ mlsGroupToChannel = new Map();
59
+ /** Latest known presence per user, updated on every presence frame (incl. the
60
+ * connect-time snapshot that may arrive before app listeners attach). */
61
+ presenceState = new Map();
62
+ reconnectDelay = 1000;
63
+ closed = false;
64
+ /** Set while the app is backgrounded: the WS is intentionally dropped so the
65
+ * server clears presence and delivers pushes instead. Distinct from `closed`
66
+ * (permanent) — a suspended client reconnects on resume(). */
67
+ suspended = false;
68
+ pingTimer = null;
69
+ platform;
70
+ autoWs;
71
+ deferRegistration;
72
+ /** Set when connect() had to register a brand-new device — restore() revokes it
73
+ * if the backup turns out to hold the user's real device identity. */
74
+ justRegisteredDeviceId = null;
75
+ channels;
76
+ deriveChannelKey;
77
+ static async connect(opts) {
78
+ const np = new NoPeek(opts);
79
+ await np.ensureDevice();
80
+ // With deferDeviceRegistration and no locally saved identity there is no
81
+ // deviceId yet — key packages and the WS wait for restore()/registerDevice().
82
+ if (np.deviceId) {
83
+ await np.ensureKeyPackages();
84
+ if (np.autoWs)
85
+ await np.connectWs();
86
+ }
87
+ return np;
88
+ }
89
+ constructor(opts) {
90
+ super();
91
+ this.apiUrl = opts.apiUrl.replace(/\/$/, "");
92
+ this.token = opts.sessionToken;
93
+ this.appId = opts.appId;
94
+ this.userId = opts.userId;
95
+ this.store = opts.storage ?? defaultStore();
96
+ this.deriveChannelKey = opts.deriveChannelKey;
97
+ this.platform = opts.platform ?? "web";
98
+ this.autoWs = opts.autoConnectWs !== false;
99
+ this.deferRegistration = opts.deferDeviceRegistration === true;
100
+ this.channels = new Channels(this);
101
+ }
102
+ /** Ensure a channel key exists via the preshared-key hook; returns false if no hook. */
103
+ async deriveAndSet(channelId) {
104
+ if (!this.deriveChannelKey)
105
+ return false;
106
+ if (this.channelKeys.has(channelId))
107
+ return true;
108
+ this.channelKeys.set(channelId, await this.deriveChannelKey(channelId));
109
+ return true;
110
+ }
111
+ // ------------------------------------------------------------- REST ----
112
+ async api(method, path, body) {
113
+ const res = await fetch(`${this.apiUrl}${path}`, {
114
+ method,
115
+ headers: {
116
+ authorization: `Bearer ${this.token}`,
117
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
118
+ ...(this.deviceId ? { "x-nopeek-device": this.deviceId } : {}),
119
+ },
120
+ body: body !== undefined ? JSON.stringify(body) : undefined,
121
+ });
122
+ const text = await res.text();
123
+ if (!text) {
124
+ if (!res.ok)
125
+ throw new Error(`HTTP ${res.status}`);
126
+ return undefined;
127
+ }
128
+ const json = JSON.parse(text);
129
+ if (!res.ok)
130
+ throw new Error(json.error ? `${json.error.code}: ${json.error.message}` : `HTTP ${res.status}`);
131
+ return json;
132
+ }
133
+ // ----------------------------------------------------------- device ----
134
+ async ensureDevice() {
135
+ const saved = await this.store.get(`nopeek:${this.userId}:device`);
136
+ if (saved) {
137
+ const parsed = JSON.parse(saved);
138
+ this.deviceId = parsed.deviceId;
139
+ this.keys = parsed.keys;
140
+ const savedKeys = await this.store.get(`nopeek:${this.userId}:channelKeys`);
141
+ if (savedKeys) {
142
+ for (const [chId, raw] of Object.entries(JSON.parse(savedKeys))) {
143
+ this.channelKeys.set(chId, await importChannelKey(raw));
144
+ }
145
+ }
146
+ return;
147
+ }
148
+ this.keys = await generateDeviceKeys();
149
+ // Deferred: the caller decides whether to restore() the real identity from a
150
+ // backup or registerDevice() a genuinely new one. Registering here would
151
+ // orphan a device on every fresh login of a returning user.
152
+ if (this.deferRegistration)
153
+ return;
154
+ await this.registerDeviceNow();
155
+ this.justRegisteredDeviceId = this.deviceId;
156
+ }
157
+ /** Whether this client has a server-registered device identity yet. False only
158
+ * during a deferred connect, before restore()/registerDevice() completes. */
159
+ get deviceRegistered() {
160
+ return Boolean(this.deviceId);
161
+ }
162
+ /**
163
+ * Complete a deferred connect by registering a brand-new device. Call this
164
+ * only when the user has NO restorable backup (genuine new user/device) —
165
+ * otherwise call restore(), which adopts the backed-up device identity.
166
+ * No-op when a device identity already exists. Publishes key packages and
167
+ * opens the WebSocket (unless autoConnectWs was disabled).
168
+ */
169
+ async registerDevice() {
170
+ if (this.deviceId)
171
+ return;
172
+ if (!this.keys)
173
+ this.keys = await generateDeviceKeys();
174
+ await this.registerDeviceNow();
175
+ await this.ensureKeyPackages();
176
+ if (this.autoWs && !this.ws)
177
+ await this.connectWs();
178
+ }
179
+ /** POST the current keypair as a new device and persist the identity locally. */
180
+ async registerDeviceNow() {
181
+ const device = await this.api("POST", `/v1/apps/${this.appId}/users/${this.userId}/devices`, {
182
+ credentialKey: b64.enc(new TextEncoder().encode(JSON.stringify(this.keys.publicKeyJwk))),
183
+ platform: this.platform,
184
+ displayName: `${this.platform} device`,
185
+ });
186
+ this.deviceId = device.deviceId;
187
+ await this.persistDevice();
188
+ }
189
+ async persistDevice() {
190
+ await this.store.set(`nopeek:${this.userId}:device`, JSON.stringify({ deviceId: this.deviceId, keys: this.keys }));
191
+ }
192
+ async persistChannelKeys() {
193
+ const out = {};
194
+ for (const [chId, key] of this.channelKeys)
195
+ out[chId] = await exportChannelKey(key);
196
+ await this.store.set(`nopeek:${this.userId}:channelKeys`, JSON.stringify(out));
197
+ }
198
+ async ensureKeyPackages() {
199
+ const { available } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/devices/${this.deviceId}/key-packages`);
200
+ if (available >= 10)
201
+ return;
202
+ const data = keyPackageData(this.deviceId, this.keys.publicKeyJwk);
203
+ const expiresAt = new Date(Date.now() + 90 * 86_400_000).toISOString();
204
+ await this.api("POST", `/v1/apps/${this.appId}/users/${this.userId}/devices/${this.deviceId}/key-packages`, {
205
+ keyPackages: await Promise.all(Array.from({ length: 20 }, async () => ({
206
+ cipherSuite: 1,
207
+ data,
208
+ dataHash: await sha256hex(data),
209
+ expiresAt,
210
+ }))),
211
+ });
212
+ }
213
+ // --------------------------------------------------------------- WS ----
214
+ connectWs() {
215
+ return new Promise((resolve, reject) => {
216
+ const wsUrl = `${this.apiUrl.replace(/^http/, "ws")}/v1/ws?token=${encodeURIComponent(this.token)}&deviceId=${this.deviceId}`;
217
+ const ws = new WebSocket(wsUrl);
218
+ this.ws = ws;
219
+ const authTimeout = setTimeout(() => reject(new Error("WS auth timeout")), 10_000);
220
+ ws.onmessage = (ev) => void this.handleFrame(JSON.parse(String(ev.data)), resolve, authTimeout);
221
+ ws.onclose = () => {
222
+ this.stopPing();
223
+ this.emit("disconnected");
224
+ // Don't reconnect while permanently closed OR intentionally suspended
225
+ // (app backgrounded — we WANT the socket down so pushes take over).
226
+ if (!this.closed && !this.suspended) {
227
+ setTimeout(() => void this.connectWs().catch(() => { }), this.reconnectDelay);
228
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000);
229
+ }
230
+ };
231
+ ws.onerror = () => {
232
+ clearTimeout(authTimeout);
233
+ reject(new Error("WS connection failed"));
234
+ };
235
+ });
236
+ }
237
+ /** Heartbeat: keep the socket alive while foregrounded so the server's idle
238
+ * timeout only fires once the client actually stops pinging (backgrounded /
239
+ * crashed / lost network), at which point presence clears and pushes resume. */
240
+ startPing() {
241
+ this.stopPing();
242
+ this.pingTimer = setInterval(() => {
243
+ try {
244
+ if (this.ws?.readyState === WebSocket.OPEN)
245
+ this.ws.send(JSON.stringify({ type: "ping" }));
246
+ }
247
+ catch {
248
+ /* next reconnect handles it */
249
+ }
250
+ }, 25_000);
251
+ }
252
+ stopPing() {
253
+ if (this.pingTimer) {
254
+ clearInterval(this.pingTimer);
255
+ this.pingTimer = null;
256
+ }
257
+ }
258
+ /** Drop the WS when the app goes to the background (native): the server clears
259
+ * presence immediately and routes new messages to APNs/FCM push instead of a
260
+ * socket that iOS is about to freeze. Safe to call repeatedly. */
261
+ suspend() {
262
+ if (this.suspended)
263
+ return;
264
+ this.suspended = true;
265
+ this.stopPing();
266
+ try {
267
+ this.ws?.close();
268
+ }
269
+ catch {
270
+ /* already closing */
271
+ }
272
+ this.ws = null;
273
+ }
274
+ /** Reconnect when the app returns to the foreground. */
275
+ resume() {
276
+ if (!this.suspended)
277
+ return;
278
+ this.suspended = false;
279
+ this.reconnectDelay = 1000;
280
+ if (!this.closed && (!this.ws || this.ws.readyState > WebSocket.OPEN)) {
281
+ void this.connectWs().catch(() => { });
282
+ }
283
+ }
284
+ close() {
285
+ this.closed = true;
286
+ this.stopPing();
287
+ this.ws?.close();
288
+ }
289
+ sendFrame(frame) {
290
+ const reqId = `r${++this.reqCounter}`;
291
+ return new Promise((resolve, reject) => {
292
+ this.pending.set(reqId, { resolve, reject });
293
+ this.ws?.send(JSON.stringify({ ...frame, reqId }));
294
+ setTimeout(() => {
295
+ if (this.pending.delete(reqId))
296
+ reject(new Error(`timeout waiting for ack of ${String(frame.type)}`));
297
+ }, 15_000);
298
+ });
299
+ }
300
+ async handleFrame(frame, authResolve, authTimeout) {
301
+ switch (frame.type) {
302
+ case "auth.ok":
303
+ clearTimeout(authTimeout);
304
+ this.reconnectDelay = 1000;
305
+ this.startPing();
306
+ this.emit("connected", frame);
307
+ authResolve?.();
308
+ void this.ensureKeyPackages().catch(() => { });
309
+ break;
310
+ case "ack": {
311
+ const p = this.pending.get(String(frame.reqId));
312
+ if (p) {
313
+ this.pending.delete(String(frame.reqId));
314
+ p.resolve(frame);
315
+ }
316
+ break;
317
+ }
318
+ case "error": {
319
+ const p = frame.reqId ? this.pending.get(String(frame.reqId)) : undefined;
320
+ if (p) {
321
+ this.pending.delete(String(frame.reqId));
322
+ p.reject(new Error(`${String(frame.code)}: ${String(frame.message)}`));
323
+ }
324
+ else {
325
+ this.emit("error", frame);
326
+ }
327
+ break;
328
+ }
329
+ case "message.new":
330
+ case "message.updated": {
331
+ const decrypted = await this.decryptEnvelope(frame.message);
332
+ // E2EE reactions arrive as carrier messages; surface as reaction events
333
+ if (decrypted.body?.type === "reaction" && decrypted.body.targetMessageId) {
334
+ this.emit("reaction", {
335
+ channelId: decrypted.channelId,
336
+ messageId: decrypted.body.targetMessageId,
337
+ userId: decrypted.senderUserId,
338
+ emoji: decrypted.body.emoji,
339
+ op: decrypted.body.op ?? "add",
340
+ });
341
+ break;
342
+ }
343
+ // poll votes and RSVPs are carrier events aggregated client-side
344
+ if (decrypted.body?.type === "poll_vote" && decrypted.body.targetMessageId) {
345
+ const pollMessageId = String(decrypted.body.targetMessageId);
346
+ this.recordPollVote(pollMessageId, decrypted.senderUserId, decrypted.body.pollOptionIds ?? []);
347
+ this.emit("pollVote", {
348
+ channelId: decrypted.channelId,
349
+ pollMessageId,
350
+ userId: decrypted.senderUserId,
351
+ optionIds: decrypted.body.pollOptionIds ?? [],
352
+ results: this.pollResults(pollMessageId),
353
+ });
354
+ break;
355
+ }
356
+ if (decrypted.body?.type === "rsvp" && decrypted.body.targetMessageId) {
357
+ const eventMessageId = String(decrypted.body.targetMessageId);
358
+ const response = decrypted.body.rsvp;
359
+ this.recordRsvp(eventMessageId, decrypted.senderUserId, response);
360
+ this.emit("rsvp", {
361
+ channelId: decrypted.channelId,
362
+ eventMessageId,
363
+ userId: decrypted.senderUserId,
364
+ response,
365
+ results: this.rsvpResults(eventMessageId),
366
+ });
367
+ break;
368
+ }
369
+ this.emit(frame.type === "message.new" ? "message" : "messageUpdated", decrypted);
370
+ break;
371
+ }
372
+ case "message.recalled":
373
+ this.emit("messageRecalled", frame);
374
+ break;
375
+ case "reaction":
376
+ this.emit("reaction", frame);
377
+ break;
378
+ case "typing":
379
+ this.emit("typing", frame);
380
+ break;
381
+ case "signal":
382
+ // Call-signaling relay (offer/answer/ICE). Surfacing it here lets
383
+ // integrators receive signals on their existing connection instead of
384
+ // opening a second WebSocket to the same endpoint.
385
+ this.emit("signal", frame);
386
+ break;
387
+ case "receipt":
388
+ this.emit("receipt", frame);
389
+ break;
390
+ case "presence":
391
+ this.presenceState.set(String(frame.userId), {
392
+ status: frame.status,
393
+ lastSeenAt: frame.lastSeenAt ?? null,
394
+ });
395
+ this.emit("presence", frame);
396
+ break;
397
+ case "member.joined":
398
+ case "member.left":
399
+ case "channel.updated":
400
+ this.emit(frame.type, frame);
401
+ break;
402
+ case "mls.handshake": {
403
+ const hs = frame.handshake;
404
+ if (hs.kind === "welcome")
405
+ await this.handleWelcome(hs);
406
+ this.emit("mlsHandshake", hs);
407
+ break;
408
+ }
409
+ }
410
+ }
411
+ // ------------------------------------------------------------- E2EE ----
412
+ async handleWelcome(hs) {
413
+ try {
414
+ const welcome = JSON.parse(new TextDecoder().decode(b64.dec(hs.payload)));
415
+ const mine = welcome.wrapped.find((w) => w.deviceId === this.deviceId);
416
+ if (!mine)
417
+ return;
418
+ const key = await unwrapChannelKey(mine, this.keys.privateKeyJwk);
419
+ this.channelKeys.set(welcome.channelId, key);
420
+ this.mlsGroupToChannel.set(hs.mlsGroupId, welcome.channelId);
421
+ await this.persistChannelKeys();
422
+ this.emit("channelKeyReceived", { channelId: welcome.channelId });
423
+ }
424
+ catch {
425
+ /* not a dev-suite welcome or not for us */
426
+ }
427
+ }
428
+ /** Fetch any welcomes we missed while offline. */
429
+ async syncChannelKey(channelId, mlsGroupId) {
430
+ if (this.channelKeys.has(channelId))
431
+ return true;
432
+ const { handshakes } = await this.api("GET", `/v1/apps/${this.appId}/mls/groups/${mlsGroupId}/handshakes?sinceEpoch=0`);
433
+ for (const hs of handshakes.reverse()) {
434
+ if (hs.kind === "welcome" && hs.recipientDeviceIds?.includes(this.deviceId)) {
435
+ await this.handleWelcome({ mlsGroupId, payload: hs.payload });
436
+ if (this.channelKeys.has(channelId))
437
+ return true;
438
+ }
439
+ }
440
+ return false;
441
+ }
442
+ getChannelKey(channelId) {
443
+ return this.channelKeys.get(channelId) ?? null;
444
+ }
445
+ /** How many channel keys this device currently holds (for backup-freshness checks). */
446
+ get channelKeyCount() {
447
+ return this.channelKeys.size;
448
+ }
449
+ // ---------------------------------------------- encrypted attachments ----
450
+ /** Encrypt bytes under a fresh content key and upload the ciphertext to S3. */
451
+ async uploadEncrypted(bytes) {
452
+ const key = await generateContentKey();
453
+ const { iv, data } = await encryptBytes(key, bytes);
454
+ const { blobUrl, uploadUrl } = await this.api("POST", `/v1/apps/${this.appId}/uploads`, { sizeBytes: data.byteLength });
455
+ const put = await fetch(uploadUrl, { method: "PUT", body: data, headers: { "content-type": "application/octet-stream" } });
456
+ if (!put.ok)
457
+ throw new Error(`attachment upload failed: HTTP ${put.status}`);
458
+ return { blobUrl, contentKey: await exportRawKeyB64(key), iv, size: data.byteLength };
459
+ }
460
+ // ------------------------------------------- WhatsApp-style key backup ----
461
+ /**
462
+ * Enable/refresh an encrypted key backup so the user can restore their chats
463
+ * on a new device. Back up your channel keys + device identity under a key
464
+ * derived from `recoveryCode` (which the user keeps; NoPeek never sees it).
465
+ * Call `generateRecoveryCode()` to make one, show it to the user ONCE, then
466
+ * pass it here. Returns the backupId.
467
+ */
468
+ /**
469
+ * Encrypted history backup. The chat payload is encrypted with a random data
470
+ * key, which is then WRAPPED separately by each provided secret — so the same
471
+ * backup can be unlocked by the account **password** OR the **recovery code**.
472
+ * This is what lets a user restore on a new device by just signing in with
473
+ * their password; the recovery code is only needed as a last resort (e.g. the
474
+ * password was reset). Accepts a string (treated as a recovery code, legacy)
475
+ * or `{ password?, recoveryCode? }`.
476
+ */
477
+ async backup(secrets) {
478
+ const s = typeof secrets === "string" ? { recoveryCode: secrets } : secrets;
479
+ if (!s.password && !s.recoveryCode)
480
+ throw new Error("backup requires a password or a recovery code");
481
+ // WRAP PRESERVATION: a refresh that only knows ONE secret (the common case —
482
+ // after login only the password is in memory) must NOT drop the other unlock
483
+ // path. If we can unwrap the existing envelope's data key, reuse it and carry
484
+ // forward any wraps we can't recreate; otherwise start a fresh envelope.
485
+ let dataKey = await generateChannelKey();
486
+ let rawDK = await exportRawKeyB64(dataKey);
487
+ const carried = {};
488
+ try {
489
+ const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
490
+ const kd = backups[0]?.keyDerivation;
491
+ if (kd?.scheme === "wrapped-v2" && kd.wraps) {
492
+ let oldDK = null;
493
+ const unlockers = [
494
+ ["password", s.password],
495
+ ["password", s.previousPassword],
496
+ ["recovery", s.recoveryCode],
497
+ ];
498
+ for (const [kind, secret] of unlockers) {
499
+ const w = secret ? kd.wraps[kind] : undefined;
500
+ if (!w)
501
+ continue;
502
+ try {
503
+ const kek = await deriveBackupKey(secret, w.salt, w.iterations);
504
+ oldDK = (await decryptPayload(kek, w.wrapped)).dk;
505
+ break;
506
+ }
507
+ catch {
508
+ /* stale wrap (rotated secret) — try the next unlocker */
509
+ }
510
+ }
511
+ // Escrow-restored session: this device just recovered the data key via
512
+ // the server escrow endpoint (restoreViaEscrow) — the old password wrap
513
+ // is stale (password was reset) and no recovery code is in memory, but
514
+ // we still KNOW the envelope's data key, so reuse it and carry the
515
+ // recovery wrap forward instead of orphaning it under a fresh dk.
516
+ if (!oldDK && this.escrowRestoredDK)
517
+ oldDK = this.escrowRestoredDK;
518
+ if (oldDK) {
519
+ rawDK = oldDK;
520
+ dataKey = await importRawKeyB64(oldDK);
521
+ for (const kind of ["password", "recovery"]) {
522
+ const provided = kind === "password" ? s.password : s.recoveryCode;
523
+ if (!provided && kd.wraps[kind])
524
+ carried[kind] = kd.wraps[kind];
525
+ }
526
+ }
527
+ }
528
+ }
529
+ catch {
530
+ /* no existing backup / fetch failed — fresh envelope below */
531
+ }
532
+ const channelKeys = {};
533
+ for (const [chId, k] of this.channelKeys)
534
+ channelKeys[chId] = await exportChannelKey(k);
535
+ const ciphertext = await encryptPayload(dataKey, { v: 2, device: { deviceId: this.deviceId, keys: this.keys }, channelKeys });
536
+ const iterations = 210_000;
537
+ const wraps = { ...carried };
538
+ for (const [kind, secret] of [["password", s.password], ["recovery", s.recoveryCode]]) {
539
+ if (!secret)
540
+ continue;
541
+ const salt = b64.enc(globalThis.crypto.getRandomValues(new Uint8Array(16)));
542
+ const kek = await deriveBackupKey(secret, salt, iterations);
543
+ wraps[kind] = { salt, iterations, wrapped: await encryptPayload(kek, { dk: rawDK }) };
544
+ }
545
+ // OPTIONAL server-side key escrow (per-workspace opt-in; strict workspaces
546
+ // never get this wrap). The data key is ADDITIONALLY wrapped under the
547
+ // server's escrow PUBLIC key (RSA-OAEP-SHA256), so backups stay
548
+ // ciphertext-only server-side at backup time; only the escrow endpoint —
549
+ // gated on an authenticated session in a key_escrow workspace — can unwrap
550
+ // with the private key held in Secrets Manager. This is what lets a user
551
+ // recover ALL messages with only email + password after a password reset
552
+ // (see restoreViaEscrow). Best-effort: escrow being off/unreachable never
553
+ // blocks a backup.
554
+ try {
555
+ const esc = await this.escrowConfig();
556
+ if (esc?.enabled && esc.escrowPublicKey) {
557
+ wraps.escrow = { alg: "RSA-OAEP-256", wrapped: await rsaOaepWrapB64(esc.escrowPublicKey, b64.dec(rawDK)) };
558
+ }
559
+ }
560
+ catch {
561
+ /* escrow unavailable — password/recovery wraps still protect the backup */
562
+ }
563
+ const bytes = b64.dec(ciphertext);
564
+ const manifestHash = await sha256hex(ciphertext);
565
+ const { backup: rec, uploadUrl } = await this.api("POST", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`, { keyDerivation: { scheme: "wrapped-v2", wraps }, sizeBytes: bytes.byteLength, manifestHash });
566
+ const put = await fetch(uploadUrl, { method: "PUT", body: bytes, headers: { "content-type": "application/octet-stream" } });
567
+ if (!put.ok)
568
+ throw new Error(`backup upload failed: HTTP ${put.status}`);
569
+ return rec.backupId;
570
+ }
571
+ /**
572
+ * Restore chats on a NEW device from the latest encrypted backup. Pass the
573
+ * account password (preferred) and/or the recovery code — whichever unlocks
574
+ * the backup is used. Rehydrates channel keys so server-stored history
575
+ * decrypts. Returns the number of channels restored.
576
+ */
577
+ async restore(secrets) {
578
+ const s = typeof secrets === "string" ? { recoveryCode: secrets } : secrets;
579
+ const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
580
+ if (!backups.length)
581
+ throw new Error("No backup found for this user.");
582
+ const b = backups[0]; // newest first
583
+ const kd = b.keyDerivation || {};
584
+ const res = await fetch(b.downloadUrl);
585
+ if (!res.ok)
586
+ throw new Error(`backup download failed: HTTP ${res.status}`);
587
+ const ct = b64.enc(new Uint8Array(await res.arrayBuffer()));
588
+ let payload;
589
+ if (kd.scheme === "wrapped-v2") {
590
+ let dataKey = null;
591
+ for (const [kind, secret] of [["password", s.password], ["recovery", s.recoveryCode]]) {
592
+ const w = secret && kd.wraps?.[kind];
593
+ if (!w)
594
+ continue;
595
+ try {
596
+ const kek = await deriveBackupKey(secret, w.salt, w.iterations);
597
+ const { dk } = await decryptPayload(kek, w.wrapped);
598
+ dataKey = await importRawKeyB64(dk);
599
+ break;
600
+ }
601
+ catch {
602
+ /* wrong secret for this wrap — try the next */
603
+ }
604
+ }
605
+ if (!dataKey)
606
+ throw new Error("Couldn't unlock the backup with the provided password or recovery code.");
607
+ payload = await decryptPayload(dataKey, ct);
608
+ }
609
+ else {
610
+ // legacy single-layer backup: payload keyed directly off the recovery code
611
+ const code = s.recoveryCode ?? s.password;
612
+ if (!code)
613
+ throw new Error("This backup requires a recovery code.");
614
+ const key = await deriveBackupKey(code, kd.salt, kd.iterations);
615
+ try {
616
+ payload = await decryptPayload(key, ct);
617
+ }
618
+ catch {
619
+ throw new Error("Recovery code is incorrect — could not decrypt the backup.");
620
+ }
621
+ }
622
+ await this.adoptBackedUpDevice(payload.device);
623
+ for (const [chId, raw] of Object.entries(payload.channelKeys)) {
624
+ this.channelKeys.set(chId, await importChannelKey(raw));
625
+ }
626
+ await this.persistChannelKeys();
627
+ return Object.keys(payload.channelKeys).length;
628
+ }
629
+ // ------------------------------------------------- server-side escrow ----
630
+ /** Cached escrow availability for THIS caller ({enabled, escrowPublicKey}). */
631
+ escrowCfg;
632
+ /** Set by restoreViaEscrow(): the recovered raw data key (b64), so a follow-up
633
+ * backup() can preserve wraps it can't unlock (e.g. after a password reset). */
634
+ escrowRestoredDK = null;
635
+ /** Whether the server escrows backup keys for this user's workspace (cached). */
636
+ async escrowConfig() {
637
+ if (this.escrowCfg !== undefined)
638
+ return this.escrowCfg;
639
+ try {
640
+ this.escrowCfg = await this.api("GET", `/v1/apps/${this.appId}/escrow-public-key`);
641
+ }
642
+ catch {
643
+ this.escrowCfg = null; // older server / escrow off — cache the miss
644
+ }
645
+ return this.escrowCfg;
646
+ }
647
+ /**
648
+ * Recover chats with NO client-held secret, via server-side key escrow
649
+ * (workspace opt-in). The newest backup's `wraps.escrow` blob — the data key
650
+ * wrapped under the server's escrow PUBLIC key at backup time — is posted to
651
+ * the authenticated escrow-unwrap endpoint; the server decrypts it with the
652
+ * escrow PRIVATE key (Secrets Manager, never the DB) and returns the raw
653
+ * data key. The rest is identical to the password restore path.
654
+ *
655
+ * SECURITY: possession of a valid session is the gate. After a password
656
+ * reset, a session means the user proved email ownership via the emailed
657
+ * reset code — so "email owner → full recovery" is the EXPLICIT tradeoff of
658
+ * escrow mode. Strict (zero-custody) workspaces have no escrow wrap and the
659
+ * endpoint refuses them. Returns the number of channels restored.
660
+ */
661
+ async restoreViaEscrow() {
662
+ const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
663
+ if (!backups.length)
664
+ throw new Error("No backup found for this user.");
665
+ const b = backups[0]; // newest first
666
+ const kd = b.keyDerivation || {};
667
+ const esc = kd.scheme === "wrapped-v2" ? kd.wraps?.escrow : undefined;
668
+ if (!esc?.wrapped)
669
+ throw new Error("This backup has no escrow wrap.");
670
+ const { dk } = await this.api("POST", `/v1/apps/${this.appId}/users/${this.userId}/history-backups/escrow-unwrap`, { wrapped: esc.wrapped });
671
+ const dataKey = await importRawKeyB64(dk);
672
+ const res = await fetch(b.downloadUrl);
673
+ if (!res.ok)
674
+ throw new Error(`backup download failed: HTTP ${res.status}`);
675
+ const ct = b64.enc(new Uint8Array(await res.arrayBuffer()));
676
+ const payload = await decryptPayload(dataKey, ct);
677
+ this.escrowRestoredDK = dk; // lets the next backup() carry old wraps forward
678
+ await this.adoptBackedUpDevice(payload.device);
679
+ for (const [chId, raw] of Object.entries(payload.channelKeys)) {
680
+ this.channelKeys.set(chId, await importChannelKey(raw));
681
+ }
682
+ await this.persistChannelKeys();
683
+ return Object.keys(payload.channelKeys).length;
684
+ }
685
+ /**
686
+ * Adopt the device identity stored in a backup so a returning user on a fresh
687
+ * install ends up with EXACTLY their original deviceId — not a freshly minted
688
+ * one. Only applies when this client has no established local identity (a
689
+ * deferred connect, or an identity registered moments ago during this very
690
+ * connect, which is then revoked as an orphan). A long-standing local device
691
+ * identity is never hijacked — for those, restore() only rehydrates channel
692
+ * keys, exactly as before.
693
+ */
694
+ async adoptBackedUpDevice(device) {
695
+ if (!device?.deviceId || !device.keys)
696
+ return;
697
+ if (this.deviceId === device.deviceId)
698
+ return; // already the real device
699
+ const established = this.deviceId && this.deviceId !== this.justRegisteredDeviceId;
700
+ if (established)
701
+ return;
702
+ const orphan = this.justRegisteredDeviceId;
703
+ this.justRegisteredDeviceId = null;
704
+ this.deviceId = device.deviceId;
705
+ this.keys = device.keys;
706
+ await this.persistDevice();
707
+ // Revoke the device connect() just registered — it would otherwise linger
708
+ // forever and poison peers' MLS key-package claims. Best-effort.
709
+ if (orphan && orphan !== device.deviceId) {
710
+ await this.api("DELETE", `/v1/apps/${this.appId}/users/${this.userId}/devices/${orphan}`).catch(() => { });
711
+ }
712
+ // Replenish key packages for the RESTORED device. If the server no longer
713
+ // knows it (revoked/pruned), fall back to re-registering the restored keys
714
+ // under a fresh deviceId so the account stays usable.
715
+ try {
716
+ await this.ensureKeyPackages();
717
+ }
718
+ catch {
719
+ this.deviceId = undefined;
720
+ await this.registerDeviceNow();
721
+ await this.ensureKeyPackages().catch(() => { });
722
+ }
723
+ // The WS session is bound to a deviceId: connect it now (deferred path), or
724
+ // reconnect if it was opened under the orphan identity.
725
+ if (this.ws) {
726
+ const old = this.ws;
727
+ this.ws = null;
728
+ old.onclose = null; // don't let the old socket's close trigger a reconnect race
729
+ try {
730
+ old.close();
731
+ }
732
+ catch {
733
+ /* ignore */
734
+ }
735
+ }
736
+ // Swallow WS errors here: a transient socket failure must not make restore()
737
+ // look like a wrong password. The onclose handler keeps retrying anyway.
738
+ if (this.autoWs && !this.closed)
739
+ await this.connectWs().catch(() => { });
740
+ }
741
+ /** Whether this user has any restorable backup on the server. */
742
+ async hasBackup() {
743
+ const { backups } = await this.api("GET", `/v1/apps/${this.appId}/users/${this.userId}/history-backups`);
744
+ return backups.length > 0;
745
+ }
746
+ /** Fetch an encrypted attachment blob and decrypt it back to bytes. */
747
+ async downloadDecrypt(ref) {
748
+ const rest = ref.blobUrl.replace(/^nopeek-blob:\/\//, "");
749
+ const path = rest.slice(rest.indexOf("/") + 1); // strip the leading appId/
750
+ const res = await fetch(`${this.apiUrl}/v1/apps/${this.appId}/blobs/${path}`, {
751
+ headers: { authorization: `Bearer ${this.token}` },
752
+ });
753
+ if (!res.ok)
754
+ throw new Error(`attachment download failed: HTTP ${res.status}`);
755
+ return decryptBytes(ref.contentKey, ref.iv, await res.arrayBuffer());
756
+ }
757
+ /** App branding/theme tokens (colors, font, radius…) set by the customer. Null until loaded. */
758
+ branding = null;
759
+ /** Fetch the app's branding tokens (customer white-label theme). */
760
+ async loadBranding() {
761
+ try {
762
+ const res = await this.api("GET", `/v1/apps/${this.appId}/branding`);
763
+ this.branding = res.branding ?? null;
764
+ }
765
+ catch {
766
+ this.branding = null;
767
+ }
768
+ return this.branding;
769
+ }
770
+ /** Current known presence for a user (survives listener-attach timing). */
771
+ getPresence(userId) {
772
+ return this.presenceState.get(userId) ?? null;
773
+ }
774
+ isOnline(userId) {
775
+ return this.presenceState.get(userId)?.status === "online";
776
+ }
777
+ // ------------------------------------------------------ poll tallies ----
778
+ /** pollMessageId -> (userId -> selected optionIds). Latest vote wins. */
779
+ pollVotes = new Map();
780
+ recordPollVote(pollMessageId, userId, optionIds) {
781
+ if (!this.pollVotes.has(pollMessageId))
782
+ this.pollVotes.set(pollMessageId, new Map());
783
+ const byUser = this.pollVotes.get(pollMessageId);
784
+ if (optionIds.length === 0)
785
+ byUser.delete(userId);
786
+ else
787
+ byUser.set(userId, optionIds);
788
+ }
789
+ /** Public entry so Channel.history() can fold in historical votes. */
790
+ applyPollVote(pollMessageId, userId, optionIds) {
791
+ this.recordPollVote(pollMessageId, userId, optionIds);
792
+ }
793
+ /** Aggregate results for a poll: { optionId -> count } plus total voters. */
794
+ pollResults(pollMessageId) {
795
+ const byUser = this.pollVotes.get(pollMessageId);
796
+ const counts = {};
797
+ if (!byUser)
798
+ return { counts, voters: 0 };
799
+ for (const opts of byUser.values())
800
+ for (const o of opts)
801
+ counts[o] = (counts[o] ?? 0) + 1;
802
+ return { counts, voters: byUser.size };
803
+ }
804
+ myPollVote(pollMessageId) {
805
+ return this.pollVotes.get(pollMessageId)?.get(this.userId) ?? [];
806
+ }
807
+ // ------------------------------------------------------ event RSVPs ----
808
+ /** eventMessageId -> (userId -> "yes" | "no" | "maybe"). Latest response wins. */
809
+ rsvps = new Map();
810
+ recordRsvp(eventMessageId, userId, response) {
811
+ if (!this.rsvps.has(eventMessageId))
812
+ this.rsvps.set(eventMessageId, new Map());
813
+ this.rsvps.get(eventMessageId).set(userId, response);
814
+ }
815
+ /** Public entry so Channel.history() can fold in historical RSVPs. */
816
+ applyRsvp(eventMessageId, userId, response) {
817
+ this.recordRsvp(eventMessageId, userId, response);
818
+ }
819
+ /** Tally for an event: counts by response + the responding userIds per bucket. */
820
+ rsvpResults(eventMessageId) {
821
+ const byUser = this.rsvps.get(eventMessageId);
822
+ const voters = { yes: [], no: [], maybe: [] };
823
+ if (byUser)
824
+ for (const [uid, r] of byUser)
825
+ voters[r].push(uid);
826
+ return { counts: { yes: voters.yes.length, no: voters.no.length, maybe: voters.maybe.length }, voters };
827
+ }
828
+ myRsvp(eventMessageId) {
829
+ return this.rsvps.get(eventMessageId)?.get(this.userId) ?? null;
830
+ }
831
+ setChannelKey(channelId, key) {
832
+ this.channelKeys.set(channelId, key);
833
+ void this.persistChannelKeys();
834
+ }
835
+ async decryptEnvelope(env) {
836
+ const channelId = String(env.channelId);
837
+ const base = {
838
+ messageId: String(env.messageId),
839
+ channelId,
840
+ senderUserId: String(env.senderUserId),
841
+ seq: Number(env.seq),
842
+ threadParentId: env.threadParentId ?? null,
843
+ createdAt: String(env.createdAt),
844
+ editedAt: env.editedAt ?? null,
845
+ recalledAt: env.recalledAt ?? null,
846
+ body: null,
847
+ encrypted: Boolean(env.ciphertext),
848
+ };
849
+ if (env.recalledAt)
850
+ return base;
851
+ if (env.plaintext) {
852
+ base.body = env.plaintext;
853
+ return base;
854
+ }
855
+ if (env.ciphertext) {
856
+ if (!this.channelKeys.has(channelId))
857
+ await this.deriveAndSet(channelId);
858
+ const key = this.channelKeys.get(channelId);
859
+ if (!key) {
860
+ base.decryptionFailed = true;
861
+ return base;
862
+ }
863
+ try {
864
+ base.body = await decryptPayload(key, String(env.ciphertext));
865
+ }
866
+ catch {
867
+ base.decryptionFailed = true;
868
+ }
869
+ }
870
+ return base;
871
+ }
872
+ }
873
+ class Channels {
874
+ np;
875
+ constructor(np) {
876
+ this.np = np;
877
+ }
878
+ async list() {
879
+ const { channels } = await this.np.api("GET", `/v1/apps/${this.np.appId}/channels`);
880
+ return channels.map((c) => new Channel(this.np, c));
881
+ }
882
+ async get(channelId) {
883
+ const rec = await this.np.api("GET", `/v1/apps/${this.np.appId}/channels/${channelId}`);
884
+ return new Channel(this.np, rec);
885
+ }
886
+ /**
887
+ * Create a channel. For E2EE channels this performs the full key ceremony:
888
+ * generate channel key → claim key packages for every member device →
889
+ * commit epoch 0→1 → send Welcome with the wrapped key to each device.
890
+ */
891
+ async create(opts) {
892
+ const rec = await this.np.api("POST", `/v1/apps/${this.np.appId}/channels`, opts);
893
+ const channel = new Channel(this.np, rec);
894
+ if (rec.e2ee && !this.np.getChannelKey(rec.channelId)) {
895
+ // preshared-key mode derives locally; otherwise run the MLS ceremony
896
+ if (!(await this.np.deriveAndSet(rec.channelId)) && rec.mlsGroupId) {
897
+ await channel.establishKeys(opts.memberIds ?? []);
898
+ }
899
+ }
900
+ return channel;
901
+ }
902
+ }
903
+ export class Channel {
904
+ np;
905
+ record;
906
+ constructor(np, record) {
907
+ this.np = np;
908
+ this.record = record;
909
+ }
910
+ get channelId() {
911
+ return this.record.channelId;
912
+ }
913
+ get e2ee() {
914
+ return this.record.e2ee;
915
+ }
916
+ /** E2EE key ceremony (creator side). */
917
+ async establishKeys(memberIds) {
918
+ const key = await generateChannelKey();
919
+ this.np.setChannelKey(this.channelId, key);
920
+ const recipients = [];
921
+ const others = memberIds.filter((m) => m !== this.np.userId);
922
+ for (const userId of others) {
923
+ const { keyPackages } = await this.np.api("POST", `/v1/apps/${this.np.appId}/users/${userId}/key-packages/claim`);
924
+ for (const kp of keyPackages) {
925
+ const parsed = JSON.parse(new TextDecoder().decode(b64.dec(kp.data)));
926
+ recipients.push({ deviceId: kp.deviceId, publicKeyJwk: parsed.pub });
927
+ }
928
+ }
929
+ // advance epoch 0 -> 1 (server-enforced single writer)
930
+ await this.np.api("POST", `/v1/apps/${this.np.appId}/mls/groups/${this.record.mlsGroupId}/handshakes`, {
931
+ epoch: 0,
932
+ kind: "commit",
933
+ senderDeviceId: this.np.deviceId,
934
+ payload: b64.enc(new TextEncoder().encode(JSON.stringify({ suite: "npdev-1", op: "init" }))),
935
+ });
936
+ if (recipients.length) {
937
+ const wrapped = await wrapChannelKey(key, recipients);
938
+ const welcome = { suite: "npdev-1", channelId: this.channelId, wrapped };
939
+ await this.np.api("POST", `/v1/apps/${this.np.appId}/mls/groups/${this.record.mlsGroupId}/handshakes`, {
940
+ epoch: 1,
941
+ kind: "welcome",
942
+ senderDeviceId: this.np.deviceId,
943
+ recipientDeviceIds: wrapped.map((w) => w.deviceId),
944
+ payload: b64.enc(new TextEncoder().encode(JSON.stringify(welcome))),
945
+ });
946
+ }
947
+ }
948
+ /** Joiner side: derive (preshared mode) or pull the welcome if we lack the key. */
949
+ async ensureKey() {
950
+ if (!this.e2ee)
951
+ return true;
952
+ if (this.np.getChannelKey(this.channelId))
953
+ return true;
954
+ if (await this.np.deriveAndSet(this.channelId))
955
+ return true;
956
+ if (!this.record.mlsGroupId)
957
+ return false;
958
+ if (await this.np.syncChannelKey(this.channelId, this.record.mlsGroupId))
959
+ return true;
960
+ // No key and no welcome yet — e.g. the channel was created server-side, or the
961
+ // original creator's key was lost. Bootstrap the ceremony as the originator:
962
+ // the server enforces a single epoch-0→1 writer, so if a peer races us we lose
963
+ // the commit and fall back to pulling their welcome.
964
+ try {
965
+ const { members } = await this.np.api("GET", `/v1/apps/${this.np.appId}/channels/${this.channelId}/members`);
966
+ await this.establishKeys(members.map((m) => m.userId));
967
+ if (this.np.getChannelKey(this.channelId))
968
+ return true;
969
+ }
970
+ catch {
971
+ /* lost the epoch race, or a member has no key package — fall through */
972
+ }
973
+ return this.np.syncChannelKey(this.channelId, this.record.mlsGroupId);
974
+ }
975
+ /** Low-level: send an arbitrary structured payload (image, poll, contact…). */
976
+ async sendPayload(payload, opts = {}) {
977
+ const clientMessageId = opts.clientMessageId ?? `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
978
+ if (this.e2ee) {
979
+ if (!(await this.ensureKey()))
980
+ throw new Error("No channel key — has the key ceremony completed?");
981
+ const ciphertext = await encryptPayload(this.np.getChannelKey(this.channelId), { v: 1, ...payload });
982
+ const ack = (await this.np.sendFrame({
983
+ type: "message.send",
984
+ channelId: this.channelId,
985
+ clientMessageId,
986
+ threadParentId: opts.threadParentId,
987
+ mlsEpoch: 1,
988
+ ciphertext,
989
+ }));
990
+ return this.np.decryptEnvelope(ack.message);
991
+ }
992
+ const ack = (await this.np.sendFrame({
993
+ type: "message.send",
994
+ channelId: this.channelId,
995
+ clientMessageId,
996
+ threadParentId: opts.threadParentId,
997
+ plaintext: { type: payload.type ?? "text", ...payload },
998
+ }));
999
+ return this.np.decryptEnvelope(ack.message);
1000
+ }
1001
+ async send(body) {
1002
+ return this.sendPayload({ type: body.type ?? "text", text: body.text, metadata: body.metadata, linkPreview: body.linkPreview }, { threadParentId: body.threadParentId });
1003
+ }
1004
+ // ---------------------------------------------------- rich content ----
1005
+ /** Encrypt + upload a file and send it as an image/video/audio/file message. */
1006
+ async sendAttachment(a, opts = {}) {
1007
+ const up = await this.np.uploadEncrypted(a.bytes);
1008
+ let thumb = {};
1009
+ if (a.thumbnail) {
1010
+ const t = await this.np.uploadEncrypted(a.thumbnail.bytes);
1011
+ thumb = { thumbBlobUrl: t.blobUrl, thumbContentKey: t.contentKey, thumbIv: t.iv };
1012
+ }
1013
+ const type = a.contentType.startsWith("image/")
1014
+ ? "image"
1015
+ : a.contentType.startsWith("video/")
1016
+ ? "video"
1017
+ : a.contentType.startsWith("audio/")
1018
+ ? "audio"
1019
+ : "file";
1020
+ return this.sendPayload({
1021
+ type,
1022
+ text: opts.caption,
1023
+ attachments: [
1024
+ {
1025
+ blobUrl: up.blobUrl,
1026
+ contentKey: up.contentKey,
1027
+ iv: up.iv,
1028
+ size: up.size,
1029
+ name: a.name,
1030
+ contentType: a.contentType,
1031
+ width: a.width,
1032
+ height: a.height,
1033
+ durationSec: a.durationSec,
1034
+ ...thumb,
1035
+ },
1036
+ ],
1037
+ }, { threadParentId: opts.threadParentId });
1038
+ }
1039
+ /** Convenience wrapper for a browser File/Blob (uses .name/.type). */
1040
+ async sendFile(file, opts) {
1041
+ return this.sendAttachment({
1042
+ bytes: await file.arrayBuffer(),
1043
+ name: file.name ?? "file",
1044
+ contentType: file.type || "application/octet-stream",
1045
+ thumbnail: opts?.thumbnail,
1046
+ width: opts?.width,
1047
+ height: opts?.height,
1048
+ durationSec: opts?.durationSec,
1049
+ }, { caption: opts?.caption });
1050
+ }
1051
+ /** Download + decrypt an attachment; returns a Blob you can objectURL. */
1052
+ async fetchAttachment(att) {
1053
+ const buf = await this.np.downloadDecrypt(att);
1054
+ return new Blob([buf], { type: att.contentType || "application/octet-stream" });
1055
+ }
1056
+ async sendPoll(poll) {
1057
+ const id = `poll_${Math.random().toString(36).slice(2, 10)}`;
1058
+ return this.sendPayload({
1059
+ type: "poll",
1060
+ poll: {
1061
+ id,
1062
+ question: poll.question,
1063
+ options: poll.options.map((label, i) => ({ id: `o${i}`, label })),
1064
+ multiple: poll.multiple ?? false,
1065
+ anonymous: poll.anonymous ?? false,
1066
+ closesAt: poll.closesAt,
1067
+ },
1068
+ });
1069
+ }
1070
+ /** Cast (or, with an empty array, retract) a vote on a poll message. */
1071
+ async votePoll(pollMessageId, optionIds) {
1072
+ await this.sendPayload({ type: "poll_vote", targetMessageId: pollMessageId, pollOptionIds: optionIds });
1073
+ }
1074
+ async sendContact(contact) {
1075
+ return this.sendPayload({ type: "contact", contact });
1076
+ }
1077
+ async sendCalendarEvent(event) {
1078
+ return this.sendPayload({ type: "calendar_event", calendarEvent: { allDay: false, ...event } });
1079
+ }
1080
+ async rsvp(eventMessageId, response) {
1081
+ await this.sendPayload({ type: "rsvp", targetMessageId: eventMessageId, rsvp: response });
1082
+ }
1083
+ async sendLocation(lat, lng, label) {
1084
+ return this.sendPayload({ type: "location", location: { lat, lng, label } });
1085
+ }
1086
+ // ------------------------------------------------------- bot actions ----
1087
+ // Structured E2EE bodies for the bots feature — no new crypto, no new
1088
+ // events: they ride the normal encrypted message path and surface through
1089
+ // the generic `message` event like any other typed body.
1090
+ /**
1091
+ * Bot → humans: a message with tappable action buttons under the text
1092
+ * (Telegram-style inline keyboard). Humans respond via invokeAction().
1093
+ */
1094
+ async sendActions(opts) {
1095
+ return this.sendPayload({ type: "actions", text: opts.text, actions: opts.actions });
1096
+ }
1097
+ /**
1098
+ * Bot → humans: advertise (or replace) the bot's persistent quick-action
1099
+ * bar for this chat. Clients render the LATEST action_bar per bot as chips
1100
+ * above the composer; sending a new one supersedes the previous.
1101
+ */
1102
+ async sendActionBar(opts) {
1103
+ return this.sendPayload({ type: "action_bar", actions: opts.actions });
1104
+ }
1105
+ /**
1106
+ * Human → bot: what a tapped action button sends. `label` is optional
1107
+ * display sugar so clients can echo "You ran <label>" without a lookup.
1108
+ */
1109
+ async invokeAction(opts) {
1110
+ return this.sendPayload({
1111
+ type: "action_invoke",
1112
+ actionId: opts.actionId,
1113
+ ...(opts.label !== undefined ? { label: opts.label } : {}),
1114
+ ...(opts.params !== undefined ? { params: opts.params } : {}),
1115
+ });
1116
+ }
1117
+ async edit(messageId, body) {
1118
+ if (this.e2ee) {
1119
+ const key = this.np.getChannelKey(this.channelId);
1120
+ if (!key)
1121
+ throw new Error("No channel key");
1122
+ // the edited body must keep the message's real type — writing a synthetic
1123
+ // "edit" type made every consumer treat the message as non-text afterwards
1124
+ const ciphertext = await encryptPayload(key, { v: 1, type: "text", text: body.text });
1125
+ await this.np.api("PATCH", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}`, {
1126
+ mlsEpoch: 1,
1127
+ ciphertext,
1128
+ });
1129
+ }
1130
+ else {
1131
+ await this.np.api("PATCH", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}`, {
1132
+ plaintext: { type: "text", text: body.text },
1133
+ });
1134
+ }
1135
+ }
1136
+ async recall(messageId) {
1137
+ await this.np.api("DELETE", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}`);
1138
+ }
1139
+ async react(messageId, emoji, op = "add") {
1140
+ if (this.e2ee) {
1141
+ const key = this.np.getChannelKey(this.channelId);
1142
+ if (!key)
1143
+ throw new Error("No channel key");
1144
+ const ciphertext = await encryptPayload(key, { v: 1, type: "reaction", targetMessageId: messageId, emoji, op });
1145
+ await this.np.sendFrame({
1146
+ type: "message.send",
1147
+ channelId: this.channelId,
1148
+ clientMessageId: `rx${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
1149
+ mlsEpoch: 1,
1150
+ ciphertext,
1151
+ });
1152
+ }
1153
+ else if (op === "add") {
1154
+ await this.np.api("POST", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}/reactions`, { emoji });
1155
+ }
1156
+ else {
1157
+ await this.np.api("DELETE", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages/${messageId}/reactions?emoji=${encodeURIComponent(emoji)}`);
1158
+ }
1159
+ }
1160
+ typing(isTyping) {
1161
+ this.np["ws"]?.send(JSON.stringify({ type: isTyping ? "typing.start" : "typing.stop", channelId: this.channelId }));
1162
+ }
1163
+ async markRead(messageId) {
1164
+ await this.np.sendFrame({ type: "receipt.mark", channelId: this.channelId, kind: "read", messageId });
1165
+ }
1166
+ async history(opts = {}) {
1167
+ await this.ensureKey();
1168
+ const params = new URLSearchParams();
1169
+ if (opts.beforeSeq)
1170
+ params.set("beforeSeq", String(opts.beforeSeq));
1171
+ if (opts.limit)
1172
+ params.set("limit", String(opts.limit));
1173
+ const { messages } = await this.np.api("GET", `/v1/apps/${this.np.appId}/channels/${this.channelId}/messages?${params}`);
1174
+ const out = [];
1175
+ for (const env of messages) {
1176
+ const m = await this.np.decryptEnvelope(env);
1177
+ // carrier messages (reactions, poll votes, rsvps) aggregate client-side
1178
+ if (m.body?.type === "reaction")
1179
+ continue;
1180
+ if (m.body?.type === "rsvp") {
1181
+ this.np.applyRsvp(m.body.targetMessageId, m.senderUserId, m.body.rsvp);
1182
+ continue;
1183
+ }
1184
+ if (m.body?.type === "poll_vote") {
1185
+ this.np.applyPollVote(m.body.targetMessageId, m.senderUserId, m.body.pollOptionIds ?? []);
1186
+ continue;
1187
+ }
1188
+ out.push(m);
1189
+ }
1190
+ return out;
1191
+ }
1192
+ pollResults(pollMessageId) {
1193
+ return this.np.pollResults(pollMessageId);
1194
+ }
1195
+ myPollVote(pollMessageId) {
1196
+ return this.np.myPollVote(pollMessageId);
1197
+ }
1198
+ rsvpResults(eventMessageId) {
1199
+ return this.np.rsvpResults(eventMessageId);
1200
+ }
1201
+ myRsvp(eventMessageId) {
1202
+ return this.np.myRsvp(eventMessageId);
1203
+ }
1204
+ on(event, fn) {
1205
+ return this.np.on(event, ((payload) => {
1206
+ if (!payload.channelId || payload.channelId === this.channelId)
1207
+ fn(payload);
1208
+ }));
1209
+ }
1210
+ }
1211
+ export { b64, deriveSharedChannelKey, generateRecoveryCode } from "./crypto.js";
1212
+ // ---------------------------------------------------------------------------
1213
+ // Browser helpers for attaching media. No-ops outside a DOM. Native apps
1214
+ // (Capacitor/React Native) should use their platform camera/file plugins and
1215
+ // pass the resulting bytes to channel.sendAttachment().
1216
+ // ---------------------------------------------------------------------------
1217
+ /** Open the OS file picker. `accept` e.g. "image/*", "video/*", "*​/*". */
1218
+ export function pickFiles(opts = {}) {
1219
+ return new Promise((resolve) => {
1220
+ const input = document.createElement("input");
1221
+ input.type = "file";
1222
+ if (opts.accept)
1223
+ input.accept = opts.accept;
1224
+ if (opts.multiple)
1225
+ input.multiple = true;
1226
+ if (opts.capture)
1227
+ input.setAttribute("capture", opts.capture);
1228
+ input.style.display = "none";
1229
+ input.addEventListener("change", () => {
1230
+ resolve(input.files ? Array.from(input.files) : []);
1231
+ input.remove();
1232
+ });
1233
+ document.body.appendChild(input);
1234
+ input.click();
1235
+ });
1236
+ }
1237
+ /** Pick images from the gallery. */
1238
+ export const pickImages = (multiple = false) => pickFiles({ accept: "image/*", multiple });
1239
+ /** Pick a video. */
1240
+ export const pickVideo = () => pickFiles({ accept: "video/*" });
1241
+ /** Open the rear camera to take a photo (mobile web). */
1242
+ export const capturePhoto = () => pickFiles({ accept: "image/*", capture: "environment" });
1243
+ /**
1244
+ * Downscale an image File to a JPEG thumbnail (browser only). Returns bytes +
1245
+ * intrinsic dimensions, suitable for channel.sendFile({ thumbnail, width, height }).
1246
+ */
1247
+ export async function makeImageThumbnail(file, max = 480) {
1248
+ const bitmap = await createImageBitmap(file);
1249
+ const { width, height } = bitmap;
1250
+ const scale = Math.min(1, max / Math.max(width, height));
1251
+ const canvas = document.createElement("canvas");
1252
+ canvas.width = Math.round(width * scale);
1253
+ canvas.height = Math.round(height * scale);
1254
+ canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height);
1255
+ const blob = await new Promise((r) => canvas.toBlob((b) => r(b), "image/jpeg", 0.8));
1256
+ return { thumbnail: { bytes: await blob.arrayBuffer(), contentType: "image/jpeg" }, width, height };
1257
+ }
1258
+ //# sourceMappingURL=index.js.map