@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.
- package/dist/abracadabra-provider.cjs +355 -8
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +354 -9
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +208 -4
- package/package.json +1 -1
- package/src/AbracadabraProvider.ts +10 -0
- package/src/AbracadabraWS.ts +10 -2
- package/src/ChatClient.ts +234 -0
- package/src/IdentityDoc.ts +11 -0
- package/src/NotificationsClient.ts +185 -0
- package/src/index.ts +16 -0
- package/src/types.ts +86 -0
- package/src/webrtc/DevicePairingChannel.ts +18 -6
|
@@ -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
|
}
|