aboutsummaryrefslogtreecommitdiffstats
path: root/README.md
Commit message (Expand)AuthorAgeFilesLines
* biome formatAdam Soltys2024-10-181-5/+5
* .env fix and Readme updatendungudedan2024-10-031-1/+1
* .env fix and Readme updatendungudedan2024-10-021-2/+10
* touchAdam Soltys2024-03-311-0/+1
* admin routeAdam Soltys2023-03-161-1/+0
* trigger buildAdam Soltys2023-03-131-0/+1
* touchAdam Soltys2023-02-181-1/+0
* touchAdam Soltys2023-02-171-0/+1
* update readmeAdam Soltys2023-01-241-24/+6
* refactor tx history to remove extra containersecondl1ght2022-09-251-1/+1
* testAdam Soltys2022-09-241-1/+1
* lots of style updates after design review, bug fixes, and more improvesecondl1ght2022-09-241-2/+1
* testsecondl1ght2022-09-241-1/+1
* trigger buildAdam Soltys2022-09-241-0/+1
* remove netlify badgesecondl1ght2022-09-161-2/+0
* update CoinOS to coinossecondl1ght2022-09-131-3/+3
* update READMEAdrien Hopkins2022-08-231-1/+3
* formatsecondl1ght2022-08-161-0/+2
* format README.md and add a bit more infoAdrien Hopkins2022-08-161-5/+18
* run formatsecondl1ght2022-08-161-1/+1
* tweaked locale selection & added i18n technical documentationAdrien Hopkins2022-08-081-1/+1
* minor text tweaksAdrien Hopkins2022-08-081-1/+1
* fix link from prevous commitAdrien Hopkins2022-08-081-1/+1
* move i18n instructions to separate fileAdrien Hopkins2022-08-081-64/+2
* second draft of i18n instructionsAdrien Hopkins2022-08-081-11/+12
* first draft of i18n instructionsAdrien Hopkins2022-08-081-0/+65
* add netlify badgesecondl1ght2022-07-201-0/+2
* initial commit, setup the project with sveltekit, tailwindcss, prettiersecondl1ght2022-07-201-0/+9
id='n356' href='#n356'>356 357 358 359 360 361 362 363 364 365
// Cordn in the app: this device's standing with a coordinator (its MLS
// signature key and a published KeyPackage), its groups, and the traffic
// with the coordinator. Protocol rules live in cordn.js, the transport in
// contextvm.js; the messages feature hears about everything through
// `on(type, group, data)`, the same way it hears from marmot-client.js, and
// drives both through one interface.
//
// Like Marmot, MLS state is per DEVICE and lives in IndexedDB: a second
// device of the same account is a second leaf with its own KeyPackage.

import * as C from './cordn.js';
import { cvmClient } from './contextvm.js';
import { marmotStore } from './marmot-store.js';
import { finalizeEvent, nip44 } from './nostr.js';
import { dlog } from './debug.js';

const DAY = 86400;
const KP_REFRESH = 7 * DAY;    // republish well inside the 84-day lifetime; the coordinator keeps one last-resort package per identity
const POLL_FOCUSED = 4000;      // a Cordn group on screen
const POLL_IDLE = 45_000;       // groups held, none on screen
const POLL_EMPTY = 180_000;     // no Cordn groups at all: only Welcomes to look for
const now = () => Math.floor(Date.now() / 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// `store` is injectable for tests outside a browser (no IndexedDB there).
export function cordnClient({ scope, pubkey, identity, on, coordinator = C.DEFAULT_COORDINATOR, subClosed, store = marmotStore(scope + '|cordn') }) {
  const groups = new Map();   // gid -> record
  let dev = null, loaded = null, stopped = false, cvm = null, cvmSigner = null;
  let kpBusy = null, pollTimer = 0, polling = null, focused = null, rotateTimer = 0;
  const dirty = new Set();
  let flushTimer = 0;

  const emit = (type, g, data) => { try { on(type, g, data); } catch (e) { dlog('cordn: listener', e); } };

  // ---- storage ----

  function load() {
    if (!loaded)
      loaded = store.all().then((all) => {
        dev = all.get('dev') || null;
        for (const [k, v] of all) if (k.startsWith('g:') && v && v.tip) groups.set(v.id, v);
      }).catch((e) => { dlog('cordn: store', e); });
    return loaded;
  }
  const touch = (g) => { dirty.add(g.id); clearTimeout(flushTimer); flushTimer = setTimeout(flush, 400); };
  async function flush() {
    clearTimeout(flushTimer);
    const ids = [...dirty];
    dirty.clear();
    await Promise.all(ids.map((id) => groups.has(id) ? store.put('g:' + id, groups.get(id)) : null)).catch((e) => dlog('cordn: save', e));
  }
  // a ratchet that moved is on disk before anything of ours leaves
  const persist = (g) => { dirty.delete(g.id); return store.put('g:' + g.id, g); };
  const saveDev = () => store.put('dev', dev);

  // ---- this device and its coordinator ----

  async function signer({ interactive = false } = {}) {
    const id = await identity();
    if (!id || !id.signer || id.pubkey !== pubkey) return null;
    if (id.signer instanceof Uint8Array) {
      const sk = id.signer;
      return { raw: sk, sign: async (e) => finalizeEvent(e, sk), decrypt: async (peer, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, peer)) };
    }
    // Every coordinator call is signed, and every reply decrypted, by the
    // signer: an interactive one (a trip through another app per call) is
    // never used unasked.
    if (id.signer.interactive && !interactive) return null;
    if (!id.signer.decryptFrom) return null;
    return { raw: null, sign: (e) => id.signer.signEvent(e), decrypt: (peer, ct) => id.signer.decryptFrom(peer, ct) };
  }

  // The transport, once a signer can back it. Rebuilt if the signer changes.
  async function transport(opts) {
    const s = await signer(opts);
    if (!s) return null;
    const key = s.raw ? Array.from(s.raw.slice(0, 8)).join(',') : 'adapter';
    if (cvm && cvmSigner === key) return cvm;
    if (cvm) cvm.stop();
    cvmSigner = key;
    cvm = cvmClient({ server: coordinator.pubkey, relays: coordinator.relays, pubkey, sign: s.sign, decrypt: s.decrypt,
      subClosed: () => { if (!stopped && subClosed) subClosed(); } });
    return cvm;
  }
  // The public coordinator is slow and sometimes silent (2 of 12 probes went
  // unanswered in 15 s on 2026-10-06), so a call is tried twice. A repeated
  // msg_post carries the same bytes, which every reader drops as a duplicate;
  // kp_take is never repeated (a second take would spend a second package).
  const CALL_MS = 12_000;
  const call = async (name, args, opts) => {
    const t = await transport(opts);
    if (!t) throw new Error('no signer');
    const tries = name === 'kp_take' ? 1 : 2;
    let err;
    for (let i = 0; i < tries; i++) {
      try { return await t.call(name, args, { timeoutMs: CALL_MS }); }
      catch (e) { err = e; if (!/did not answer|no relay took/.test(e.message)) throw e; }
    }
    throw err;
  };

  // A device record and a current last-resort KeyPackage on the coordinator.
  function ready(opts = {}) {
    if (!kpBusy) kpBusy = ensure(opts).catch((e) => { dlog('cordn: keypackage', e); return false; }).finally(() => { kpBusy = null; });
    return kpBusy;
  }
  async function ensure(opts) {
    await load();
    const cur = dev && dev.kps && dev.kps.find((k) => k.published && !k.spent);
    if (cur && now() - cur.published < KP_REFRESH) return true;
    const t = await transport(opts);
    if (!t) return false;
    if (!dev) { dev = { sig: C.newSigner(), kps: [] }; await saveDev(); }
    return publishKeyPackage(t);
  }
  async function publishKeyPackage(t) {
    const kp = C.newKeyPackage(dev.sig, pubkey);
    // the private half is on disk before the public half is findable
    dev.kps.push(kp);
    await saveDev();
    try {
      await call('kp_publish', { kp_ref: kp.ref, kp_64: C.keyPackageBase64(kp) });
    } catch (e) {
      dev.kps = dev.kps.filter((k) => k !== kp);
      await saveDev();
      throw e;
    }
    kp.published = now();
    // older packages give way; the ones a Welcome may still name stay a while
    const old = dev.kps.filter((k) => k !== kp && k.published);
    for (const k of old) if (now() - k.at > 2 * DAY) { dev.kps = dev.kps.filter((x) => x !== k); call('kp_remove', { kp_refs: [k.ref] }).catch(() => {}); }
    await saveDev();
    return true;
  }

  // Can this person be invited? Their KeyPackage on the coordinator, or null.
  const reach = new Map(); // pk -> { at, p }
  function lookup(pk) {
    const hit = reach.get(pk);
    if (hit && Date.now() - hit.at < 5 * 60_000) return hit.p;
    const p = (async () => {
      const r = await call('kp_list', {});
      const mine = (r.keyPackages || []).filter((k) => k.pk === pk);
      return mine.length ? { pubkey: pk, refs: mine.map((k) => k.kp_ref) } : null;
    })().catch(() => null);
    reach.set(pk, { at: Date.now(), p });
    return p;
  }
  // Take their package off the coordinator for an invitation (a regular one
  // is consumed by this; a last-resort one stays).
  async function takeKeyPackage(pk) {
    const r = await call('kp_take', { id: pk }, { interactive: true });
    if (!r.keyPackage) { const e = new Error('not reachable'); e.missing = [pk]; throw e; }
    return C.readKeyPackage(r.keyPackage.event ? keyPackageOfEvent(r.keyPackage.event) : r.keyPackage.kp_64, pk);
  }
  // The coordinator serves the publication event verbatim; the package is its kp_64 argument.
  function keyPackageOfEvent(ev) {
    try { return JSON.parse(ev.content).params.arguments.kp_64; } catch { throw new Error('bad key package record'); }
  }

  // ---- the stream ----

  function heard(g, out) {
    for (const o of out) {
      if (o.id) emit('message', g, o);
      else if (o.epoch !== undefined) emit('state', g);
      else if (o.removed) emit('removed', g);
      else if (o.invalid) dlog('cordn: skipped record', g.id, o.invalid);
    }
  }

  // Welcomes waiting for us, then every live group's records past its cursor.
  async function poll() {
    if (polling) return polling;
    polling = (async () => {
      const t = await transport();
      if (!t || stopped) return;
      await load();
      try {
        const w = await call('welcome_take', {});
        for (const welcome of w.welcomes || []) await acceptWelcome(welcome).catch((e) => dlog('cordn: welcome', e));
      } catch (e) { dlog('cordn: welcomes', e); }
      const live = [...groups.values()].filter((g) => !g.removed);
      if (!live.length || stopped) return;
      let r;
      try { r = await call('msg_fetch_many', { groups: live.map((g) => ({ gid: g.id, ...(g.cursor ? { after: g.cursor } : {}) })) }); } catch (e) { dlog('cordn: fetch', e.message); return; }
      const byGid = new Map();
      for (const m of r.messages || []) (byGid.get(m.gid) || byGid.set(m.gid, []).get(m.gid)).push(m);
      for (const [gid, recs] of byGid) {
        const g = groups.get(gid);
        if (!g) continue;
        recs.sort((a, b) => a.cursor - b.cursor);
        for (const rec of recs) {
          let out;
          try { out = C.receive(g, rec); } catch (e) { dlog('cordn: receive', e); continue; }
          heard(g, out);
        }
        touch(g);
      }
    })().catch((e) => dlog('cordn: poll', e)).finally(() => { polling = null; schedule(); });
    return polling;
  }
  function schedule() {
    clearTimeout(pollTimer);
    if (stopped) return;
    const live = [...groups.values()].some((g) => !g.removed);
    pollTimer = setTimeout(poll, focused ? POLL_FOCUSED : live ? POLL_IDLE : POLL_EMPTY);
  }
  // Which group is on screen: that one is read every few seconds.
  function focus(gid) {
    gid = gid || null;
    if (gid === focused) return;
    focused = gid;
    if (focused) { clearTimeout(pollTimer); poll(); } else schedule();
  }

  async function acceptWelcome(welcome) {
    if (!dev) return null;
    let j;
    try { j = C.join(dev.sig, pubkey, welcome, dev.kps, { cursor: welcome.after || 0 }); } catch (e) { dlog('cordn: welcome', e.message); return null; }
    const old = groups.get(j.g.id);
    // a retained copy is never silently replaced; a group we were removed from can be rejoined
    if (old && !old.removed) return null;
    if (old) j.g.log = old.log;
    groups.set(j.g.id, j.g);
    await persist(j.g);
    emit('joined', j.g);
    // A regular package that got us in is spent; a last-resort one is not,
    // but its keys have been seen: rotate either, a little later (more
    // Welcomes may name it while we were away).
    if (j.used) { j.used.spent = now(); await saveDev(); }
    clearTimeout(rotateTimer);
    rotateTimer = setTimeout(() => { if (!stopped) transport().then((t) => t && publishKeyPackage(t)).catch(() => {}); }, 45_000);
    return j.g;
  }

  // ---- changes of ours ----

  // Post-then-confirm: the commit is group state once the coordinator has
  // it (its own cursor says so) and no peer's commit got in first. The
  // stream is read up to our record so a race is settled in order.
  async function commit(g, proposals, { interactive = true } = {}) {
    const t = await transport({ interactive });
    if (!t) throw new Error('no signer');
    await poll().catch(() => {});
    if (g.removed) throw new Error('not in this group');
    const p = C.stage(g, proposals);
    await persist(g);
    let posted;
    try { posted = await call('msg_post', { gid: g.id, msg_64: p.msg_64 }, { interactive }); }
    catch (e) { C.drop(g); await persist(g); throw e; }
    await settle(g, t, posted.cursor);
    if (g.pending === p) { C.drop(g); await persist(g); throw new Error('the coordinator did not echo the change'); }
    if (g.tip !== p.state) throw new Error('another change got there first');
    return p;
  }
  // Read the stream up to a cursor of ours, so what happened before it is applied first.
  async function settle(g, t, cursor) {
    for (let i = 0; i < 4 && g.cursor < cursor && !stopped; i++) {
      if (i) await sleep(500 * i);
      let r;
      try { r = await call('msg_fetch_many', { groups: [{ gid: g.id, ...(g.cursor ? { after: g.cursor } : {}) }] }); } catch { continue; }
      const recs = (r.messages || []).filter((m) => m.gid === g.id).sort((a, b) => a.cursor - b.cursor);
      for (const rec of recs) { let out; try { out = C.receive(g, rec); } catch { continue; } heard(g, out); }
    }
    await persist(g);
  }

  async function deliverWelcomes(g, t, welcome, cands, after) {
    if (!welcome) return;
    const welcome_64 = C.welcomeBase64(welcome);
    for (const c of cands) {
      try { await call('welcome_store', { target_pk: c.pubkey, kp_ref: c.ref, welcome_64, ...(after ? { after } : {}) }, { interactive: true }); }
      catch (e) { dlog('cordn: welcome_store', e); }
    }
  }

  async function candidates(pks) {
    const out = [];
    const missing = [];
    for (const pk of pks) { try { out.push(await takeKeyPackage(pk)); } catch (e) { if (e.missing) missing.push(pk); else throw e; } }
    if (missing.length) { const e = new Error('not reachable'); e.missing = missing; throw e; }
    return out;
  }

  async function create({ name = '', icon = '', members = [] }) {
    if (!(await ready({ interactive: true }))) throw new Error('no signer');
    const t = await transport({ interactive: true });
    const cands = await candidates(members);
    const { g, pending } = C.found(dev.sig, pubkey, { name, icon, invitees: cands });
    groups.set(g.id, g);
    await persist(g);
    if (pending) {
      let posted;
      try { posted = await call('msg_post', { gid: g.id, msg_64: pending.msg_64 }, { interactive: true }); }
      catch (e) { groups.delete(g.id); store.del('g:' + g.id).catch(() => {}); throw e; }
      // nobody else is in the group yet: the first commit cannot be raced
      g.cursor = posted.cursor;
      const out = C.confirm(g);
      await persist(g);
      heard(g, out);
      await deliverWelcomes(g, t, pending.welcome, cands, posted.cursor);
    }
    emit('state', g);
    focus(g.id);
    return g;
  }

  // `inner` comes from event(): the app shows it before it has left.
  async function send(g, inner) {
    const t = await transport({ interactive: true });
    if (!t) throw new Error('no signer');
    const { msg_64 } = C.send(g, inner);
    await persist(g);
    try { await call('msg_post', { gid: g.id, msg_64 }, { interactive: true }); return true; }
    catch (e) { dlog('cordn: post', e); g.log = g.log.filter((m) => m.id !== inner.id); touch(g); return false; }
  }

  async function add(g, pks) {
    const cands = await candidates(pks);
    const p = await commit(g, C.invite(cands));
    await deliverWelcomes(g, await transport({ interactive: true }), p.welcome, cands, g.cursor);
  }
  const kick = (g, pk) => commit(g, C.remove(g, pk));
  const rename = (g, name) => commit(g, [C.setMetadata(g, { name })]);

  // Cordn has no self-removal yet (their client's "leave" only forgets the
  // group locally); ours does the same, and a member can still remove us.
  async function leave(g) { forget(g); }
  function forget(g) {
    groups.delete(g.id);
    dirty.delete(g.id);
    store.del('g:' + g.id).catch(() => {});
    if (focused === g.id) focus(null);
    emit('gone', g);
  }
  function accept(g) { g.fresh = false; touch(g); emit('state', g); }

  async function start() {
    await load();
    if (stopped) return;
    emit('loaded', null);
    poll();
  }
  function resubscribe() { if (cvm) cvm.listen(); poll(); }
  function stop() {
    stopped = true;
    clearTimeout(pollTimer); clearTimeout(rotateTimer);
    if (cvm) cvm.stop();
    flush();
  }

  return {
    pubkey, groups, load, start, stop, resubscribe, ready, lookup, accept, focus, poll,
    create, send, add, kick, rename, leave, forget,
    event: (kind, content, tags) => C.appEvent(pubkey, kind, content, tags),
    view: (g) => C.groupView(g.tip),
    peer: () => null,
    accounts: (g) => C.accounts(g.tip),
    isAdmin: (g) => C.isAdmin(g) && !g.removed,
    groupRef: (g) => C.encodeGroupRef({ gid: g.id, coordinator: coordinator.pubkey, relays: coordinator.relays }),
    isSetUp: () => !!dev,
    coordinator,
  };
}