1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
|
// Cordn in the app: this device's standing with a coordinator (its MLS
// signature key and a published KeyPackage), its groups, and the traffic
// with the coordinator. Protocol rules live in cordn.js, the transport in
// contextvm.js; the messages feature hears about everything through
// `on(type, group, data)`, the same way it hears from marmot-client.js, and
// drives both through one interface.
//
// Like Marmot, MLS state is per DEVICE and lives in IndexedDB: a second
// device of the same account is a second leaf with its own KeyPackage.
import * as C from './cordn.js';
import { cvmClient } from './contextvm.js';
import { marmotStore } from './marmot-store.js';
import { finalizeEvent, nip44 } from './nostr.js';
import { dlog } from './debug.js';
const DAY = 86400;
const KP_REFRESH = 7 * DAY; // republish well inside the 84-day lifetime; the coordinator keeps one last-resort package per identity
const POLL_FOCUSED = 4000; // a Cordn group on screen
const POLL_IDLE = 45_000; // groups held, none on screen
const POLL_EMPTY = 180_000; // no Cordn groups at all: only Welcomes to look for
const now = () => Math.floor(Date.now() / 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// `store` is injectable for tests outside a browser (no IndexedDB there).
export function cordnClient({ scope, pubkey, identity, on, coordinator = C.DEFAULT_COORDINATOR, subClosed, store = marmotStore(scope + '|cordn') }) {
const groups = new Map(); // gid -> record
let dev = null, loaded = null, stopped = false, cvm = null, cvmSigner = null;
let kpBusy = null, pollTimer = 0, polling = null, focused = null, rotateTimer = 0;
const dirty = new Set();
let flushTimer = 0;
const emit = (type, g, data) => { try { on(type, g, data); } catch (e) { dlog('cordn: listener', e); } };
// ---- storage ----
function load() {
if (!loaded)
loaded = store.all().then((all) => {
dev = all.get('dev') || null;
for (const [k, v] of all) if (k.startsWith('g:') && v && v.tip) groups.set(v.id, v);
}).catch((e) => { dlog('cordn: store', e); });
return loaded;
}
const touch = (g) => { dirty.add(g.id); clearTimeout(flushTimer); flushTimer = setTimeout(flush, 400); };
async function flush() {
clearTimeout(flushTimer);
const ids = [...dirty];
dirty.clear();
await Promise.all(ids.map((id) => groups.has(id) ? store.put('g:' + id, groups.get(id)) : null)).catch((e) => dlog('cordn: save', e));
}
// a ratchet that moved is on disk before anything of ours leaves
const persist = (g) => { dirty.delete(g.id); return store.put('g:' + g.id, g); };
const saveDev = () => store.put('dev', dev);
// ---- this device and its coordinator ----
async function signer({ interactive = false } = {}) {
const id = await identity();
if (!id || !id.signer || id.pubkey !== pubkey) return null;
if (id.signer instanceof Uint8Array) {
const sk = id.signer;
return { raw: sk, sign: async (e) => finalizeEvent(e, sk), decrypt: async (peer, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, peer)) };
}
// Every coordinator call is signed, and every reply decrypted, by the
// signer: an interactive one (a trip through another app per call) is
// never used unasked.
if (id.signer.interactive && !interactive) return null;
if (!id.signer.decryptFrom) return null;
return { raw: null, sign: (e) => id.signer.signEvent(e), decrypt: (peer, ct) => id.signer.decryptFrom(peer, ct) };
}
// The transport, once a signer can back it. Rebuilt if the signer changes.
async function transport(opts) {
const s = await signer(opts);
if (!s) return null;
const key = s.raw ? Array.from(s.raw.slice(0, 8)).join(',') : 'adapter';
if (cvm && cvmSigner === key) return cvm;
if (cvm) cvm.stop();
cvmSigner = key;
cvm = cvmClient({ server: coordinator.pubkey, relays: coordinator.relays, pubkey, sign: s.sign, decrypt: s.decrypt,
subClosed: () => { if (!stopped && subClosed) subClosed(); } });
return cvm;
}
// The public coordinator is slow and sometimes silent (2 of 12 probes went
// unanswered in 15 s on 2026-10-06), so a call is tried twice. A repeated
// msg_post carries the same bytes, which every reader drops as a duplicate;
// kp_take is never repeated (a second take would spend a second package).
const CALL_MS = 12_000;
const call = async (name, args, opts) => {
const t = await transport(opts);
if (!t) throw new Error('no signer');
const tries = name === 'kp_take' ? 1 : 2;
let err;
for (let i = 0; i < tries; i++) {
try { return await t.call(name, args, { timeoutMs: CALL_MS }); }
catch (e) { err = e; if (!/did not answer|no relay took/.test(e.message)) throw e; }
}
throw err;
};
// A device record and a current last-resort KeyPackage on the coordinator.
function ready(opts = {}) {
if (!kpBusy) kpBusy = ensure(opts).catch((e) => { dlog('cordn: keypackage', e); return false; }).finally(() => { kpBusy = null; });
return kpBusy;
}
async function ensure(opts) {
await load();
const cur = dev && dev.kps && dev.kps.find((k) => k.published && !k.spent);
if (cur && now() - cur.published < KP_REFRESH) return true;
const t = await transport(opts);
if (!t) return false;
if (!dev) { dev = { sig: C.newSigner(), kps: [] }; await saveDev(); }
return publishKeyPackage(t);
}
async function publishKeyPackage(t) {
const kp = C.newKeyPackage(dev.sig, pubkey);
// the private half is on disk before the public half is findable
dev.kps.push(kp);
await saveDev();
try {
await call('kp_publish', { kp_ref: kp.ref, kp_64: C.keyPackageBase64(kp) });
} catch (e) {
dev.kps = dev.kps.filter((k) => k !== kp);
await saveDev();
throw e;
}
kp.published = now();
// older packages give way; the ones a Welcome may still name stay a while
const old = dev.kps.filter((k) => k !== kp && k.published);
for (const k of old) if (now() - k.at > 2 * DAY) { dev.kps = dev.kps.filter((x) => x !== k); call('kp_remove', { kp_refs: [k.ref] }).catch(() => {}); }
await saveDev();
return true;
}
// Can this person be invited? Their KeyPackage on the coordinator, or null.
const reach = new Map(); // pk -> { at, p }
function lookup(pk) {
const hit = reach.get(pk);
if (hit && Date.now() - hit.at < 5 * 60_000) return hit.p;
const p = (async () => {
const r = await call('kp_list', {});
const mine = (r.keyPackages || []).filter((k) => k.pk === pk);
return mine.length ? { pubkey: pk, refs: mine.map((k) => k.kp_ref) } : null;
})().catch(() => null);
reach.set(pk, { at: Date.now(), p });
return p;
}
// Take their package off the coordinator for an invitation (a regular one
// is consumed by this; a last-resort one stays).
async function takeKeyPackage(pk) {
const r = await call('kp_take', { id: pk }, { interactive: true });
if (!r.keyPackage) { const e = new Error('not reachable'); e.missing = [pk]; throw e; }
return C.readKeyPackage(r.keyPackage.event ? keyPackageOfEvent(r.keyPackage.event) : r.keyPackage.kp_64, pk);
}
// The coordinator serves the publication event verbatim; the package is its kp_64 argument.
function keyPackageOfEvent(ev) {
try { return JSON.parse(ev.content).params.arguments.kp_64; } catch { throw new Error('bad key package record'); }
}
// ---- the stream ----
function heard(g, out) {
for (const o of out) {
if (o.id) emit('message', g, o);
else if (o.epoch !== undefined) emit('state', g);
else if (o.removed) emit('removed', g);
else if (o.invalid) dlog('cordn: skipped record', g.id, o.invalid);
}
}
// Welcomes waiting for us, then every live group's records past its cursor.
async function poll() {
if (polling) return polling;
polling = (async () => {
const t = await transport();
if (!t || stopped) return;
await load();
try {
const w = await call('welcome_take', {});
for (const welcome of w.welcomes || []) await acceptWelcome(welcome).catch((e) => dlog('cordn: welcome', e));
} catch (e) { dlog('cordn: welcomes', e); }
const live = [...groups.values()].filter((g) => !g.removed);
if (!live.length || stopped) return;
let r;
try { r = await call('msg_fetch_many', { groups: live.map((g) => ({ gid: g.id, ...(g.cursor ? { after: g.cursor } : {}) })) }); } catch (e) { dlog('cordn: fetch', e.message); return; }
const byGid = new Map();
for (const m of r.messages || []) (byGid.get(m.gid) || byGid.set(m.gid, []).get(m.gid)).push(m);
for (const [gid, recs] of byGid) {
const g = groups.get(gid);
if (!g) continue;
recs.sort((a, b) => a.cursor - b.cursor);
for (const rec of recs) {
let out;
try { out = C.receive(g, rec); } catch (e) { dlog('cordn: receive', e); continue; }
heard(g, out);
}
touch(g);
}
})().catch((e) => dlog('cordn: poll', e)).finally(() => { polling = null; schedule(); });
return polling;
}
function schedule() {
clearTimeout(pollTimer);
if (stopped) return;
const live = [...groups.values()].some((g) => !g.removed);
pollTimer = setTimeout(poll, focused ? POLL_FOCUSED : live ? POLL_IDLE : POLL_EMPTY);
}
// Which group is on screen: that one is read every few seconds.
function focus(gid) {
gid = gid || null;
if (gid === focused) return;
focused = gid;
if (focused) { clearTimeout(pollTimer); poll(); } else schedule();
}
async function acceptWelcome(welcome) {
if (!dev) return null;
let j;
try { j = C.join(dev.sig, pubkey, welcome, dev.kps, { cursor: welcome.after || 0 }); } catch (e) { dlog('cordn: welcome', e.message); return null; }
const old = groups.get(j.g.id);
// a retained copy is never silently replaced; a group we were removed from can be rejoined
if (old && !old.removed) return null;
if (old) j.g.log = old.log;
groups.set(j.g.id, j.g);
await persist(j.g);
emit('joined', j.g);
// A regular package that got us in is spent; a last-resort one is not,
// but its keys have been seen: rotate either, a little later (more
// Welcomes may name it while we were away).
if (j.used) { j.used.spent = now(); await saveDev(); }
clearTimeout(rotateTimer);
rotateTimer = setTimeout(() => { if (!stopped) transport().then((t) => t && publishKeyPackage(t)).catch(() => {}); }, 45_000);
return j.g;
}
// ---- changes of ours ----
// Post-then-confirm: the commit is group state once the coordinator has
// it (its own cursor says so) and no peer's commit got in first. The
// stream is read up to our record so a race is settled in order.
async function commit(g, proposals, { interactive = true } = {}) {
const t = await transport({ interactive });
if (!t) throw new Error('no signer');
await poll().catch(() => {});
if (g.removed) throw new Error('not in this group');
const p = C.stage(g, proposals);
await persist(g);
let posted;
try { posted = await call('msg_post', { gid: g.id, msg_64: p.msg_64 }, { interactive }); }
catch (e) { C.drop(g); await persist(g); throw e; }
await settle(g, t, posted.cursor);
if (g.pending === p) { C.drop(g); await persist(g); throw new Error('the coordinator did not echo the change'); }
if (g.tip !== p.state) throw new Error('another change got there first');
return p;
}
// Read the stream up to a cursor of ours, so what happened before it is applied first.
async function settle(g, t, cursor) {
for (let i = 0; i < 4 && g.cursor < cursor && !stopped; i++) {
if (i) await sleep(500 * i);
let r;
try { r = await call('msg_fetch_many', { groups: [{ gid: g.id, ...(g.cursor ? { after: g.cursor } : {}) }] }); } catch { continue; }
const recs = (r.messages || []).filter((m) => m.gid === g.id).sort((a, b) => a.cursor - b.cursor);
for (const rec of recs) { let out; try { out = C.receive(g, rec); } catch { continue; } heard(g, out); }
}
await persist(g);
}
async function deliverWelcomes(g, t, welcome, cands, after) {
if (!welcome) return;
const welcome_64 = C.welcomeBase64(welcome);
for (const c of cands) {
try { await call('welcome_store', { target_pk: c.pubkey, kp_ref: c.ref, welcome_64, ...(after ? { after } : {}) }, { interactive: true }); }
catch (e) { dlog('cordn: welcome_store', e); }
}
}
async function candidates(pks) {
const out = [];
const missing = [];
for (const pk of pks) { try { out.push(await takeKeyPackage(pk)); } catch (e) { if (e.missing) missing.push(pk); else throw e; } }
if (missing.length) { const e = new Error('not reachable'); e.missing = missing; throw e; }
return out;
}
async function create({ name = '', icon = '', members = [] }) {
if (!(await ready({ interactive: true }))) throw new Error('no signer');
const t = await transport({ interactive: true });
const cands = await candidates(members);
const { g, pending } = C.found(dev.sig, pubkey, { name, icon, invitees: cands });
groups.set(g.id, g);
await persist(g);
if (pending) {
let posted;
try { posted = await call('msg_post', { gid: g.id, msg_64: pending.msg_64 }, { interactive: true }); }
catch (e) { groups.delete(g.id); store.del('g:' + g.id).catch(() => {}); throw e; }
// nobody else is in the group yet: the first commit cannot be raced
g.cursor = posted.cursor;
const out = C.confirm(g);
await persist(g);
heard(g, out);
await deliverWelcomes(g, t, pending.welcome, cands, posted.cursor);
}
emit('state', g);
focus(g.id);
return g;
}
// `inner` comes from event(): the app shows it before it has left.
async function send(g, inner) {
const t = await transport({ interactive: true });
if (!t) throw new Error('no signer');
const { msg_64 } = C.send(g, inner);
await persist(g);
try { await call('msg_post', { gid: g.id, msg_64 }, { interactive: true }); return true; }
catch (e) { dlog('cordn: post', e); g.log = g.log.filter((m) => m.id !== inner.id); touch(g); return false; }
}
async function add(g, pks) {
const cands = await candidates(pks);
const p = await commit(g, C.invite(cands));
await deliverWelcomes(g, await transport({ interactive: true }), p.welcome, cands, g.cursor);
}
const kick = (g, pk) => commit(g, C.remove(g, pk));
const rename = (g, name) => commit(g, [C.setMetadata(g, { name })]);
// Cordn has no self-removal yet (their client's "leave" only forgets the
// group locally); ours does the same, and a member can still remove us.
async function leave(g) { forget(g); }
function forget(g) {
groups.delete(g.id);
dirty.delete(g.id);
store.del('g:' + g.id).catch(() => {});
if (focused === g.id) focus(null);
emit('gone', g);
}
function accept(g) { g.fresh = false; touch(g); emit('state', g); }
async function start() {
await load();
if (stopped) return;
emit('loaded', null);
poll();
}
function resubscribe() { if (cvm) cvm.listen(); poll(); }
function stop() {
stopped = true;
clearTimeout(pollTimer); clearTimeout(rotateTimer);
if (cvm) cvm.stop();
flush();
}
return {
pubkey, groups, load, start, stop, resubscribe, ready, lookup, accept, focus, poll,
create, send, add, kick, rename, leave, forget,
event: (kind, content, tags) => C.appEvent(pubkey, kind, content, tags),
view: (g) => C.groupView(g.tip),
peer: () => null,
accounts: (g) => C.accounts(g.tip),
isAdmin: (g) => C.isAdmin(g) && !g.removed,
groupRef: (g) => C.encodeGroupRef({ gid: g.id, coordinator: coordinator.pubkey, relays: coordinator.relays }),
isSetUp: () => !!dev,
coordinator,
};
}
|