@abraca/dabra 1.6.0 → 1.8.1

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,185 @@
1
+ import EventEmitter from "./EventEmitter.ts";
2
+ import type {
3
+ CreateNotificationInput,
4
+ FetchNotificationsInput,
5
+ NotificationReadUpdate,
6
+ NotificationRecord,
7
+ } from "./types.ts";
8
+ import type { ChatClientTransport } from "./ChatClient.ts";
9
+
10
+ type PendingResolver<T> = {
11
+ resolve: (value: T) => void;
12
+ reject: (err: Error) => void;
13
+ timer: ReturnType<typeof setTimeout>;
14
+ };
15
+
16
+ const DEFAULT_TIMEOUT_MS = 10_000;
17
+
18
+ /**
19
+ * Typed client for the Abracadabra notifications feature.
20
+ *
21
+ * Emits:
22
+ * - `new` → NotificationRecord (incoming notify:new broadcast)
23
+ * - `readUpdate` → NotificationReadUpdate (broadcast after mark_read / mark_all_read)
24
+ */
25
+ export class NotificationsClient extends EventEmitter {
26
+ private readonly provider: ChatClientTransport;
27
+ private readonly responseTimeoutMs: number;
28
+ private readonly pending: Map<string, PendingResolver<any>[]> = new Map();
29
+ private readonly boundOnStateless: (data: { payload: string }) => void;
30
+
31
+ constructor(provider: ChatClientTransport, options?: { responseTimeoutMs?: number }) {
32
+ super();
33
+ this.provider = provider;
34
+ this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS;
35
+ this.boundOnStateless = (data) => this.handleStateless(data.payload);
36
+ this.provider.on("stateless", this.boundOnStateless);
37
+ }
38
+
39
+ destroy(): void {
40
+ this.provider.off("stateless", this.boundOnStateless);
41
+ for (const queue of this.pending.values()) {
42
+ for (const p of queue) {
43
+ clearTimeout(p.timer);
44
+ p.reject(new Error("NotificationsClient destroyed"));
45
+ }
46
+ }
47
+ this.pending.clear();
48
+ this.removeAllListeners();
49
+ }
50
+
51
+ // ── Outgoing requests ──────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Create a notification targeting a specific recipient. Requires elevated role
55
+ * (service or admin); a `server:error` event with code `forbidden` is emitted
56
+ * by the underlying provider if the caller lacks permission.
57
+ */
58
+ create(input: CreateNotificationInput): void {
59
+ this.provider.sendStateless(
60
+ JSON.stringify({
61
+ type: "notify:create",
62
+ recipient_id: input.recipient_id,
63
+ ...(input.notification_type !== undefined ? { notification_type: input.notification_type } : {}),
64
+ title: input.title,
65
+ ...(input.body !== undefined ? { body: input.body } : {}),
66
+ ...(input.icon !== undefined ? { icon: input.icon } : {}),
67
+ ...(input.link !== undefined ? { link: input.link } : {}),
68
+ ...(input.source_id !== undefined ? { source_id: input.source_id } : {}),
69
+ }),
70
+ );
71
+ }
72
+
73
+ /** Fetch notification history for the current user. */
74
+ fetch(input: FetchNotificationsInput = {}): Promise<{ notifications: NotificationRecord[] }> {
75
+ const promise = this.enqueue<{ notifications: NotificationRecord[] }>("notify:history");
76
+ this.provider.sendStateless(
77
+ JSON.stringify({
78
+ type: "notify:fetch",
79
+ ...(input.before !== undefined ? { before: input.before } : {}),
80
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
81
+ ...(input.unread_only !== undefined ? { unread_only: input.unread_only } : {}),
82
+ }),
83
+ );
84
+ return promise;
85
+ }
86
+
87
+ /** Mark a single notification, or a batch, as read. */
88
+ markRead(target: { id: string } | { ids: string[] }): void {
89
+ const body: Record<string, unknown> = { type: "notify:mark_read" };
90
+ if ("id" in target) body.id = target.id;
91
+ else body.ids = target.ids;
92
+ this.provider.sendStateless(JSON.stringify(body));
93
+ }
94
+
95
+ /** Mark every notification for the current user as read. */
96
+ markAllRead(): void {
97
+ this.provider.sendStateless(JSON.stringify({ type: "notify:mark_all_read" }));
98
+ }
99
+
100
+ /** Mark all notifications generated by the given source (e.g. a chat channel) as read. */
101
+ markReadBySource(sourceId: string): void {
102
+ this.provider.sendStateless(
103
+ JSON.stringify({ type: "notify:mark_read_by_source", source_id: sourceId }),
104
+ );
105
+ }
106
+
107
+ // ── Event helpers ─────────────────────────────────────────────────────────
108
+
109
+ onNew(fn: (n: NotificationRecord) => void): this {
110
+ return this.on("new", fn) as this;
111
+ }
112
+
113
+ onReadUpdate(fn: (u: NotificationReadUpdate) => void): this {
114
+ return this.on("readUpdate", fn) as this;
115
+ }
116
+
117
+ // ── Internals ─────────────────────────────────────────────────────────────
118
+
119
+ private enqueue<T>(type: string): Promise<T> {
120
+ return new Promise<T>((resolve, reject) => {
121
+ const timer = setTimeout(() => {
122
+ this.removePending(type, entry);
123
+ reject(new Error(`NotificationsClient: timeout waiting for ${type} response`));
124
+ }, this.responseTimeoutMs);
125
+ const entry: PendingResolver<T> = { resolve, reject, timer };
126
+ const queue = this.pending.get(type) ?? [];
127
+ queue.push(entry);
128
+ this.pending.set(type, queue);
129
+ });
130
+ }
131
+
132
+ private removePending(type: string, entry: PendingResolver<any>): void {
133
+ const queue = this.pending.get(type);
134
+ if (!queue) return;
135
+ const idx = queue.indexOf(entry);
136
+ if (idx >= 0) queue.splice(idx, 1);
137
+ if (queue.length === 0) this.pending.delete(type);
138
+ }
139
+
140
+ private resolveNext<T>(type: string, value: T): boolean {
141
+ const queue = this.pending.get(type);
142
+ if (!queue || queue.length === 0) return false;
143
+ const next = queue.shift()!;
144
+ if (queue.length === 0) this.pending.delete(type);
145
+ clearTimeout(next.timer);
146
+ next.resolve(value);
147
+ return true;
148
+ }
149
+
150
+ private handleStateless(payload: string): void {
151
+ let parsed: any;
152
+ try {
153
+ parsed = JSON.parse(payload);
154
+ } catch {
155
+ return;
156
+ }
157
+ const type: unknown = parsed?.type;
158
+ if (typeof type !== "string" || !type.startsWith("notify:")) return;
159
+
160
+ switch (type) {
161
+ case "notify:new": {
162
+ const { type: _t, ...rest } = parsed;
163
+ this.emit("new", rest as NotificationRecord);
164
+ break;
165
+ }
166
+ case "notify:history": {
167
+ this.resolveNext("notify:history", {
168
+ notifications: parsed.notifications ?? [],
169
+ });
170
+ break;
171
+ }
172
+ case "notify:read_update": {
173
+ const update: NotificationReadUpdate = {
174
+ recipient_id: parsed.recipient_id,
175
+ };
176
+ if (parsed.ids !== undefined) update.ids = parsed.ids;
177
+ if (parsed.all !== undefined) update.all = parsed.all;
178
+ this.emit("readUpdate", update);
179
+ break;
180
+ }
181
+ default:
182
+ break;
183
+ }
184
+ }
185
+ }
package/src/index.ts CHANGED
@@ -36,6 +36,22 @@ export type {
36
36
  DeviceTier,
37
37
  } from "./IdentityDoc.ts";
38
38
  export { DeviceRegistrationService } from "./DeviceRegistrationService.ts";
39
+ export { ChatClient } from "./ChatClient.ts";
40
+ export type { ChatClientTransport } from "./ChatClient.ts";
41
+ export { NotificationsClient } from "./NotificationsClient.ts";
42
+ export type {
43
+ ChatMessage,
44
+ ChatChannel,
45
+ ChatTypingEvent,
46
+ ChatReadReceipt,
47
+ ChatReadCursor,
48
+ SendChatMessageInput,
49
+ GetChatHistoryInput,
50
+ NotificationRecord,
51
+ NotificationReadUpdate,
52
+ CreateNotificationInput,
53
+ FetchNotificationsInput,
54
+ } from "./types.ts";
39
55
  export {
40
56
  generateMnemonic,
41
57
  validateMnemonic,
package/src/types.ts CHANGED
@@ -348,3 +348,89 @@ export interface DocEncryptionInfo {
348
348
  effective_mode: "none" | "cse" | "e2e";
349
349
  inherited_from?: string;
350
350
  }
351
+
352
+ // ── Chat (stateless channel) ─────────────────────────────────────────────────
353
+
354
+ export interface ChatMessage {
355
+ id: string;
356
+ channel: string;
357
+ sender_id: string;
358
+ sender_name?: string | null;
359
+ content: string;
360
+ created_at: number;
361
+ }
362
+
363
+ export interface ChatChannel {
364
+ channel: string;
365
+ label?: string | null;
366
+ last_message: ChatMessage;
367
+ unread_count: number;
368
+ }
369
+
370
+ export interface ChatTypingEvent {
371
+ channel: string;
372
+ sender_id: string;
373
+ sender_name?: string | null;
374
+ }
375
+
376
+ export interface ChatReadReceipt {
377
+ channel: string;
378
+ user_id: string;
379
+ last_read_at: number;
380
+ }
381
+
382
+ export interface ChatReadCursor {
383
+ user_id: string;
384
+ last_read_at: number;
385
+ }
386
+
387
+ export interface SendChatMessageInput {
388
+ channel: string;
389
+ content: string;
390
+ sender_name?: string;
391
+ }
392
+
393
+ export interface GetChatHistoryInput {
394
+ channel: string;
395
+ before?: number;
396
+ limit?: number;
397
+ }
398
+
399
+ // ── Notifications (stateless channel) ────────────────────────────────────────
400
+
401
+ export interface NotificationRecord {
402
+ id: string;
403
+ recipient_id: string;
404
+ notification_type: string;
405
+ title: string;
406
+ body: string;
407
+ icon?: string | null;
408
+ link?: string | null;
409
+ source_id?: string | null;
410
+ read: boolean;
411
+ created_at: number;
412
+ }
413
+
414
+ export interface NotificationReadUpdate {
415
+ /** Present when a specific set of notifications was marked read. */
416
+ ids?: string[];
417
+ /** Present when mark_all_read was called. */
418
+ all?: boolean;
419
+ recipient_id: string;
420
+ }
421
+
422
+ export interface CreateNotificationInput {
423
+ recipient_id: string;
424
+ notification_type?: string;
425
+ title: string;
426
+ body?: string;
427
+ icon?: string;
428
+ link?: string;
429
+ source_id?: string;
430
+ }
431
+
432
+ export interface FetchNotificationsInput {
433
+ before?: number;
434
+ limit?: number;
435
+ unread_only?: boolean;
436
+ }
@@ -77,11 +77,15 @@ interface PairRequestMsg {
77
77
 
78
78
  interface PairApprovedMsg {
79
79
  type: "pair-approved";
80
+ /** Approver's account-level Ed25519 public key (base64url). */
81
+ masterPublicKey?: string;
80
82
  }
81
83
 
82
84
  interface PairInviteCodeMsg {
83
85
  type: "pair-invite-code";
84
86
  code: string;
87
+ /** Approver's account-level Ed25519 public key (base64url). */
88
+ masterPublicKey?: string;
85
89
  }
86
90
 
87
91
  interface PairRejectedMsg {
@@ -171,8 +175,12 @@ export class DevicePairingChannel extends EventEmitter {
171
175
  /**
172
176
  * Approve the pending pairing request. Calls `client.addKey()` to
173
177
  * register Device B's public key, then notifies Device B.
178
+ *
179
+ * @param client Authenticated REST client.
180
+ * @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
181
+ * Sent to Device B so it can adopt the master's identity doc.
174
182
  */
175
- async approve(client: AbracadabraClient): Promise<PairingResult> {
183
+ async approve(client: AbracadabraClient, masterPublicKey?: string): Promise<PairingResult> {
176
184
  if (this.role !== "approver") {
177
185
  return { success: false, error: "Only the approver can approve" };
178
186
  }
@@ -192,7 +200,7 @@ export class DevicePairingChannel extends EventEmitter {
192
200
  x25519Key: req.x25519Key,
193
201
  });
194
202
 
195
- this.sendMessage({ type: "pair-approved" });
203
+ this.sendMessage({ type: "pair-approved", masterPublicKey });
196
204
  this._pendingRequest = null;
197
205
  this.emit("pairingComplete", { success: true } as PairingResult);
198
206
  return { success: true };
@@ -210,8 +218,12 @@ export class DevicePairingChannel extends EventEmitter {
210
218
  * Approve via server-side device invite. Creates a single-use invite code
211
219
  * and sends it to Device B over the E2EE channel. Device B redeems it
212
220
  * independently via HTTP — Device A can go offline after this.
221
+ *
222
+ * @param client Authenticated REST client.
223
+ * @param masterPublicKey Approver's account-level Ed25519 public key (base64url).
224
+ * Sent to Device B so it can adopt the master's identity doc.
213
225
  */
214
- async approveWithInvite(client: AbracadabraClient): Promise<PairingResult> {
226
+ async approveWithInvite(client: AbracadabraClient, masterPublicKey?: string): Promise<PairingResult> {
215
227
  if (this.role !== "approver") {
216
228
  return { success: false, error: "Only the approver can approve" };
217
229
  }
@@ -224,7 +236,7 @@ export class DevicePairingChannel extends EventEmitter {
224
236
 
225
237
  try {
226
238
  const { code } = await client.createDeviceInvite();
227
- this.sendMessage({ type: "pair-invite-code", code });
239
+ this.sendMessage({ type: "pair-invite-code", code, masterPublicKey });
228
240
  this._pendingRequest = null;
229
241
  this.emit("pairingComplete", { success: true } as PairingResult);
230
242
  return { success: true };
@@ -462,7 +474,7 @@ export class DevicePairingChannel extends EventEmitter {
462
474
 
463
475
  case "pair-approved":
464
476
  if (this.role !== "requester") return;
465
- this.emit("approved");
477
+ this.emit("approved", { masterPublicKey: msg.masterPublicKey });
466
478
  this.emit("pairingComplete", { success: true } as PairingResult);
467
479
  break;
468
480
 
@@ -477,7 +489,7 @@ export class DevicePairingChannel extends EventEmitter {
477
489
 
478
490
  case "pair-invite-code":
479
491
  if (this.role !== "requester") return;
480
- this.emit("inviteCode", msg.code);
492
+ this.emit("inviteCode", msg.code, msg.masterPublicKey);
481
493
  break;
482
494
  }
483
495
  }