wordinweb 0.1.22 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1281 @@
1
+ import { ReactNode } from 'react';
2
+ import { X as XmlElement, D as DocxDocument, C as ClientMessage, S as ServerMessage, P as PresencePosition, R as RosterEntry, a as ParticipantProfile, I as Intent, b as IdSidecar, L as LineageHead, T as ToolbarFeature, c as ToolbarMode, d as DocxViewApi, W as WriteStatus, U as UndoOutcome } from './index-C2J9egkh.js';
3
+ export { e as bytesToB64, f as deriveEpochKeys, g as docKeyFromFragment, m as mintDocKey, s as sealCheckpoint, h as stretchShareCode } from './index-C2J9egkh.js';
4
+
5
+ /**
6
+ * How much of the document an applied intent disturbed — the input to
7
+ * dirty-scoped reconciliation (perf B9/B10). Reconciling an edit used to cost
8
+ * O(document) on every path (full model reparse + full id walk), so per-op
9
+ * cost grew with the document; the scope lets the caller pay for THE EDIT.
10
+ *
11
+ * `doc` is the conservative default and means exactly today's behavior: full
12
+ * refresh() + assignFromRoots(). Only intents whose blast radius is provably
13
+ * one paragraph (or the two sides of a split) report a narrower scope; every
14
+ * structural, document-level, or exotic intent keeps the document scope.
15
+ */
16
+ type Scope = {
17
+ kind: "doc";
18
+ }
19
+ /** Only these paragraphs' contents changed. */
20
+ | {
21
+ kind: "block";
22
+ blocks: XmlElement[];
23
+ }
24
+ /** `before` was split; `after` is the new sibling that follows it. */
25
+ | {
26
+ kind: "split";
27
+ before: XmlElement;
28
+ after: XmlElement;
29
+ };
30
+
31
+ /**
32
+ * Media transfer client (plan doc 16 §5) — the half that was specced and
33
+ * left unbuilt while the server relay shipped.
34
+ *
35
+ * Bytes NEVER ride the WebSocket sequencer: the intent carries only an
36
+ * address (`blobSha`) and the blob travels over HTTP. Everything here is
37
+ * organised around one rule from doc 16 §1.1 — nobody is ever TOLD a hash.
38
+ * The placer commits `blobSha` inside the sequenced, authenticated intent;
39
+ * from then on every participant re-derives sha256 itself and compares
40
+ * against that commitment. A deliverer's claim about what it is delivering
41
+ * is never consulted, so a hostile relay or peer can at worst withhold
42
+ * bytes, never substitute them.
43
+ *
44
+ * ENVELOPE (as SHIPPED, which differs from doc 16 §1's prose): the blob is
45
+ * the BARE AES-GCM ciphertext and the 12-byte IV rides in the intent's `iv`
46
+ * field — the shape doc 16 §2 specifies and `sealMediaBlob` implements.
47
+ * §1/§5.1's "prepend IV to the blob" is stale text. The sha is over the
48
+ * ciphertext either way, which is what lets the blind relay verify it.
49
+ */
50
+ /** One registered media part's address, as it travels to a late joiner:
51
+ * plaintext rooms in `welcome.media`, encrypted rooms inside the SEALED
52
+ * checkpoint body (the server must not learn part structure). */
53
+ interface MediaAddress {
54
+ part: string;
55
+ sha: string;
56
+ iv?: string;
57
+ }
58
+ /** The late-join address map for a document: every registered part whose sha
59
+ * is actually KNOWN. Parse-derived holes (sha === "") are omitted — an empty
60
+ * address is not an address, and passing one on would only teach the joiner
61
+ * the same nothing. Mirrors the hub's filter for plaintext welcomes. */
62
+ declare function mediaAddressesOf(doc: DocxDocument): MediaAddress[];
63
+ /** Injectable fetch so tests drive the relay without a socket or a server. */
64
+ type FetchLike = (url: string, init?: {
65
+ method?: string;
66
+ body?: Uint8Array;
67
+ }) => Promise<{
68
+ ok: boolean;
69
+ status: number;
70
+ arrayBuffer(): Promise<ArrayBuffer>;
71
+ }>;
72
+ interface MediaTransportOptions {
73
+ /** Origin serving the doc-16 §3 routes, e.g. "http://localhost:1234". */
74
+ httpBase: string;
75
+ /** Defaults to global fetch. */
76
+ fetchImpl?: FetchLike;
77
+ }
78
+ /** How a pending part is faring, for the placeholder UI (doc 16 §5.2 step 4). */
79
+ type MediaState = "fetching" | "waiting" | "unavailable";
80
+ /** Crypto seam: plaintext mode supplies neither, E2EE mode supplies both. */
81
+ interface MediaCrypto {
82
+ /** Seal plaintext for upload; reusing `iv` reproduces a blob byte-identically. */
83
+ seal(plaintext: Uint8Array, iv?: string): Promise<{
84
+ blob: Uint8Array;
85
+ iv: string;
86
+ }>;
87
+ /** Open a downloaded blob; MUST throw on a GCM tag failure. */
88
+ open(blob: Uint8Array, iv: string): Promise<Uint8Array>;
89
+ }
90
+ interface MediaClientCallbacks {
91
+ /** Bytes landed (or a state changed) — repaint. */
92
+ onChange?: () => void;
93
+ /** Placeholder state per part, for the "unavailable" affordance. */
94
+ onState?: (part: string, state: MediaState) => void;
95
+ /** Ask the room for a sha (the connection sends the WS frame). */
96
+ need: (sha: string) => void;
97
+ /** Volunteer holdings (the connection sends the WS frame). */
98
+ have: (shas: string[]) => void;
99
+ }
100
+ /**
101
+ * Per-replica media duties: eager fetch for holes, re-supply for holdings.
102
+ *
103
+ * Deliberately owns no transport and no keys of its own — the connection
104
+ * hands it a doc getter, a crypto seam (absent in plaintext mode), and the
105
+ * two WS senders. That keeps ONE implementation of the state machine behind
106
+ * both the plaintext and encrypted connections, which had drifted to "fully
107
+ * built" and "entirely absent" respectively.
108
+ */
109
+ declare class MediaClient {
110
+ private opts;
111
+ private getDoc;
112
+ private cb;
113
+ private crypto?;
114
+ private inFlight;
115
+ private states;
116
+ /** Shas we asked the room for, awaiting a media-ready. */
117
+ private waitingFor;
118
+ constructor(opts: MediaTransportOptions, getDoc: () => DocxDocument | null, cb: MediaClientCallbacks, crypto?: MediaCrypto | undefined);
119
+ /** Current placeholder state of a pending part (undefined once ready). */
120
+ stateOf(part: string): MediaState | undefined;
121
+ /**
122
+ * PLACER (doc 16 §5.1). Seal (E2EE) → address → upload. Returns the intent
123
+ * fields on success, or null when the relay refused — in which case the
124
+ * caller MUST NOT emit an insertImage, or the room gets a reservation
125
+ * pointing at a blob that does not exist.
126
+ */
127
+ upload(docId: string, plaintext: Uint8Array): Promise<{
128
+ blobSha: string;
129
+ bytesLen: number;
130
+ iv?: string;
131
+ } | null>;
132
+ /** Plaintext of blobs THIS client uploaded, so its own images never take a
133
+ * network round trip. Bounded: an entry is dropped once installed. */
134
+ private ownBlobs;
135
+ /**
136
+ * RECEIVER (doc 16 §5.2). Eager-fetch every pending part the doc knows
137
+ * about. Idempotent and safe to call after every applied broadcast: parts
138
+ * already ready or already in flight are skipped.
139
+ */
140
+ fetchPending(docId: string): Promise<void>;
141
+ private setState;
142
+ private fetchOne;
143
+ /**
144
+ * Verify then install. The sha check is against the value COMMITTED IN THE
145
+ * INTENT, never against anything the deliverer said — defense in depth
146
+ * behind the relay's own check (doc 16 §5.2 step 3), and the only thing
147
+ * standing between a compromised relay and arbitrary bytes in the document.
148
+ */
149
+ private installVerified;
150
+ /** Server says the blob is fetchable now — retry the parts waiting on it. */
151
+ onReady(docId: string, sha: string): Promise<void>;
152
+ /** No holder is online. The registration stays; a later media-ready (or a
153
+ * holder rejoining, §5.4) recovers it with no action from the user. */
154
+ onUnavailable(sha: string): void;
155
+ /** Shas this replica can serve: parts whose bytes are PRESENT. */
156
+ heldShas(): string[];
157
+ /** §5.4: intersect the room's outstanding needs with local holdings and
158
+ * volunteer — this is what makes evicted media reappear when a holder
159
+ * comes back, with no polling anywhere. */
160
+ volunteer(mediaNeeded: string[] | undefined): void;
161
+ /** Someone in the room needs a sha: answer if we hold it (§5.3). */
162
+ answerRequest(sha: string): void;
163
+ /**
164
+ * HOLDER DUTY (doc 16 §5.3): chosen to re-supply — upload our copy.
165
+ *
166
+ * In an encrypted room this RE-SEALS with the IV recorded on the part, not
167
+ * a fresh one: same key + same IV + same plaintext reproduces the exact
168
+ * ciphertext, which is the only thing that still hashes to the address the
169
+ * intent committed to. A fresh IV would produce a perfectly valid blob at
170
+ * the WRONG address, and the upload would be rejected — do not "fix" it
171
+ * that way (doc 16 §5.3 / doc 13 §4 both carry this warning).
172
+ *
173
+ * The local sha assertion before PUT is deliberate: failing it means THIS
174
+ * replica's pixels are corrupt, and uploading them would just burn the
175
+ * re-supply rotation on bytes the relay will reject anyway.
176
+ */
177
+ resupply(docId: string, sha: string): Promise<boolean>;
178
+ }
179
+
180
+ /**
181
+ * A transport the connection drives: send a client message, and register a
182
+ * handler for inbound server messages. A WebSocket adapter (or a fake, in
183
+ * tests) implements it. The connection contains no socket code — the client
184
+ * counterpart to the transport-free server hub (plan doc 07).
185
+ */
186
+ interface ClientTransport {
187
+ send(msg: ClientMessage): void;
188
+ onMessage(cb: (msg: ServerMessage) => void): void;
189
+ }
190
+ interface ConnectionCallbacks {
191
+ /** The document changed (welcome loaded, a broadcast applied). */
192
+ onChange?: () => void;
193
+ /** A remote participant's cursor/selection moved (or cleared: null). */
194
+ onPresence?: (participant: string, position: PresencePosition | null) => void;
195
+ /** The server refused the connection (e.g. version mismatch). */
196
+ onRefused?: (reason: string) => void;
197
+ /**
198
+ * Resume landed in a DIFFERENT epoch than the bundle's (doc 12 §5 case 2):
199
+ * someone re-seeded while this client was away. The connection has taken
200
+ * the server's state and did NOT replay the bundle's pending intents
201
+ * (they belong to the old epoch — replaying them would be a silent
202
+ * cross-epoch merge, which the fork rule forbids). The consumer decides
203
+ * what to do with the old bundle: doc-15 lineage fast-forward/draft, or
204
+ * pre-lineage, "your offline copy is saved as a draft".
205
+ */
206
+ onEpochChange?: (storedGenesisId: string, currentGenesisId: string) => void;
207
+ /** The session roster changed (join/leave/rename) — full snapshot. */
208
+ onRoster?: (roster: RosterEntry[]) => void;
209
+ /**
210
+ * The connection detected that the local OPTIMISTIC replica drifted from
211
+ * the canonical document at quiescence and rebuilt it in place (encrypted
212
+ * mode: the mirror is local ground truth, so the check costs no network).
213
+ * The doc object was swapped and docEpoch bumped — the renderer remounts
214
+ * exactly like any true-conflict reload. Informational: the heal already
215
+ * happened; consumers surface/telemeter it (the B6a typist-drift class).
216
+ */
217
+ onSelfHeal?: (info: {
218
+ seq: number;
219
+ liveHash: string;
220
+ canonicalHash: string;
221
+ }) => void;
222
+ /**
223
+ * A submit was DROPPED instead of sent, and the caller needs to know.
224
+ *
225
+ * Today the only reason is "not-ready": a submit arriving before the
226
+ * welcome has nothing to apply against, no confirmed seq to base itself on,
227
+ * and (encrypted) no key to seal with, so it cannot be honoured or queued.
228
+ * It used to return silently — no error, no callback, no counter, no
229
+ * replay — which made lost edits indistinguishable from edits that were
230
+ * never made. B13 measured 403 submits, 172 envelopes on the wire and 172
231
+ * in the document, with a completely clean server log; a channel that
232
+ * consumes intents without a trace is what that requires.
233
+ *
234
+ * NOT `onRefused`: that one means the SERVER refused the connection and the
235
+ * demo renders a "please refresh" screen on it. This is non-fatal and
236
+ * per-intent — the connection stays perfectly usable.
237
+ */
238
+ onSubmitDropped?: (reason: "not-ready") => void;
239
+ /**
240
+ * Something threw on an async path that has nowhere to return an error —
241
+ * a seal, a transport send, a persistence write. These used to die in a
242
+ * serial chain's `.catch(() => {})`, which is how B13's lost edits and a
243
+ * silently-failing durable copy both stayed invisible.
244
+ *
245
+ * DELIBERATELY SEPARATE from onRefused ("the server refused the
246
+ * connection", which tears the session down) and onSubmitDropped ("an edit
247
+ * was lost before it could be sent"). Each of the three now means exactly
248
+ * one thing; conflating failure channels is precisely how the bugs this
249
+ * arc is cleaning up managed to hide.
250
+ *
251
+ * `where` is a fixed code-site label, never interpolated with session data.
252
+ */
253
+ onError?: (info: {
254
+ where: string;
255
+ error: unknown;
256
+ }) => void;
257
+ /**
258
+ * Resume landed in a different epoch that is a STRICT DESCENDANT of this
259
+ * client's stored head (doc 15 §1 fast-forward): the seed's lineage
260
+ * contains our (genesisId, hash) at ≥ our seq, so adopting the server's
261
+ * state loses nothing — no draft, no banner noise. The consumer should
262
+ * still bank the superseded bundle recoverable-not-gone (version ring /
263
+ * a superseded slot) per doc 15's fabricated-lineage mitigation.
264
+ */
265
+ onFastForward?: (fromGenesisId: string, toGenesisId: string) => void;
266
+ /**
267
+ * The SERVER is going to end this session, and this is the grace period
268
+ * before it does (server lifecycle arc). Render a countdown from `inMs`.
269
+ *
270
+ * - `idle` nobody has done anything for the idle window. Cancellable:
271
+ * any qualifying activity — an accepted edit, an admin
272
+ * action, a media transfer, someone joining, but NEVER
273
+ * presence — resets the clock and fires
274
+ * {@link onSessionWarningCleared}. Typing genuinely saves the
275
+ * session, so the countdown is worth showing prominently.
276
+ * - `lifetime` the room hit its absolute age cap. NOT cancellable by
277
+ * anything: the deadline was fixed when the room was created,
278
+ * which is the point (an activity-based clock is exactly what
279
+ * a keepalive script defeats). The document is not lost — the
280
+ * local bundle re-seeds it into a new epoch.
281
+ *
282
+ * `inMs` is the measured remainder at send time, not a configured constant,
283
+ * so counting down from it lands on the real ending.
284
+ *
285
+ * NOT `onRefused`: nothing has been refused yet and the session is fully
286
+ * usable during the grace. The kick that eventually follows arrives as
287
+ * `onRefused("idle-timeout")` / `onRefused("session-expired")`.
288
+ */
289
+ onSessionWarning?: (info: {
290
+ reason: "idle" | "lifetime";
291
+ inMs: number;
292
+ }) => void;
293
+ /**
294
+ * A previously announced deadline is no longer approaching — take the
295
+ * countdown down. Only ever `idle`, because the lifetime cap cannot be
296
+ * cancelled; the narrow type means a consumer cannot write a branch the
297
+ * server is incapable of producing.
298
+ */
299
+ onSessionWarningCleared?: (info: {
300
+ reason: "idle";
301
+ }) => void;
302
+ /**
303
+ * ONE OF THIS CLIENT'S OWN INTENTS WAS REJECTED by canonical validation —
304
+ * it will never apply, anywhere, and the edit is gone.
305
+ *
306
+ * This channel exists because of a bug that cost a user their photos. The
307
+ * validator carried a stale image-size bound, so every large image was
308
+ * rejected as "bad size" — and the failure was completely invisible. The
309
+ * reason is worth stating exactly, because it inverts the usual assumption:
310
+ * a rejection is sequenced, so EVERY REPLICA AGREES the intent is dead.
311
+ * Nothing diverges, nothing throws, no upload fails. Our entire invariant
312
+ * arsenal is built to detect disagreement, and this bug's signature was
313
+ * agreement. The image simply never appeared.
314
+ *
315
+ * A wrong bound can happen again; next time somebody should SEE it. Fired
316
+ * only for intents THIS client authored — another participant's rejection is
317
+ * their business and not actionable here.
318
+ *
319
+ * `reason` is the validator's fixed vocabulary (`insertImage: bad size`,
320
+ * `invalid base`, …) — never session content, so it is safe to surface.
321
+ */
322
+ onIntentRejected?: (info: {
323
+ reason: string;
324
+ clientSeq: number;
325
+ }) => void;
326
+ /** Media re-supply control (doc 16 §5): the room asks who holds `sha`;
327
+ * a holder answers via `mediaHave` and uploads over HTTP when chosen. */
328
+ onMediaRequest?: (sha: string) => void;
329
+ /** A needed blob became fetchable / definitively unavailable. */
330
+ onMediaReady?: (sha: string) => void;
331
+ onMediaUnavailable?: (sha: string) => void;
332
+ /** A pending part's placeholder state changed (fetching/waiting/
333
+ * unavailable) — the renderer shows the "unavailable" affordance off this. */
334
+ onMediaState?: (part: string, state: MediaState) => void;
335
+ }
336
+ /**
337
+ * Client-side session: joins a document, applies canonical broadcasts to a
338
+ * ClientReplica (optimistic apply + reconciliation), and relays presence.
339
+ * The editor layer submits local intents and reads `replica.doc`.
340
+ */
341
+ declare class CollabConnection {
342
+ private transport;
343
+ private clientId;
344
+ private cb;
345
+ private replica;
346
+ private clientSeq;
347
+ /** Bumps only when reconciliation RELOADED the document (a true conflict) —
348
+ * the renderer re-mounts on this, but updates in place otherwise (no flash
349
+ * for the common non-conflicting edits). */
350
+ docEpoch: number;
351
+ /** Per-client id allocator for carried node ids (split/format-range/insert):
352
+ * a client-specific base block keeps concurrently-allocated ids disjoint
353
+ * across clients so they never collide. */
354
+ private idCounter;
355
+ private idBase;
356
+ /** The epoch id of the session this connection joined (from the welcome).
357
+ * The resume layer compares it against the bundle's stored genesisId:
358
+ * same ⇒ seamless rejoin; different ⇒ someone re-seeded while away
359
+ * (doc 12 §5 case 2 — take server state, keep the local copy; the doc-15
360
+ * lineage check decides fast-forward vs draft). Null until welcomed. */
361
+ genesisId: string | null;
362
+ /** Session encryption mode from the welcome (doc 13 §6). The E2EE layer
363
+ * hard-refuses a value contradicting the link's `#k` fragment. */
364
+ mode: "plaintext" | "encrypted" | null;
365
+ /**
366
+ * The relay's per-blob upload limit in bytes, from the welcome — so a file
367
+ * can be checked BEFORE it is uploaded rather than after a 413.
368
+ *
369
+ * `null` means the server did not publish one (an older host). Callers must
370
+ * treat that as "skip the pre-check", NOT as "no limit" and not as a
371
+ * default they invent: the server still enforces its real limit either way,
372
+ * and a client guessing a larger number would promise the user an upload
373
+ * that cannot succeed.
374
+ */
375
+ mediaMaxBlobBytes: number | null;
376
+ /** Out-of-band media transfer (doc 16 §5). Null until `httpBase` is
377
+ * supplied — without a relay origin a client simply has no media duties
378
+ * (every existing caller keeps working unchanged). */
379
+ media: MediaClient | null;
380
+ /** The room this connection joined; media URLs are per-doc. */
381
+ private docId;
382
+ constructor(transport: ClientTransport, clientId: string, cb?: ConnectionCallbacks,
383
+ /** Origin serving the doc-16 §3 media routes; enables media transfer. */
384
+ mediaOpts?: MediaTransportOptions);
385
+ /** Upload bytes and get the intent's media fields, or null if the relay
386
+ * refused — the caller must not emit an insertImage in that case. */
387
+ uploadMedia(plaintext: Uint8Array): Promise<{
388
+ blobSha: string;
389
+ bytesLen: number;
390
+ iv?: string;
391
+ } | null>;
392
+ /** Replace the callbacks (used by bindEditor to attach after construction). */
393
+ setCallbacks(cb: ConnectionCallbacks): void;
394
+ /** Allocate `n` fresh carried node ids in this client's disjoint block. */
395
+ allocIds(n: number): number[];
396
+ /**
397
+ * Join a document. The server replies with a welcome (snapshot + sidecar +
398
+ * tail). The hello carries this connection's clientId — the hub binds it to
399
+ * the socket and refuses submits under any other id (doc 11 decision 8).
400
+ * `takeover: true` claims the identity from an existing live connection
401
+ * (the doc-12 §7 "use here instead" path for a second same-profile tab);
402
+ * without it, a duplicate join is refused `already-open`.
403
+ */
404
+ join(docId: string, token?: string, opts?: {
405
+ takeover?: boolean;
406
+ profile?: ParticipantProfile;
407
+ codeProof?: string;
408
+ ownerToken?: string;
409
+ }): void;
410
+ /** Owner admin op (doc 14 §2.5): honored only if this connection proved
411
+ * the owner token at hello — otherwise the server refuses `not-owner`. */
412
+ admin(action: {
413
+ op: "kick";
414
+ clientId: string;
415
+ } | {
416
+ op: "readOnly";
417
+ on: boolean;
418
+ } | {
419
+ op: "setRole";
420
+ clientId: string;
421
+ role: "editor" | "viewer";
422
+ }): void;
423
+ /** The latest roster snapshot (doc 14 §2); empty until the first fan-out. */
424
+ roster: RosterEntry[];
425
+ /**
426
+ * Attribution layer 1 (doc 14 §3): the canonical log IS the attribution
427
+ * record — every applied entry carries its author's bound clientId. This
428
+ * is the bounded client-side derivation an activity panel renders
429
+ * (clientId → roster name/color). Newest last, capped, derived state:
430
+ * never persisted, dies with the connection (zero custody).
431
+ */
432
+ activity: {
433
+ seq: number;
434
+ clientId: string;
435
+ kind: string;
436
+ }[];
437
+ private static ACTIVITY_CAP;
438
+ private recordActivity;
439
+ /** Ask the room for a blob this replica needs (doc 16 §5.2). */
440
+ mediaNeed(sha: string): void;
441
+ /** Volunteer holdings (reply to a request, or after a welcome whose
442
+ * mediaNeeded intersects local media — §5.4). */
443
+ mediaHave(shas: string[]): void;
444
+ /** Shas this replica can re-supply: metadata of parts whose bytes are
445
+ * PRESENT (pending parts are exactly what we can't serve). */
446
+ heldMediaShas(): string[];
447
+ /** Rename/recolor this participant mid-session. The server sanitizes and
448
+ * fans out the updated roster; the local copy updates on that echo (no
449
+ * optimistic roster — a 1-RTT lag on your own rename is imperceptible and
450
+ * keeps one code path). */
451
+ setProfile(profile: ParticipantProfile): void;
452
+ /** Bundle being resumed from (set by resume(), consumed at welcome). */
453
+ private resuming;
454
+ /**
455
+ * Rejoin a document from a persisted bundle (doc 12 §5). Restores the
456
+ * clientSeq watermark FIRST — a fresh counter would reuse already-
457
+ * sequenced (clientId, clientSeq) keys and the server would dedup this
458
+ * client's NEW edits as re-sends (silent edit loss). The welcome decides
459
+ * the case: same epoch ⇒ replay pending (below); different ⇒
460
+ * onEpochChange, pending withheld.
461
+ */
462
+ resume(bundle: DocBundle, token?: string, opts?: {
463
+ profile?: ParticipantProfile;
464
+ codeProof?: string;
465
+ ownerToken?: string;
466
+ }): void;
467
+ /**
468
+ * The current durable state as a doc-12 bundle, or null before welcome.
469
+ * `savedAt` is stamped by the persister at write time (clock injection —
470
+ * this module never reads Date.now itself).
471
+ */
472
+ exportBundle(docId: string): DocBundle | null;
473
+ exportBundleAsync(docId: string): Promise<DocBundle | null>;
474
+ /** The live document (null until welcome). The editor renders this. */
475
+ get doc(): DocxDocument | null;
476
+ /** Accumulates doc changes across replica rebuilds (welcome), so the getter
477
+ * below stays monotonic per connection. */
478
+ private docVersionBase;
479
+ /**
480
+ * Counts changes TO THE RENDERED DOCUMENT — the repaint signal, as opposed
481
+ * to onChange (which also fires for pure bookkeeping such as a tracked own
482
+ * echo). The editor-driven typing path mutates and paints the doc itself,
483
+ * so its submit + echo leave this untouched; remote applies, optimistic
484
+ * canonical applies (toolbar ops), reloads, and media installs advance it.
485
+ * The react layer repaints on THIS, not on onChange — repainting on
486
+ * onChange queued a whole-document relayout per keystroke, catastrophic
487
+ * past the background-layout page threshold.
488
+ */
489
+ get docVersion(): number;
490
+ /** docVersionBase at the last takeRenderScope — a base bump between takes
491
+ * (welcome doc replacement, media install) has no narrow scope. */
492
+ private takenDocVersionBase;
493
+ /**
494
+ * Drain the dirty scope behind the docVersion movement since the last take
495
+ * — what the repaint answering `docVersion` must relayout. Replica applies
496
+ * carry their per-intent scope; connection-level bumps (a replaced doc
497
+ * object, media pixels landing) report document scope. Null means NOTHING
498
+ * was recorded since the last take — the caller already painted everything
499
+ * and may skip the repaint.
500
+ */
501
+ takeRenderScope(): Scope | null;
502
+ get ready(): boolean;
503
+ /** Un-confirmed local intents in flight — the drained-replay discipline
504
+ * (doc 15 §2 / rebase.ts) polls this to submit one intent at a time. */
505
+ get pendingCount(): number;
506
+ /**
507
+ * Submit a local edit. The caller supplies the intent minus its wire
508
+ * bookkeeping (clientId/clientSeq/base) — the connection fills those from
509
+ * the current confirmed seq and applies it optimistically before sending.
510
+ */
511
+ submit(intent: Omit<Intent, "clientId" | "clientSeq" | "base">): void;
512
+ /** Submit an intent whose mutation the caller ALREADY performed on this
513
+ * connection's live doc (the editor-driven path: DocxEditor applies the
514
+ * command to `conn.doc` and then emits the intent). Skips the optimistic
515
+ * re-apply — applying twice doubled every keystroke — but tracks pending and
516
+ * sends identically, so echoes and reconciliation work unchanged. */
517
+ submitPreApplied(intent: Omit<Intent, "clientId" | "clientSeq" | "base">): void;
518
+ /** Submits dropped because the connection was not ready (see
519
+ * onSubmitDropped). Exposed like `selfHeals` so a harness or a UI can read
520
+ * it: when a run loses intents, this number says how many died HERE rather
521
+ * than anywhere else on the path. */
522
+ droppedPreReady: number;
523
+ private submitFull;
524
+ /** Broadcast this client's cursor/selection (ephemeral). Selection ranges
525
+ * are clamped on the way OUT too, so an over-long selection costs the room
526
+ * a bounded payload rather than every receiver a discard. */
527
+ setPresence(position: PresencePosition | null): void;
528
+ /**
529
+ * Surface any of OUR OWN intents that canonical validation rejected.
530
+ *
531
+ * A rejection is sequenced and agreed by every replica, so it produces no
532
+ * divergence to notice — which is exactly how a stale image-size bound
533
+ * silently ate a user's photos. See ConnectionCallbacks.onIntentRejected.
534
+ */
535
+ private reportRejections;
536
+ private onServer;
537
+ private rlRedriveTimer;
538
+ private rlRedriveBackoffMs;
539
+ }
540
+
541
+ /**
542
+ * The client-side document bundle (plan doc 12 §4) — under zero custody this
543
+ * is THE durable copy of a document anywhere: the server deletes everything
544
+ * at session end, so each participating browser persists its own bundle and
545
+ * any holder can re-seed the same link later.
546
+ *
547
+ * Contents are the replica's CONFIRMED state plus the pending queue:
548
+ * `confirmedBytes` is a complete docx at `confirmedSeq`; `confirmedSidecar`
549
+ * rides with it always (round-4 F10 — bytes alone cannot reproduce the id
550
+ * table across split-created ids); `pending` is idempotency-keyed and safe
551
+ * to replay after a crash (the server dedups by (clientId, clientSeq)).
552
+ */
553
+ interface DocBundle {
554
+ docId: string;
555
+ /** Epoch of the session this state came from (doc 12 §5): on rejoin,
556
+ * same ⇒ seamless resume; different ⇒ someone re-seeded while away. */
557
+ genesisId: string;
558
+ confirmedSeq: number;
559
+ confirmedBytes: Uint8Array;
560
+ confirmedSidecar: IdSidecar;
561
+ /** Local intents not yet confirmed when the bundle was written — replayed
562
+ * verbatim on resume; idempotent by (clientId, clientSeq). */
563
+ pending: Intent[];
564
+ /**
565
+ * The clientSeq watermark at write time. Resume MUST restore this before
566
+ * submitting anything new: a fresh connection restarting from 1 would
567
+ * reuse already-sequenced (clientId, clientSeq) keys and the server would
568
+ * dedup its NEW edits as re-sends — self-inflicted silent edit loss (the
569
+ * same failure shape doc 12 §7's single-tab rule guards against).
570
+ */
571
+ clientSeq: number;
572
+ savedAt: number;
573
+ /**
574
+ * The lineage chain (doc 15 §1): every epoch this copy has passed
575
+ * through, newest last — `[..., {this bundle's genesisId, seq, hash}]`.
576
+ * Rides with re-seeds so returning holders can prove ancestry and
577
+ * fast-forward instead of forking. Capped; ancestry checks are O(chain).
578
+ */
579
+ lineage: LineageHead[];
580
+ /**
581
+ * Local intents recorded while the session was UNREACHABLE (doc 15 §2 /
582
+ * doc 12 §5 "offline mode is first-class"): the user kept editing after
583
+ * the socket died. On rejoin these are the offline tail the arrival
584
+ * ladder reconciles — replayed as ordinary intents (same epoch), as
585
+ * suggestions (diverged, small), or parked in a draft (diverged, large).
586
+ * Distinct from `pending` (in-flight when a LIVE session was
587
+ * interrupted): tail entries carry NO wire bookkeeping — clientId /
588
+ * clientSeq / base are assigned at replay time by the ordinary submit
589
+ * path, so the stored objects are the payload half of an Intent only.
590
+ */
591
+ offlineTail?: Intent[];
592
+ /**
593
+ * The genesisId of the epoch the offline tail was recorded AGAINST. This
594
+ * is what lets a resume decide the arrival rung without guessing: same
595
+ * epoch ⇒ the tail is morally a large pending queue and replays silently;
596
+ * different (or absent, for a tail written by an older build) ⇒ true
597
+ * divergence, offer suggest/draft — never silently bulldoze.
598
+ */
599
+ offlineTailEpoch?: string;
600
+ /** Per-part media metadata (doc 16 §5.3): the sha (and E2EE iv/epoch)
601
+ * of every media part this copy knows. The docx bytes carry the PIXELS;
602
+ * this carries the ADDRESSES — without it a resumed holder couldn't
603
+ * answer re-supply requests or verify fetches (metadata is in-memory on
604
+ * the doc and would otherwise die with the session). */
605
+ mediaMeta?: [string, {
606
+ sha: string;
607
+ iv?: string;
608
+ genesisId?: string;
609
+ }][];
610
+ }
611
+ /**
612
+ * THE KEY CONVENTION, in one place. Everything a browser stores rides the
613
+ * bundle store under a key derived from the base docId:
614
+ *
615
+ * - `<docId>` — the live bundle
616
+ * - `<docId>#version-<ts>[-<label>]` — a frozen restore point
617
+ * - `<docId>#draft-<genesis>` — an offline copy parked on divergence
618
+ * - `<docId>#superseded-<genesis>` — a banked copy after a fast-forward
619
+ * - `local:…` — a document that never went live
620
+ *
621
+ * The builders and the parser below are the only code that knows these
622
+ * shapes; UIs render from `parseBundleKey` instead of re-deriving them.
623
+ */
624
+ type StoredDocKind = "live" | "version" | "draft" | "superseded" | "local" | "unknown";
625
+ interface ParsedBundleKey {
626
+ /** The base document id (the part before any `#` suffix). */
627
+ docId: string;
628
+ kind: StoredDocKind;
629
+ /** A version's human label, when the key carries one. */
630
+ label?: string;
631
+ /** A version's freeze time (ms), parsed from the key. */
632
+ versionSavedAt?: number;
633
+ }
634
+ declare function versionKey(docId: string, savedAt: number, label?: string): string;
635
+ declare function draftKey(docId: string, genesisId: string): string;
636
+ declare function supersededKey(docId: string, genesisId: string): string;
637
+ /** Parse any store key. An unrecognised `#` suffix degrades to `unknown`
638
+ * rather than throwing — a future key shape must not break today's listing. */
639
+ declare function parseBundleKey(key: string): ParsedBundleKey;
640
+ /**
641
+ * One stored entry, as METADATA ONLY — everything a list needs to render
642
+ * (what it is, when, how big) without the document bytes. A 500-page doc is
643
+ * megabytes; listing ten of them must not deserialise all ten, so `list()`
644
+ * returns these and `get(key)` fetches bytes only when one is opened.
645
+ */
646
+ interface StoredDocSummary extends ParsedBundleKey {
647
+ /** The full store key — what `get()`/`delete()` take. */
648
+ key: string;
649
+ /** When this entry was written (bundle.savedAt, ms). */
650
+ savedAt: number;
651
+ /** Size of the stored document bytes. */
652
+ byteLength: number;
653
+ }
654
+ /**
655
+ * Storage seam for bundles. The browser implementation is IndexedDB (the
656
+ * only storage that fits multi-MB binary docs); tests and Node use the
657
+ * in-memory implementation — the doc-12 test plan's "in-memory IndexedDB
658
+ * stub", which keeps every bundle test deterministic and dependency-free.
659
+ */
660
+ interface BundleStore {
661
+ get(docId: string): Promise<DocBundle | null>;
662
+ put(bundle: DocBundle): Promise<void>;
663
+ delete(docId: string): Promise<void>;
664
+ /** Enumerate everything stored, metadata only (see StoredDocSummary). */
665
+ list(): Promise<StoredDocSummary[]>;
666
+ }
667
+ declare class InMemoryBundleStore implements BundleStore {
668
+ private bundles;
669
+ /** Write count — lets tests assert the throttle's coalescing precisely. */
670
+ writes: number;
671
+ get(docId: string): Promise<DocBundle | null>;
672
+ put(bundle: DocBundle): Promise<void>;
673
+ delete(docId: string): Promise<void>;
674
+ list(): Promise<StoredDocSummary[]>;
675
+ }
676
+ /**
677
+ * Persists a connection's state to a BundleStore on a THROTTLE — not a
678
+ * debounce (round-4 F8): a debounce resets per event, so sustained typing
679
+ * defers the write for the whole burst and an OS kill loses the burst; a
680
+ * throttle bounds data loss to the throttle window (default 1s), which is
681
+ * the actual durability guarantee doc 12 §4 states ("RPO ≈ your last bundle
682
+ * write ≈ the throttle window").
683
+ *
684
+ * Wiring: call `notify()` from the connection's onChange (every local edit,
685
+ * echo, and remote broadcast); call `flush()` from pagehide/visibilitychange
686
+ * — best-effort (IndexedDB has no synchronous API; the throttle is the real
687
+ * mechanism, the flush just narrows the tail).
688
+ *
689
+ * The scheduler (setTimeout/clearTimeout) and clock are injectable so tests
690
+ * drive time deterministically.
691
+ */
692
+ declare class BundlePersister {
693
+ private conn;
694
+ private store;
695
+ private docId;
696
+ private opts;
697
+ private lastWrite;
698
+ private trailing;
699
+ private stopped;
700
+ /** Writes are async (digest + store I/O) and must not overlap on one
701
+ * store; a serial chain guarantees ordering and lets flush() await
702
+ * every in-flight write (round-4 F8: the flush is only meaningful if it
703
+ * durably lands the latest state before pagehide). */
704
+ private chain;
705
+ constructor(conn: CollabConnection, store: BundleStore, docId: string, opts?: {
706
+ throttleMs?: number;
707
+ now?: () => number;
708
+ setTimer?: (fn: () => void, ms: number) => unknown;
709
+ clearTimer?: (t: unknown) => void;
710
+ /**
711
+ * A persistence write FAILED. Wire this — in a zero-custody design the
712
+ * browser's stored bundle IS the durable copy, so a swallowed quota or
713
+ * blocked-storage error means the user's only durable copy silently
714
+ * stops updating while the editor looks perfectly healthy. That is the
715
+ * most dangerous silence in this codebase, which is why it is now a
716
+ * callback instead of an empty catch.
717
+ */
718
+ onError?: (err: unknown) => void;
719
+ /**
720
+ * The current offline tail (doc 15 §2), read at write time. The
721
+ * connection's exportBundle cannot know it (the tail is recorded by
722
+ * the layer ABOVE the connection, across connection rebuilds), so the
723
+ * owner of the tail injects a getter. Returning an empty tail (or
724
+ * omitting the getter) ERASES any stored tail — which is exactly
725
+ * right once a replay has drained it.
726
+ */
727
+ offlineTail?: () => {
728
+ tail: Intent[];
729
+ epoch?: string;
730
+ } | null;
731
+ });
732
+ private get throttleMs();
733
+ private now;
734
+ /** State changed — write now if the window allows, else arm ONE trailing
735
+ * write for the window's end (never more than one timer in flight; N
736
+ * notifies inside a window coalesce into a single trailing write). */
737
+ notify(): void;
738
+ /** Immediate best-effort write (pagehide/visibilitychange/session-end).
739
+ * Awaits ALL in-flight writes plus this one so the latest state is
740
+ * durably landed before the tab goes away. */
741
+ flush(): Promise<void>;
742
+ /** Append a write to the serial chain (never overlapping). */
743
+ private enqueueWrite;
744
+ /** Detach (unmount): cancel the trailing timer; no further writes. */
745
+ stop(): void;
746
+ private write;
747
+ }
748
+
749
+ /**
750
+ * Whether this client's edits can currently reach the sequencer.
751
+ *
752
+ * - `live` the round trip completed recently; submits will land.
753
+ * - `reconnecting` the socket is gone and a retry is scheduled. TRANSIENT and
754
+ * usually invisible — a laptop lid closed for ten seconds
755
+ * passes through here and back to `live` without the user
756
+ * noticing.
757
+ * - `lost` the retries are exhausted. Needs a human: reconnect or
758
+ * reload.
759
+ *
760
+ * A NON-LIVE STATE IS NOT A WRITE GATE (doc 12 §5: offline editing is
761
+ * first-class). What consumers MUST guarantee on anything but `live` is that
762
+ * nothing typed can be LOST: edits apply locally and are recorded to the
763
+ * durable offline tail (doc 15 §2) for replay on reconnect — the react
764
+ * layer's capture path does exactly this. The write gate (`writesBlocked`)
765
+ * stays reserved for server REFUSALS, where accepting a keystroke really is
766
+ * accepting-then-losing it. An editable surface whose disconnected edits are
767
+ * neither kept nor gated remains the silent-loss bug this type was created
768
+ * to surface — keep them either captured or blocked, never dropped.
769
+ */
770
+ type ConnectionState = "live" | "reconnecting" | "lost";
771
+ interface LivenessOptions {
772
+ /** Gap between probes once the previous one was answered. */
773
+ intervalMs?: number;
774
+ /** How long a probe may go unanswered before the socket is declared dead. */
775
+ timeoutMs?: number;
776
+ setTimer?: (fn: () => void, ms: number) => unknown;
777
+ clearTimer?: (t: unknown) => void;
778
+ }
779
+
780
+ /**
781
+ * The canonical document hash (plan doc 05): sha256 over the
782
+ * deterministically-serialized editable roots in fixed part order.
783
+ * `serializeXml` is a pure function of the tree (attribute order preserved
784
+ * from parse, fixed escaping, no environment reads), so replicas that hold
785
+ * the same document produce the same hash on any engine — which is what
786
+ * makes the hash a convergence VERIFIER rather than a hope.
787
+ *
788
+ * Doc-05 scope note: the full recipe also folds in relationship roots,
789
+ * content-types, and declared media shas (package state is
790
+ * divergence-capable). Those parts are immutable in the demo's enabled
791
+ * intent surface (media is gated off; no intent edits rels/content-types
792
+ * outside media), so the editable roots are the complete divergence surface
793
+ * today — the fold-in lands with the media gate (doc 16).
794
+ */
795
+ declare function docHash(doc: DocxDocument): Promise<string>;
796
+
797
+ declare class IndexedDbBundleStore implements BundleStore {
798
+ private dbName;
799
+ private db;
800
+ private persistRequested;
801
+ constructor(dbName?: string);
802
+ private open;
803
+ /** One request in one transaction, as a promise. */
804
+ private tx;
805
+ get(docId: string): Promise<DocBundle | null>;
806
+ put(bundle: DocBundle): Promise<void>;
807
+ delete(docId: string): Promise<void>;
808
+ list(): Promise<StoredDocSummary[]>;
809
+ }
810
+
811
+ /**
812
+ * React binding for wordinweb collaboration. Imported from the SEPARATE
813
+ * `wordinweb/collab` entry so that non-collab apps never pull the collab
814
+ * engine into their bundle (plan doc 07: unreachable beats shakeable — a
815
+ * local-only `import { DocxView } from "wordinweb"` has no path to this file).
816
+ */
817
+ interface UseCollabOptions {
818
+ /** WebSocket URL of the collab server (ws://… / wss://…). */
819
+ url: string;
820
+ /** Document id to join (the magic-link id, plan doc 11). */
821
+ docId: string;
822
+ /** This client's stable id (persist per browser/tab). */
823
+ clientId: string;
824
+ /** Optional auth token (JWT minted by the app's backend, plan doc 07). */
825
+ token?: string;
826
+ /** Construct the socket. Defaults to `new WebSocket(url)`; injectable for
827
+ * tests / custom transports. */
828
+ createSocket?: (url: string) => WebSocket;
829
+ /**
830
+ * Bundle persistence + resume (plan doc 12 §4/§5). When set, the hook:
831
+ * resumes from a stored bundle if one exists (replaying pending intents,
832
+ * idempotently) instead of joining cold; persists the confirmed state on a
833
+ * ~1s throttle after every change, plus a best-effort flush on pagehide/
834
+ * hidden; and on an epoch change (someone re-seeded while away) preserves
835
+ * the superseded bundle as a draft (`<docId>#draft-<oldGenesis>`) before
836
+ * adopting the server's state — the fork rule made visible, never a silent
837
+ * merge. Browser apps pass an `IndexedDbBundleStore` (from `wordinweb`);
838
+ * tests inject the in-memory store. Without it, behavior is unchanged
839
+ * (join cold, keep nothing).
840
+ */
841
+ store?: BundleStore;
842
+ /** Claim the identity from an existing live connection (doc 12 §7 "use
843
+ * here instead"): set after an `already-open` refusal and remount. */
844
+ takeover?: boolean;
845
+ /**
846
+ * E2EE mode (doc 13): the document master key from the link's `#k=`
847
+ * fragment. Its PRESENCE selects the encrypted connection — mode is
848
+ * derived from the link, never the wire; a plaintext welcome is
849
+ * hard-refused (`mode-downgrade`). */
850
+ docKey?: string;
851
+ /** Share code (doc 13 §7) when the doc has one — stretched client-side
852
+ * and mixed into key derivation + sent as the hello proof. */
853
+ shareCode?: string;
854
+ /** Owner capability token (doc 14 §2.5): held by the seeder (returned by
855
+ * go-live, kept in their bundle), never in the shared link. Presence of
856
+ * a valid token unlocks the admin controls. */
857
+ ownerToken?: string;
858
+ /**
859
+ * HTTP origin of the media relay (plan doc 16 §3), e.g.
860
+ * "http://localhost:1234" — the same server the WebSocket points at.
861
+ * Image bytes travel over these routes and NEVER over the sequencer.
862
+ * Without it the room has no media duties and the image toolbar stays
863
+ * inert, so an app that doesn't want media simply omits it.
864
+ */
865
+ httpBase?: string;
866
+ /** Display profile sent at join (doc 14 §2) — self-asserted; persist it in
867
+ * localStorage next to the clientId so identity is stable per browser. */
868
+ profile?: ParticipantProfile;
869
+ /**
870
+ * Heartbeat + reconnect tuning. Defaults suit a real network (15s probe,
871
+ * 10s answer deadline, 6 retries); tests inject short values and a fake
872
+ * clock. `maxRetries` is how many backoff attempts run before the session
873
+ * gives up and reports `lost` — the point at which a human has to act.
874
+ */
875
+ liveness?: LivenessOptions & {
876
+ maxRetries?: number;
877
+ };
878
+ /**
879
+ * Bound on the offline tail (doc 15 §4.3) — how many intents may be
880
+ * recorded while disconnected before the editor gates read-only with an
881
+ * explicit "offline limit" state. Defaults to OFFLINE_TAIL_CAP; injectable
882
+ * so tests can reach the cap without recording thousands of edits.
883
+ */
884
+ offlineTailCap?: number;
885
+ }
886
+ interface CollabSession {
887
+ /** The live document to render (null until the welcome arrives). */
888
+ doc: DocxDocument | null;
889
+ /** Monotonically increases on every reconciled change — a cheap re-render
890
+ * signal (the doc object may be reloaded by reconciliation). */
891
+ version: number;
892
+ /**
893
+ * Increases only when the RENDERED DOCUMENT changed outside the editor —
894
+ * remote applies, optimistic toolbar ops, reloads, self-heals, media
895
+ * installs. The editor-driven typing path (submit + own echo) leaves it
896
+ * untouched: the editor already mutated AND painted the doc, so repainting
897
+ * again on `version` queued a redundant whole-document relayout per
898
+ * keystroke — invisible on 5 pages, catastrophic past DocxView's
899
+ * background-layout threshold (a 500-page doc relaid out asynchronously
900
+ * behind an inert container, per keystroke). DocxView's `renderSignal`
901
+ * rides THIS; `version` remains the observer/bookkeeping signal.
902
+ */
903
+ renderVersion: number;
904
+ /**
905
+ * Drain the union of the dirty scopes behind `renderVersion` since the last
906
+ * take — what the repaint answering it must relayout. A remote text edit
907
+ * reports its one paragraph, so DocxView relayouts that paragraph
908
+ * incrementally instead of the whole document (the far-page repaint stall);
909
+ * structural or unverifiable intents, doc reloads, and media installs
910
+ * report `doc`, which keeps today's whole-document path. Null means nothing
911
+ * dirty was recorded since the last take (an earlier paint covered it) and
912
+ * the repaint may be skipped. Consumed on the repaint itself so a coalesced
913
+ * repaint sees every batched intent's scope.
914
+ */
915
+ takeRenderScope: () => Scope | null;
916
+ /** Increases only when reconciliation RELOADED the document (a true
917
+ * conflict). The editor re-mounts on this; between reloads it updates in
918
+ * place (no flash for the common non-conflicting edits). */
919
+ docEpoch: number;
920
+ /** True once joined and the snapshot is loaded. */
921
+ ready: boolean;
922
+ /** Submit a local edit the editor ALREADY applied to the live doc
923
+ * (bookkeeping filled by the connection; no local re-apply). */
924
+ submit: (intent: Omit<Intent, "clientId" | "clientSeq" | "base">) => void;
925
+ /** Submit an operation NOT yet applied locally: it is applied optimistically
926
+ * through the same canonical applyIntent code the server runs, so the local
927
+ * result is byte-identical to every replica by construction. Used for
928
+ * toolbar/API commands (insert chart, set link, page layout, ...). */
929
+ submitOp: (intent: Omit<Intent, "clientId" | "clientSeq" | "base">) => void;
930
+ /** Broadcast this client's cursor/selection. */
931
+ setPresence: (pos: PresencePosition | null) => void;
932
+ /** Allocate carried node ids (sub-range format / split / insert). */
933
+ allocIds: (n: number) => number[];
934
+ /** Remote participants' latest cursor/selection positions. */
935
+ presence: Record<string, PresencePosition | null>;
936
+ /** Set if the server refused the connection (e.g. version mismatch). */
937
+ refused: string | null;
938
+ /**
939
+ * WHETHER THIS CLIENT CAN REACH THE SEQUENCER AT ALL.
940
+ *
941
+ * Distinct from `refused`, which means the server answered and said no —
942
+ * this means nobody answered. A refusal ends the session; this is a
943
+ * transport fault that usually heals by itself.
944
+ *
945
+ * - `live` the last heartbeat round trip completed.
946
+ * - `reconnecting` the socket dropped; a backoff retry is scheduled. Most
947
+ * drops pass through here and back to `live` unnoticed,
948
+ * so UI should stay quiet or subtle here.
949
+ * - `lost` retries exhausted. Show something modal: the document is
950
+ * safe but nothing further will sync until a human acts.
951
+ *
952
+ * A NON-LIVE CONNECTION DOES NOT BLOCK WRITES (doc 12 §5: offline editing
953
+ * is first-class). "I cannot reach the server" is a different fact from
954
+ * "the server said no": nothing typed offline is doomed — it applies
955
+ * locally, is recorded to the offline tail, persists with the bundle, and
956
+ * replays on reconnect (doc 15's arrival ladder). {@link offline} is the
957
+ * state a consumer renders for it; {@link writesBlocked} stays reserved
958
+ * for genuine refusals, where accepting keystrokes would be a lie.
959
+ */
960
+ connection: ConnectionState;
961
+ /**
962
+ * OFFLINE EDITING IS ACTIVE (doc 15 §2 / doc 12 §5): the sequencer is
963
+ * unreachable, a document is in hand, and nothing has refused this
964
+ * client's writes — so edits apply locally and accumulate in the durable
965
+ * offline tail instead of being dropped.
966
+ *
967
+ * - `editsHeld` how many intents the tail currently holds. Show it: the
968
+ * difference between "editing is paused" and "your changes are being
969
+ * kept (N so far)" is the whole point of the state.
970
+ * - `capped` the tail hit its bound (doc 15 §4.3). Recording has STOPPED
971
+ * and {@link writesBlocked} is raised — loudly, with this flag saying
972
+ * why, never by silently discarding keystrokes. The escape hatches are
973
+ * reconnecting or downloading a copy.
974
+ *
975
+ * Null while the connection is live, and null when a refusal already
976
+ * blocks writes (a viewer's link does not become writable by going
977
+ * through a tunnel). On reconnect the tail replays: silently as ordinary
978
+ * edits when the session is the same epoch, else via {@link arrival}.
979
+ */
980
+ offline: {
981
+ editsHeld: number;
982
+ capped: boolean;
983
+ } | null;
984
+ /**
985
+ * Retry the connection immediately, resetting the backoff and the attempt
986
+ * budget. Wired to a "Try again" control on the `lost` UI — from `lost`
987
+ * nothing else will ever retry, because exhausting the budget is precisely
988
+ * the statement that automatic recovery has stopped.
989
+ */
990
+ reconnect: () => void;
991
+ /**
992
+ * The server announced it will END this session, and this is the grace
993
+ * period before it does. `inMs` is the measured remainder at the moment the
994
+ * warning arrived — tick a countdown from it locally rather than expecting
995
+ * refreshed messages.
996
+ *
997
+ * - `idle` cancellable: any qualifying activity (an accepted edit, an
998
+ * admin action, a media transfer, a join — never presence)
999
+ * resets the clock and this goes back to null on its own.
1000
+ * - `lifetime` NOT cancellable by anything; it counts down to the ending.
1001
+ *
1002
+ * Null whenever no deadline is approaching — including after ANY session
1003
+ * end (kick, refusal), because a countdown that outlived its session would
1004
+ * keep promising an ending that already happened.
1005
+ */
1006
+ sessionWarning: {
1007
+ reason: "idle" | "lifetime";
1008
+ inMs: number;
1009
+ } | null;
1010
+ /** True after an edit was refused under the owner's read-only lock
1011
+ * (doc 14 §2.5). NON-FATAL: the live view keeps working.
1012
+ *
1013
+ * LEGACY/FALLBACK now: sticky until reload or `retryWrites`, because a
1014
+ * refusal can only ever say "blocked". Prefer {@link writesBlocked}, which
1015
+ * reads the server's positive roster status when one is published and only
1016
+ * falls back to this against an older server. */
1017
+ readOnlyBlocked: boolean;
1018
+ /**
1019
+ * The server has told this client it is NOT the owner of this document.
1020
+ *
1021
+ * Holding an `ownerToken` string is not the same as it being valid — a room
1022
+ * re-seeded into a new epoch mints a fresh one, leaving the old token
1023
+ * truthy in a browser that now has no admin rights. The welcome carries no
1024
+ * owner flag, so until an admin op is attempted this is genuinely unknown;
1025
+ * a `not-owner` refusal is the authoritative answer.
1026
+ *
1027
+ * Consumers should HIDE admin controls when this is true rather than
1028
+ * disable them: an offered control that cannot work is worse than an absent
1029
+ * one, and the refusal it produces is not a session failure.
1030
+ */
1031
+ notOwner: boolean;
1032
+ /**
1033
+ * THE SERVER WILL NOT ACCEPT THIS CLIENT'S WRITES. Consumers must render the
1034
+ * editor READ-ONLY on this, never merely annotate it: an editable surface
1035
+ * over a server that refuses the writes applies every keystroke locally and
1036
+ * then loses it, which is silent data loss that looks like it worked.
1037
+ *
1038
+ * One predicate rather than a per-reason check, so a new blocking state
1039
+ * inherits the gate instead of needing to remember it.
1040
+ *
1041
+ * NOW READS THE SERVER'S ROSTER STATUS (`RosterEntry.write`), which closed
1042
+ * the seam this comment used to describe. That matters in both directions:
1043
+ * the status is present AT JOIN, so edit 1 is gated rather than applied and
1044
+ * then healed away; and it is POSITIVE, so a lift arrives on its own instead
1045
+ * of leaving the client in viewer mode until reload. Against a server that
1046
+ * publishes no status it falls back to the refusal-derived flag — never to
1047
+ * "allowed", which would restore both bugs at once.
1048
+ *
1049
+ * The status also distinguishes the three server conditions the single
1050
+ * `read-only` refusal could not (owner lock, per-client demotion, viewer
1051
+ * token), so a UI can finally say which one applies rather than inventing a
1052
+ * distinction it could not see.
1053
+ *
1054
+ * A DEAD CONNECTION IS DELIBERATELY NOT A BLOCK — that is the refusal /
1055
+ * unreachable split (doc 12 §5). This predicate means "nothing you type
1056
+ * can ever land"; a dropped socket means "nothing you type can land YET",
1057
+ * and the offline tail is what keeps the two honest: disconnected edits
1058
+ * apply locally, persist durably, and replay on reconnect, so accepting
1059
+ * them is not a lie. The one offline condition that does fold in is the
1060
+ * tail CAP ({@link offline}.capped): past it an edit genuinely cannot be
1061
+ * kept, so the gate closes loudly rather than recording silently stopping.
1062
+ * An earlier revision folded every non-live connection in here; that
1063
+ * over-reached — it was written for server refusals and extended to
1064
+ * transport faults, which made first-class offline editing impossible.
1065
+ *
1066
+ * A REFUSED SESSION FOLDS IN TOO, by the same rule. A refusal ends the
1067
+ * session — no socket, nothing to sequence against — so any document still
1068
+ * on screen (e.g. behind a "session ended" dialog) must be read-only, and
1069
+ * it inherits that from this predicate rather than from a refusal-specific
1070
+ * gate someone could forget to compose.
1071
+ */
1072
+ writesBlocked: boolean;
1073
+ /**
1074
+ * WHY the server refuses this client's writes, straight from the roster.
1075
+ *
1076
+ * UNDEFINED means the server published nothing (an older build), NOT that
1077
+ * writing is allowed — the same contract as the media limit's null. A
1078
+ * consumer must keep its copy generic in that case rather than naming a
1079
+ * cause it was never told.
1080
+ *
1081
+ * The three causes are genuinely different to the person reading the banner:
1082
+ * `owner-lock` may lift at any moment, `demoted` is about them specifically,
1083
+ * and `viewer-role` is a property of their link that nothing the owner does
1084
+ * in-session will change. Telling that last group to wait for the owner is
1085
+ * telling them to wait for something that will never happen.
1086
+ */
1087
+ writeStatus?: WriteStatus;
1088
+ /**
1089
+ * Optimistically clear the FALLBACK block and let the user try again.
1090
+ *
1091
+ * BELT AND BRACES now, not the primary escape: where the server publishes a
1092
+ * write status, a lift arrives on its own and the editor becomes writable
1093
+ * without anyone clicking anything. This remains for the older-server
1094
+ * fallback path — where the block is sticky, no lift is announced, and
1095
+ * attempting a write is the only way to discover the lock is gone — and as a
1096
+ * manual override if a status update is ever missed.
1097
+ */
1098
+ retryWrites: () => void;
1099
+ /**
1100
+ * Set when a resume landed in a different epoch than the stored bundle
1101
+ * (doc 12 §5 case 2): the session took the server's state; the old copy
1102
+ * was saved as a draft. UI copy: "restored by another participant — your
1103
+ * offline copy is saved as a draft." Null otherwise.
1104
+ */
1105
+ epochChanged: {
1106
+ from: string;
1107
+ to: string;
1108
+ } | null;
1109
+ /** Session roster (doc 14 §2): everyone who joined this session, with
1110
+ * connection state — the identity keyspace presence/attribution share. */
1111
+ roster: RosterEntry[];
1112
+ /** Rename/recolor this participant (server sanitizes + fans out). */
1113
+ setProfile: (profile: ParticipantProfile) => void;
1114
+ /** Attribution layer 1 (doc 14 §3): recent applied entries
1115
+ * {seq, clientId, kind} — join clientId to `roster` for names/colors. */
1116
+ activity: {
1117
+ seq: number;
1118
+ clientId: string;
1119
+ kind: string;
1120
+ }[];
1121
+ /** Owner admin ops (doc 14 §2.5) — no-op unless this connection proved
1122
+ * the owner token; the server refuses `not-owner` otherwise. */
1123
+ admin: (action: {
1124
+ op: "kick";
1125
+ clientId: string;
1126
+ } | {
1127
+ op: "readOnly";
1128
+ on: boolean;
1129
+ } | {
1130
+ op: "setRole";
1131
+ clientId: string;
1132
+ role: "editor" | "viewer";
1133
+ }) => void;
1134
+ /**
1135
+ * Offline reconciliation (doc 15 arrival ladder). When a rejoin lands in
1136
+ * a DIFFERENT epoch than the tail was recorded against, the recommended
1137
+ * mode is `suggest` (replay as tracked changes; ≤ the suggest threshold)
1138
+ * or `draft` (large tails). `structural` counts the tail intents that
1139
+ * have no suggestion form (splits/merges/tables/format — rebase.ts):
1140
+ * they are NOT replayed by `suggest` and survive in the banked draft
1141
+ * slot instead — say so, or their absence reads as silent loss. A
1142
+ * same-epoch rejoin never surfaces here: its tail replays silently
1143
+ * through the ordinary submit path, like a large pending queue.
1144
+ * Null when there's nothing to reconcile. `reconcile()` runs the choice.
1145
+ */
1146
+ arrival: {
1147
+ mode: "suggest" | "draft";
1148
+ tailLength: number;
1149
+ structural: number;
1150
+ } | null;
1151
+ reconcile: (mode: "suggest" | "draft") => void;
1152
+ /** How many times the connection self-healed a drifted optimistic replica
1153
+ * (encrypted mode's quiescent mirror check — the B6a class). Telemetry;
1154
+ * the heal itself is automatic and already reflected in doc/docEpoch. */
1155
+ selfHeals: number;
1156
+ /**
1157
+ * Submits the connection DROPPED because it was not ready yet (no replica
1158
+ * to apply against, no confirmed seq, no key to seal with). Should stay 0:
1159
+ * the editor is gated on `ready`, so nothing user-driven can reach that
1160
+ * guard. A non-zero value means some caller is submitting before the
1161
+ * welcome — which is a silent edit loss, and was the shape of B13's
1162
+ * 231-intent gap. Telemetry, deliberately visible rather than swallowed.
1163
+ */
1164
+ droppedPreReady: number;
1165
+ /**
1166
+ * Persistence writes that FAILED (quota exceeded, blocked storage, private
1167
+ * mode). Non-zero means this browser's stored bundle is stale — and in a
1168
+ * zero-custody design that bundle IS the durable copy, so "saved" is
1169
+ * currently a lie. Consumers should SHOW this, not just log it: the user
1170
+ * can still rescue the document with the download button, but only if they
1171
+ * are told. Sticky (never decremented): the doubt persists until reload.
1172
+ */
1173
+ persistErrors: number;
1174
+ /**
1175
+ * The connect-path storage read exceeded its deadline, so this session
1176
+ * joined COLD instead of resuming. Says nothing about whether storage is
1177
+ * full — a slow or blocked store looks identical from here, and
1178
+ * conflating the two with {@link persistErrors} told one user their
1179
+ * storage was full when it held a few megabytes.
1180
+ */
1181
+ storeSlow: boolean;
1182
+ /**
1183
+ * Submits whose seal or transport send THREW after the edit was already
1184
+ * applied optimistically — i.e. lost edits, counted at the far end of the
1185
+ * path from droppedPreReady.
1186
+ *
1187
+ * The two together are a PARTITION, which is the point: droppedPreReady is
1188
+ * "refused before it was ever applied", this is "accepted locally then lost
1189
+ * on the way out". A swarm run that loses N intents can now subtract both
1190
+ * from N and see what remains unexplained, instead of a night of guessing —
1191
+ * see B13, where exactly that arithmetic was missing.
1192
+ */
1193
+ sendFailures: number;
1194
+ /** Upload image bytes to the relay and get the address fields an
1195
+ * insertImage intent must carry (doc 16 §5.1). Null = the relay refused,
1196
+ * and the caller must not reserve anything. Always null when the app
1197
+ * supplied no `httpBase`. */
1198
+ uploadMedia: (bytes: Uint8Array) => Promise<{
1199
+ blobSha: string;
1200
+ bytesLen: number;
1201
+ iv?: string;
1202
+ } | null>;
1203
+ /**
1204
+ * Largest single upload the relay accepts, in bytes, as published in the
1205
+ * welcome — so an oversized file can be refused locally instead of after a
1206
+ * full seal-hash-upload round trip.
1207
+ *
1208
+ * NULL MEANS "NO PUBLISHED LIMIT", and the only correct response to it is to
1209
+ * skip the check: a server that publishes nothing still enforces its real
1210
+ * limit, so a client that invents a default either blocks uploads that
1211
+ * would have succeeded or promises a size that will be refused.
1212
+ */
1213
+ mediaMaxBlobBytes: number | null;
1214
+ /**
1215
+ * Reverse this participant's last SEQUENCED action (plan doc 03 Phase 8).
1216
+ * The inverse is computed locally and submitted as an ORDINARY intent, so
1217
+ * it converges, broadcasts, and — if the target has since been deleted or
1218
+ * changed — is rejected cleanly on every replica alike.
1219
+ *
1220
+ * The outcome is reported rather than thrown so the UI can say the right
1221
+ * thing: `cannot-undo` means the last action has no inverse yet (a
1222
+ * different message from having nothing to undo), and `unavailable` means
1223
+ * this connection has no collaborative undo at all — today the plaintext
1224
+ * connection, whose authority lives on the server.
1225
+ */
1226
+ undoLast: () => UndoOutcome;
1227
+ }
1228
+ declare function useCollab(opts: UseCollabOptions): CollabSession;
1229
+ /**
1230
+ * A complete collaborative editor: joins a document over the collab server and
1231
+ * renders its live, reconciled state, forwarding local edits as intents. This
1232
+ * is the inbound + outbound integration composed for you — the app supplies
1233
+ * only the connection params.
1234
+ *
1235
+ * Rendering strategy: DocxView renders the replica's LIVE document object
1236
+ * directly (`collab.doc`). Broadcasts mutate that same instance in place and
1237
+ * bump `version`, which DocxView receives as `renderSignal` and turns into a
1238
+ * single in-place repaint — no per-broadcast serialize/parse round-trip. The
1239
+ * `source` bytes are computed only on a true-conflict reload (keyed on
1240
+ * `docEpoch`) as the placeholder/fallback. The visual rendering runs in the
1241
+ * browser (as all of DocxView does); the protocol/convergence/binding it rides
1242
+ * on are covered by the headless test suites.
1243
+ */
1244
+ /** Toolbar groups OFF by default in the collab editor. Uploads (image/icon/
1245
+ * screenshot/3D/media/object) are excluded per the demo threat model (no
1246
+ * object upload → no stored-payload surface); the rest are commands not yet
1247
+ * routed through intents — showing them would make local-only edits that
1248
+ * silently diverge from other participants. Flip any of these on explicitly
1249
+ * via `toolbarFeatures` once wired. */
1250
+ declare const COLLAB_TOOLBAR_DEFAULTS: Partial<Record<ToolbarFeature, boolean>>;
1251
+ declare function CollabEditor(opts: UseCollabOptions & {
1252
+ editable?: boolean;
1253
+ /** Render the Word-style ribbon toolbar above the page (default true). */
1254
+ toolbar?: boolean;
1255
+ /** Ribbon mode: "simple" (Home only) or "advanced" (default). */
1256
+ toolbarMode?: ToolbarMode;
1257
+ /** Per-group overrides merged over the collab-safe defaults. */
1258
+ toolbarFeatures?: Partial<Record<ToolbarFeature, boolean>>;
1259
+ /** Observe the imperative document API (find/replace, inserts, ...). */
1260
+ onReady?: (api: DocxViewApi) => void;
1261
+ /** Observe the live CollabSession (roster, activity, epochChanged,
1262
+ * doc-for-download) — how an app shell renders chips/banners/buttons
1263
+ * around the editor without re-implementing its composition. */
1264
+ onSession?: (session: CollabSession) => void;
1265
+ /** Custom refusal UI (e.g. already-open -> "use here instead",
1266
+ * no-session -> "bring it back live"). Default: a refresh notice.
1267
+ *
1268
+ * `ctx.docVisible` says which surface the content lands on. False (the
1269
+ * pre-live refusals: already-open, room-full, version mismatch, a code
1270
+ * gate): the content IS the page, exactly as before — render a full
1271
+ * screen. True (post-live refusals: idle-timeout, session-expired): the
1272
+ * document is still in hand and stays on screen READ-ONLY behind the
1273
+ * content, which is rendered in an overlay covering it — render a
1274
+ * dialog/scrim, not a full page. Existing one-argument hosts keep their
1275
+ * old markup untouched; the second argument only ADDS the information. */
1276
+ refusedContent?: (reason: string, ctx?: {
1277
+ docVisible: boolean;
1278
+ }) => ReactNode;
1279
+ }): ReactNode;
1280
+
1281
+ export { BundlePersister, type BundleStore, COLLAB_TOOLBAR_DEFAULTS, CollabEditor, type CollabSession, type DocBundle, InMemoryBundleStore, IndexedDbBundleStore, type CollabSession as InjectedCollabSession, type ParsedBundleKey, type StoredDocKind, type StoredDocSummary, UndoOutcome, type UseCollabOptions, docHash, draftKey, mediaAddressesOf, parseBundleKey, supersededKey, useCollab, versionKey };