diff options
| author | Adam Soltys <[email protected]> | 2026-10-06 23:26:23 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-06 23:26:23 -0700 |
| commit | 9b3a440f9e56054d23bf24b9c0b2010c92e98d68 (patch) | |
| tree | e1eb6495242c5887e4919fa4c21ad4e824f6235d /src/marmot-client.js | |
| parent | d657d79bc95062a8fc5e128e795e23bcefd0bf04 (diff) | |
| download | coinosv3-9b3a440f9e56054d23bf24b9c0b2010c92e98d68.tar.gz coinosv3-9b3a440f9e56054d23bf24b9c0b2010c92e98d68.zip | |
White Noise groups reach every device of an account
An invitation adds all of a person's fresh KeyPackages (one per slot, at
most ten) instead of one, and where our account is an admin a device that
finds a sibling's KeyPackage not yet in a group adds it, so the Welcome
reaches the other device through our own inbox. These are the same-account
rules Marmot's multi-device direction marks as valid today; the device
group and pairing it plans are not here. Verified against MDK (wn): a group
with two coinos leaves of one npub plus a wn member, all three reading and
writing (tools/marmot-multidevice-test.js). marmotClient takes an
injectable store for tests.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi
Diffstat (limited to 'src/marmot-client.js')
| -rw-r--r-- | src/marmot-client.js | 69 |
1 files changed, 56 insertions, 13 deletions
diff --git a/src/marmot-client.js b/src/marmot-client.js index d63cb04..4a79b21 100644 --- a/src/marmot-client.js +++ b/src/marmot-client.js @@ -30,8 +30,8 @@ const REACH_TTL = 10 * 60_000; const now = () => Math.floor(Date.now() / 1000); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAYS, subClosed }) { - const store = marmotStore(scope); +// `store` is injectable for tests outside a browser (no IndexedDB there). +export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAYS, subClosed, store = marmotStore(scope) }) { const groups = new Map(); // mls group id (hex) -> record const subs = new Map(); // route id -> { unsub, gid } const reach = new Map(); // pubkey -> { at, p } @@ -150,8 +150,11 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY return true; } - // Somebody else's KeyPackage, or null when they cannot be invited. - function lookup(pk) { + // Somebody's KeyPackages: one per device (a slot is one installation), the + // best first, at most ten. An invitation adds every one of them, so a person + // with a phone and a laptop gets the group on both — the rule Marmot's + // multi-device design settles on. `lookup` is the best one, or null. + function lookupAll(pk) { const hit = reach.get(pk); if (hit && Date.now() - hit.at < REACH_TTL) return hit.p; const p = (async () => { @@ -159,11 +162,12 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY const newest = lists.sort((a, b) => b.created_at - a.created_at)[0]; const where = [...new Set([...(newest ? writeRelays(newest).slice(0, 5) : []), ...PROFILE_RELAYS])]; const evs = await queryOn(where, { kinds: [M.KIND.KEY_PACKAGE], authors: [pk] }, 2500); - return M.pickKeyPackage(evs.map((e) => M.readKeyPackageEvent(e)).filter(Boolean)); - })().catch(() => null); + return M.pickKeyPackages(evs.map((e) => M.readKeyPackageEvent(e)).filter(Boolean)); + })().catch(() => []); reach.set(pk, { at: Date.now(), p }); return p; } + const lookup = (pk) => lookupAll(pk).then((all) => all[0] || null); // ---- relay traffic ---- @@ -250,8 +254,8 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY return p; } - async function deliverWelcome(g, welcome, cands) { - const s = await signer({ interactive: true }); + async function deliverWelcome(g, welcome, cands, { interactive = true } = {}) { + const s = await signer({ interactive }); if (!s) throw new Error('no signer'); const where = routeRelays(g); for (const c of cands) { @@ -264,10 +268,47 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY // ---- what the app can do ---- async function candidates(pks) { - const cands = await Promise.all(pks.map(lookup)); - const missing = pks.filter((_, i) => !cands[i]); + const lists = await Promise.all(pks.map(lookupAll)); + const missing = pks.filter((_, i) => !lists[i].length); if (missing.length) { const e = new Error('not reachable'); e.missing = missing; throw e; } - return cands; + return lists.flat(); + } + + // Our account's other devices. Each installation publishes its own + // KeyPackage under its own slot; where this account is an admin, a device + // that finds a sibling's fresh package not yet in a group adds it, and the + // Welcome reaches the sibling through our own inbox. The same-account rule + // of Marmot's multi-device direction, applied to the groups we already have + // (a non-admin cannot add anyone, so those groups wait for the spec). + let siblingTimer = 0, siblingBusy = false; + async function siblingAdds() { + if (stopped || !dev || siblingBusy) return; + siblingBusy = true; + try { + const s = await signer(); + if (!s) return; + const mine = await lookupAll(pubkey); + const own = new Set((dev.kps || []).map((k) => k.ref)); + const others = mine.filter((c) => c.slot !== dev.slot && !own.has(c.ref)); + if (!others.length) return; + for (const g of [...groups.values()]) { + if (stopped || g.removed || g.leaving || g.pending || g.fresh || !M.isAdmin(g)) continue; + const have = new Set(M.leafKeys(g.tip)); + const cands = others.filter((c) => !have.has(c.sigKey) && M.fits(g.tip, c)); + if (!cands.length || M.leafKeys(g.tip).length + cands.length > 64) continue; + try { + const p = await commit(g, M.invite(cands)); + await deliverWelcome(g, p.welcome, cands, { interactive: false }); + dlog('marmot: sibling add', g.id.slice(0, 8), cands.length); + } catch (e) { dlog('marmot: sibling add', e); } + await sleep(1500); + } + } catch (e) { dlog('marmot: siblings', e); } + finally { siblingBusy = false; } + } + function scheduleSiblings(delay = 25_000) { + clearTimeout(siblingTimer); + siblingTimer = setTimeout(() => { siblingAdds().then(() => { if (!stopped) scheduleSiblings(6 * 3600_000); }); }, delay); } // A direct chat (no name, both sides admins) or a named group. @@ -355,6 +396,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY const g = groups.get(j.g.id); if (g && !stopped && !g.removed && !g.pending) commit(g, []).catch((e) => dlog('marmot: self-update', e)); }, 4000 + Math.random() * 6000); + scheduleSiblings(40_000); return j.g; } @@ -378,6 +420,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY } } emit('loaded', null); + scheduleSiblings(); } function resubscribe() { @@ -390,14 +433,14 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY function stop() { stopped = true; rebuilding = true; - clearTimeout(rotateTimer); + clearTimeout(rotateTimer); clearTimeout(siblingTimer); for (const s of subs.values()) { try { s.unsub(); } catch {} } subs.clear(); flush(); } return { - pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, welcome, accept, + pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept, create, send, add, kick, rename, leave, forget, event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags), // attachments: seal for the epoch a message is about to go out in, and |
