aboutsummaryrefslogtreecommitdiffstats
path: root/tools/cordn-interop-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-06 11:22:06 -0700
committerAdam Soltys <[email protected]>2026-10-06 11:22:06 -0700
commitd657d79bc95062a8fc5e128e795e23bcefd0bf04 (patch)
tree04a9d39281ce7784f1a4a966f0a532194ee7ded7 /tools/cordn-interop-test.js
parentf571e46937d423d9ba44154a92aeb4136466daaa (diff)
downloadcoinosv3-d657d79bc95062a8fc5e128e795e23bcefd0bf04.tar.gz
coinosv3-d657d79bc95062a8fc5e128e795e23bcefd0bf04.zip
Cordn group chats: MLS groups delivered through a coordinator (Amethyst 1.17 interop)
src/contextvm.js speaks ContextVM (kind 25910 inside ephemeral gift wraps) to a coordinator; src/cordn.js is the protocol (KeyPackages with hex-text identities, the 0xC04D metadata extension, exporter-keyed ChaCha20 sealing, unsigned nostr-shaped envelopes with the sender as MLS AAD, cordn1 group refs, a record engine like marmot.js); src/cordn-client.js mirrors marmot-client.js with the same interface, so the Messages group screens drive both — New group gains a White Noise / Cordn toggle. Verified against @cordn/cli (ts-mls) on the public coordinator both ways, from the engine (tools/cordn-interop-test.js) and from the real app (tools/cordn-ui-test.js). mls.js: the OpenMLS required_capabilities rule for GroupContextExtensions now applies only to groups that carry one. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi
Diffstat (limited to 'tools/cordn-interop-test.js')
-rw-r--r--tools/cordn-interop-test.js127
1 files changed, 127 insertions, 0 deletions
diff --git a/tools/cordn-interop-test.js b/tools/cordn-interop-test.js
new file mode 100644
index 0000000..e3f3f82
--- /dev/null
+++ b/tools/cordn-interop-test.js
@@ -0,0 +1,127 @@
+// Cordn interop, both directions, against the public coordinator: our
+// cordn-client.js on one side, the official @cordn/cli (ts-mls) on the other.
+//
+// 1. Alice (CLI) invites Bob (us): Bob's KeyPackage is on the coordinator,
+// Alice adds him, Bob joins from the Welcome, reads Alice, answers, and
+// Alice's CLI shows the answer.
+// 2. Bob (us) founds a group and invites Alice: Alice accepts the Welcome
+// the way her CLI does, reads Bob, answers, and Bob reads her.
+//
+// Needs the CLI rig: npm i @cordn/cli in CORDN_CLI_DIR (default: the
+// scratchpad path below) with a state file alice.json that already has a
+// group "office" and a published KeyPackage ("gen-kp main").
+//
+// Run: bun tools/cordn-interop-test.js
+// CORDN_DEBUG=1 shows the client's dlog lines (skipped records and why)
+if (process.env.CORDN_DEBUG && typeof localStorage === 'undefined') {
+ globalThis.localStorage = { getItem: (k) => (k === 'coinos-debug' ? '1' : null), setItem() {}, removeItem() {} };
+}
+import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+// imported after the shim above (static imports would run first)
+const { cordnClient } = await import('../src/cordn-client.js');
+const C = await import('../src/cordn.js');
+
+const CLI_DIR = process.env.CORDN_CLI_DIR || '/tmp/claude-1000/-home-adam-coinosv3/77efc6a1-2602-46f7-b851-5580b0742cf1/scratchpad/cordn/cli';
+const CLI = `${CLI_DIR}/node_modules/.bin/cordn`;
+const ALICE_STATE = `${CLI_DIR}/alice.json`;
+let failed = false;
+const ok = (name, cond, detail = '') => { console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) failed = true; return cond; };
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function alice(cmd) {
+ const p = Bun.spawnSync([CLI, '--state-file', ALICE_STATE, '--command', cmd], { timeout: 90_000, stdout: 'pipe', stderr: 'pipe' });
+ const out = (p.stdout.toString() + p.stderr.toString()).replace(/\x1b\[[0-9;]*m/g, '').trim();
+ console.log(` [alice] ${cmd}\n ${out.split('\n').slice(-5).join('\n ')}`);
+ return out;
+}
+const alicePk = () => /stablePubkey:\s*([0-9a-f]{64})/.exec(alice('status'))?.[1];
+
+// an in-memory stand-in for the IndexedDB store
+const memStore = () => { const m = new Map(); return { all: async () => new Map(m), put: async (k, v) => { m.set(k, structuredClone(v)); }, del: async (k) => { m.delete(k); }, wipe: async () => m.clear() }; };
+
+const sk = generateSecretKey(), bob = getPublicKey(sk);
+console.log('bob =', bob);
+const events = [];
+const bobClient = cordnClient({
+ scope: 'test', pubkey: bob, identity: async () => ({ pubkey: bob, signer: sk }),
+ on: (type, g, data) => { events.push({ type, g, data }); if (type === 'message') console.log(` [bob] ${g.id.slice(0, 8)} <- ${data.pubkey.slice(0, 8)} k${data.kind}: ${data.content}`); },
+ store: memStore(),
+});
+const waitFor = async (fn, ms = 90_000) => { const t = Date.now(); while (Date.now() - t < ms) { if (fn()) return true; await bobClient.poll(); await sleep(1500); } return false; };
+
+try {
+ await bobClient.start();
+ ok('ready: KeyPackage published', await bobClient.ready());
+ const apk = alicePk();
+ // her regular packages are consumed by every invitation: a fresh one, and its ref names the Welcome to look for
+ const aliceRef = /ref=([0-9a-f]{64})/.exec(alice('gen-kp kp' + Date.now().toString(36)))?.[1];
+ ok('alice reachable (her KeyPackage is on the coordinator)', !!(await bobClient.lookup(apk)), apk && apk.slice(0, 8));
+
+ // ---- 1. Alice invites Bob ---- (CORDN_TEST_PART=2 skips it)
+ if (process.env.CORDN_TEST_PART !== '2') {
+ alice(`add-member office ${bob}`);
+ ok('joined from the Welcome', await waitFor(() => [...bobClient.groups.values()].some((g) => g.fresh)));
+ const g1 = [...bobClient.groups.values()].find((g) => g.fresh);
+ if (g1) {
+ const v = bobClient.view(g1);
+ ok('metadata + inviter + members', v.name === 'Office' && g1.inviter === apk && bobClient.accounts(g1).includes(apk), JSON.stringify({ name: v.name, icon: v.icon, admins: v.admins.length, members: bobClient.accounts(g1).length, epoch: g1.tip.epoch }));
+ bobClient.accept(g1);
+ bobClient.focus(g1.id);
+ alice('send-to office hello bob from the cli');
+ ok('read alice', await waitFor(() => g1.log.some((m) => m.content === 'hello bob from the cli')));
+ const inner = bobClient.event(9, 'hello alice from coinos', []);
+ ok('sent', await bobClient.send(g1, inner));
+ alice('sync office');
+ ok("alice's CLI shows our message", alice('messages office').includes('hello alice from coinos'));
+ // a reaction travels too
+ ok('reaction sent', await bobClient.send(g1, bobClient.event(7, '👍', [['e', g1.log.find((m) => m.content === 'hello bob from the cli').id], ['p', apk], ['k', '9']])));
+ }
+ }
+
+ // ---- 2. Bob founds a group and invites Alice ----
+ const alias = 'fc' + Date.now().toString(36);
+ let g2 = null;
+ try { g2 = await bobClient.create({ name: 'From coinos', icon: '🪙', members: [apk] }); } catch (e) { console.log(' create failed:', e.message); }
+ ok('founded a group with alice in it', !!g2 && bobClient.accounts(g2).includes(apk) && g2.tip.epoch === 1, g2 && JSON.stringify({ gid: g2.id, members: bobClient.accounts(g2).length, cursor: g2.cursor }));
+ if (g2) {
+ ok('group ref encodes/decodes', C.decodeGroupRef(bobClient.groupRef(g2))?.gid === g2.id, bobClient.groupRef(g2).slice(0, 40) + '…');
+ ok('sent to the new group', await bobClient.send(g2, bobClient.event(9, 'welcome to my group, alice', [])));
+ alice('fetch-welcomes');
+ const w = alice('welcomes');
+ // several Welcomes may be pending from earlier runs: the one for the package we took
+ const id = w.split('\n').map((l) => /([0-9a-f]{64}:([0-9a-f]{64}):\d+)/.exec(l)).filter(Boolean).sort((a, b) => Number(b[1].split(':')[2]) - Number(a[1].split(':')[2]))[0]?.[1]; // the newest: the one just stored
+ ok('alice sees the Welcome', !!id, id && id.slice(0, 24));
+ if (id) {
+ const acc = alice(`accept-welcome ${id} ${alias}`);
+ ok('alice accepted it', !/error|failed/i.test(acc) || /accept|joined/i.test(acc));
+ alice(`sync ${alias}`);
+ ok("alice reads bob's message", alice(`messages ${alias}`).includes('welcome to my group, alice'));
+ alice(`send-to ${alias} hi bob, nice group`);
+ bobClient.focus(g2.id);
+ ok("bob reads alice's reply", await waitFor(() => g2.log.some((m) => m.content === 'hi bob, nice group')));
+ if (process.env.CORDN_DEBUG) {
+ const { debugOn } = await import('../src/debug.js');
+ const { cvmClient } = await import('../src/contextvm.js');
+ const { finalizeEvent } = await import('nostr-tools/pure');
+ const { nip44 } = await import('nostr-tools');
+ console.log(' [dbg] debugOn', debugOn(), 'g2 cursor', g2.cursor, 'epoch', g2.tip.epoch, 'seen', g2.seen.length, 'log', g2.log.map((m) => m.kind + ':' + m.content.slice(0, 20)));
+ const probe = cvmClient({ server: C.DEFAULT_COORDINATOR.pubkey, relays: C.DEFAULT_COORDINATOR.relays, pubkey: bob, sign: async (e) => finalizeEvent(e, sk), decrypt: async (p, ct) => nip44.v2.decrypt(ct, nip44.v2.utils.getConversationKey(sk, p)) });
+ try {
+ const r = await probe.call('msg_fetch_many', { groups: [{ gid: g2.id }] });
+ console.log(' [dbg] coordinator holds cursors', (r.messages || []).map((m) => m.cursor + '@' + m.gid.slice(0, 8)).join(' '));
+ for (const m of (r.messages || [])) { try { const out = C.receive(g2, { ...m, cursor: m.cursor }); console.log(' [dbg] receive', m.cursor, JSON.stringify(out).slice(0, 160)); } catch (e) { console.log(' [dbg] receive threw', m.cursor, e.message); } }
+ } catch (e) { console.log(' [dbg] probe failed', e.message); }
+ probe.stop();
+ }
+ // rename: a GroupContextExtensions commit their side must apply
+ try { await bobClient.rename(g2, 'Renamed by coinos'); } catch (e) { console.log(' rename failed:', e.message); }
+ ok('rename applied here', bobClient.view(g2).name === 'Renamed by coinos', bobClient.view(g2).name);
+ alice(`sync ${alias}`);
+ ok("alice's CLI sees the new name", /Renamed by coinos/.test(alice(`group-info ${alias}`)));
+ alice(`send-to ${alias} still here after the rename`);
+ ok('bob reads after the rename', await waitFor(() => g2.log.some((m) => m.content === 'still here after the rename')));
+ }
+ }
+ console.log(failed ? '\nFAILED' : '\nINTEROP OK');
+} catch (e) { console.log('\nCRASH:', e.stack || e.message); failed = true; }
+finally { bobClient.stop(); await sleep(300); process.exit(failed ? 1 : 0); }