aboutsummaryrefslogtreecommitdiffstats
path: root/src/contextvm.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 /src/contextvm.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 'src/contextvm.js')
-rw-r--r--src/contextvm.js87
1 files changed, 87 insertions, 0 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 };
+}