@abraca/dabra 1.8.2 → 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.
Files changed (37) hide show
  1. package/dist/abracadabra-provider.cjs +12722 -9050
  2. package/dist/abracadabra-provider.cjs.map +1 -1
  3. package/dist/abracadabra-provider.esm.js +12683 -9061
  4. package/dist/abracadabra-provider.esm.js.map +1 -1
  5. package/dist/index.d.ts +1485 -118
  6. package/package.json +1 -1
  7. package/src/AbracadabraBaseProvider.ts +51 -2
  8. package/src/AbracadabraClient.ts +516 -66
  9. package/src/AbracadabraProvider.ts +22 -7
  10. package/src/AbracadabraWS.ts +1 -1
  11. package/src/ChatClient.ts +193 -113
  12. package/src/ContentManager.ts +228 -0
  13. package/src/CryptoIdentityKeystore.ts +3 -3
  14. package/src/DocConverters.ts +1862 -0
  15. package/src/DocKeyManager.ts +60 -12
  16. package/src/DocTypes.ts +628 -0
  17. package/src/DocUtils.ts +89 -0
  18. package/src/DocumentManager.ts +319 -0
  19. package/src/E2EAbracadabraProvider.ts +189 -0
  20. package/src/EncryptedChatClient.ts +173 -0
  21. package/src/EncryptedY.ts +2 -2
  22. package/src/FileBlobStore.ts +10 -0
  23. package/src/IdentityDoc.ts +25 -0
  24. package/src/MetaManager.ts +100 -0
  25. package/src/MnemonicKeyDerivation.ts +4 -4
  26. package/src/NotificationsClient.ts +120 -98
  27. package/src/OutgoingMessages/SubdocMessage.ts +2 -2
  28. package/src/RpcClient.ts +659 -0
  29. package/src/TreeManager.ts +473 -0
  30. package/src/TreeTimestamps.ts +28 -25
  31. package/src/index.ts +71 -1
  32. package/src/messageRecord.ts +121 -0
  33. package/src/types.ts +174 -16
  34. package/src/webrtc/AbracadabraWebRTC.ts +2 -2
  35. package/src/webrtc/DataChannelRouter.ts +2 -2
  36. package/src/webrtc/E2EEChannel.ts +3 -3
  37. 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 ─────────────────────────────────────────────────────────────
@@ -287,6 +287,16 @@ export class FileBlobStore extends EventEmitter {
287
287
  }
288
288
  }
289
289
 
290
+ /**
291
+ * Clear the 404 negative-cache entry for (docId, uploadId) so the next
292
+ * getBlobUrl() re-fetches from the server instead of short-circuiting.
293
+ * Use on explicit user retry or after reconnect, when the file may have
294
+ * become available since the last 404.
295
+ */
296
+ clearNotFound(docId: string, uploadId: string): void {
297
+ this._notFound.delete(this.blobKey(docId, uploadId));
298
+ }
299
+
290
300
  /** Revoke the object URL and remove the blob from cache. */
291
301
  async evictBlob(docId: string, uploadId: string): Promise<void> {
292
302
  const key = this.blobKey(docId, uploadId);
@@ -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 {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * MetaManager — read/write PageMeta on tree entries.
3
+ *
4
+ * Extracted from `mcp/tools/meta.ts` and `cli/commands/meta.ts`.
5
+ */
6
+ import type { PageMeta } from "./DocTypes.ts";
7
+ import { toPlain } from "./DocUtils.ts";
8
+ import type { DocumentManager } from "./DocumentManager.ts";
9
+
10
+ export interface DocumentMetaInfo {
11
+ id: string;
12
+ label: string;
13
+ type?: string;
14
+ meta: PageMeta;
15
+ }
16
+
17
+ export class MetaManager {
18
+ constructor(private dm: DocumentManager) {}
19
+
20
+ /** Read metadata for a document. Returns null if not found. */
21
+ get(docId: string): DocumentMetaInfo | null {
22
+ const treeMap = this.dm.getTreeMap();
23
+ if (!treeMap) return null;
24
+
25
+ const raw = treeMap.get(docId);
26
+ if (!raw) return null;
27
+
28
+ const entry = toPlain(raw) as Record<string, unknown>;
29
+ return {
30
+ id: docId,
31
+ label: (entry.label as string) || "Untitled",
32
+ type: entry.type as string | undefined,
33
+ meta: (entry.meta as PageMeta) ?? {},
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Merge fields into a document's metadata.
39
+ * Existing keys not in the update are preserved.
40
+ */
41
+ update(docId: string, meta: Partial<PageMeta>): void {
42
+ const treeMap = this.dm.getTreeMap();
43
+ if (!treeMap) throw new Error("Not connected");
44
+
45
+ const raw = treeMap.get(docId);
46
+ if (!raw) throw new Error(`Document ${docId} not found`);
47
+
48
+ const entry = toPlain(raw) as Record<string, unknown>;
49
+ treeMap.set(docId, {
50
+ ...entry,
51
+ meta: { ...((entry.meta as PageMeta) ?? {}), ...meta },
52
+ updatedAt: Date.now(),
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Replace all metadata on a document.
58
+ * This overwrites the entire meta object.
59
+ */
60
+ set(docId: string, meta: PageMeta): void {
61
+ const treeMap = this.dm.getTreeMap();
62
+ if (!treeMap) throw new Error("Not connected");
63
+
64
+ const raw = treeMap.get(docId);
65
+ if (!raw) throw new Error(`Document ${docId} not found`);
66
+
67
+ const entry = toPlain(raw) as Record<string, unknown>;
68
+ treeMap.set(docId, {
69
+ ...entry,
70
+ meta,
71
+ updatedAt: Date.now(),
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Clear specific metadata keys (set them to null/undefined).
77
+ */
78
+ clear(docId: string, keys: string[]): void {
79
+ const treeMap = this.dm.getTreeMap();
80
+ if (!treeMap) throw new Error("Not connected");
81
+
82
+ const raw = treeMap.get(docId);
83
+ if (!raw) throw new Error(`Document ${docId} not found`);
84
+
85
+ const entry = toPlain(raw) as Record<string, unknown>;
86
+ const existingMeta = ((entry.meta as PageMeta) ?? {}) as Record<
87
+ string,
88
+ unknown
89
+ >;
90
+ const updated = { ...existingMeta };
91
+ for (const key of keys) {
92
+ delete updated[key];
93
+ }
94
+ treeMap.set(docId, {
95
+ ...entry,
96
+ meta: updated,
97
+ updatedAt: Date.now(),
98
+ });
99
+ }
100
+ }
@@ -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
- * Typed client for the Abracadabra notifications feature.
42
+ * Thin façade over the `messages:inbox_*` stateless protocol.
20
43
  *
21
- * Emits:
22
- * - `new` → NotificationRecord (incoming notify:new broadcast)
23
- * - `readUpdate` NotificationReadUpdate (broadcast after mark_read / mark_all_read)
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
- // ── 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");
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: "notify:fetch",
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
- /** 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;
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>(type: string): Promise<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(type, entry);
123
- reject(new Error(`NotificationsClient: timeout waiting for ${type} response`));
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(type) ?? [];
135
+ const queue = this.pending.get(source) ?? [];
127
136
  queue.push(entry);
128
- this.pending.set(type, queue);
137
+ this.pending.set(source, queue);
129
138
  });
130
139
  }
131
140
 
132
- private removePending(type: string, entry: PendingResolver<any>): void {
133
- const queue = this.pending.get(type);
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(type);
146
+ if (queue.length === 0) this.pending.delete(source);
138
147
  }
139
148
 
140
- private resolveNext<T>(type: string, value: T): boolean {
141
- const queue = this.pending.get(type);
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(type);
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" || !type.startsWith("notify:")) return;
177
+ if (typeof type !== "string") return;
159
178
 
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;
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 { OutgoingMessageArguments } from "../types.ts";
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<OutgoingMessageArguments>) {
21
+ get(args: Partial<AbracadabraOutgoingMessageArguments>) {
22
22
  if (!args.documentName) {
23
23
  throw new Error("SubdocMessage requires `documentName` (parent id).");
24
24
  }