aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
6 files changed, 919 insertions, 25 deletions
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)) {