diff options
| author | Adam Soltys <[email protected]> | 2026-10-06 11:22:06 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-06 11:22:06 -0700 |
| commit | d657d79bc95062a8fc5e128e795e23bcefd0bf04 (patch) | |
| tree | 04a9d39281ce7784f1a4a966f0a532194ee7ded7 /src | |
| parent | f571e46937d423d9ba44154a92aeb4136466daaa (diff) | |
| download | coinosv3-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
Diffstat (limited to 'src')
| -rw-r--r-- | src/contextvm.js | 87 | ||||
| -rw-r--r-- | src/cordn-client.js | 365 | ||||
| -rw-r--r-- | src/cordn.js | 368 | ||||
| -rw-r--r-- | src/features/messages.js | 108 | ||||
| -rw-r--r-- | src/i18n.js | 6 | ||||
| -rw-r--r-- | src/mls.js | 10 |
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', @@ -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)) { |
