cautpartener / client / src / core / conversations.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
477
478
479
480
481
482
483
484
485
486
487
/**
 * ChatService (M3a): the conversation registry + room lifecycle orchestrator
 * over the RoomHub.
 *
 * Registry: conversations are DERIVED, not stored —
 *   - 1:1: every ACTIVE contact (verified xPub) is an available conversation
 *     (id = peer pub), materialized lazily on first open / first message
 *     (core/pairwise.ts);
 *   - group: every chatreg in the folded home log (id = chatreg id,
 *     core/groupchat.ts).
 *
 * Room lifecycle: a conversation's room opens on view and on "has new mail",
 * bounded by the hub's LRU budget (self room pinned + a handful of
 * conversations). Closed rooms still get incoming-message detection for the
 * list via the cheap mailbox probe (core/chatlog.ts MailboxProbe) — run on
 * boot and on Conversații-tab opens, throttled, with per-room probe state
 * (derived secrets/ids + auth) cached across passes.
 *
 * Group invites: pair rooms double as the control channel (§5). This service
 * wires every pair ChatRoom's onControl to groupchat.registerGinvite, which
 * appends the chatreg to MY home log — the fold change then surfaces the new
 * group conversation on all my devices.
 */

import { matchByConvId, matchConvId, matchRoomSecret, matches } from "../public/match-service";
import type { LogEntryMeta } from "../lib/log";
import { ChatRoom, getPreview, getSeenTs, MailboxProbe, markSeen, type ConvPreview, type ConvRef } from "./chatlog";
import { isValidCard, verifyContactCert, type ContactCard } from "./contacts";
import type { ContactOp } from "./home-log";
import { ginviteOf, groupConversations, mintGroupChat, registerGinvite, CHAT_MEMBERS_MAX } from "./groupchat";
import { pairConversations, pairSecret } from "./pairwise";
import type { SelfRoom } from "./selfroom";

const PROBE_THROTTLE_MS = 30_000;
const PROBE_MAX = 30; // conversations probed per pass (newest-activity first)
const BOOT_OPEN_MAX = 4; // rooms auto-opened at boot (unread first, then recent)

export interface Conversation {
  ref: ConvRef;
  name: string;
  /** Group only: member count (incl. me). */
  memberCount?: number;
  /** Match only (P3): the matched peer's pub. */
  matchPeer?: string;
  preview: ConvPreview | null;
  unread: boolean;
  /** Ordering stamp: last message, else registration/contact time. */
  activityTs: number;
}

export class ChatService {
  #selfroom: SelfRoom;
  #myPub: string;
  #seed: string;
  #rooms = new Map<string, ChatRoom>(); // convId → open room
  #opening = new Map<string, Promise<ChatRoom | null>>();
  #probeUnread = new Map<string, boolean>(); // closed-room probe results
  // derived §2 pair secrets, keyed peerPub|peerXPub — X25519+HKDF runs once
  // per binding (a TOFU key change means a new xPub ⇒ a new cache key)
  #pairSecrets = new Map<string, Promise<string>>();
  // reusable probes (MailboxClient + the shared app-scoped auth), keyed by room secret
  #probes = new Map<string, MailboxProbe>();
  // WIRE roomSecret each open match room was derived from — when the folded
  // mlike (or the received store) later picks a different winner, the cached
  // room is closed so the next open derives from the winning secret (§5c
  // multi-device double-like convergence).
  #matchOpenSecrets = new Map<string, string>();
  #lastProbeAt = 0;
  #probing = false;
  #listeners = new Set<() => void>();
  #roomListeners = new Map<string, Set<() => void>>();

  /**
   * §5 audience-grant sink (M5b): a pairwise fold surfaced a {t:"grant"} op.
   * `senderPub` is the entry's VERIFIED author. Wired by boot to
   * core/audience.ts registerGrant.
   */
  onPairGrant: (senderPub: string, op: unknown) => void = () => {};

  constructor(selfroom: SelfRoom, identity: { seed: string; pub: string }) {
    this.#selfroom = selfroom;
    this.#seed = identity.seed;
    this.#myPub = identity.pub;
    // contacts/chatregs synced from other devices change the list live
    selfroom.onStateChanged(() => {
      this.#reconcileMatchRooms();
      this.#notify();
    });
  }

  get myPub(): string {
    return this.#myPub;
  }

  /** Conversation-list change feed (previews, unread, registry). */
  onListChanged(fn: () => void): () => void {
    this.#listeners.add(fn);
    return () => this.#listeners.delete(fn);
  }

  /** Per-conversation message feed (the chat view subscribes while mounted). */
  onRoomChanged(convId: string, fn: () => void): () => void {
    let set = this.#roomListeners.get(convId);
    if (!set) this.#roomListeners.set(convId, (set = new Set()));
    set.add(fn);
    return () => {
      set.delete(fn);
      if (set.size === 0) this.#roomListeners.delete(convId);
    };
  }

  #notify(): void {
    for (const fn of this.#listeners) {
      try {
        fn();
      } catch {}
    }
  }

  #notifyRoom(convId: string): void {
    for (const fn of this.#roomListeners.get(convId) ?? []) {
      try {
        fn();
      } catch {}
    }
  }

  // ---- registry --------------------------------------------------------------

  /** The merged conversation list, most recent activity first. */
  conversations(): Conversation[] {
    const state = this.#selfroom.state;
    const out: Conversation[] = [];
    for (const c of pairConversations(state)) {
      const preview = getPreview(c.pub);
      out.push({
        ref: { kind: "pair", id: c.pub },
        name: c.name,
        preview,
        unread: this.#isUnread(c.pub, preview),
        activityTs: preview?.ts ?? c.ts,
      });
    }
    for (const g of groupConversations(state)) {
      const preview = getPreview(g.id);
      out.push({
        ref: { kind: "group", id: g.id },
        name: g.name,
        memberCount: g.members.length,
        preview,
        unread: this.#isUnread(g.id, preview),
        activityTs: preview?.ts ?? g.ts,
      });
    }
    // P3 match conversations: derived from mutual likes (home-log mlikes ∩
    // received wraps) — no contact relationship required
    for (const m of matches()) {
      const id = matchConvId(m.peer);
      const preview = getPreview(id);
      out.push({
        ref: { kind: "match", id },
        name: m.name || `${m.peer.slice(0, 10)}…`,
        matchPeer: m.peer,
        preview,
        unread: this.#isUnread(id, preview),
        activityTs: preview?.ts ?? m.ts,
      });
    }
    return out.sort((a, b) => b.activityTs - a.activityTs);
  }

  /** ACTIVE contact record (P4 calls derive pair secrets outside the open path). */
  contactOf(pub: string): ContactOp | null {
    const c = this.#selfroom.state.contacts.get(pub);
    return c?.status === "active" ? c : null;
  }

  /** Resolve a conversation id (pair pub, chatreg id or m~<pub>) to its ref. */
  resolve(convId: string): ConvRef | null {
    const state = this.#selfroom.state;
    const contact = state.contacts.get(convId);
    if (contact?.status === "active") return { kind: "pair", id: convId };
    if (state.chats.has(convId)) return { kind: "group", id: convId };
    if (matchByConvId(convId)) return { kind: "match", id: convId };
    return null;
  }

  /** External match-state change (received-like store) — refresh the list. */
  pokeList(): void {
    this.#reconcileMatchRooms();
    this.#notify();
  }

  /**
   * Multi-device double-like convergence (§5c): when the folded mlike for a
   * matched pair changes its winning roomSecret (LWW settled on the other
   * device's like), an already-open ChatRoom keeps serving the STALE derived
   * secret. Close it so the next open re-derives from the winner. Messages
   * appended into the loser room stay stranded there — documented honest
   * limit of the race, not silently merged.
   */
  #reconcileMatchRooms(): void {
    for (const [convId, wireSecret] of [...this.#matchOpenSecrets]) {
      const room = this.#rooms.get(convId);
      if (!room) {
        this.#matchOpenSecrets.delete(convId);
        continue;
      }
      const match = matchByConvId(convId);
      if (match && match.roomSecret === wireSecret) continue;
      this.#matchOpenSecrets.delete(convId);
      this.#rooms.delete(convId);
      if (room.handle) void this.#selfroom.hub.closeRoom(room.handle).catch(() => {});
      this.#notify();
    }
  }

  /** §2 pairSecret with a per-binding cache (used by open AND every probe pass). */
  #pairSecretOf(contact: { pub: string; xPub: string }): Promise<string> {
    const key = `${contact.pub}|${contact.xPub}`;
    let p = this.#pairSecrets.get(key);
    if (!p) {
      p = pairSecret({ seed: this.#seed, pub: this.#myPub }, { pub: contact.pub, xPub: contact.xPub });
      p.catch(() => this.#pairSecrets.delete(key)); // never cache a failure
      this.#pairSecrets.set(key, p);
    }
    return p;
  }

  #isUnread(convId: string, preview: ConvPreview | null): boolean {
    if (this.#probeUnread.get(convId) === true) return true;
    return preview !== null && preview.from !== this.#myPub && preview.ts > getSeenTs(convId);
  }

  // ---- room lifecycle --------------------------------------------------------

  /** The open ChatRoom for a conversation (bumps LRU), or null. */
  getOpen(convId: string): ChatRoom | null {
    const room = this.#rooms.get(convId) ?? null;
    if (room?.handle) this.#selfroom.hub.peek(room.handle.secret);
    return room;
  }

  /** Open (or return) the conversation's room; null when it doesn't resolve. */
  open(convId: string): Promise<ChatRoom | null> {
    const existing = this.getOpen(convId);
    if (existing) return Promise.resolve(existing);
    let inflight = this.#opening.get(convId);
    if (!inflight) {
      inflight = this.#doOpen(convId).finally(() => this.#opening.delete(convId));
      this.#opening.set(convId, inflight);
    }
    return inflight;
  }

  async #doOpen(convId: string): Promise<ChatRoom | null> {
    const ref = this.resolve(convId);
    if (!ref) return null;
    // first opened conversation ⇒ go live (lazy; a failure costs nothing —
    // the room's mailbox transport below never depends on it)
    this.#selfroom.hub.ensureLive();
    const state = this.#selfroom.state;
    let secret: string;
    let authorized: ReadonlySet<string>;
    let matchWireSecret: string | null = null;
    if (ref.kind === "pair") {
      const contact = state.contacts.get(convId)!;
      secret = await this.#pairSecretOf(contact);
      authorized = new Set([this.#myPub, contact.pub]);
    } else if (ref.kind === "match") {
      // P3 (§5c): the wire pick is the pubLo party's like's roomSecret, but
      // the hub NEVER sees that peer-chosen value — the room opens with the
      // HKDF-derived effective secret (token-bound, per-pair unique). Only
      // the two matched identities may render.
      const match = matchByConvId(convId);
      if (!match) return null;
      try {
        secret = await matchRoomSecret(match);
      } catch {
        return null;
      }
      matchWireSecret = match.roomSecret;
      authorized = new Set([this.#myPub, match.peer]);
    } else {
      const reg = state.chats.get(convId)!;
      secret = reg.secret;
      authorized = new Set(reg.members);
    }

    const room = new ChatRoom(ref, this.#myPub, authorized);
    room.onChange = () => {
      this.#notifyRoom(convId);
      this.#notify(); // previews / unread on the list
    };
    if (ref.kind === "pair") room.onControl = (e) => this.#onPairControl(e);
    if (ref.kind === "match") room.onControl = (e) => this.#onMatchControl(convId, e);

    const handle = await this.#selfroom.hub.openRoom(ref.kind === "match" ? "pair" : ref.kind, secret, {
      onEntry: (e) => room.applyEntry(e),
      onError: () => {}, // undecryptable garbage — never fatal
      onSync: () => this.#notify(),
      onEvict: () => {
        // LRU closed this room under us: drop the projection; previews and
        // last-seen persist, so the list stays truthful and a later open
        // re-folds from IndexedDB.
        this.#rooms.delete(convId);
        this.#notify();
      },
    });
    await room.attach(handle);
    this.#rooms.set(convId, room);
    if (matchWireSecret !== null) {
      // record AFTER the room lands in #rooms, then reconcile once: a fold
      // that flipped the winning secret while this open was in flight closes
      // the now-stale room instead of leaving it to serve the loser secret
      this.#matchOpenSecrets.set(convId, matchWireSecret);
      this.#reconcileMatchRooms();
    }
    // the room is syncing now — the stale probe verdict no longer applies
    // (unread is computed from preview vs last-seen from here on)
    this.#probeUnread.delete(convId);
    this.#notify();
    return room;
  }

  /** Pairwise control channel (§5): ginvite → register chatreg in MY home log.
   * `e.from` is the entry's VERIFIED author (the pair fold already dropped
   * non-member entries) — registerGinvite rejects a ginvite whose sender is
   * not in the invited member set (hidden-reader defense, §5). */
  #onPairControl(e: LogEntryMeta): void {
    const op = e.op as unknown as { t?: string };
    if (op.t === "grant") {
      // §5 audience key delivery — the sender is already the verified contact
      try {
        this.onPairGrant(e.from, e.op);
      } catch {}
      return;
    }
    if (op.t !== "ginvite") return;
    void registerGinvite(this.#selfroom, this.#myPub, e.from, e.op).then((registered) => {
      if (registered) this.#notify();
    });
  }

  // ---- match-room control channel (P3): in-chat contact-card exchange --------

  /** Latest VERIFIED contact card received in a match room, per conv id. */
  #matchCards = new Map<string, ContactCard>();

  /** The peer's verified card offered inside a match chat (null = none yet). */
  matchCardOf(convId: string): ContactCard | null {
    return this.#matchCards.get(convId) ?? null;
  }

  /**
   * {t:"card", card} inside a match room: the optional "Adaugă în contacte"
   * flow. Receiver rules mirror the rendezvous exchange (§2b): shape clamps,
   * the card's pub MUST equal the entry's VERIFIED author (which the room
   * fold already restricted to the matched peer), cert verified — and the
   * mandatory fingerprint sheet runs in the UI before addContact.
   */
  #onMatchControl(convId: string, e: LogEntryMeta): void {
    const op = e.op as unknown as { t?: string; card?: unknown };
    if (op.t !== "card") return;
    if (e.from === this.#myPub) return; // my own offer
    const card = op.card;
    if (!isValidCard(card) || card.pub !== e.from) return;
    void verifyContactCert(card.pub, card.xPub, card.xSig, card.name).then((okCert) => {
      if (!okCert) return;
      this.#matchCards.set(convId, card);
      this.#notifyRoom(convId);
      this.#notify();
    });
  }

  /** The user is looking at a conversation: clear its unread bookkeeping. */
  markViewed(convId: string): void {
    this.#probeUnread.delete(convId);
    const room = this.#rooms.get(convId);
    if (room) room.markAllSeen();
    else markSeen(convId, Date.now());
    this.#notify();
  }

  // ---- group creation --------------------------------------------------------

  /**
   * Create a group chat with ACTIVE contacts (≤ CHAT_MEMBERS_MAX incl. me):
   * chatreg in my home log, then a ginvite through each member's pair room.
   * Returns the conversation id. Ginvite delivery is per-member best-effort —
   * the ops queue in each pair room's persisted mailbox queue.
   */
  async createGroup(name: string, memberPubs: string[]): Promise<string> {
    const state = this.#selfroom.state;
    const members = [...new Set(memberPubs)].filter((p) => state.contacts.get(p)?.status === "active");
    if (members.length !== new Set(memberPubs).size) throw new Error("not an active contact");
    if (members.length + 1 > CHAT_MEMBERS_MAX) throw new Error("too many members");
    const reg = await mintGroupChat(this.#selfroom, this.#myPub, name, members);
    for (const pub of members) {
      try {
        const pairRoom = await this.open(pub); // pair conv materializes on first message
        await pairRoom?.handle?.append(ginviteOf(reg));
      } catch {} // best-effort per member; the chatreg is already durable
    }
    // open the group room so the creator's first message can follow at once
    await this.open(reg.id).catch(() => {});
    this.#notify();
    return reg.id;
  }

  // ---- closed-room probes + boot ---------------------------------------------

  /**
   * Cheap "has new mail" pass over conversations WITHOUT an open room (see
   * core/chatlog.ts MailboxProbe for the seq-comparison contract). Throttled;
   * `force` bypasses the throttle (boot).
   */
  async probeClosed(force = false): Promise<void> {
    const mailbox = this.#selfroom.hub.mailbox;
    if (!mailbox || this.#probing) return;
    if (!force && Date.now() - this.#lastProbeAt < PROBE_THROTTLE_MS) return;
    this.#probing = true;
    this.#lastProbeAt = Date.now();
    try {
      const state = this.#selfroom.state;
      const closed = this.conversations()
        .filter((c) => !this.#rooms.has(c.ref.id))
        .slice(0, PROBE_MAX);
      let changed = false;
      for (const conv of closed) {
        // per-room probe state (derived ids + client + minted auth) is cached
        // across passes — a 30 s cycle over ≤30 conversations re-derives and
        // re-mints NOTHING for rooms it has probed before
        let probe: MailboxProbe;
        try {
          let secret: string;
          if (conv.ref.kind === "pair") {
            const contact = state.contacts.get(conv.ref.id) as ContactOp;
            secret = await this.#pairSecretOf(contact);
          } else if (conv.ref.kind === "match") {
            const match = matchByConvId(conv.ref.id);
            if (!match) continue;
            // probe the DERIVED room — the wire secret never names a hub room
            secret = await matchRoomSecret(match);
          } else {
            secret = state.chats.get(conv.ref.id)!.secret;
          }
          const cached = this.#probes.get(secret);
          if (cached) {
            probe = cached;
          } else {
            probe = await MailboxProbe.create(secret, mailbox);
            if (this.#probes.size >= 128) this.#probes.clear(); // bound: rebuilt on demand
            this.#probes.set(secret, probe);
          }
        } catch {
          continue;
        }
        if (this.#rooms.has(conv.ref.id)) continue; // opened meanwhile
        const unread = await probe.check();
        if ((this.#probeUnread.get(conv.ref.id) ?? false) !== unread) {
          this.#probeUnread.set(conv.ref.id, unread);
          changed = true;
        }
      }
      if (changed) this.#notify();
    } finally {
      this.#probing = false;
    }
  }

  /**
   * Boot pass (after the selfroom is up): probe everything, then auto-open
   * the rooms with new mail — bounded — so offline-delivered messages and
   * pairwise ginvites fold without the user opening each conversation.
   */
  async boot(): Promise<void> {
    await this.probeClosed(true);
    const unread = this.conversations().filter((c) => c.unread && !this.#rooms.has(c.ref.id));
    let opened = 0;
    for (const conv of unread) {
      if (opened >= BOOT_OPEN_MAX) break;
      await this.open(conv.ref.id).catch(() => {});
      opened++;
    }
  }
}

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