cautpartener / client / src / core / hub.ts
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
/**
 * RoomHub (M3): ONE shared libp2p node + ONE Helia for the whole session,
 * plus a registry of open E2E rooms (self / pair / group). Every room = lib
 * RoomCrypto + RoomLog + MailboxSync over the shared Helia; per-room OrbitDB
 * state lives in its own `sueta/<roomId>` directory, so rooms never share
 * oplog storage while sharing the block/data stores.
 *
 * Transports (M3b): the node carries the FULL browser transport set from
 * lib/net.ts — websockets + webRTC + circuit relay, noise, yamux, gossipsub,
 * pubsub-peer-discovery, identify, ping — but stays IDLE (no dials, nothing
 * inbound) until core/livemesh.ts is started via ensureLive(), lazily on the
 * first opened conversation. Once live, OrbitDB's own pubsub sync replicates
 * every open room over direct WebRTC links for free — sealed entries only;
 * the E2E layer, not the transport, gates trust. The mailbox path never
 * depends on any of this (live is an enhancement, not load-bearing).
 *
 * Budgets: at most MAX_OPEN_ROOMS OrbitDB stores open at once (each carries
 * a keystore, an oplog index and a sync worker). The self room is pinned and
 * never evicted; conversation rooms are closed least-recently-used when the
 * cap is hit. Closing is graceful: MailboxSync.stop() (detaches listeners +
 * timers; its deposit queue is persisted in localStorage, so nothing queued
 * is lost) then RoomLog.close(). A closed room reopens later from IndexedDB.
 */

import { gossipsub } from "@chainsafe/libp2p-gossipsub";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
import { identify } from "@libp2p/identify";
import { ping } from "@libp2p/ping";
import { pubsubPeerDiscovery } from "@libp2p/pubsub-peer-discovery";
import { webRTC } from "@libp2p/webrtc";
import { webSockets } from "@libp2p/websockets";
import { createLibp2p } from "libp2p";
import { APP_SALT, nsDb, nsKey } from "../config";
import { RoomCrypto } from "../lib/crypto";
import type { MailboxConfig, RelayConfig } from "../lib/descriptor";
import type { Identity } from "../lib/identity";
import { openHelia, RoomLog, type LogEntryMeta } from "../lib/log";
import { type MailboxAuth, MailboxClient, MailboxSync, type ReplayResult, type DepositResult } from "../lib/mailbox";
import { appMailboxAuth } from "./app-mailbox-auth";
import type { LogOp } from "../lib/protocol";
import { IceConfig } from "../lib/turn";
import { LiveMesh, type HubNode } from "./livemesh";

export type RoomKind = "self" | "pair" | "group";

/** Self room always open + the visible conversation + a few recent ones. */
const MAX_OPEN_ROOMS = 6;

/** Opaque mailbox seqs order by (length, lexicographic) — mailbox.ts rule. */
export function seqGt(a: string | null, b: string | null): boolean {
  if (!a) return false;
  if (!b) return true;
  return a.length !== b.length ? a.length > b.length : a > b;
}

/**
 * MailboxClient that (a) reports when a replay page chain reached the end of
 * the server's history — MailboxSync has no completion callback for a replay
 * cycle that joined 0 entries, so observing the final page here is the only
 * truthful "caught up" signal without touching the vendored lib — and
 * (b) records the seq of this device's LAST accepted deposit per room. The
 * conversation list's cheap "has new mail" probe (core/chatlog.ts) compares
 * the room's latest mailbox seq against the replay cursor; without (b) our
 * own just-sent messages (deposits the cursor only passes on the next
 * replay) would light the unread dot on our own conversations.
 */
export class TruthfulMailboxClient extends MailboxClient {
  onReplayEnd: () => void = () => {};
  #lastDepositKey: string;

  constructor(baseUrl: string, mbxRoomId: string, auth: MailboxAuth, lastDepositKey: string) {
    super(baseUrl, mbxRoomId, auth);
    this.#lastDepositKey = lastDepositKey;
  }

  override async deposit(payload: Uint8Array): Promise<DepositResult> {
    const r = await super.deposit(payload);
    if (r.ok && r.seq) {
      try {
        const prev = localStorage.getItem(this.#lastDepositKey);
        if (seqGt(r.seq, prev)) localStorage.setItem(this.#lastDepositKey, r.seq);
      } catch {} // best-effort: losing it costs one spurious unread dot
    }
    return r;
  }

  override async replay(after: string | null, limit: number): Promise<ReplayResult> {
    const r = await super.replay(after, limit);
    if (r.ok && !r.page.hasMore) {
      try {
        this.onReplayEnd();
      } catch {}
    }
    return r;
  }
}

/** Mailbox-side sync state of one room (drives the profile/sync UI lines). */
export interface RoomSyncState {
  mailbox: boolean;
  queued: number;
  retry: number;
  cursor: string | null;
  lastReplayAt: number | null;
  lastReplayCount: number;
  synced: boolean;
  gap: boolean;
  gapNotice: string | null;
}

export interface RoomHooks {
  /** Live + replayed entries, deduped by hash; wired before replication starts. */
  onEntry: (e: LogEntryMeta) => void;
  onError?: (err: Error) => void;
  /** Sync-state changes (replay done, gap detected…). */
  onSync?: (s: RoomSyncState) => void;
  /** The hub evicted this room (LRU) — drop your projections; reopen at will. */
  onEvict?: () => void;
}

export class RoomHandle {
  readonly kind: RoomKind;
  readonly secret: string;
  readonly crypto: RoomCrypto;
  readonly log: RoomLog;
  readonly mbx: MailboxSync | null;
  /** Room id (HKDF of the secret) — NOT the mailbox room id the server sees. */
  readonly roomId: string;

  #hooks: RoomHooks;
  /**
   * How many OTHER devices are live on this room's replication topic right
   * now (gossipsub subscribers of the OrbitDB address) — wired by the hub.
   * Always 0 while the live mesh is off; drives the "online acum" presence
   * dot. Honest limit: my own other device counts too (peer ids are
   * session-ephemeral by design).
   */
  peersOnline: () => number = () => 0;
  #lastReplayAt: number | null = null;
  #lastReplayCount = 0;
  #synced = false;
  #gap = false;
  #gapNotice: string | null = null;
  #closed = false;
  /** LRU stamp, maintained by the hub. */
  lastUsed = Date.now();
  pinned = false;

  constructor(kind: RoomKind, secret: string, crypto: RoomCrypto, log: RoomLog, mbx: MailboxSync | null, hooks: RoomHooks) {
    this.kind = kind;
    this.secret = secret;
    this.crypto = crypto;
    this.log = log;
    this.mbx = mbx;
    this.roomId = crypto.roomId;
    this.#hooks = hooks;
  }

  get closed(): boolean {
    return this.#closed;
  }

  get sync(): RoomSyncState {
    const s = this.mbx?.state ?? { queued: 0, retry: 0, cursor: null };
    return {
      mailbox: this.mbx !== null,
      queued: s.queued,
      retry: s.retry,
      cursor: s.cursor,
      lastReplayAt: this.#lastReplayAt,
      lastReplayCount: this.#lastReplayCount,
      synced: this.#synced,
      gap: this.#gap,
      gapNotice: this.#gapNotice,
    };
  }

  /** Append an app-plane op. The vendored lib types append() against board's
   * LogOp vocabulary; the runtime contract is `{ t: string, ... }`, so app
   * ops cross with a cast — the pattern the protocol doc blesses. */
  append(op: { t: string }): Promise<string> {
    return this.log.append(op as unknown as LogOp);
  }

  /**
   * Replay this device's local copy (fully offline-capable). State-shaped
   * logs (the home log) must pass Infinity — LWW keys older than a tail-N
   * cutoff would vanish from the fold; chat rooms cap the tail.
   */
  loadLocal(amount: number): Promise<number> {
    return this.log.loadTail(amount);
  }

  /** Kick the mailbox transport (replay + flush, non-blocking). Owners call
   * this AFTER loadLocal() so replayed entries fold onto restored state. */
  startSync(): void {
    this.mbx?.start();
  }

  /** MailboxSync callback plumbing (wired by the hub). */
  noteReplayed(count: number): void {
    this.#lastReplayAt = Date.now();
    this.#lastReplayCount = count;
    this.#synced = true;
    this.#emitSync();
  }

  /** Replay reached the end of mailbox history — truthful even with 0 joins. */
  noteReplayComplete(): void {
    this.#synced = true;
    this.#lastReplayAt ??= Date.now();
    this.#emitSync();
  }

  /** History is incomplete (expired mailbox entries) — surface, don't hide. */
  noteGap(msg: string): void {
    this.#gap = true;
    this.#gapNotice = msg;
    this.#emitSync();
  }

  #emitSync(): void {
    if (this.#closed) return;
    try {
      this.#hooks.onSync?.(this.sync);
    } catch {}
  }

  emitEntry(e: LogEntryMeta): void {
    if (this.#closed) return;
    try {
      this.#hooks.onEntry(e);
    } catch {}
  }

  emitError(err: Error): void {
    try {
      this.#hooks.onError?.(err);
    } catch {}
  }

  /** Graceful close: mailbox first (persisted queue), then the OrbitDB log. */
  async close(evicted = false): Promise<void> {
    if (this.#closed) return;
    this.#closed = true;
    this.mbx?.stop();
    await this.log.close().catch(() => {});
    if (evicted) {
      try {
        this.#hooks.onEvict?.();
      } catch {}
    }
  }
}

export class RoomHub {
  readonly helia: Awaited<ReturnType<typeof openHelia>>;
  readonly identity: Identity;
  /** Live-mesh controller over the shared node; idle until ensureLive(). */
  readonly live: LiveMesh;
  #libp2p: HubNode;
  #mailbox: MailboxConfig | null;
  #rooms = new Map<string, RoomHandle>(); // room secret → open handle
  #opening = new Map<string, Promise<RoomHandle>>(); // dedupes concurrent opens
  #openChain: Promise<unknown> = Promise.resolve(); // serializes opens (budget + OrbitDB)
  #stopped = false;

  private constructor(
    libp2p: HubNode,
    helia: Awaited<ReturnType<typeof openHelia>>,
    identity: Identity,
    relay: RelayConfig | null,
  ) {
    this.#libp2p = libp2p;
    this.helia = helia;
    this.identity = identity;
    this.#mailbox = relay?.mailbox ?? null;
    this.live = new LiveMesh(libp2p, relay?.relayMultiaddr ?? "");
  }

  static async start(identity: Identity, relay: RelayConfig | null): Promise<RoomHub> {
    // ONE full browser node for every room (lib/net.ts assembly, sans
    // bootstrap — LiveMesh dials the relay itself, lazily). Until ensureLive()
    // runs nothing dials and nothing can dial in, so this behaves exactly like
    // the M3a transportless node: gossipsub publishes of new heads go to zero
    // peers (allowed) and bitswap stays idle. Mailbox does the moving either
    // way. Fresh ephemeral node key per session — the relay can't link page
    // loads (net.ts newSessionKey rationale; createLibp2p defaults to one).
    const ice = relay?.turnCredsUrl ? new IceConfig(relay.turnCredsUrl) : null;
    const libp2p = (await createLibp2p({
      addresses: { listen: ["/p2p-circuit", "/webrtc"] },
      transports: [
        webSockets(),
        webRTC({ rtcConfiguration: () => (ice ? ice.get() : Promise.resolve({})) }),
        circuitRelayTransport(),
      ],
      connectionEncrypters: [noise()],
      streamMuxers: [yamux()],
      ...(relay
        ? { peerDiscovery: [pubsubPeerDiscovery({ interval: 5000, topics: [relay.discoveryTopic] })] }
        : {}),
      services: {
        identify: identify(), // gossipsub declares it as a required capability
        ping: ping(),
        // gossipsub deliberately does NOT run on limited (circuit) connections:
        // its streams only ever bind to direct webrtc links, so an established
        // mesh keeps flowing when the relay dies (lib/net.ts rationale).
        pubsub: gossipsub({ allowPublishToZeroTopicPeers: true, emitSelf: false }),
      },
      connectionManager: { maxConnections: 32 },
    })) as HubNode;
    const helia = await openHelia(libp2p, { blocks: nsDb("blocks"), data: nsDb("data") });
    return new RoomHub(libp2p, helia, identity, relay);
  }

  get mailbox(): MailboxConfig | null {
    return this.#mailbox;
  }

  /**
   * Go live, lazily and idempotently — called on the first opened
   * conversation and on Conversații-tab visits, never at boot. A failure
   * (bad config, relay down) costs nothing: mailbox transport is unaffected.
   */
  ensureLive(): void {
    if (this.#stopped) return;
    this.live.start();
  }

  /** Debug/e2e visibility — no room ids or secrets, just budget accounting. */
  get debug(): {
    open: number;
    rooms: { kind: RoomKind; pinned: boolean; queued: number; online: number }[];
    live: LiveMesh["debug"];
  } {
    const rooms = [...this.#rooms.values()].map((r) => ({
      kind: r.kind,
      pinned: r.pinned,
      queued: r.mbx?.state.queued ?? 0,
      online: r.peersOnline(),
    }));
    return { open: rooms.length, rooms, live: this.live.debug };
  }

  /** The already-open handle for a secret (bumps its LRU stamp), or null. */
  peek(secret: string): RoomHandle | null {
    const h = this.#rooms.get(secret);
    if (h) h.lastUsed = Date.now();
    return h ?? null;
  }

  /**
   * Open (or return the already-open) room for a secret. `hooks` bind only
   * on a fresh open — each room has exactly ONE owning service (SelfRoom for
   * "self", ChatService for pair/group); UI listeners subscribe to the owner,
   * never to the handle.
   */
  openRoom(kind: RoomKind, secret: string, hooks: RoomHooks, opts?: { pinned?: boolean }): Promise<RoomHandle> {
    const existing = this.peek(secret);
    if (existing) return Promise.resolve(existing);
    let inflight = this.#opening.get(secret);
    if (!inflight) {
      // serialize ALL opens: two concurrent opens would each pass the budget
      // check and could transiently exceed it (and interleave OrbitDB setup)
      inflight = this.#openChain
        .then(() => {
          const again = this.peek(secret); // opened while we queued
          return again ?? this.#doOpen(kind, secret, hooks, opts?.pinned === true);
        })
        .finally(() => this.#opening.delete(secret));
      this.#openChain = inflight.catch(() => {});
      this.#opening.set(secret, inflight);
    }
    return inflight;
  }

  async #doOpen(kind: RoomKind, secret: string, hooks: RoomHooks, pinned: boolean): Promise<RoomHandle> {
    if (this.#stopped) throw new Error("hub stopped");
    await this.#evictOverBudget(1);

    const rc = await RoomCrypto.create(secret, APP_SALT);

    // Entry hooks are wired BEFORE replication/replay starts; buffer the gap
    // until the handle exists (its owner attached its fold via `hooks`).
    const pending: LogEntryMeta[] = [];
    let handle: RoomHandle | null = null;
    let mbxRef: MailboxSync | null = null; // assigned below; the hook closes over it
    const log = await RoomLog.open(this.helia, rc, this.identity, {
      onEntry: (e) => {
        if (handle) {
          handle.emitEntry(e);
          // live replication may have backfilled a mailbox gap — cancel/drain
          // pending mailbox retries instead of waiting out their backoff
          mbxRef?.onLiveEntry();
        } else {
          pending.push(e);
        }
      },
      onError: (err) => handle?.emitError(err), // undecryptable garbage — never fatal
    });

    let mbx: MailboxSync | null = null;
    if (this.#mailbox) {
      const mailbox = this.#mailbox;
      const mbxRoomId = await rc.mailboxRoomId();
      const client = new TruthfulMailboxClient(
        mailbox.baseUrl,
        mbxRoomId,
        appMailboxAuth(mailbox.credsUrl),
        nsKey(`mbx-lastdep:${rc.roomId}`),
      );
      client.onReplayEnd = () => handle?.noteReplayComplete();
      mbx = new MailboxSync({
        log,
        client,
        cipher: await rc.mbxCipher(),
        maxMessageKb: mailbox.maxMessageKb,
        keys: {
          cursor: nsKey(`mbx-cursor:${rc.roomId}`),
          queue: nsKey(`mbx-queue:${rc.roomId}`),
          sent: nsKey(`mbx-sent:${rc.roomId}`),
          retry: nsKey(`mbx-retry:${rc.roomId}`),
          ident: nsKey(`mbx-ident:${rc.roomId}`),
        },
        hasIdentity: true,
        onReplayed: (n) => handle?.noteReplayed(n),
        // gap honesty: cursor < oldest_seq or permanently-unjoinable entries —
        // history is incomplete and the UI must say so, not claim "synced"
        onNotice: (msg) => handle?.noteGap(msg),
      });
      log.onAppended = (h) => mbx!.enqueueEntry(h);
      log.onImageStored = (c) => mbx!.enqueueImage(c);
      // any unixfs blob block must fit one mailbox frame (envelope ≈ 40 B)
      log.imageChunkBytes = Math.min((mailbox.maxMessageKb - 1) * 1024, 61_440);
      mbxRef = mbx;
    }

    handle = new RoomHandle(kind, secret, rc, log, mbx, hooks);
    handle.pinned = pinned;
    // presence: gossipsub subscribers of this room's replication topic
    handle.peersOnline = () => this.live.subscribersOf(log.address);
    const buffered = pending.splice(0);
    for (const e of buffered) handle.emitEntry(e);
    if (buffered.length > 0) mbxRef?.onLiveEntry();
    this.#rooms.set(secret, handle);
    return handle;
  }

  /** Close LRU non-pinned rooms until `incoming` more rooms fit the budget. */
  async #evictOverBudget(incoming: number): Promise<void> {
    while (this.#rooms.size + incoming > MAX_OPEN_ROOMS) {
      const victims = [...this.#rooms.values()].filter((r) => !r.pinned).sort((a, b) => a.lastUsed - b.lastUsed);
      const victim = victims[0];
      if (!victim) return; // everything pinned — over-budget rather than broken
      this.#rooms.delete(victim.secret);
      await victim.close(true);
    }
  }

  /** Explicitly close one room (owner is going away). */
  async closeRoom(handle: RoomHandle): Promise<void> {
    this.#rooms.delete(handle.secret);
    await handle.close(false);
  }

  /** Session teardown: live mesh, every room, then the shared libp2p node. */
  stop(): void {
    this.#stopped = true;
    this.live.stop();
    for (const room of this.#rooms.values()) void room.close(false);
    this.#rooms.clear();
    void Promise.resolve(this.#libp2p.stop()).catch(() => {});
  }
}

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git