@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,121 @@
1
+ /**
2
+ * Decode + fold message records from a period doc's `messages` Y.Array
3
+ * into the flat `ChatMessage[]` shape the dashboard renders.
4
+ *
5
+ * The server appends three kinds of records to the array (see
6
+ * `abracadabra-rs/crates/abracadabra/src/messages.rs`):
7
+ *
8
+ * - `record_kind: "message"` — a real message (default kind).
9
+ * - `record_kind: "edit"` — an updated version of `target_id`.
10
+ * - `record_kind: "tombstone"` — `target_id` is deleted.
11
+ *
12
+ * Folding rules:
13
+ * - For each `target_id`, apply edits in `ts` order; the *last* edit's
14
+ * content wins. The original message's id, sender_id, and ts stay.
15
+ * `editedAt` is the latest edit's ts.
16
+ * - Any target_id with a tombstone is dropped from the output entirely.
17
+ * - Records without record_kind default to "message" (back-compat).
18
+ *
19
+ * Cross-period folding: edits and tombstones can in principle target a
20
+ * message in a *prior* period (e.g. user edits a message that lived on
21
+ * the previous period after rollover). Callers that paginate across
22
+ * periods should fold the joined list, not each period independently.
23
+ */
24
+
25
+ export interface MessageRecord {
26
+ record_kind?: 'message' | 'edit' | 'tombstone'
27
+ id: string
28
+ target_id?: string
29
+ sender_id: string
30
+ channel_doc_id: string
31
+ period_id: string
32
+ ts: number
33
+ content: string
34
+ mentions?: string[]
35
+ reply_to?: string | null
36
+ sig?: string
37
+ server_sig?: string
38
+ }
39
+
40
+ export interface FoldedMessage {
41
+ id: string
42
+ channel: string
43
+ senderId: string
44
+ senderName?: string
45
+ content: string
46
+ createdAt: number
47
+ /** Set if the message has been edited at least once. */
48
+ editedAt?: number
49
+ /** Reply target message id, if any. */
50
+ replyTo?: string
51
+ }
52
+
53
+ /**
54
+ * Fold an ordered list of records (oldest-first) into the visible
55
+ * messages a UI should render. Records may come from one period or
56
+ * across multiple periods concatenated in time order.
57
+ */
58
+ export function foldRecords(records: MessageRecord[]): FoldedMessage[] {
59
+ const tombstoned = new Set<string>()
60
+ for (const r of records) {
61
+ if (r.record_kind === 'tombstone' && r.target_id) tombstoned.add(r.target_id)
62
+ }
63
+
64
+ const base = new Map<string, FoldedMessage>()
65
+ for (const r of records) {
66
+ const kind = r.record_kind ?? 'message'
67
+ if (kind !== 'message') continue
68
+ if (tombstoned.has(r.id)) continue
69
+ base.set(r.id, {
70
+ id: r.id,
71
+ channel: r.channel_doc_id,
72
+ senderId: r.sender_id,
73
+ content: r.content,
74
+ createdAt: r.ts,
75
+ ...(r.reply_to ? { replyTo: r.reply_to } : {}),
76
+ })
77
+ }
78
+
79
+ for (const r of records) {
80
+ if (r.record_kind !== 'edit' || !r.target_id) continue
81
+ if (tombstoned.has(r.target_id)) continue
82
+ const original = base.get(r.target_id)
83
+ if (!original) continue
84
+ original.content = r.content
85
+ original.editedAt = r.ts
86
+ }
87
+
88
+ return [...base.values()].sort((a, b) => a.createdAt - b.createdAt)
89
+ }
90
+
91
+ /**
92
+ * Decode whatever the Y.Array stored for an entry into a MessageRecord.
93
+ * Note: the server stamps `ts` server-side via `Any::BigInt(now_ts_ms())`,
94
+ * so on the wire it round-trips as a JS BigInt — `typeof` is `'bigint'`,
95
+ * not `'number'`. Accept both and coerce.
96
+ */
97
+ export function recordFromYAny(any: unknown): MessageRecord | null {
98
+ if (!any || typeof any !== 'object') return null
99
+ const o = any as Record<string, unknown>
100
+ const tsRaw = o.ts
101
+ const tsIsValid = typeof tsRaw === 'number' || typeof tsRaw === 'bigint'
102
+ if (typeof o.id !== 'string' || typeof o.sender_id !== 'string'
103
+ || typeof o.channel_doc_id !== 'string' || !tsIsValid) {
104
+ return null
105
+ }
106
+ const ts = typeof tsRaw === 'bigint' ? Number(tsRaw) : (tsRaw as number)
107
+ return {
108
+ record_kind: typeof o.record_kind === 'string' ? o.record_kind as MessageRecord['record_kind'] : 'message',
109
+ id: o.id,
110
+ target_id: typeof o.target_id === 'string' ? o.target_id : undefined,
111
+ sender_id: o.sender_id,
112
+ channel_doc_id: o.channel_doc_id,
113
+ period_id: typeof o.period_id === 'string' ? o.period_id : '',
114
+ ts,
115
+ content: typeof o.content === 'string' ? o.content : '',
116
+ mentions: Array.isArray(o.mentions) ? o.mentions as string[] : [],
117
+ reply_to: typeof o.reply_to === 'string' ? o.reply_to : null,
118
+ sig: typeof o.sig === 'string' ? o.sig : undefined,
119
+ server_sig: typeof o.server_sig === 'string' ? o.server_sig : undefined,
120
+ }
121
+ }
package/src/types.ts CHANGED
@@ -124,6 +124,14 @@ export type onStatelessParameters = {
124
124
  payload: string;
125
125
  };
126
126
 
127
+ /** Fired by E2EAbracadabraProvider when the server acknowledges an E2E
128
+ * client-side compaction (via the `snapshot:compacted` stateless broadcast). */
129
+ export type onCompactedParameters = {
130
+ docId: string;
131
+ /** User id that performed the compaction; undefined if payload was malformed. */
132
+ by: string | undefined;
133
+ };
134
+
127
135
  export type onServerErrorParameters = {
128
136
  source: string;
129
137
  code: string;
@@ -175,20 +183,68 @@ export interface UserProfile {
175
183
  role: string;
176
184
  /** Account-level Ed25519 public key (base64url). Canonical user identity. */
177
185
  publicKey: string | null;
186
+ /**
187
+ * Whether the user has a password set. `false` for key-based soft
188
+ * identities until they opt in via {@link AbracadabraClient.setPassword}.
189
+ * Drives the UI's "Set password" vs "Change password" branching.
190
+ */
191
+ hasPassword?: boolean;
178
192
  }
179
193
 
180
194
  export interface DocumentMeta {
181
195
  id: string;
182
196
  parent_id: string | null;
197
+ /**
198
+ * Renderer hint — `"kanban"`, `"checklist"`, `"outline"`, `"graph"`,
199
+ * `"sheet"`, `"mindmap"`, `"doc"`, etc. Drives the dashboard's choice of
200
+ * Vue component for this doc. Orthogonal to {@link kind}.
201
+ */
183
202
  doc_type?: string | null;
184
203
  label?: string | null;
185
204
  description?: string | null;
186
205
  public_access?: string | null;
187
206
  owner_id?: string | null;
188
- is_hub?: boolean;
207
+ /**
208
+ * Structural role hint — `"server"` for the reserved server-root doc,
209
+ * `"space"` for top-level workspace containers, `null`/absent otherwise.
210
+ * Distinct from {@link doc_type}: a Space (`kind: "space"`) can contain
211
+ * many Kanban boards (each `doc_type: "kanban"`).
212
+ */
213
+ kind?: string | null;
189
214
  updated_at?: number | null;
190
215
  }
191
216
 
217
+ /**
218
+ * Well-known structural roles for the {@link DocumentMeta.kind} field. These
219
+ * are conventions only — the server treats `kind` as an opaque string. Use
220
+ * these constants instead of string literals to avoid typos.
221
+ */
222
+ export const Kind = {
223
+ /** The reserved server root document. There is exactly one per server. */
224
+ Server: "server",
225
+ /** A top-level workspace container — what the dashboard calls a "Space". */
226
+ Space: "space",
227
+ /** A regular content page rendered by the editor. */
228
+ Page: "page",
229
+ /** A group chat container under a Space. */
230
+ Channel: "channel",
231
+ /** A direct-message container at the server root with two-member permissions. */
232
+ Dm: "dm",
233
+ /** A child of channel/dm holding the messages Y.Array. */
234
+ ChannelPeriod: "channel-period",
235
+ /** A per-user inbox doc holding inbox entries; child of server root. */
236
+ Inbox: "inbox",
237
+ } as const;
238
+ export type Kind = typeof Kind[keyof typeof Kind];
239
+
240
+ /**
241
+ * Hardcoded UUID of the reserved server root document. Every doc in the tree
242
+ * chains up to this row, so a permission grant on this doc cascades into
243
+ * every descendant. Mirrors the constant in the Rust crate
244
+ * (`crate::types::SERVER_ROOT_ID_STR`).
245
+ */
246
+ export const SERVER_ROOT_ID = "00000000-0000-0000-0000-000000000000";
247
+
192
248
  export interface UploadMeta {
193
249
  id: string;
194
250
  doc_id: string;
@@ -242,10 +298,30 @@ export interface SnapshotMeta {
242
298
  label?: string | null;
243
299
  created_by?: string | null;
244
300
  created_at: number;
301
+ /** Number of uploads referenced by this snapshot's CRDT state.
302
+ * Always 0 on servers older than the `snapshot_files` migration, or for
303
+ * snapshots created before that migration ran (until the operator runs
304
+ * `POST /admin/snapshots/backfill-refs`). */
305
+ file_count?: number;
306
+ /** Signed byte delta vs. the previous-version snapshot for this doc.
307
+ * `null` for the first snapshot of a doc (or when the previous version
308
+ * has been pruned). Lets the UI render `+12 KB` / `−3 KB`. */
309
+ delta_bytes?: number | null;
310
+ }
311
+
312
+ export interface SnapshotFileEntry {
313
+ id: string;
314
+ doc_id: string;
315
+ filename: string;
316
+ mime_type?: string | null;
317
+ size?: number | null;
245
318
  }
246
319
 
247
320
  export interface SnapshotData extends SnapshotMeta {
248
321
  data: string;
322
+ /** Populated only when fetched with `?include=files`. Each entry is an
323
+ * upload referenced by this snapshot's CRDT state. */
324
+ files?: SnapshotFileEntry[];
249
325
  }
250
326
 
251
327
  export interface SnapshotCreateResult {
@@ -277,10 +353,23 @@ export interface ServerInfo {
277
353
  version?: string;
278
354
  /** Hocuspocus wire protocol version (currently 2). */
279
355
  protocol_version?: number;
280
- /** Entry-point document ID advertised by the server, if configured. */
281
- index_doc_id?: string;
282
- /** Default role assigned to users without explicit permissions. */
283
- default_role?: string;
356
+ /**
357
+ * The reserved server root document id. Every doc in the tree chains up
358
+ * to this row; granting Admin here cascades to every doc. Hardcoded by
359
+ * the server — clients can also use {@link SERVER_ROOT_ID} directly.
360
+ */
361
+ root_doc_id?: string;
362
+ /**
363
+ * Server-wide access policy. Drives client-side decisions like "show the
364
+ * sign-up CTA?" (anonymous mode) or "warn before publishing?" (anonymous
365
+ * grants).
366
+ */
367
+ access?: {
368
+ /** `"none"` rejects unauthenticated requests; `"observer"` allows public read. */
369
+ anonymous?: "none" | "observer";
370
+ /** Server-wide floor for authed users with no explicit grant. */
371
+ authenticated?: "none" | "observer" | "viewer" | "editor";
372
+ };
284
373
  /** Enabled auth methods (e.g. ["crypto", "jwt"]). */
285
374
  auth_methods?: string[];
286
375
  /** Whether open registration is enabled. */
@@ -289,6 +378,13 @@ export interface ServerInfo {
289
378
  invite_only?: boolean;
290
379
  /** Server encryption configuration. */
291
380
  encryption?: { default_mode?: string; minimum_mode?: string };
381
+ /**
382
+ * Ed25519 public key (base64url, no padding) the server uses to sign every
383
+ * accepted `messages:*` record (`server_sig`). Clients can verify message
384
+ * placement / ordering by checking each record's `server_sig` against this
385
+ * key over the canonical `{ msg_id, period_id, ts, client_sig }` payload.
386
+ */
387
+ messages_signer_pubkey?: string;
292
388
  }
293
389
 
294
390
  // ── Search ───────────────────────────────────────────────────────────────────
@@ -299,21 +395,83 @@ export interface SearchResult {
299
395
  score: number;
300
396
  }
301
397
 
302
- // ── Spaces ───────────────────────────────────────────────────────────────────
398
+ /**
399
+ * A single hit returned by the server's full-text search endpoint
400
+ * (`GET /docs/search`). Distinct from {@link SearchResult}, which is the
401
+ * client-side trigram index in `SearchIndex.ts`.
402
+ *
403
+ * `snippet` is server-rendered HTML with `<mark>` wrappers around the
404
+ * matched tokens — safe to inject after standard sanitization. `rank` is
405
+ * a backend-specific score (SQLite bm25 vs Postgres ts_rank); the only
406
+ * guarantee is that hits arrive in best-first order.
407
+ */
408
+ export interface DocSearchHit {
409
+ doc_id: string;
410
+ parent_id: string | null;
411
+ label: string | null;
412
+ kind: string | null;
413
+ /** HTML-safe snippet with `<mark>` markers around the matched tokens. */
414
+ snippet: string;
415
+ rank: number;
416
+ }
417
+
418
+ // ── Audit log (admin) ────────────────────────────────────────────────────────
303
419
 
304
- export interface SpaceMeta {
420
+ /**
421
+ * A single row from `GET /admin/audit`. Mirrors the server's audit log
422
+ * row format (see `audit::AuditLogRow`). `metadata` is the parsed JSON
423
+ * object the server stored, or `null` if the row had no metadata. Each
424
+ * row carries an internal `prev_hash` / `row_hash` chain that admins
425
+ * verify with {@link AuditVerifyResult}.
426
+ */
427
+ export interface AuditLogEntry {
305
428
  id: string;
306
- doc_id: string;
307
- name: string;
308
- description: string | null;
309
- visibility: "public" | "private" | "invite";
310
- is_hub: boolean;
311
- owner_id: string | null;
312
- created_at: number;
313
- updated_at: number;
314
- public_access?: string | null;
429
+ ts: number;
430
+ event_type: string;
431
+ actor_user_id: string | null;
432
+ actor_ip: string | null;
433
+ request_id: string | null;
434
+ target_type: string | null;
435
+ target_id: string | null;
436
+ metadata: Record<string, unknown> | null;
437
+ outcome: string;
438
+ }
439
+
440
+ /** Filters for `GET /admin/audit`. All optional and AND-combined. */
441
+ export interface AuditQueryOpts {
442
+ event_type?: string;
443
+ actor_user_id?: string;
444
+ target_type?: string;
445
+ target_id?: string;
446
+ since_ts?: number;
447
+ until_ts?: number;
448
+ limit?: number;
449
+ offset?: number;
315
450
  }
316
451
 
452
+ /** Response of `GET /admin/audit/verify`. `status: "broken"` carries `break`. */
453
+ export interface AuditVerifyResult {
454
+ status: "ok" | "broken";
455
+ message?: string;
456
+ break?: { rows_checked: number; row_id: string; reason: string };
457
+ }
458
+
459
+ // ── Readiness ────────────────────────────────────────────────────────────────
460
+
461
+ /** Response of `GET /readyz`. HTTP 200 when ready, 503 when not. */
462
+ export interface ReadyzStatus {
463
+ status: "ready" | "unready";
464
+ version: string;
465
+ checks: { database: "ok" | "error" };
466
+ }
467
+
468
+ // ── Spaces ───────────────────────────────────────────────────────────────────
469
+ //
470
+ // Spaces are documents whose `parent_id` is the server root and whose `kind`
471
+ // is `"space"`. Listing them is `client.listSpaces()`; they come back as
472
+ // regular `DocumentMeta` objects. Visibility is encoded in `public_access`:
473
+ // `null` (or `"none"`) = private, `"observer"` = public read-only.
474
+
317
475
  // ── Invites ──────────────────────────────────────────────────────────────────
318
476
 
319
477
  export interface InviteRow {
@@ -527,7 +527,7 @@ export class AbracadabraWebRTC extends EventEmitter {
527
527
  pendingKeyExchangeChannel = channel;
528
528
  return;
529
529
  }
530
- channel.send(e2ee.getKeyExchangeMessage());
530
+ channel.send(e2ee.getKeyExchangeMessage() as unknown as ArrayBuffer);
531
531
  });
532
532
 
533
533
  this.resolveE2ee().then(async (identity) => {
@@ -541,7 +541,7 @@ export class AbracadabraWebRTC extends EventEmitter {
541
541
 
542
542
  // Drain buffered key-exchange channel open
543
543
  if (pendingKeyExchangeChannel) {
544
- pendingKeyExchangeChannel.send(e2ee.getKeyExchangeMessage());
544
+ pendingKeyExchangeChannel.send(e2ee.getKeyExchangeMessage() as unknown as ArrayBuffer);
545
545
  }
546
546
  // Drain buffered messages
547
547
  for (const msg of pendingMessages) {
@@ -100,9 +100,9 @@ export class DataChannelRouter extends EventEmitter {
100
100
 
101
101
  if (this.encryptor?.isEstablished && !this.plaintextChannels.has(name)) {
102
102
  const encrypted = await this.encryptor.encrypt(data);
103
- channel.send(encrypted);
103
+ channel.send(encrypted as unknown as ArrayBuffer);
104
104
  } else {
105
- channel.send(data);
105
+ channel.send(data as unknown as ArrayBuffer);
106
106
  }
107
107
  return true;
108
108
  }
@@ -98,7 +98,7 @@ export class E2EEChannel extends EventEmitter {
98
98
 
99
99
  this.sessionKey = await crypto.subtle.importKey(
100
100
  "raw",
101
- keyBytes,
101
+ keyBytes as BufferSource,
102
102
  { name: "AES-GCM" },
103
103
  false,
104
104
  ["encrypt", "decrypt"],
@@ -130,9 +130,9 @@ export class E2EEChannel extends EventEmitter {
130
130
  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
131
131
  const ciphertext = new Uint8Array(
132
132
  await crypto.subtle.encrypt(
133
- { name: "AES-GCM", iv: nonce },
133
+ { name: "AES-GCM", iv: nonce as BufferSource },
134
134
  this.sessionKey,
135
- plaintext,
135
+ plaintext as BufferSource,
136
136
  ),
137
137
  );
138
138
 
@@ -45,6 +45,13 @@ export class FileTransferHandle extends EventEmitter {
45
45
  _setStatus(s: FileTransferStatus): void {
46
46
  this.status = s;
47
47
  }
48
+
49
+ /** @internal — public alias for the protected emit so the parent
50
+ * `FileTransferChannel` (a different class instance) can dispatch events
51
+ * on this handle. Protected access is by-class, not by-hierarchy. */
52
+ _emit(event: string, ...args: unknown[]): void {
53
+ this.emit(event, ...args);
54
+ }
48
55
  }
49
56
 
50
57
  interface ReceiveState {
@@ -102,7 +109,7 @@ export class FileTransferChannel extends EventEmitter {
102
109
  const channel = this.router.getChannel(CHANNEL_NAMES.FILE_TRANSFER);
103
110
  if (!channel || channel.readyState !== "open") {
104
111
  handle._setStatus("error");
105
- handle.emit("error", new Error("File transfer channel not open"));
112
+ handle._emit("error", new Error("File transfer channel not open"));
106
113
  return handle;
107
114
  }
108
115
 
@@ -174,7 +181,7 @@ export class FileTransferChannel extends EventEmitter {
174
181
  channel.send(completeMsg);
175
182
 
176
183
  handle._setStatus("complete");
177
- handle.emit("complete");
184
+ handle._emit("complete");
178
185
 
179
186
  return handle;
180
187
  }