@dtelecom/secure-chat-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,435 @@
1
+ interface MintTokenResponse {
2
+ chatToken: string;
3
+ expiresAt: number;
4
+ /**
5
+ * Closest dtelecom node WebSocket URL for this client's IP, computed
6
+ * server-side via the existing @dtelecom/server-sdk-js node discovery
7
+ * (Solana registry → /relevants ranking). The SDK uses this for /chat/ws.
8
+ */
9
+ chatNodeWsUrl: string;
10
+ }
11
+ interface OneTimeKey {
12
+ id: string;
13
+ public: string;
14
+ }
15
+ interface ClaimedDevice {
16
+ deviceId: string;
17
+ identityKeyCurve: string;
18
+ identityKeyEd: string;
19
+ signedPrekey: string;
20
+ signedPrekeySig: string;
21
+ oneTimeKey: OneTimeKey | null;
22
+ fallbackPrekey: string;
23
+ fallbackPrekeySig: string;
24
+ fingerprint: string;
25
+ lastActiveAt: number;
26
+ }
27
+
28
+ /** Public material the SDK uploads via POST /api/chat/keys/upload. */
29
+ interface UploadBundle {
30
+ identityKeyCurve: string;
31
+ identityKeyEd: string;
32
+ signedPrekey: string;
33
+ signedPrekeySig: string;
34
+ fallbackPrekey: string;
35
+ fallbackPrekeySig: string;
36
+ fingerprint: string;
37
+ oneTimeKeys: {
38
+ id: string;
39
+ public: string;
40
+ }[];
41
+ }
42
+ /** Output of CryptoAdapter.encryptForPeer. */
43
+ interface OutboundEnvelope {
44
+ /** base64-encoded Olm ciphertext. */
45
+ ciphertext: string;
46
+ /**
47
+ * "prekey" if this is the first message in a fresh outbound session
48
+ * (consumes one of the recipient's one-time-keys); "normal" once the
49
+ * session has ratcheted at least once.
50
+ */
51
+ msgType: "prekey" | "normal";
52
+ }
53
+ /**
54
+ * Olm primitives surface. Methods are async because the underlying WASM
55
+ * library is async-loaded and persistence is async (IndexedDB).
56
+ *
57
+ * The adapter owns Olm account + per-(peerUser, peerDevice) session state
58
+ * and persists across restarts. Callers (sessions.ts, key_bundle.ts) are
59
+ * stateless wrappers that orchestrate via this interface.
60
+ */
61
+ interface CryptoAdapter {
62
+ /** Initialize underlying WASM library. Idempotent. */
63
+ init(): Promise<void>;
64
+ /** True if this adapter has a persisted Olm account. */
65
+ hasAccount(): Promise<boolean>;
66
+ /**
67
+ * Generate a new Olm account and return the public bundle to upload.
68
+ * Persists private state. Throws if an account already exists — call
69
+ * hasAccount() first.
70
+ */
71
+ generateAccount(otkCount: number): Promise<UploadBundle>;
72
+ /**
73
+ * Re-emit the current bundle without generating new keys. Used when
74
+ * the device id is already known but the server doesn't yet have the
75
+ * bundle (e.g. backend was wiped during testing).
76
+ */
77
+ getCurrentBundle(): Promise<UploadBundle>;
78
+ /** Generate N more one-time keys for top-up. Returns public material. */
79
+ generateOneTimeKeys(n: number): Promise<{
80
+ id: string;
81
+ public: string;
82
+ }[]>;
83
+ /**
84
+ * Locally tracked count of unconsumed one-time keys. The server's count
85
+ * is authoritative; this is the SDK's view (decrements on every
86
+ * outbound prekey-message it knows about, but the server may consume
87
+ * OTKs without notifying the SDK).
88
+ */
89
+ unusedOneTimeKeyCount(): Promise<number>;
90
+ /**
91
+ * Encrypt for a peer device. Creates an outbound Olm session lazily
92
+ * from peerBundle if no session exists with (peerUserId, peerDeviceId);
93
+ * subsequent calls reuse the established session.
94
+ */
95
+ encryptForPeer(peerUserId: string, peerDeviceId: string, peerBundle: ClaimedDevice, plaintext: string): Promise<OutboundEnvelope>;
96
+ /**
97
+ * Decrypt an inbound ciphertext. If `msgType === "prekey"` and no
98
+ * session exists yet for (peerUserId, peerDeviceId), creates an
99
+ * inbound session from the prekey message.
100
+ */
101
+ decryptFromPeer(peerUserId: string, peerDeviceId: string, ciphertext: string, msgType: "prekey" | "normal"): Promise<string>;
102
+ /** Drop the session with a peer device. Used for explicit reset. */
103
+ forgetSession(peerUserId: string, peerDeviceId: string): Promise<void>;
104
+ /** Whether a session exists with this peer device. */
105
+ hasSession(peerUserId: string, peerDeviceId: string): Promise<boolean>;
106
+ }
107
+
108
+ interface KVStore {
109
+ getString(key: string): Promise<string | null>;
110
+ setString(key: string, value: string): Promise<void>;
111
+ getBytes(key: string): Promise<Uint8Array | null>;
112
+ setBytes(key: string, value: Uint8Array): Promise<void>;
113
+ delete(key: string): Promise<void>;
114
+ /** List keys with the given prefix. Used to enumerate per-peer-device sessions. */
115
+ listKeys(prefix: string): Promise<string[]>;
116
+ }
117
+
118
+ /**
119
+ * Persisted shape of a message. `senderUserId` is the truth — the SDK
120
+ * sets it to either "self" (when the local user sent it) or to the
121
+ * peer's user id (on inbound). Edit/delete authorization compares this
122
+ * field on the stored row to the sender of the inbound mutation event.
123
+ */
124
+ interface StoredMessage {
125
+ id: string;
126
+ peerUserId: string;
127
+ senderUserId: string;
128
+ text: string;
129
+ /** Original clientSentAt of the text event; never mutated by edits. */
130
+ sentAt: number;
131
+ /** Set on edit; null otherwise. */
132
+ editedAt: number | null;
133
+ /** Set on delete; the message becomes a tombstone. */
134
+ deletedAt: number | null;
135
+ replyTo?: string;
136
+ }
137
+
138
+ type MessageStatus = "pending" | "sent" | "delivered" | "deliveredAll" | "read";
139
+
140
+ /**
141
+ * Consumer-supplied callback that mints a chat token via the tenant backend.
142
+ * Must return the full `POST /api/chat/token` response so the SDK can use the
143
+ * server-discovered node URL (chatNodeWsUrl) — no Solana code on the client.
144
+ */
145
+ type FetchChatToken = (deviceId: string) => Promise<MintTokenResponse>;
146
+
147
+ interface OlmAdapterOptions {
148
+ store: KVStore;
149
+ }
150
+ declare class OlmCryptoAdapter implements CryptoAdapter {
151
+ private opts;
152
+ private account;
153
+ private sessions;
154
+ constructor(opts: OlmAdapterOptions);
155
+ init(): Promise<void>;
156
+ hasAccount(): Promise<boolean>;
157
+ generateAccount(otkCount: number): Promise<UploadBundle>;
158
+ getCurrentBundle(): Promise<UploadBundle>;
159
+ generateOneTimeKeys(n: number): Promise<{
160
+ id: string;
161
+ public: string;
162
+ }[]>;
163
+ unusedOneTimeKeyCount(): Promise<number>;
164
+ encryptForPeer(peerUserId: string, peerDeviceId: string, peerBundle: ClaimedDevice, plaintext: string): Promise<OutboundEnvelope>;
165
+ decryptFromPeer(peerUserId: string, peerDeviceId: string, ciphertext: string, msgType: "prekey" | "normal"): Promise<string>;
166
+ forgetSession(peerUserId: string, peerDeviceId: string): Promise<void>;
167
+ hasSession(peerUserId: string, peerDeviceId: string): Promise<boolean>;
168
+ private requireAccount;
169
+ private buildBundle;
170
+ private persistAccount;
171
+ private loadSession;
172
+ private persistSession;
173
+ }
174
+
175
+ declare class FakeCryptoAdapter implements CryptoAdapter {
176
+ private account;
177
+ private sessions;
178
+ private otkCounter;
179
+ init(): Promise<void>;
180
+ hasAccount(): Promise<boolean>;
181
+ generateAccount(otkCount: number): Promise<UploadBundle>;
182
+ getCurrentBundle(): Promise<UploadBundle>;
183
+ generateOneTimeKeys(n: number): Promise<{
184
+ id: string;
185
+ public: string;
186
+ }[]>;
187
+ unusedOneTimeKeyCount(): Promise<number>;
188
+ encryptForPeer(peerUserId: string, peerDeviceId: string, _peerBundle: ClaimedDevice, plaintext: string): Promise<OutboundEnvelope>;
189
+ decryptFromPeer(peerUserId: string, peerDeviceId: string, ciphertext: string, msgType: "prekey" | "normal"): Promise<string>;
190
+ forgetSession(peerUserId: string, peerDeviceId: string): Promise<void>;
191
+ hasSession(peerUserId: string, peerDeviceId: string): Promise<boolean>;
192
+ private snapshot;
193
+ private makeOtks;
194
+ }
195
+
196
+ declare class MemoryKVStore implements KVStore {
197
+ private map;
198
+ getString(key: string): Promise<string | null>;
199
+ setString(key: string, value: string): Promise<void>;
200
+ getBytes(key: string): Promise<Uint8Array | null>;
201
+ setBytes(key: string, value: Uint8Array): Promise<void>;
202
+ delete(key: string): Promise<void>;
203
+ listKeys(prefix: string): Promise<string[]>;
204
+ }
205
+
206
+ declare class WebKVStore implements KVStore {
207
+ getString(key: string): Promise<string | null>;
208
+ setString(key: string, value: string): Promise<void>;
209
+ getBytes(key: string): Promise<Uint8Array | null>;
210
+ setBytes(key: string, value: Uint8Array): Promise<void>;
211
+ delete(key: string): Promise<void>;
212
+ listKeys(prefix: string): Promise<string[]>;
213
+ }
214
+
215
+ declare const VERSION = "0.0.0";
216
+ declare const CONTENT_PROTOCOL_VERSION = 1;
217
+ interface ConnectOptions {
218
+ /** dmeet-backend (or mock) base URL — e.g. https://dmeet.example.com */
219
+ apiBaseURL: string;
220
+ /** Function that mints a chat token. The SDK calls this whenever a fresh
221
+ * token is needed; the consumer's implementation handles their own
222
+ * outer auth (Privy, an internal cookie, etc.). */
223
+ fetchChatToken: FetchChatToken;
224
+ /** Optional. Defaults to WebKVStore (IndexedDB) in browsers. Tests pass
225
+ * MemoryKVStore to avoid the IDB dependency. */
226
+ store?: KVStore;
227
+ /** Optional. Defaults to OlmCryptoAdapter wrapping vodozemac. Tests
228
+ * pass FakeCryptoAdapter to avoid bundling WASM in the test runner. */
229
+ crypto?: CryptoAdapter;
230
+ /** Optional fetch implementation. Defaults to globalThis.fetch. Tests
231
+ * pass a mocked fetch to avoid real network calls. */
232
+ fetchImpl?: typeof fetch;
233
+ }
234
+ interface MessageReceived {
235
+ peerUserId: string;
236
+ peerDeviceId: string;
237
+ /** The user that authored this message. Equals selfUserId when the
238
+ * message arrived via self-echo from another own device. */
239
+ senderUserId: string;
240
+ message: {
241
+ id: string;
242
+ text: string;
243
+ replyTo?: string;
244
+ sentAt: number;
245
+ };
246
+ }
247
+ interface MessageEdited {
248
+ peerUserId: string;
249
+ /** Author of the edit. Equals selfUserId for self-echoed edits. */
250
+ editorUserId: string;
251
+ targetId: string;
252
+ newText: string;
253
+ editedAt: number;
254
+ }
255
+ interface MessageDeleted {
256
+ peerUserId: string;
257
+ /** Author of the delete. Equals selfUserId for self-echoed deletes. */
258
+ deleterUserId: string;
259
+ targetId: string;
260
+ deletedAt: number;
261
+ }
262
+ interface ReadReceiptEvent {
263
+ peerUserId: string;
264
+ peerDeviceId: string;
265
+ upToId: string;
266
+ }
267
+ interface TypingEvt {
268
+ peerUserId: string;
269
+ peerDeviceId: string;
270
+ state: "started" | "stopped";
271
+ }
272
+ interface StatusChangeEvt {
273
+ peerUserId: string;
274
+ messageId: string;
275
+ status: MessageStatus;
276
+ }
277
+ /**
278
+ * Emitted the first time the SDK observes a previously-unknown peer device,
279
+ * either via an inbound prekey-message from it OR via a refreshed device
280
+ * list. Apps render this as the "Bob is using a new device — verify?"
281
+ * banner per plan §17.
282
+ */
283
+ interface PeerNewDeviceEvt {
284
+ peerUserId: string;
285
+ peerDeviceId: string;
286
+ fingerprint: string;
287
+ }
288
+ interface EventMap {
289
+ message: MessageReceived;
290
+ messageEdited: MessageEdited;
291
+ messageDeleted: MessageDeleted;
292
+ readReceipt: ReadReceiptEvent;
293
+ typing: TypingEvt;
294
+ statusChange: StatusChangeEvt;
295
+ peerNewDevice: PeerNewDeviceEvt;
296
+ }
297
+ type EventName = keyof EventMap;
298
+ type Listener<T extends EventName> = (event: EventMap[T]) => void;
299
+ /** Public shape returned by `getKnownPeerDevices()`. */
300
+ interface KnownPeerDevice {
301
+ deviceId: string;
302
+ fingerprint: string;
303
+ lastActiveAt: number;
304
+ /** True if the local user has explicitly verified this device. */
305
+ verified: boolean;
306
+ }
307
+ declare class DTelecomSecureChat {
308
+ private deviceId;
309
+ private http;
310
+ private ws;
311
+ private crypto;
312
+ private store;
313
+ private keyBundle;
314
+ private sessions;
315
+ private peerDevices;
316
+ private messages;
317
+ private status;
318
+ private outbox;
319
+ private typingMgr;
320
+ private listeners;
321
+ /** Per-peer-device queue of received-event ids awaiting batch send. */
322
+ private pendingReceived;
323
+ private receivedFlushTimer;
324
+ /** Self user id derived from chat-token claims after first mint. */
325
+ private selfUserId;
326
+ /** Devices we've already emitted `peerNewDevice` for, to avoid duplicates. */
327
+ private announcedNewDevices;
328
+ /** True after we've reuploaded the bundle once for this connection — set
329
+ * after a "peer has zero devices" outcome that suggests the backend
330
+ * forgot us (registry mismatch / wipe). Don't loop. */
331
+ private bundleReuploadAttempted;
332
+ /** Cache of the read-receipts preference (loaded lazily). */
333
+ private readReceiptsCache;
334
+ /**
335
+ * Connect to the dtelecom mesh. Generates an Olm account on first run,
336
+ * uploads the bundle, opens /chat/ws to the closest discovered node,
337
+ * and pulls any pending offline envelopes.
338
+ */
339
+ static connect(opts: ConnectOptions): Promise<DTelecomSecureChat>;
340
+ private constructor();
341
+ /** Stable per-install device id. Useful for app diagnostics. */
342
+ get currentDeviceId(): string;
343
+ sendText(peerUserId: string, text: string, opts?: {
344
+ replyTo?: string;
345
+ }): Promise<string>;
346
+ editMessage(peerUserId: string, targetId: string, newText: string): Promise<string>;
347
+ deleteMessage(peerUserId: string, targetId: string): Promise<string>;
348
+ /**
349
+ * Send a read-watermark to `peerUserId`. No-op when read receipts are
350
+ * disabled by `setReadReceiptsEnabled(false)` — the local user remains
351
+ * invisible to senders, but inbound `read` events from peers are still
352
+ * consumed (the sender's preference is their own call).
353
+ */
354
+ markRead(peerUserId: string, upToMessageId: string): Promise<void>;
355
+ setTyping(peerUserId: string, isTyping: boolean): void;
356
+ /** Enable/disable outbound read receipts. Persisted in the local KV store. */
357
+ setReadReceiptsEnabled(enabled: boolean): Promise<void>;
358
+ /** Read the current preference. Default true. */
359
+ areReadReceiptsEnabled(): Promise<boolean>;
360
+ /**
361
+ * Returns the cached peer-device list for `peerUserId`. Refreshes via
362
+ * `list_devices` if the local cache is empty or stale. Used to render
363
+ * the "Known Devices" settings panel. Doesn't consume OTKs.
364
+ */
365
+ getKnownPeerDevices(peerUserId: string): Promise<KnownPeerDevice[]>;
366
+ /** Single-device fingerprint accessor. Returns null if unknown. */
367
+ getPeerDeviceFingerprint(peerUserId: string, peerDeviceId: string): Promise<string | null>;
368
+ /**
369
+ * Mark a peer device as verified (or unverified). Local-only — doesn't
370
+ * change the protocol's behavior, just exposes a flag the UI can render.
371
+ */
372
+ markPeerDeviceVerified(peerUserId: string, peerDeviceId: string, verified: boolean): Promise<void>;
373
+ isPeerDeviceVerified(peerUserId: string, peerDeviceId: string): Promise<boolean>;
374
+ /**
375
+ * Read persisted message history with `peerUserId`, oldest→newest within
376
+ * the page. Use `beforeSentAt` + `limit` to paginate older messages.
377
+ * Returns include local-sent messages (sender = self), inbound messages
378
+ * (sender = peer), and tombstoned/edited rows reflecting the latest state.
379
+ */
380
+ getHistory(peerUserId: string, opts?: {
381
+ limit?: number;
382
+ beforeSentAt?: number;
383
+ }): Promise<StoredMessage[]>;
384
+ blockUser(peerUserId: string): Promise<{
385
+ ok: true;
386
+ }>;
387
+ unblockUser(peerUserId: string): Promise<{
388
+ ok: true;
389
+ }>;
390
+ getBlockedUsers(): Promise<string[]>;
391
+ on<T extends EventName>(event: T, fn: Listener<T>): () => void;
392
+ disconnect(): Promise<void>;
393
+ private bootstrap;
394
+ /**
395
+ * State-listener for the underlying WsClient. On every transition to
396
+ * "open" (initial connect AND auto-reconnect), drain any queued
397
+ * outbound sends and re-pull pending offline envelopes — closes the
398
+ * gap during disconnect.
399
+ */
400
+ private onWsState;
401
+ /** Set true while drainPending is running to avoid overlapping calls
402
+ * (would otherwise hand the same envelope to two concurrent decrypt
403
+ * invocations — Olm rejects the second). */
404
+ private drainingPending;
405
+ private drainPending;
406
+ private onFrame;
407
+ private handleInboundCiphertext;
408
+ /**
409
+ * If `peerDeviceId` is not in our local cache for `peerUserId`, refresh
410
+ * the peer's device list and emit `peerNewDevice` exactly once. Idempotent
411
+ * across repeated calls — second message from the same new device is a
412
+ * cheap cache hit. Failures (HTTP error fetching the device list) are
413
+ * swallowed; we'll re-attempt on the next inbound from this device.
414
+ */
415
+ private maybeAnnouncePeerDevice;
416
+ private dispatchInboundEvent;
417
+ /**
418
+ * Multi-device self-echo. Wraps the original event in a `selfEcho`
419
+ * envelope and ships it to our own user (mesh fanout filters our
420
+ * own device). Other devices belonging to the same user receive,
421
+ * unwrap, and persist the event so their local history mirrors this
422
+ * device's. No-op when:
423
+ * - we don't yet know our own user id
424
+ * - the original was addressed to ourselves (avoids loops)
425
+ * - we have no other devices registered (encryptForPeer returns [])
426
+ * Best-effort: failures here don't surface to the caller.
427
+ */
428
+ private selfEcho;
429
+ private sendContent;
430
+ private queueReceivedAck;
431
+ private flushReceivedBatch;
432
+ private dispatch;
433
+ }
434
+
435
+ export { CONTENT_PROTOCOL_VERSION, type ConnectOptions, type CryptoAdapter, DTelecomSecureChat, FakeCryptoAdapter, type KVStore, type KnownPeerDevice, MemoryKVStore, type MessageDeleted, type MessageEdited, type MessageReceived, type MessageStatus, OlmCryptoAdapter, type PeerNewDeviceEvt, type ReadReceiptEvent, type StatusChangeEvt, type StoredMessage, type TypingEvt, VERSION, WebKVStore };