aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-06 11:22:06 -0700
committerAdam Soltys <[email protected]>2026-10-06 11:22:06 -0700
commitd657d79bc95062a8fc5e128e795e23bcefd0bf04 (patch)
tree04a9d39281ce7784f1a4a966f0a532194ee7ded7
parentf571e46937d423d9ba44154a92aeb4136466daaa (diff)
downloadcoinosv3-d657d79bc95062a8fc5e128e795e23bcefd0bf04.tar.gz
coinosv3-d657d79bc95062a8fc5e128e795e23bcefd0bf04.zip
Cordn group chats: MLS groups delivered through a coordinator (Amethyst 1.17 interop)
src/contextvm.js speaks ContextVM (kind 25910 inside ephemeral gift wraps) to a coordinator; src/cordn.js is the protocol (KeyPackages with hex-text identities, the 0xC04D metadata extension, exporter-keyed ChaCha20 sealing, unsigned nostr-shaped envelopes with the sender as MLS AAD, cordn1 group refs, a record engine like marmot.js); src/cordn-client.js mirrors marmot-client.js with the same interface, so the Messages group screens drive both — New group gains a White Noise / Cordn toggle. Verified against @cordn/cli (ts-mls) on the public coordinator both ways, from the engine (tools/cordn-interop-test.js) and from the real app (tools/cordn-ui-test.js). mls.js: the OpenMLS required_capabilities rule for GroupContextExtensions now applies only to groups that carry one. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi
-rw-r--r--README.md6
-rw-r--r--src/contextvm.js87
-rw-r--r--src/cordn-client.js365
-rw-r--r--src/cordn.js368
-rw-r--r--src/features/messages.js108
-rw-r--r--src/i18n.js6
-rw-r--r--src/mls.js10
-rw-r--r--tools/cordn-interop-test.js127
-rw-r--r--tools/cordn-ui-test.js89
9 files changed, 1139 insertions, 27 deletions
diff --git a/README.md b/README.md
index 3ef7ae0..b925cf3 100644
--- a/README.md
+++ b/README.md
@@ -156,7 +156,9 @@ Ark servers: gRPC-web and MuSig2 spoken directly, no WASM. coinos runs
- **Communities** on Concord: end-to-end encrypted channels compatible with
Vector, including Vector's custom emoji packs (NIP-30) and community icons.
Every user joins the coinos community; anyone can create or join others.
-- **Group chats** on White Noise (Marmot), with a native-JS MLS (RFC 9420)
+- **Group chats** on White Noise (Marmot) and on **Cordn** (the MLS group
+ chats in Amethyst 1.17 and the Cordn app, delivered through a coordinator
+ that only ever sees sealed bytes), both on a native-JS MLS (RFC 9420)
implementation.
- **Notifications** for mentions, replies, reactions and zaps, with push
delivery to your devices.
@@ -256,7 +258,7 @@ The app is static; these small services run beside it (Bun):
| `src/features/` | Feature plugins (see Modular builds); `index.js` is rewritten at build time by `COINOS_FEATURES` |
| `src/nostr.js` / `src/sync-*.js` | Encrypted state sync, relay pool, Blossom envelopes, the sync outbox |
| `src/nostr-login.js` / `src/passkey.js` / `src/pomegranate.js` | Nostr signers, passkey PRF sign-in, Google via FROST |
-| `src/dm.js` / `src/concord.js` / `src/mls.js` / `src/marmot*.js` | NIP-17 DMs, Concord communities, MLS and the White Noise layer |
+| `src/dm.js` / `src/concord.js` / `src/mls.js` / `src/marmot*.js` / `src/cordn*.js` / `src/contextvm.js` | NIP-17 DMs, Concord communities, MLS, the White Noise layer, and Cordn over ContextVM |
| `src/bip353.js` / `src/dnssec.js` / `src/bolt12.js` / `src/noffer.js` / `src/clink.js` / `src/lnurl.js` | Payment-name resolution with DNSSEC validation, BOLT12, CLINK, LNURL and zaps |
| `src/sw-nwc.js` / `src/nwc-*.js` | Background NWC answering in the service worker |
| `src/app.js` | UI controller (vanilla DOM, no framework) and feature registry |
diff --git a/src/contextvm.js b/src/contextvm.js
new file mode 100644
index 0000000..7af55a9
--- /dev/null
+++ b/src/contextvm.js
@@ -0,0 +1,87 @@
+// ContextVM — MCP JSON-RPC over nostr, the transport Cordn coordinators speak.
+// https://github.com/ContextVM/contextvm-docs
+//
+// A call is one kind-25910 event: the JSON-RPC request as content, a `p` tag
+// naming the server. It travels sealed in an ephemeral gift wrap (kind 21059,
+// a throwaway key, NIP-44 to the server), the only form the public Cordn
+// coordinator answered when probed (2026-10-06); plain 25910 requests from
+// us went unanswered. The reply comes back the same way, addressed to our
+// pubkey, its inner event signed by the server and `e`-tagged to the request.
+// Nothing here is Cordn-specific: any ContextVM tool server works.
+
+import { subscribeOn, publishOn, nip44, finalizeEvent, generateSecretKey } from './nostr.js';
+import { verifyEvent } from 'nostr-tools/pure';
+
+export const KIND = { MESSAGE: 25910, WRAP: 21059 };
+const now = () => Math.floor(Date.now() / 1000);
+
+// { server, relays, pubkey, sign, decrypt, subClosed }
+// sign(unsigned) -> signed event (the caller's key: the server reads the
+// caller's identity off the inner event's signature)
+// decrypt(peerPubkey, ciphertext) -> plaintext (NIP-44, our key)
+export function cvmClient({ server, relays, pubkey, sign, decrypt, subClosed }) {
+ const waiting = new Map(); // inner request event id -> { id, resolve }
+ let unsub = null, stopped = false, seq = 0;
+
+ function listen() {
+ if (stopped) return;
+ if (unsub) { try { unsub(); } catch {} }
+ unsub = subscribeOn(relays, { kinds: [KIND.MESSAGE, KIND.WRAP], '#p': [pubkey], since: now() - 60 }, onEvent,
+ subClosed ? { onclose: () => { if (!stopped) subClosed(); } } : {});
+ }
+
+ async function onEvent(ev) {
+ let inner = ev;
+ if (ev.kind === KIND.WRAP) {
+ try { inner = JSON.parse(await decrypt(ev.pubkey, ev.content)); } catch { return; }
+ if (!inner || typeof inner !== 'object' || !verifyEvent(inner)) return;
+ }
+ if (inner.pubkey !== server) return;
+ let msg;
+ try { msg = JSON.parse(inner.content); } catch { return; }
+ if (!msg || msg.method) return; // a notification (stream frames): not used yet
+ // matched by the request event it answers, never by JSON-RPC id: another
+ // client of the same pubkey (a second tab) numbers its requests the same way
+ const re = (inner.tags.find((t) => t[0] === 'e') || [])[1];
+ const w = re && waiting.get(re);
+ if (w) w.resolve(msg);
+ }
+
+ // One tools/call. Resolves to the tool's structured result; throws on a
+ // JSON-RPC error, a tool error, or silence.
+ async function call(name, args = {}, { timeoutMs = 20_000 } = {}) {
+ if (stopped) throw new Error('transport closed');
+ if (!unsub) listen();
+ const id = (++seq) + '-' + Math.random().toString(36).slice(2, 8);
+ const inner = await sign({
+ pubkey, kind: KIND.MESSAGE, created_at: now(), tags: [['p', server]],
+ content: JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }),
+ });
+ const wsk = generateSecretKey();
+ const wrap = finalizeEvent({
+ kind: KIND.WRAP, created_at: now(), tags: [['p', server]],
+ content: nip44.encrypt(JSON.stringify(inner), nip44.getConversationKey(wsk, server)),
+ }, wsk);
+ const res = await new Promise((resolve, reject) => {
+ const t = setTimeout(() => { waiting.delete(inner.id); reject(new Error('coordinator did not answer: ' + name)); }, timeoutMs);
+ waiting.set(inner.id, { id, resolve: (m) => { clearTimeout(t); waiting.delete(inner.id); resolve(m); } });
+ publishOn(relays, wrap).then((ok) => {
+ if (!ok) { clearTimeout(t); waiting.delete(inner.id); reject(new Error('no relay took the request')); }
+ });
+ });
+ if (res.error) { const e = new Error(res.error.message || 'coordinator error'); e.code = res.error.code; throw e; }
+ const r = res.result || {};
+ if (r.isError) throw new Error((r.content || []).map((c) => c.text).filter(Boolean).join(' ') || 'tool error');
+ return r.structuredContent || r;
+ }
+
+ function stop() {
+ stopped = true;
+ if (unsub) { try { unsub(); } catch {} }
+ unsub = null;
+ for (const w of waiting.values()) w.resolve({ error: { message: 'transport closed' } });
+ waiting.clear();
+ }
+
+ return { server, relays, call, listen, stop };
+}
diff --git a/src/cordn-client.js b/src/cordn-client.js
new file mode 100644
index 0000000..d09488d
--- /dev/null
+++ b/src/cordn-client.js
@@ -0,0 +1,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,
+ };
+}
diff --git a/src/cordn.js b/src/cordn.js
new file mode 100644
index 0000000..c0e4168
--- /dev/null
+++ b/src/cordn.js
@@ -0,0 +1,368 @@
+// Cordn — MLS group chats delivered through a coordinator
+// https://github.com/Cordn-msg/cordn (spec/00..03, applications/)
+//
+// The shape of the thing: members hold MLS state (RFC 9420, one ciphersuite,
+// src/mls.js); a coordinator stores KeyPackages, Welcomes and an ordered
+// stream of sealed messages per group, and reads none of it. Messages are
+// sealed with a key the group derives from its epoch (the MLS exporter), so
+// the coordinator sees bytes and cursors. Inside, an application message is
+// a nostr-shaped event with no signature: MLS already says who sent it.
+//
+// This module is the protocol: KeyPackages, the group metadata extension,
+// sealing, envelopes, group references, and the record engine that turns a
+// coordinator's stream into delivered events. Network and storage live in
+// cordn-client.js. Interop was proven against @cordn/cli (ts-mls) both ways
+// on 2026-10-06; the gotchas met there are noted where they bit.
+
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
+import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
+import { base64, bech32 } from '@scure/base';
+import { getEventHash } from 'nostr-tools/pure';
+import * as mls from './mls.js';
+
+export const EXT = { METADATA: 0xc04d, APP_DATA_DICTIONARY: 0x0006 };
+const COMP_LAST_RESORT = 0x0004;
+export const KIND = { CHAT: 9, REACTION: 7, DELETE: 5, EDIT: 1009, SYSTEM: 1210 };
+
+// The public coordinator Cordn's own client and Amethyst default to.
+export const DEFAULT_COORDINATOR = {
+ pubkey: '92753cbe63e943d0c4a0c61d745437892af6e98f179ce04a7a863aad4e00b1a5',
+ relays: ['wss://relay.contextvm.org', 'wss://relay2.contextvm.org', 'wss://relay.primal.net'],
+};
+
+const utf8 = (s) => new TextEncoder().encode(s);
+const text = (b) => new TextDecoder().decode(b);
+const now = () => Math.floor(Date.now() / 1000);
+const HEX64 = /^[0-9a-f]{64}$/;
+
+// ---- KeyPackages ----
+// The BasicCredential identity is the nostr pubkey AS HEX TEXT (64 bytes),
+// not the 32 raw bytes Marmot uses. The leaf has to say it understands the
+// metadata extension or no Cordn group will admit it.
+
+export const capabilities = () => ({
+ versions: [1],
+ ciphersuites: [1],
+ extensions: [EXT.METADATA, EXT.APP_DATA_DICTIONARY],
+ proposals: [],
+ credentials: [1],
+});
+
+// Last-resort by default: the coordinator hands a regular package to the
+// first inviter and deletes it, a last-resort one to everyone. One of those
+// keeps us invitable between visits.
+export function newKeyPackage(sig, pubkey, { lastResort = true } = {}) {
+ const kp = mls.generateKeyPackage({
+ sig, identity: utf8(pubkey), capabilities: capabilities(),
+ extensions: lastResort ? [{ type: EXT.APP_DATA_DICTIONARY, data: mls.encodeDict([{ id: COMP_LAST_RESORT, data: new Uint8Array() }]) }] : [],
+ });
+ return { ref: bytesToHex(kp.ref), keyPackage: kp.keyPackage, secrets: kp.secrets, at: now(), lastResort };
+}
+export const keyPackageBase64 = (kp) => base64.encode(kp.keyPackage);
+export const welcomeBase64 = (bytes) => base64.encode(bytes);
+export const newSigner = () => mls.generateSignatureKeyPair();
+
+// Someone else's package as the coordinator served it, checked (both
+// signatures) and bound to the pubkey it claims.
+export function readKeyPackage(b64, pubkey) {
+ const p = mls.parseKeyPackage(base64.decode(b64));
+ if (identityOf(p.leaf) !== pubkey) throw new Error('key package is not theirs');
+ return { pubkey, ref: bytesToHex(p.ref), keyPackage: p.keyPackage };
+}
+
+export const identityOf = (leaf) => {
+ let s;
+ try { s = text(leaf.credential.identity); } catch { return null; }
+ return HEX64.test(s) ? s : null;
+};
+
+// ---- group metadata (spec/01): a GroupContext extension ----
+// u16 version=1, then name, description, admin pubkeys (raw 32-byte
+// concatenation; empty = everyone is an admin), icon, image URL — each a
+// u16-length byte string.
+
+export function encodeMetadata({ name = '', description = '', admins = [], icon = '', imageUrl = '' } = {}) {
+ const field = (bytes) => { if (bytes.length > 0xffff) throw new Error('metadata field too long'); return [bytes.length >> 8, bytes.length & 0xff, ...bytes]; };
+ const adm = new Uint8Array(admins.length * 32);
+ admins.forEach((pk, i) => { if (!HEX64.test(pk)) throw new Error('bad admin pubkey'); adm.set(hexToBytes(pk), i * 32); });
+ return Uint8Array.from([0, 1, ...field(utf8(name)), ...field(utf8(description)), ...field(adm), ...field(utf8(icon)), ...field(utf8(imageUrl))]);
+}
+export function decodeMetadata(b) {
+ let o = 0;
+ const u16 = () => { if (o + 2 > b.length) throw new Error('short metadata'); const v = (b[o] << 8) | b[o + 1]; o += 2; return v; };
+ const field = () => { const n = u16(); if (o + n > b.length) throw new Error('short metadata'); const v = b.subarray(o, o + n); o += n; return v; };
+ const version = u16();
+ if (version !== 1) throw new Error('unknown metadata version ' + version);
+ const m = { name: text(field()), description: text(field()) };
+ const adm = field();
+ if (adm.length % 32) throw new Error('bad admin list');
+ m.admins = [];
+ for (let i = 0; i < adm.length; i += 32) m.admins.push(bytesToHex(adm.subarray(i, i + 32)));
+ m.icon = o < b.length ? text(field()) : '';
+ m.imageUrl = o < b.length ? text(field()) : '';
+ return m;
+}
+export const metadataExtension = (m) => ({ type: EXT.METADATA, data: encodeMetadata(m) });
+
+// What a group's signed state says about itself.
+const viewMemo = new WeakMap();
+export function groupView(state) {
+ let v = viewMemo.get(state);
+ if (v) return v;
+ const ext = state.extensions.find((e) => e.type === EXT.METADATA);
+ let m = { name: '', description: '', admins: [], icon: '', imageUrl: '' };
+ if (ext) { try { m = decodeMetadata(ext.data); } catch {} }
+ v = { ...m, egalitarian: m.admins.length === 0 };
+ viewMemo.set(state, v);
+ return v;
+}
+export const roster = (state) => mls.members(state).map((m) => ({ leaf: m.index, pubkey: identityOf(m.leaf) })).filter((m) => m.pubkey);
+export const accounts = (state) => [...new Set(roster(state).map((m) => m.pubkey))];
+export const isAdmin = (g) => { const v = groupView(g.tip); return v.egalitarian || v.admins.includes(g.me); };
+
+// ---- sealing (spec/03) ----
+// key = MLS-Exporter("cordn", "group-payload", 32) of the epoch; the payload
+// is nonce(12) || ChaCha20-Poly1305 ciphertext, base64. A commit is sealed
+// under the epoch it leaves, so everyone still there can open it.
+
+export const sealKey = (state) => mls.exportSecret(state, 'cordn', utf8('group-payload'), 32);
+export function seal(state, bytes) {
+ const nonce = randomBytes(12);
+ const ct = chacha20poly1305(sealKey(state), nonce).encrypt(bytes);
+ const out = new Uint8Array(12 + ct.length);
+ out.set(nonce); out.set(ct, 12);
+ return base64.encode(out);
+}
+export function unseal(state, b64) {
+ const p = base64.decode(b64);
+ if (p.length < 28) throw new Error('sealed payload too short');
+ return chacha20poly1305(sealKey(state), p.subarray(0, 12)).decrypt(p.subarray(12));
+}
+
+// ---- envelopes (spec/02) ----
+// A nostr event with an id and no signature. Their client wants the sender's
+// hex pubkey as the MLS authenticated data too, and refuses a message
+// without it ("missing authenticated sender").
+
+export function appEvent(pubkey, kind, content, tags = [], created_at = now()) {
+ const e = { pubkey, created_at, kind, tags, content };
+ return { id: getEventHash(e), ...e };
+}
+export const senderAad = (pubkey) => utf8(pubkey);
+const APP_KEYS = ['content', 'created_at', 'id', 'kind', 'pubkey', 'tags'];
+export function readAppEvent(bytes, sender, aad) {
+ let e;
+ try { e = JSON.parse(text(bytes)); } catch { return null; }
+ if (!e || typeof e !== 'object' || Object.keys(e).sort().join() !== APP_KEYS.join()) return null;
+ if (typeof e.content !== 'string' || !Number.isInteger(e.created_at) || !Number.isInteger(e.kind) || !Array.isArray(e.tags)) return null;
+ if (!e.tags.every((t) => Array.isArray(t) && t.every((x) => typeof x === 'string'))) return null;
+ // MLS authenticated the leaf; the event (and the AAD, when given) must name that leaf's account
+ if (e.pubkey !== sender) return null;
+ if (aad && aad.length && text(aad) !== sender) return null;
+ try { if (getEventHash(e) !== e.id) return null; } catch { return null; }
+ return e;
+}
+
+// ---- group references (applications/group-ref.md): cordn1… ----
+// NIP-19-style TLV: 0 = gid (UTF-8), 1 = coordinator pubkey (32 bytes), 2 = relay.
+
+export function encodeGroupRef({ gid, coordinator, relays = [] }) {
+ const parts = [];
+ const tlv = (type, bytes) => { if (bytes.length > 255) throw new Error('group ref field too long'); parts.push(type, bytes.length, ...bytes); };
+ if (coordinator) for (const r of relays) tlv(2, utf8(r));
+ if (coordinator) tlv(1, hexToBytes(coordinator));
+ tlv(0, utf8(gid));
+ return bech32.encode('cordn', bech32.toWords(Uint8Array.from(parts)), 5000);
+}
+export function decodeGroupRef(str) {
+ const m = /cordn1[a-z0-9]+/i.exec(String(str || ''));
+ if (!m) return null;
+ let d;
+ try { d = bech32.decode(m[0].toLowerCase(), 5000); } catch { return null; }
+ if (d.prefix !== 'cordn') return null;
+ const b = bech32.fromWords(d.words);
+ const out = { gid: null, coordinator: null, relays: [] };
+ for (let o = 0; o + 2 <= b.length;) {
+ const type = b[o], n = b[o + 1], v = b.subarray(o + 2, o + 2 + n);
+ o += 2 + n;
+ if (v.length !== n) return null;
+ if (type === 0) { if (out.gid !== null || !n) return null; out.gid = text(v); }
+ else if (type === 1) { if (out.coordinator || n !== 32) return null; out.coordinator = bytesToHex(v); }
+ else if (type === 2) out.relays.push(text(v));
+ }
+ if (!out.gid || (out.relays.length && !out.coordinator)) return null;
+ return out;
+}
+
+// ---- the group engine ----
+//
+// A record is plain data, persisted whole:
+// id the gid (the MLS group id as text, a UUID in practice)
+// tip the canonical MLS state; past: a few epochs behind it (a late
+// message sealed for one of them still opens)
+// pending a commit of ours that is posted but not yet seen back
+// cursor the coordinator cursor processed up to
+// seen digests of records already processed (the stream repeats
+// itself: identical bytes at consecutive cursors were seen live)
+// log delivered app events, oldest first
+
+const SEEN_MAX = 2000, LOG_MAX = 400, PAST_MAX = 3;
+
+export const record = (state, me, extra = {}) => ({
+ id: text(state.groupId),
+ cordn: true,
+ me,
+ tip: state,
+ past: [],
+ pending: null,
+ cursor: 0,
+ seen: [],
+ log: [],
+ removed: false,
+ leaving: false,
+ fresh: false,
+ inviter: null,
+ at: now(),
+ ...extra,
+});
+
+const digestOf = (msg_64) => bytesToHex(sha256(utf8(msg_64))).slice(0, 16);
+const see = (g, d) => { g.seen.push(d); if (g.seen.length > SEEN_MAX) g.seen.splice(0, g.seen.length - SEEN_MAX); };
+
+function deliver(g, out, ev, epoch) {
+ if (g.log.some((m) => m.id === ev.id)) return;
+ ev = { ...ev, epoch };
+ g.log.push(ev);
+ if (g.log.length > LOG_MAX) g.log.splice(0, g.log.length - LOG_MAX);
+ out.push(ev);
+}
+
+// Membership and settings changes between two epochs, as kind-1210 rows
+// for the transcript (local, derived from signed state).
+function changes(before, after, actor, at) {
+ const out = [];
+ const row = (type, data) => out.push(appEvent(actor || '', KIND.SYSTEM,
+ JSON.stringify({ v: 1, system_type: type, text: type.replace(/_/g, ' '), data: { actor, ...data } }), [['system', type]], at));
+ const a = accounts(before), b = accounts(after);
+ for (const pk of b) if (!a.includes(pk)) row('member_added', { subject: pk });
+ for (const pk of a) if (!b.includes(pk)) row(pk === actor ? 'member_left' : 'member_removed', { subject: pk });
+ const va = groupView(before), vb = groupView(after);
+ for (const pk of vb.admins) if (!va.admins.includes(pk) && a.includes(pk)) row('admin_added', { subject: pk });
+ for (const pk of va.admins) if (!vb.admins.includes(pk) && b.includes(pk)) row('admin_removed', { subject: pk });
+ if (va.name !== vb.name) row('group_renamed', { name: vb.name });
+ return out;
+}
+
+const leafOwner = (state, leafIndex) => { const m = mls.members(state).find((x) => x.index === leafIndex); return m ? identityOf(m.leaf) : null; };
+
+function advance(g, out, parent, state, actor, at) {
+ g.past.unshift(parent);
+ g.past.length = Math.min(g.past.length, PAST_MAX);
+ g.tip = state;
+ for (const ev of changes(parent, state, actor, at)) deliver(g, out, ev, state.epoch);
+ out.push({ epoch: state.epoch });
+}
+
+// One coordinator record in. Returns what the app needs to hear about:
+// delivered app events, { epoch } on a state change, { removed }, { invalid }.
+export function receive(g, rec) {
+ const out = [];
+ if (g.removed) return out;
+ if (rec.cursor <= g.cursor) return out;
+ g.cursor = rec.cursor;
+ const d = digestOf(rec.msg_64);
+ if (g.seen.includes(d)) return out;
+ see(g, d);
+ const at = rec.at ? Math.floor(rec.at / 1000) : now();
+ // our own commit coming back: it is group state now
+ if (g.pending && g.pending.msg_64 === rec.msg_64) { confirm(g, out, at); return out; }
+ let bytes = null, state = null;
+ for (const s of [g.tip, ...g.past]) { try { bytes = unseal(s, rec.msg_64); state = s; break; } catch {} }
+ if (!bytes) { out.push({ invalid: 'sealed for an epoch we do not hold' }); return out; }
+ let head;
+ try { head = mls.decodeMessage(bytes); } catch (e) { out.push({ invalid: e.message }); return out; }
+ if (head.epoch !== state.epoch) {
+ state = [g.tip, ...g.past].find((s) => s.epoch === head.epoch) || null;
+ if (!state) { out.push({ invalid: 'framed for an epoch we do not hold' }); return out; }
+ }
+ let r;
+ try { r = mls.processMessage(state, bytes); } catch (e) { out.push({ invalid: e.message }); return out; }
+ if (r.type === 'application') {
+ const sender = leafOwner(state, r.sender);
+ const ev = sender && readAppEvent(r.data, sender, r.aad);
+ if (ev) deliver(g, out, ev, state.epoch); else out.push({ invalid: 'bad envelope' });
+ if (state === g.tip) g.tip = r.state; else g.past[g.past.indexOf(state)] = r.state;
+ } else if (r.type === 'commit') {
+ if (state !== g.tip) { out.push({ invalid: 'commit for an old epoch' }); return out; }
+ // a peer's commit from our epoch beat ours: ours is void
+ if (g.pending) { g.pending = null; out.push({ dropped: true }); }
+ if (r.removedSelf) { g.removed = true; out.push({ removed: true }); return out; }
+ advance(g, out, g.tip, r.state, leafOwner(g.tip, r.sender), at);
+ } else if (r.type === 'proposal' && state === g.tip) g.tip = r.state;
+ return out;
+}
+
+// Our application message: encrypted, sealed, logged. The client posts msg_64.
+export function send(g, inner) {
+ const r = mls.encryptApplication(g.tip, utf8(JSON.stringify(inner)), senderAad(g.me));
+ const msg_64 = seal(g.tip, r.message);
+ g.tip = r.state;
+ see(g, digestOf(msg_64));
+ const out = [];
+ deliver(g, out, inner, g.tip.epoch);
+ return { msg_64, out };
+}
+
+// Stage a commit. Not group state until the coordinator echoes it back
+// (receive → confirm); drop() if it never got there.
+export function stage(g, proposals) {
+ if (g.pending) throw new Error('a change is already on its way');
+ const r = mls.createCommit(g.tip, { proposals });
+ g.pending = { state: r.state, msg_64: seal(g.tip, r.message), welcome: r.welcome, at: now() };
+ return g.pending;
+}
+export function confirm(g, out = [], at = now()) {
+ const p = g.pending;
+ if (!p) return out;
+ g.pending = null;
+ advance(g, out, g.tip, p.state, g.me, at);
+ return out;
+}
+export const drop = (g) => { g.pending = null; };
+
+// Proposals for the common changes.
+export const invite = (cands) => cands.map((c) => mls.proposeAdd(c.keyPackage));
+export const remove = (g, pubkey) => roster(g.tip).filter((m) => m.pubkey === pubkey).map((m) => mls.proposeRemove(m.leaf));
+export function setMetadata(g, patch) {
+ const v = groupView(g.tip);
+ const next = { name: v.name, description: v.description, admins: v.admins, icon: v.icon, imageUrl: v.imageUrl, ...patch };
+ const others = g.tip.extensions.filter((e) => e.type !== EXT.METADATA);
+ return mls.proposeGroupContextExtensions([...others, metadataExtension(next)]);
+}
+
+// Found a group: us at epoch 0 with the metadata, then one commit that adds
+// everyone (its Welcome is theirs to fetch from the coordinator).
+export function found(sig, me, { name = '', icon = '', description = '', admins = [], invitees = [] }) {
+ const gid = crypto.randomUUID();
+ const state = mls.createGroup({
+ groupId: utf8(gid), sig, identity: utf8(me), capabilities: capabilities(),
+ extensions: [metadataExtension({ name, icon, description, admins })],
+ });
+ const g = record(state, me);
+ const pending = invitees.length ? stage(g, invite(invitees)) : null;
+ return { g, pending };
+}
+
+// A Welcome the coordinator held for one of our KeyPackages.
+export function join(sig, me, welcome, kps, { cursor = 0 } = {}) {
+ const bytes = base64.decode(welcome.welcome_64);
+ let state = null, used = null, err = null;
+ const first = kps.filter((k) => k.ref === welcome.kp_ref);
+ for (const k of [...first, ...kps.filter((k) => !first.includes(k))]) {
+ try { state = mls.joinWelcome(bytes, { keyPackage: k.keyPackage, secrets: k.secrets, sig }); used = k; break; } catch (e) { err = e; }
+ }
+ if (!state) throw err || new Error('welcome is not for this device');
+ const inviter = leafOwner(state, state.joinedVia);
+ return { g: record(state, me, { fresh: true, inviter, cursor }), used };
+}
diff --git a/src/features/messages.js b/src/features/messages.js
index 7f71264..108af9a 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1269,6 +1269,7 @@ export function messagesFeature(ctx) {
if (wasDm) startDMs();
for (const room of wanted) subscribeRoom(room);
if (wn) wn.resubscribe();
+ if (cd) cd.resubscribe();
}
// ---- subscription watchdog ------------------------------------------------
@@ -3007,7 +3008,7 @@ export function messagesFeature(ctx) {
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
ui.msgSheet = null;
// a reaction travels with the message it answers
- if (m.wn) { const g = wn && wn.groups.get(m.wn); if (g) sendWnReaction(g, m, emoji); return; }
+ if (m.wn) { const g = findGroup(m.wn); if (g) sendWnReaction(g, m, emoji); return; }
const rumor = makeDMReaction(id.pubkey, peer, m.rumor.id, emoji, reactEmojiTags(emoji));
const r = dmReacts.get(m.rumor.id) || dmReacts.set(m.rumor.id, new Map()).get(m.rumor.id);
const prev = r.get(id.pubkey);
@@ -3089,6 +3090,42 @@ export function messagesFeature(ctx) {
return p;
}
+ // Cordn groups (cordn-client.js): MLS again, delivered by a coordinator
+ // instead of relays. The same interface as the White Noise client, so the
+ // group screens below drive either; gClient(g) says which one a group is.
+ let cd = null, cdLoading = null, cdReadied = false;
+ function cordnChat() {
+ const pk = identityPk();
+ if (!pk || !wallet._cacheKey) return Promise.resolve(null);
+ if (cd && cd.pubkey === pk) return Promise.resolve(cd);
+ if (cdLoading && cdLoading.pk === pk) return cdLoading.p;
+ if (cd) { try { cd.stop(); } catch {} cd = null; }
+ const p = import('../cordn-client.js').then(async (m) => {
+ if (identityPk() !== pk) return null;
+ let coordinator = null;
+ try { coordinator = JSON.parse(localStorage.getItem('coinos-cordn-coordinator') || 'null'); } catch {}
+ const c = m.cordnClient({
+ scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onCd, subClosed: noteSubClosed,
+ ...(coordinator && coordinator.pubkey ? { coordinator } : {}),
+ });
+ cd = c;
+ cdLoading = null;
+ await c.start();
+ return c;
+ }).catch((e) => { console.warn('chat: cordn groups unavailable', e); cdLoading = null; return null; });
+ cdLoading = { pk, p };
+ return p;
+ }
+ function onCd(type, g, data) {
+ if (!cd) return;
+ if (type === 'message') { try { noteEmoji(data); } catch {} }
+ wnFolds.delete(g && g.id);
+ bumpMsgRev();
+ scheduleRepaint();
+ }
+ const gClient = (g) => (g && g.cordn ? cd : wn);
+ const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || null;
+
// One delivered event of a direct group, into that person's thread.
function wnIntoThread(g, peer, m) {
const th = threadOf(peer);
@@ -3232,6 +3269,7 @@ export function messagesFeature(ctx) {
}
async function sendWnReaction(g, m, emoji) {
+ const wn = gClient(g);
const inner = wn.event(7, emoji, [['e', m.rumor.id], ['p', m.rumor.pubkey], ['k', String(m.rumor.kind)], ...reactEmojiTags(emoji)]);
const ok = await wn.send(g, inner).catch(() => false);
if (!ok) { toast(t('msgSendFailed')); return; }
@@ -3242,12 +3280,13 @@ export function messagesFeature(ctx) {
const wnRead = (id) => 'wn:' + id;
// groups with a screen of their own (a direct group lives in its DM thread)
- const wnGroups = () => (wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []);
+ const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : [])];
function wnTitle(g) {
- const v = wn.view(g);
+ const c = gClient(g);
+ const v = c.view(g);
if (v.name) return v.name;
- const others = wn.accounts(g).filter((pk) => !isMe(pk));
+ const others = c.accounts(g).filter((pk) => !isMe(pk));
return others.length ? others.slice(0, 3).map(displayName).join(', ') : t('msgGroupUnnamed');
}
@@ -3306,11 +3345,14 @@ export function messagesFeature(ctx) {
// People being gathered for a group (the New group panel, or Add person):
// each is checked for a KeyPackage as they are picked.
const wnReach = new Map(); // pk -> 'checking' | 'ok' | 'no'
- function wnCheck(pk) {
- if (wnReach.get(pk) === 'ok' || wnReach.get(pk) === 'checking') return;
- wnReach.set(pk, 'checking');
- whiteNoise().then((c) => (c ? c.lookup(pk) : null)).then((cand) => { wnReach.set(pk, cand ? 'ok' : 'no'); scheduleRepaint(); })
- .catch(() => { wnReach.set(pk, 'no'); scheduleRepaint(); });
+ const cdReach = new Map(); // the same, on the Cordn coordinator
+ const reachOf = (proto) => (proto === 'cd' ? cdReach : wnReach);
+ function wnCheck(pk, proto = 'wn') {
+ const reach = reachOf(proto);
+ if (reach.get(pk) === 'ok' || reach.get(pk) === 'checking') return;
+ reach.set(pk, 'checking');
+ (proto === 'cd' ? cordnChat() : whiteNoise()).then((c) => (c ? c.lookup(pk) : null)).then((cand) => { reach.set(pk, cand ? 'ok' : 'no'); scheduleRepaint(); })
+ .catch(() => { reach.set(pk, 'no'); scheduleRepaint(); });
}
const wnFail = (e) => {
if (e && e.missing) toast(t('msgGroupSomeCant', { names: e.missing.map(displayName).join(', ') }));
@@ -3325,11 +3367,12 @@ export function messagesFeature(ctx) {
if (ui.msgBusy) return;
ui.msgBusy = true; render();
try {
- const c = await whiteNoise();
+ const cordn = ui.msgGroupProto === 'cd';
+ const c = await (cordn ? cordnChat() : whiteNoise());
if (!c) throw new Error('no signer');
// a group with no name is still a group once it has a third person;
// with two it would be a direct chat, so it takes a name
- const name = (ui.msgGroupName || '').trim() || (members.length === 1 ? t('msgGroupUnnamed') : '');
+ const name = (ui.msgGroupName || '').trim() || (members.length === 1 || cordn ? t('msgGroupUnnamed') : '');
const g = await c.create({ name, members });
ui.msgGroupPicks = []; ui.msgGroupName = ''; ui.msgGroupTo = ''; ui.msgHomePanel = null;
dmSearcher.clear();
@@ -3339,6 +3382,7 @@ export function messagesFeature(ctx) {
async function wnSend(g) {
const text = draftFor('wn:' + g.id).trim();
+ const wn = gClient(g);
if (!text || !wn) return;
const replyTo = ui.msgReplyTo && wnFold(g).rows.some((m) => m.rumor.id === ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
@@ -11296,6 +11340,11 @@ export function messagesFeature(ctx) {
if (Date.now() - listsSyncedAt > 60_000) syncLists({ force: true }).catch(() => {});
for (const jm of communities()) ensureRoom(jm);
+ // Cordn groups the same way: a KeyPackage on the coordinator is what
+ // makes us invitable there (a seed wallet signs it unasked; a signer
+ // login is asked when it first opens a Cordn group or invites someone).
+ if (ui.msgView !== 'grp' && cd) cd.focus(null);
+ cordnChat().then((c) => { if (c && !cdReadied) { cdReadied = true; c.ready(); } });
// Group chats ride along: the client loads with the wallet, and standing
// on this screen is what makes us invitable (a KeyPackage on the relays).
whiteNoise().then(async (c) => {
@@ -11851,6 +11900,7 @@ export function messagesFeature(ctx) {
// ---- group chats (White Noise) --------------------------------------------
function wnGroupRow(g) {
+ const wn = gClient(g);
const name = wnTitle(g);
const last = wnChat(g).at(-1);
const unread = wnUnread(g);
@@ -11872,6 +11922,7 @@ export function messagesFeature(ctx) {
}
function wnInviteCard(g) {
+ const wn = gClient(g);
return h('div', { class: 'notice info col', style: 'gap:8px' },
h('div', {}, t('msgGroupInvite', { name: displayName(g.inviter), group: wnTitle(g) })),
h('div', { class: 'row gap6' },
@@ -11887,8 +11938,9 @@ export function messagesFeature(ctx) {
// Search, tap, and the person becomes a chip that says whether they can be
// reached. Shared by New group and a group's Add person.
- function wnPeoplePicker(picks, onPick, onDrop) {
- for (const pk of picks) { profileOf(pk); wnCheck(pk); }
+ function wnPeoplePicker(picks, onPick, onDrop, proto = 'wn') {
+ const wnReach = reachOf(proto);
+ for (const pk of picks) { profileOf(pk); wnCheck(pk, proto); }
const add = (pk) => { if (!isMe(pk) && !picks.includes(pk)) onPick(pk); dmSearcher.clear(); ui.msgGroupTo = ''; render(); };
return [
picks.length ? h('div', { class: 'row gap6', style: 'flex-wrap:wrap' }, picks.map((pk) => {
@@ -11914,24 +11966,32 @@ export function messagesFeature(ctx) {
function wnNewGroupPanel() {
const picks = ui.msgGroupPicks || (ui.msgGroupPicks = []);
- const ready = picks.length && picks.every((pk) => wnReach.get(pk) === 'ok');
+ const proto = ui.msgGroupProto || 'wn';
+ const ready = picks.length && picks.every((pk) => reachOf(proto).get(pk) === 'ok');
return h('div', { class: 'col gap6' },
+ // which protocol the group speaks: White Noise (Marmot) or Cordn
+ h('div', { class: 'seg' },
+ h('button', { class: proto === 'wn' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'wn'; render(); } }, t('msgGroupProtoWn')),
+ h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd'))),
+ proto === 'cd' ? h('div', { class: 'small muted' }, t('msgGroupProtoCdHint')) : null,
h('input', {
type: 'text', placeholder: t('msgGroupName'), maxlength: '64',
value: ui.msgGroupName || '', onInput: (e) => { ui.msgGroupName = e.target.value; },
}),
- ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1)),
+ ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1), proto),
h('div', { class: 'row' },
h('button', { class: 'btn-primary btn-sm', disabled: !ready || ui.msgBusy, onClick: wnCreateGroup },
ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgGroupCreate'))));
}
function wnMemberPanel(g, admin) {
+ const wn = gClient(g);
+ const proto = g.cordn ? 'cd' : 'wn';
const v = wn.view(g);
const accounts = wn.accounts(g);
const picks = ui.msgGroupAdd || (ui.msgGroupAdd = []);
return h('div', { class: 'col chat-members' },
- h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t('msgGroupInfo')),
+ h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')),
...accounts.map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) },
avatar(pk),
h('div', { class: 'row gap6 grow', style: 'align-items:center;min-width:0' },
@@ -11944,9 +12004,9 @@ export function messagesFeature(ctx) {
}, t('msgGroupRemove'))
: null)),
admin ? h('div', { class: 'col gap6 chat-invite' },
- ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1)),
+ ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1), proto),
picks.length ? h('div', { class: 'row' }, h('button', {
- class: 'btn-primary btn-sm', disabled: ui.msgBusy || !picks.every((pk) => wnReach.get(pk) === 'ok'),
+ class: 'btn-primary btn-sm', disabled: ui.msgBusy || !picks.every((pk) => reachOf(proto).get(pk) === 'ok'),
onClick: () => wnAct(async () => { await wn.add(g, [...picks]); ui.msgGroupAdd = []; }),
}, ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgAddPerson'))) : null,
h('div', { class: 'row gap6' },
@@ -11964,6 +12024,7 @@ export function messagesFeature(ctx) {
}
function wnSheet(g) {
+ const wn = gClient(g);
const fold = wnFold(g);
const m = fold.rows.find((x) => x.rumor.id === ui.msgSheet);
if (!m) { ui.msgSheet = null; return null; }
@@ -11990,18 +12051,21 @@ export function messagesFeature(ctx) {
!ui.emojiPick && m.mine && !g.removed ? item('×', t('msgDelete'), async () => {
close();
const del = wn.event(5, '', [['e', m.rumor.id], ['k', '9']]);
- if (await wn.send(g, del).catch(() => false)) onWn('message', g, del);
+ if (await wn.send(g, del).catch(() => false)) (g.cordn ? onCd : onWn)('message', g, del);
}) : null));
}
function wnView() {
- const g = wn && wn.groups.get(ui.msgPeer);
+ const g = findGroup(ui.msgPeer);
if (!g) {
// the groups are still loading (a reload straight into this screen)
- if (!wn || wnLoading) { whiteNoise().then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); }
+ if (!wn || wnLoading || !cd || cdLoading) { Promise.all([whiteNoise(), cordnChat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); }
ui.msgView = 'home';
return homeView();
}
+ const wn = gClient(g);
+ // a Cordn group on screen is read from its coordinator every few seconds
+ if (cd) cd.focus(g.cordn ? g.id : null);
const fold = wnFold(g);
const v = wn.view(g);
const my = myPubkeys();
@@ -12105,7 +12169,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))),
h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')) : null,
live
- ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, (f) => wnSendFile(g, f))
+ ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn ? null : (f) => wnSendFile(g, f))
: h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')),
ui.msgSheet ? wnSheet(g) : null);
}
diff --git a/src/i18n.js b/src/i18n.js
index 1ff5baa..b82a2ac 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -514,6 +514,12 @@ const STR = {
msgGroupAttachment: 'Attachment',
msgGroupFailed: "Couldn't update the group. Try again",
msgGroupSetup: 'Your signer is needed once to set up group chats',
+ msgGroupProto: 'Group type',
+ msgGroupProtoWn: 'White Noise',
+ msgGroupProtoCd: 'Cordn',
+ msgGroupProtoCdHint: 'Cordn groups work with Amethyst and the Cordn app; messages travel through a coordinator server that cannot read them.',
+ msgCordnInfo: 'Cordn group · end-to-end encrypted · delivered by a coordinator',
+ msgCordnNoFiles: 'Attachments are not available in Cordn groups yet',
msgRouteTitle: 'Send messages by',
msgRouteAuto: 'Automatic',
msgRouteAutoHint: 'Reply the way they last wrote',
diff --git a/src/mls.js b/src/mls.js
index 449036b..5a273b6 100644
--- a/src/mls.js
+++ b/src/mls.js
@@ -1578,9 +1578,13 @@ const applyProposals = (s, list, committer, { appData, psks, now, own } = {}) =>
let extensions = gce.length ? gce[0].proposal.extensions : s.extensions;
if (gce.length) {
uniqueTypes(extensions, "group context");
- // an OpenMLS rule, not an RFC one: whatever the context carries must be required of every member
- let required = requiredCapabilities(extensions)?.extensions || [];
- if (!extensions.every((e) => supportsExtension({ extensions: required }, e.type)))
+ // An OpenMLS rule, not an RFC one: whatever the context carries must be
+ // required of every member. Applied where the group plays by it (a
+ // required_capabilities extension in the context, as White Noise groups
+ // have); a ts-mls group (Cordn) carries none and changes its metadata
+ // extension freely, as the RFC allows.
+ let required = requiredCapabilities(extensions)?.extensions;
+ if (required && !extensions.every((e) => supportsExtension({ extensions: required }, e.type)))
fail("group context extension type missing from required_capabilities");
// with app_data_update required, the dictionary only changes through app_data_update
if (requiresAppDataUpdate(s.extensions) || requiresAppDataUpdate(extensions)) {
diff --git a/tools/cordn-interop-test.js b/tools/cordn-interop-test.js
new file mode 100644
index 0000000..e3f3f82
--- /dev/null
+++ b/tools/cordn-interop-test.js
@@ -0,0 +1,127 @@
+// Cordn interop, both directions, against the public coordinator: our
+// cordn-client.js on one side, the official @cordn/cli (ts-mls) on the other.
+//
+// 1. Alice (CLI) invites Bob (us): Bob's KeyPackage is on the coordinator,
+// Alice adds him, Bob joins from the Welcome, reads Alice, answers, and
+// Alice's CLI shows the answer.
+// 2. Bob (us) founds a group and invites Alice: Alice accepts the Welcome
+// the way her CLI does, reads Bob, answers, and Bob reads her.
+//
+// Needs the CLI rig: npm i @cordn/cli in CORDN_CLI_DIR (default: the
+// scratchpad path below) with a state file alice.json that already has a
+// group "office" and a published KeyPackage ("gen-kp main").
+//
+// Run: bun tools/cordn-interop-test.js
+// CORDN_DEBUG=1 shows the client's dlog lines (skipped records and why)
+if (process.env.CORDN_DEBUG && typeof localStorage === 'undefined') {
+ globalThis.localStorage = { getItem: (k) => (k === 'coinos-debug' ? '1' : null), setItem() {}, removeItem() {} };
+}
+import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+// imported after the shim above (static imports would run first)
+const { cordnClient } = await import('../src/cordn-client.js');
+const C = await import('../src/cordn.js');
+
+const CLI_DIR = process.env.CORDN_CLI_DIR || '/tmp/claude-1000/-home-adam-coinosv3/77efc6a1-2602-46f7-b851-5580b0742cf1/scratchpad/cordn/cli';
+const CLI = `${CLI_DIR}/node_modules/.bin/cordn`;
+const ALICE_STATE = `${CLI_DIR}/alice.json`;
+let failed = false;
+const ok = (name, cond, detail = '') => { console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) failed = true; return cond; };
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function alice(cmd) {
+ const p = Bun.spawnSync([CLI, '--state-file', ALICE_STATE, '--command', cmd], { timeout: 90_000, stdout: 'pipe', stderr: 'pipe' });
+ const out = (p.stdout.toString() + p.stderr.toString()).replace(/\x1b\[[0-9;]*m/g, '').trim();
+ console.log(` [alice] ${cmd}\n ${out.split('\n').slice(-5).join('\n ')}`);
+ return out;
+}
+const alicePk = () => /stablePubkey:\s*([0-9a-f]{64})/.exec(alice('status'))?.[1];
+
+// an in-memory stand-in for the IndexedDB store
+const memStore = () => { const m = new Map(); return { all: async () => new Map(m), put: async (k, v) => { m.set(k, structuredClone(v)); }, del: async (k) => { m.delete(k); }, wipe: async () => m.clear() }; };
+
+const sk = generateSecretKey(), bob = getPublicKey(sk);
+console.log('bob =', bob);
+const events = [];
+const bobClient = cordnClient({
+ scope: 'test', pubkey: bob, identity: async () => ({ pubkey: bob, signer: sk }),
+ on: (type, g, data) => { events.push({ type, g, data }); if (type === 'message') console.log(` [bob] ${g.id.slice(0, 8)} <- ${data.pubkey.slice(0, 8)} k${data.kind}: ${data.content}`); },
+ store: memStore(),
+});
+const waitFor = async (fn, ms = 90_000) => { const t = Date.now(); while (Date.now() - t < ms) { if (fn()) return true; await bobClient.poll(); await sleep(1500); } return false; };
+
+try {
+ await bobClient.start();
+ ok('ready: KeyPackage published', await bobClient.ready());
+ const apk = alicePk();
+ // her regular packages are consumed by every invitation: a fresh one, and its ref names the Welcome to look for
+ const aliceRef = /ref=([0-9a-f]{64})/.exec(alice('gen-kp kp' + Date.now().toString(36)))?.[1];
+ ok('alice reachable (her KeyPackage is on the coordinator)', !!(await bobClient.lookup(apk)), apk && apk.slice(0, 8));
+
+ // ---- 1. Alice invites Bob ---- (CORDN_TEST_PART=2 skips it)
+ if (process.env.CORDN_TEST_PART !== '2') {
+ alice(`add-member office ${bob}`);
+ ok('joined from the Welcome', await waitFor(() => [...bobClient.groups.values()].some((g) => g.fresh)));
+ const g1 = [...bobClient.groups.values()].find((g) => g.fresh);
+ if (g1) {
+ const v = bobClient.view(g1);
+ ok('metadata + inviter + members', v.name === 'Office' && g1.inviter === apk && bobClient.accounts(g1).includes(apk), JSON.stringify({ name: v.name, icon: v.icon, admins: v.admins.length, members: bobClient.accounts(g1).length, epoch: g1.tip.epoch }));
+ bobClient.accept(g1);
+ bobClient.focus(g1.id);
+ alice('send-to office hello bob from the cli');
+ ok('read alice', await waitFor(() => g1.log.some((m) => m.content === 'hello bob from the cli')));
+ const inner = bobClient.event(9, 'hello alice from coinos', []);
+ ok('sent', await bobClient.send(g1, inner));
+ alice('sync office');
+ ok("alice's CLI shows our message", alice('messages office').includes('hello alice from coinos'));
+ // a reaction travels too
+ ok('reaction sent', await bobClient.send(g1, bobClient.event(7, '👍', [['e', g1.log.find((m) => m.content === 'hello bob from the cli').id], ['p', apk], ['k', '9']])));
+ }
+ }
+
+ // ---- 2. Bob founds a group and invites Alice ----
+ const alias = 'fc' + Date.now().toString(36);
+ let g2 = null;
+ try { g2 = await bobClient.create({ name: 'From coinos', icon: '🪙', members: [apk] }); } catch (e) { console.log(' create failed:', e.message); }
+ ok('founded a group with alice in it', !!g2 && bobClient.accounts(g2).includes(apk) && g2.tip.epoch === 1, g2 && JSON.stringify({ gid: g2.id, members: bobClient.accounts(g2).length, cursor: g2.cursor }));
+ if (g2) {
+ ok('group ref encodes/decodes', C.decodeGroupRef(bobClient.groupRef(g2))?.gid === g2.id, bobClient.groupRef(g2).slice(0, 40) + '…');
+ ok('sent to the new group', await bobClient.send(g2, bobClient.event(9, 'welcome to my group, alice', [])));
+ alice('fetch-welcomes');
+ const w = alice('welcomes');
+ // several Welcomes may be pending from earlier runs: the one for the package we took
+ const id = w.split('\n').map((l) => /([0-9a-f]{64}:([0-9a-f]{64}):\d+)/.exec(l)).filter(Boolean).sort((a, b) => Number(b[1].split(':')[2]) - Number(a[1].split(':')[2]))[0]?.[1]; // the newest: the one just stored
+ ok('alice sees the Welcome', !!id, id && id.slice(0, 24));
+ if (id) {
+ const acc = alice(`accept-welcome ${id} ${alias}`);
+ ok('alice accepted it', !/error|failed/i.test(acc) || /accept|joined/i.test(acc));
+ alice(`sync ${alias}`);
+ ok("alice reads bob's message", alice(`messages ${alias}`).includes('welcome to my group, alice'));
+ alice(`send-to ${alias} hi bob, nice group`);
+ bobClient.focus(g2.id);
+ ok("bob reads alice's reply", await waitFor(() => g2.log.some((m) => m.content === 'hi bob, nice group')));
+ if (process.env.CORDN_DEBUG) {
+ const { debugOn } = await import('../src/debug.js');
+ const { cvmClient } = await import('../src/contextvm.js');
+ const { finalizeEvent } = await import('nostr-tools/pure');
+ const { nip44 } = await import('nostr-tools');
+ console.log(' [dbg] debugOn', debugOn(), 'g2 cursor', g2.cursor, 'epoch', g2.tip.epoch, 'seen', g2.seen.length, 'log', g2.log.map((m) => m.kind + ':' + m.content.slice(0, 20)));
+ const probe = cvmClient({ server: C.DEFAULT_COORDINATOR.pubkey, relays: C.DEFAULT_COORDINATOR.relays, pubkey: bob, sign: async (e) => finalizeEvent(e, sk), decrypt: async (p, ct) => nip44.v2.decrypt(ct, nip44.v2.utils.getConversationKey(sk, p)) });
+ try {
+ const r = await probe.call('msg_fetch_many', { groups: [{ gid: g2.id }] });
+ console.log(' [dbg] coordinator holds cursors', (r.messages || []).map((m) => m.cursor + '@' + m.gid.slice(0, 8)).join(' '));
+ for (const m of (r.messages || [])) { try { const out = C.receive(g2, { ...m, cursor: m.cursor }); console.log(' [dbg] receive', m.cursor, JSON.stringify(out).slice(0, 160)); } catch (e) { console.log(' [dbg] receive threw', m.cursor, e.message); } }
+ } catch (e) { console.log(' [dbg] probe failed', e.message); }
+ probe.stop();
+ }
+ // rename: a GroupContextExtensions commit their side must apply
+ try { await bobClient.rename(g2, 'Renamed by coinos'); } catch (e) { console.log(' rename failed:', e.message); }
+ ok('rename applied here', bobClient.view(g2).name === 'Renamed by coinos', bobClient.view(g2).name);
+ alice(`sync ${alias}`);
+ ok("alice's CLI sees the new name", /Renamed by coinos/.test(alice(`group-info ${alias}`)));
+ alice(`send-to ${alias} still here after the rename`);
+ ok('bob reads after the rename', await waitFor(() => g2.log.some((m) => m.content === 'still here after the rename')));
+ }
+ }
+ console.log(failed ? '\nFAILED' : '\nINTEROP OK');
+} catch (e) { console.log('\nCRASH:', e.stack || e.message); failed = true; }
+finally { bobClient.stop(); await sleep(300); process.exit(failed ? 1 : 0); }
diff --git a/tools/cordn-ui-test.js b/tools/cordn-ui-test.js
new file mode 100644
index 0000000..6948428
--- /dev/null
+++ b/tools/cordn-ui-test.js
@@ -0,0 +1,89 @@
+// Cordn in the app, end to end, against the public coordinator: a fresh
+// wallet makes a Cordn group from Messages → New group, invites Alice (the
+// official @cordn/cli), sends; Alice's CLI accepts the Welcome, reads it and
+// answers; the app shows her answer. Needs the CLI rig (see
+// cordn-interop-test.js).
+//
+// Run: bun tools/cordn-ui-test.js [screenshot.png]
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { npubEncode } from 'nostr-tools/nip19';
+import { buildHtml } from '../build.js';
+
+const CLI_DIR = process.env.CORDN_CLI_DIR || '/tmp/claude-1000/-home-adam-coinosv3/77efc6a1-2602-46f7-b851-5580b0742cf1/scratchpad/cordn/cli';
+const CLI = `${CLI_DIR}/node_modules/.bin/cordn`;
+const ALICE_STATE = `${CLI_DIR}/alice.json`;
+const shotPath = process.argv[2] || '/tmp/cordn-ui-test.png';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; return c; };
+function alice(cmd) {
+ const p = Bun.spawnSync([CLI, '--state-file', ALICE_STATE, '--command', cmd], { timeout: 90_000, stdout: 'pipe', stderr: 'pipe' });
+ const out = (p.stdout.toString() + p.stderr.toString()).replace(/\x1b\[[0-9;]*m/g, '').trim();
+ console.log(` [alice] ${cmd}\n ${out.split('\n').slice(-4).join('\n ')}`);
+ return out;
+}
+const apk = /stablePubkey:\s*([0-9a-f]{64})/.exec(alice('status'))?.[1];
+const aliceRef = /ref=([0-9a-f]{64})/.exec(alice('gen-kp kp' + Date.now().toString(36)))?.[1];
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5234, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => { errs.push(e.message); console.log(' [pageerror]', e.message); });
+const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase() === x.toLowerCase()) || [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
+const waitFor = async (fn, ms = 25000, arg) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(250); } return false; };
+const bodyText = () => page.evaluate(() => document.body.innerText);
+
+try {
+ await page.setViewport({ width: 420, height: 860 });
+ await page.goto('http://localhost:5234/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('button', 'Create a new wallet'); await sleep(300);
+ await click('button', 'Have an existing seed'); await sleep(300);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', generateMnemonic(wordlist));
+ await click('button', 'Open wallet');
+ await waitFor(() => /receive/i.test(document.body.innerText), 15000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => /message/i.test(b.textContent))?.click());
+ check('messages screen', await waitFor(() => /New group/.test(document.body.innerText), 15000));
+
+ // New group → Cordn → Alice → create
+ await click('button', 'New group'); await sleep(300);
+ check('protocol choice shown', await waitFor(() => [...document.querySelectorAll('.seg button')].some((b) => b.textContent.trim() === 'Cordn'), 5000));
+ await click('.seg button', 'Cordn'); await sleep(200);
+ await page.type('input[placeholder="Group name"]', 'UI test group');
+ await page.type('input[placeholder^="Add people"]', npubEncode(apk));
+ await page.keyboard.press('Enter');
+ check('alice chip shows reachable', await waitFor(() => [...document.querySelectorAll('.chat-react.on')].some((c) => !/checking|can't/.test(c.textContent)), 30000), (await bodyText()).match(/checking…|can't join[^\n]*/)?.[0]);
+ await click('button', 'Create group');
+ check('group screen opened', await waitFor(() => !!document.querySelector('.chat-title') && /UI test group/.test(document.querySelector('.chat-title').textContent), 60000));
+ await page.type('#msg-draft', 'hello from the coinos app');
+ await click('button', 'Send');
+ check('our message is on screen', await waitFor(() => [...document.querySelectorAll('.chat-bubble')].some((b) => b.textContent.includes('hello from the coinos app')), 10000));
+
+ // Alice's side
+ alice('fetch-welcomes');
+ const id = alice('welcomes').split('\n').map((l) => /([0-9a-f]{64}:([0-9a-f]{64}):\d+)/.exec(l)).filter(Boolean).sort((a, b) => Number(b[1].split(':')[2]) - Number(a[1].split(':')[2]))[0]?.[1]; // the newest: the one just stored
+ check('alice got the Welcome', !!id);
+ const alias = 'ui' + Date.now().toString(36);
+ if (id) {
+ alice(`accept-welcome ${id} ${alias}`);
+ alice(`sync ${alias}`);
+ check("alice reads the app's message", alice(`messages ${alias}`).includes('hello from the coinos app'));
+ alice(`send-to ${alias} hello app, from the cli`);
+ check("the app shows alice's reply", await waitFor(() => [...document.querySelectorAll('.chat-bubble')].some((b) => b.textContent.includes('hello app, from the cli')), 30000));
+ check('member list shows both', await waitFor(() => /2 members/.test(document.body.innerText), 5000));
+ }
+ await sleep(500);
+ await page.screenshot({ path: shotPath });
+ // back home: the group row is in the list with the last message
+ await page.evaluate(() => document.querySelector('.chat-head button')?.click());
+ check('home lists the group', await waitFor(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => /UI test group/.test(r.textContent)), 5000));
+ check('no page errors', errs.length === 0, errs.join(' | ').slice(0, 300));
+ console.log(ok ? '\nUI OK' : '\nFAILED');
+} finally { await browser.close(); server.stop(); process.exit(ok ? 0 : 1); }