@abraca/dabra 1.9.1 → 2.0.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/abracadabra-provider.cjs +12680 -9133
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +12697 -9200
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +1426 -118
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +51 -2
- package/src/AbracadabraClient.ts +516 -66
- package/src/AbracadabraProvider.ts +22 -7
- package/src/AbracadabraWS.ts +1 -1
- package/src/ChatClient.ts +193 -113
- package/src/ContentManager.ts +80 -12
- package/src/CryptoIdentityKeystore.ts +3 -3
- package/src/DocConverters.ts +161 -6
- package/src/DocKeyManager.ts +60 -12
- package/src/DocTypes.ts +10 -0
- package/src/DocumentManager.ts +62 -85
- package/src/EncryptedChatClient.ts +173 -0
- package/src/EncryptedY.ts +2 -2
- package/src/IdentityDoc.ts +25 -0
- package/src/MnemonicKeyDerivation.ts +4 -4
- package/src/NotificationsClient.ts +120 -98
- package/src/OutgoingMessages/SubdocMessage.ts +2 -2
- package/src/RpcClient.ts +659 -0
- package/src/TreeManager.ts +61 -17
- package/src/TreeTimestamps.ts +28 -25
- package/src/index.ts +71 -1
- package/src/messageRecord.ts +121 -0
- package/src/types.ts +166 -16
- package/src/webrtc/AbracadabraWebRTC.ts +2 -2
- package/src/webrtc/DataChannelRouter.ts +2 -2
- package/src/webrtc/E2EEChannel.ts +3 -3
- package/src/webrtc/FileTransferChannel.ts +9 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EncryptedChatClient — `ChatClient` wrapper that transparently encrypts
|
|
3
|
+
* message content for channels in `e2e` mode and decrypts entries read
|
|
4
|
+
* back from the period doc's `messages` Y.Array.
|
|
5
|
+
*
|
|
6
|
+
* Threat model
|
|
7
|
+
* - Server cannot read message bodies (the `content` field is opaque
|
|
8
|
+
* ciphertext bytes, base64-encoded into the wire string).
|
|
9
|
+
* - Server CAN see `sender_id`, `channel_doc_id`, `period_id`, `ts`,
|
|
10
|
+
* `mentions[]` (kept cleartext for fan-out), `message_id`,
|
|
11
|
+
* and the `server_sig` it stamps. That envelope is the only thing
|
|
12
|
+
* the server inspects to do its job.
|
|
13
|
+
*
|
|
14
|
+
* Wire format
|
|
15
|
+
* `content` for an e2e message is the literal string
|
|
16
|
+
* `"e2e:1:<base64>"`
|
|
17
|
+
* where the decoded blob is `[nonce(12) || AES-GCM-ciphertext]` and
|
|
18
|
+
* the AES-256-GCM key is the channel's existing DocKey (the same one
|
|
19
|
+
* used for `EncryptedY` doc-level encryption — same threat boundary,
|
|
20
|
+
* no extra key derivation, identical envelope distribution).
|
|
21
|
+
*
|
|
22
|
+
* The `e2e:1:` sentinel is a strict prefix to disambiguate from
|
|
23
|
+
* cleartext content that happens to start with "{". Never unwrap a
|
|
24
|
+
* message whose content doesn't start with the sentinel — passing
|
|
25
|
+
* ciphertext from a server you don't trust through `decrypt` would
|
|
26
|
+
* open the door to malformed-input oracles.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { encryptField, decryptField } from "./EncryptedY.ts";
|
|
30
|
+
import { DocKeyManager } from "./DocKeyManager.ts";
|
|
31
|
+
import type { AbracadabraClient } from "./AbracadabraClient.ts";
|
|
32
|
+
import type { CryptoIdentityKeystore } from "./CryptoIdentityKeystore.ts";
|
|
33
|
+
import type {
|
|
34
|
+
ChatClient,
|
|
35
|
+
SendMessageInput,
|
|
36
|
+
SendMessageResult,
|
|
37
|
+
EditMessageInput,
|
|
38
|
+
} from "./ChatClient.ts";
|
|
39
|
+
|
|
40
|
+
const SENTINEL = "e2e:1:";
|
|
41
|
+
|
|
42
|
+
function toBase64(bytes: Uint8Array): string {
|
|
43
|
+
let s = "";
|
|
44
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!);
|
|
45
|
+
return btoa(s);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fromBase64(b64: string): Uint8Array {
|
|
49
|
+
const bin = atob(b64);
|
|
50
|
+
const out = new Uint8Array(bin.length);
|
|
51
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns true if `content` is wrapped by this module (i.e. an `e2e:1:`
|
|
57
|
+
* envelope). Cleartext content always returns false.
|
|
58
|
+
*/
|
|
59
|
+
export function isEncryptedContent(content: string): boolean {
|
|
60
|
+
return typeof content === "string" && content.startsWith(SENTINEL);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decrypt an `e2e:1:` envelope back to cleartext using the channel's
|
|
65
|
+
* DocKey. Returns null if the envelope is malformed (don't surface
|
|
66
|
+
* partial decryption — show the user a placeholder instead).
|
|
67
|
+
*/
|
|
68
|
+
export async function decryptChatContent(content: string, docKey: CryptoKey): Promise<string | null> {
|
|
69
|
+
if (!isEncryptedContent(content)) return content; // already cleartext
|
|
70
|
+
try {
|
|
71
|
+
const blob = fromBase64(content.slice(SENTINEL.length));
|
|
72
|
+
const plain = await decryptField(blob, docKey);
|
|
73
|
+
return new TextDecoder().decode(plain);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Encrypt cleartext into an `e2e:1:` envelope ready to send as the
|
|
81
|
+
* stateless `messages:send` `content` field.
|
|
82
|
+
*/
|
|
83
|
+
export async function encryptChatContent(content: string, docKey: CryptoKey): Promise<string> {
|
|
84
|
+
const blob = await encryptField(new TextEncoder().encode(content), docKey);
|
|
85
|
+
return SENTINEL + toBase64(blob);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Per-channel encryption-mode + DocKey resolver. Caches the encryption
|
|
90
|
+
* mode lookup for `cacheTtlMs`; the DocKey itself is cached inside
|
|
91
|
+
* `DocKeyManager`. Returns `null` for non-E2E channels.
|
|
92
|
+
*/
|
|
93
|
+
export class ChannelKeyResolver {
|
|
94
|
+
private modeCache = new Map<string, { mode: "none" | "cse" | "e2e"; fetchedAt: number }>();
|
|
95
|
+
private static readonly DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
private readonly client: AbracadabraClient,
|
|
99
|
+
private readonly docKeys: DocKeyManager,
|
|
100
|
+
private readonly keystore: CryptoIdentityKeystore,
|
|
101
|
+
private readonly options: { cacheTtlMs?: number } = {},
|
|
102
|
+
) {}
|
|
103
|
+
|
|
104
|
+
async modeFor(channelDocId: string): Promise<"none" | "cse" | "e2e"> {
|
|
105
|
+
const cached = this.modeCache.get(channelDocId);
|
|
106
|
+
const ttl = this.options.cacheTtlMs ?? ChannelKeyResolver.DEFAULT_TTL_MS;
|
|
107
|
+
if (cached && Date.now() - cached.fetchedAt < ttl) return cached.mode;
|
|
108
|
+
const info = await this.client.getDocEncryption(channelDocId);
|
|
109
|
+
const mode = info.effective_mode;
|
|
110
|
+
this.modeCache.set(channelDocId, { mode, fetchedAt: Date.now() });
|
|
111
|
+
return mode;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns the DocKey for an E2E channel, or null for cleartext channels
|
|
116
|
+
* (no envelope to fetch). Throws if the channel is E2E but no envelope
|
|
117
|
+
* exists for the current user — the caller should let the send fail
|
|
118
|
+
* loudly rather than fall back to cleartext, which would silently
|
|
119
|
+
* downgrade the threat model.
|
|
120
|
+
*/
|
|
121
|
+
async docKeyFor(channelDocId: string): Promise<CryptoKey | null> {
|
|
122
|
+
const mode = await this.modeFor(channelDocId);
|
|
123
|
+
if (mode !== "e2e") return null;
|
|
124
|
+
const key = await this.docKeys.getDocKey(channelDocId, this.client, this.keystore);
|
|
125
|
+
if (!key) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`EncryptedChatClient: no key envelope for channel ${channelDocId} — user is not provisioned for this E2E channel.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return key;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Drop the cached encryption mode for `channelDocId` (or all). */
|
|
134
|
+
clearCache(channelDocId?: string): void {
|
|
135
|
+
if (channelDocId) this.modeCache.delete(channelDocId);
|
|
136
|
+
else this.modeCache.clear();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Drop-in wrapper around an existing ChatClient that intercepts the
|
|
142
|
+
* write side (`send` / `edit`) and encrypts content for E2E channels.
|
|
143
|
+
* Reading is handled separately — the consumer pumps the period's Y.Array
|
|
144
|
+
* directly and calls `decryptChatContent` per record.
|
|
145
|
+
*
|
|
146
|
+
* Channels in `none` or `cse` mode are passed through untouched.
|
|
147
|
+
*/
|
|
148
|
+
export class EncryptedChatClient {
|
|
149
|
+
constructor(
|
|
150
|
+
private readonly inner: ChatClient,
|
|
151
|
+
private readonly resolver: ChannelKeyResolver,
|
|
152
|
+
) {}
|
|
153
|
+
|
|
154
|
+
async send(input: SendMessageInput): Promise<SendMessageResult> {
|
|
155
|
+
const docKey = await this.resolver.docKeyFor(input.channel_doc_id);
|
|
156
|
+
if (!docKey) return this.inner.send(input);
|
|
157
|
+
const ciphertext = await encryptChatContent(input.content, docKey);
|
|
158
|
+
return this.inner.send({ ...input, content: ciphertext });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async edit(input: EditMessageInput): Promise<void> {
|
|
162
|
+
const docKey = await this.resolver.docKeyFor(input.channel_doc_id);
|
|
163
|
+
if (!docKey) return this.inner.edit(input);
|
|
164
|
+
const ciphertext = await encryptChatContent(input.content, docKey);
|
|
165
|
+
return this.inner.edit({ ...input, content: ciphertext });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Pass-through methods that don't carry encryptable content.
|
|
169
|
+
delete(...args: Parameters<ChatClient["delete"]>) { return this.inner.delete(...args); }
|
|
170
|
+
markRead(...args: Parameters<ChatClient["markRead"]>) { return this.inner.markRead(...args); }
|
|
171
|
+
typing(...args: Parameters<ChatClient["typing"]>) { return this.inner.typing(...args); }
|
|
172
|
+
destroy() { this.inner.destroy(); }
|
|
173
|
+
}
|
package/src/EncryptedY.ts
CHANGED
|
@@ -16,7 +16,7 @@ import * as Y from "yjs";
|
|
|
16
16
|
export async function encryptField(value: Uint8Array, docKey: CryptoKey): Promise<Uint8Array> {
|
|
17
17
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
18
18
|
const ciphertext = new Uint8Array(
|
|
19
|
-
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, docKey, value),
|
|
19
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce as BufferSource }, docKey, value as BufferSource),
|
|
20
20
|
);
|
|
21
21
|
const result = new Uint8Array(12 + ciphertext.length);
|
|
22
22
|
result.set(nonce, 0);
|
|
@@ -28,7 +28,7 @@ export async function encryptField(value: Uint8Array, docKey: CryptoKey): Promis
|
|
|
28
28
|
export async function decryptField(ciphertext: Uint8Array, docKey: CryptoKey): Promise<Uint8Array> {
|
|
29
29
|
const nonce = ciphertext.slice(0, 12);
|
|
30
30
|
const ct = ciphertext.slice(12);
|
|
31
|
-
return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, docKey, ct));
|
|
31
|
+
return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce as BufferSource }, docKey, ct as BufferSource));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// ── EncryptedYMap ─────────────────────────────────────────────────────────────
|
package/src/IdentityDoc.ts
CHANGED
|
@@ -33,6 +33,31 @@ export function deriveIdentityDocId(publicKeyB64: string): string {
|
|
|
33
33
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Derives a deterministic UUID for the DM doc between two participants
|
|
38
|
+
* (independent of which side computes it). The two pubkeys are sorted
|
|
39
|
+
* lexicographically before hashing so both clients produce the same id —
|
|
40
|
+
* this lets `findOrCreateDmDoc` race-tolerantly create-or-claim the doc
|
|
41
|
+
* without coordinating; whichever client wins the create-with-id call
|
|
42
|
+
* is fine, the other gets a Conflict and reuses the existing id.
|
|
43
|
+
*
|
|
44
|
+
* @param pubkeyA Base64url-encoded Ed25519 public key (32 bytes).
|
|
45
|
+
* @param pubkeyB Base64url-encoded Ed25519 public key (32 bytes).
|
|
46
|
+
*/
|
|
47
|
+
export function deriveDmDocId(pubkeyA: string, pubkeyB: string): string {
|
|
48
|
+
const [first, second] = [pubkeyA, pubkeyB].sort();
|
|
49
|
+
const hash = sha256(
|
|
50
|
+
new TextEncoder().encode(`abracadabra:dm:${first}:${second}`),
|
|
51
|
+
);
|
|
52
|
+
const bytes = new Uint8Array(hash.buffer, hash.byteOffset, 16);
|
|
53
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
54
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
55
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
|
|
56
|
+
"",
|
|
57
|
+
);
|
|
58
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
37
62
|
|
|
38
63
|
export interface IdentityProfile {
|
|
@@ -142,8 +142,8 @@ export async function wrapSeed(
|
|
|
142
142
|
wrappingKeyBytes: Uint8Array,
|
|
143
143
|
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> {
|
|
144
144
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
145
|
-
const key = await crypto.subtle.importKey("raw", wrappingKeyBytes, "AES-GCM", false, ["encrypt"]);
|
|
146
|
-
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
|
|
145
|
+
const key = await crypto.subtle.importKey("raw", wrappingKeyBytes as BufferSource, "AES-GCM", false, ["encrypt"]);
|
|
146
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv as BufferSource }, key, seed as BufferSource);
|
|
147
147
|
return { ciphertext, iv };
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -161,7 +161,7 @@ export async function unwrapSeed(
|
|
|
161
161
|
iv: Uint8Array,
|
|
162
162
|
wrappingKeyBytes: Uint8Array,
|
|
163
163
|
): Promise<Uint8Array> {
|
|
164
|
-
const key = await crypto.subtle.importKey("raw", wrappingKeyBytes, "AES-GCM", false, ["decrypt"]);
|
|
165
|
-
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
|
164
|
+
const key = await crypto.subtle.importKey("raw", wrappingKeyBytes as BufferSource, "AES-GCM", false, ["decrypt"]);
|
|
165
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv as BufferSource }, key, ciphertext);
|
|
166
166
|
return new Uint8Array(plaintext);
|
|
167
167
|
}
|
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import EventEmitter from "./EventEmitter.ts";
|
|
2
|
-
import type {
|
|
3
|
-
CreateNotificationInput,
|
|
4
|
-
FetchNotificationsInput,
|
|
5
|
-
NotificationReadUpdate,
|
|
6
|
-
NotificationRecord,
|
|
7
|
-
} from "./types.ts";
|
|
8
2
|
import type { ChatClientTransport } from "./ChatClient.ts";
|
|
9
3
|
|
|
10
4
|
type PendingResolver<T> = {
|
|
@@ -15,29 +9,79 @@ type PendingResolver<T> = {
|
|
|
15
9
|
|
|
16
10
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
17
11
|
|
|
12
|
+
/** Per-user inbox doc entry. Mirrors `InboxEntry` on the server. */
|
|
13
|
+
export interface InboxEntry {
|
|
14
|
+
id: string;
|
|
15
|
+
/** "mention" | "reply" | "dm" | "system". */
|
|
16
|
+
kind: string;
|
|
17
|
+
channel_doc_id: string;
|
|
18
|
+
message_id?: string | null;
|
|
19
|
+
sender_id?: string | null;
|
|
20
|
+
sender_name?: string | null;
|
|
21
|
+
/** Empty for E2E channels (server can't read body). Filled otherwise. */
|
|
22
|
+
preview?: string | null;
|
|
23
|
+
ts: number;
|
|
24
|
+
/** Synthesized client-side from the inbox doc's `read` Y.Map. */
|
|
25
|
+
read: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FetchInboxInput {
|
|
29
|
+
before?: number;
|
|
30
|
+
limit?: number;
|
|
31
|
+
unread_only?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MarkReadInput {
|
|
35
|
+
id?: string;
|
|
36
|
+
ids?: string[];
|
|
37
|
+
source_id?: string;
|
|
38
|
+
all?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
18
41
|
/**
|
|
19
|
-
*
|
|
42
|
+
* Thin façade over the `messages:inbox_*` stateless protocol.
|
|
20
43
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
44
|
+
* Per-user notifications live as Y.Array entries on the user's inbox doc
|
|
45
|
+
* (`kind = "inbox"`, owned by the user, child of the server root, server-only
|
|
46
|
+
* writer). Real-time observation of new entries is a Y.Array observer the
|
|
47
|
+
* consumer attaches via `AbracadabraProvider` on the inbox doc.
|
|
48
|
+
*
|
|
49
|
+
* This client handles only the request/response layer:
|
|
50
|
+
* - `fetch()` issues `messages:inbox_fetch`, returns the entry list.
|
|
51
|
+
* - `markRead()` issues `messages:inbox_mark_read` (id / ids / source_id /
|
|
52
|
+
* all variants).
|
|
53
|
+
*
|
|
54
|
+
* For incoming notification observation, open an AbracadabraProvider on the
|
|
55
|
+
* inbox doc and observe its `entries` Y.Array directly. The dashboard's
|
|
56
|
+
* `useNotifications` composable does this.
|
|
24
57
|
*/
|
|
25
58
|
export class NotificationsClient extends EventEmitter {
|
|
26
59
|
private readonly provider: ChatClientTransport;
|
|
27
60
|
private readonly responseTimeoutMs: number;
|
|
28
61
|
private readonly pending: Map<string, PendingResolver<any>[]> = new Map();
|
|
29
62
|
private readonly boundOnStateless: (data: { payload: string }) => void;
|
|
63
|
+
private readonly boundOnServerError: (data: {
|
|
64
|
+
source: string;
|
|
65
|
+
code: string;
|
|
66
|
+
message: string;
|
|
67
|
+
meta?: unknown;
|
|
68
|
+
}) => void;
|
|
30
69
|
|
|
31
70
|
constructor(provider: ChatClientTransport, options?: { responseTimeoutMs?: number }) {
|
|
32
71
|
super();
|
|
33
72
|
this.provider = provider;
|
|
34
73
|
this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
35
74
|
this.boundOnStateless = (data) => this.handleStateless(data.payload);
|
|
75
|
+
this.boundOnServerError = (data) => this.handleServerError(data);
|
|
36
76
|
this.provider.on("stateless", this.boundOnStateless);
|
|
77
|
+
// BaseProvider emits `serverError` for `{type:"error",source,code}` frames
|
|
78
|
+
// instead of `stateless`. Subscribe so inbox request rejections surface.
|
|
79
|
+
this.provider.on("serverError", this.boundOnServerError);
|
|
37
80
|
}
|
|
38
81
|
|
|
39
82
|
destroy(): void {
|
|
40
83
|
this.provider.off("stateless", this.boundOnStateless);
|
|
84
|
+
this.provider.off("serverError", this.boundOnServerError);
|
|
41
85
|
for (const queue of this.pending.values()) {
|
|
42
86
|
for (const p of queue) {
|
|
43
87
|
clearTimeout(p.timer);
|
|
@@ -48,34 +92,12 @@ export class NotificationsClient extends EventEmitter {
|
|
|
48
92
|
this.removeAllListeners();
|
|
49
93
|
}
|
|
50
94
|
|
|
51
|
-
|
|
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");
|
|
95
|
+
/** Fetch a slice of the calling user's inbox. Resolves with the entries. */
|
|
96
|
+
fetch(input: FetchInboxInput = {}): Promise<{ entries: InboxEntry[] }> {
|
|
97
|
+
const promise = this.enqueue<{ entries: InboxEntry[] }>("messages:inbox_history");
|
|
76
98
|
this.provider.sendStateless(
|
|
77
99
|
JSON.stringify({
|
|
78
|
-
type: "
|
|
100
|
+
type: "messages:inbox_fetch",
|
|
79
101
|
...(input.before !== undefined ? { before: input.before } : {}),
|
|
80
102
|
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
|
81
103
|
...(input.unread_only !== undefined ? { unread_only: input.unread_only } : {}),
|
|
@@ -84,69 +106,66 @@ export class NotificationsClient extends EventEmitter {
|
|
|
84
106
|
return promise;
|
|
85
107
|
}
|
|
86
108
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Mark inbox entries read. One of `id`, `ids`, `source_id`, or `all`.
|
|
111
|
+
* Returns when the server has acked.
|
|
112
|
+
*/
|
|
113
|
+
markRead(input: MarkReadInput): Promise<void> {
|
|
114
|
+
const body: Record<string, unknown> = { type: "messages:inbox_mark_read" };
|
|
115
|
+
if (input.all === true) body.all = true;
|
|
116
|
+
else if (input.source_id !== undefined) body.source_id = input.source_id;
|
|
117
|
+
else if (input.ids !== undefined) body.ids = input.ids;
|
|
118
|
+
else if (input.id !== undefined) body.id = input.id;
|
|
119
|
+
else return Promise.reject(new Error("markRead: one of id/ids/source_id/all required"));
|
|
120
|
+
|
|
121
|
+
const promise = this.enqueue<void>("messages:inbox_mark_read");
|
|
92
122
|
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;
|
|
123
|
+
return promise;
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
// ── Internals ─────────────────────────────────────────────────────────────
|
|
118
127
|
|
|
119
|
-
private enqueue<T>(
|
|
128
|
+
private enqueue<T>(source: string): Promise<T> {
|
|
120
129
|
return new Promise<T>((resolve, reject) => {
|
|
121
130
|
const timer = setTimeout(() => {
|
|
122
|
-
this.removePending(
|
|
123
|
-
reject(new Error(`NotificationsClient: timeout waiting for ${
|
|
131
|
+
this.removePending(source, entry);
|
|
132
|
+
reject(new Error(`NotificationsClient: timeout waiting for ${source}`));
|
|
124
133
|
}, this.responseTimeoutMs);
|
|
125
134
|
const entry: PendingResolver<T> = { resolve, reject, timer };
|
|
126
|
-
const queue = this.pending.get(
|
|
135
|
+
const queue = this.pending.get(source) ?? [];
|
|
127
136
|
queue.push(entry);
|
|
128
|
-
this.pending.set(
|
|
137
|
+
this.pending.set(source, queue);
|
|
129
138
|
});
|
|
130
139
|
}
|
|
131
140
|
|
|
132
|
-
private removePending(
|
|
133
|
-
const queue = this.pending.get(
|
|
141
|
+
private removePending(source: string, entry: PendingResolver<any>): void {
|
|
142
|
+
const queue = this.pending.get(source);
|
|
134
143
|
if (!queue) return;
|
|
135
144
|
const idx = queue.indexOf(entry);
|
|
136
145
|
if (idx >= 0) queue.splice(idx, 1);
|
|
137
|
-
if (queue.length === 0) this.pending.delete(
|
|
146
|
+
if (queue.length === 0) this.pending.delete(source);
|
|
138
147
|
}
|
|
139
148
|
|
|
140
|
-
private resolveNext<T>(
|
|
141
|
-
const queue = this.pending.get(
|
|
149
|
+
private resolveNext<T>(source: string, value: T): boolean {
|
|
150
|
+
const queue = this.pending.get(source);
|
|
142
151
|
if (!queue || queue.length === 0) return false;
|
|
143
152
|
const next = queue.shift()!;
|
|
144
|
-
if (queue.length === 0) this.pending.delete(
|
|
153
|
+
if (queue.length === 0) this.pending.delete(source);
|
|
145
154
|
clearTimeout(next.timer);
|
|
146
155
|
next.resolve(value);
|
|
147
156
|
return true;
|
|
148
157
|
}
|
|
149
158
|
|
|
159
|
+
private rejectNext(source: string, err: Error): boolean {
|
|
160
|
+
const queue = this.pending.get(source);
|
|
161
|
+
if (!queue || queue.length === 0) return false;
|
|
162
|
+
const next = queue.shift()!;
|
|
163
|
+
if (queue.length === 0) this.pending.delete(source);
|
|
164
|
+
clearTimeout(next.timer);
|
|
165
|
+
next.reject(err);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
150
169
|
private handleStateless(payload: string): void {
|
|
151
170
|
let parsed: any;
|
|
152
171
|
try {
|
|
@@ -155,31 +174,34 @@ export class NotificationsClient extends EventEmitter {
|
|
|
155
174
|
return;
|
|
156
175
|
}
|
|
157
176
|
const type: unknown = parsed?.type;
|
|
158
|
-
if (typeof type !== "string"
|
|
177
|
+
if (typeof type !== "string") return;
|
|
159
178
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
default:
|
|
182
|
-
break;
|
|
179
|
+
if (type === "messages:inbox_history") {
|
|
180
|
+
this.resolveNext("messages:inbox_history", {
|
|
181
|
+
entries: parsed.entries ?? [],
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (type === "messages:ok" && parsed.source === "messages:inbox_mark_read") {
|
|
186
|
+
this.resolveNext("messages:inbox_mark_read", undefined);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private handleServerError(data: {
|
|
192
|
+
source: string;
|
|
193
|
+
code: string;
|
|
194
|
+
message: string;
|
|
195
|
+
meta?: unknown;
|
|
196
|
+
}): void {
|
|
197
|
+
if (data.source !== "messages:inbox_fetch" && data.source !== "messages:inbox_mark_read") {
|
|
198
|
+
return;
|
|
183
199
|
}
|
|
200
|
+
const key =
|
|
201
|
+
data.source === "messages:inbox_fetch" ? "messages:inbox_history" : data.source;
|
|
202
|
+
this.rejectNext(
|
|
203
|
+
key,
|
|
204
|
+
new Error(`${data.code ?? "error"}: ${data.message ?? "inbox request failed"}`),
|
|
205
|
+
);
|
|
184
206
|
}
|
|
185
207
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeVarString, writeVarUint } from "lib0/encoding";
|
|
2
|
-
import type {
|
|
2
|
+
import type { AbracadabraOutgoingMessageArguments } from "../types.ts";
|
|
3
3
|
import { MessageType } from "../types.ts";
|
|
4
4
|
import { OutgoingMessage } from "../OutgoingMessage.ts";
|
|
5
5
|
|
|
@@ -18,7 +18,7 @@ export class SubdocMessage extends OutgoingMessage {
|
|
|
18
18
|
|
|
19
19
|
description = "SubdocRegistration";
|
|
20
20
|
|
|
21
|
-
get(args: Partial<
|
|
21
|
+
get(args: Partial<AbracadabraOutgoingMessageArguments>) {
|
|
22
22
|
if (!args.documentName) {
|
|
23
23
|
throw new Error("SubdocMessage requires `documentName` (parent id).");
|
|
24
24
|
}
|