aboutsummaryrefslogtreecommitdiffstats
path: root/src/marmot-store.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-02 21:13:27 -0700
committerAdam Soltys <[email protected]>2026-10-02 21:13:27 -0700
commit0d698629a75f21046d6e544b34c42e0b2803b5a9 (patch)
tree1f211e1a71be848e8c1dc09d76d6ce148b651214 /src/marmot-store.js
parent544c61e8c27652c71501277b10875eeead0a402c (diff)
downloadcoinosv3-0d698629a75f21046d6e544b34c42e0b2803b5a9.tar.gz
coinosv3-0d698629a75f21046d6e544b34c42e0b2803b5a9.zip
White Noise (Marmot) chats: MLS group messaging in native JS
The Chat tab now speaks the protocol the White Noise app speaks, beside Concord communities and NIP-17 DMs, without anyone having to pick one: - A two-person group with no name is a direct message. It lands in the same thread as that person's NIP-17 messages, and a reply goes back the way their last message came. A first message goes by White Noise only to someone whose KeyPackage another app published (a coinos wallet reads NIP-17 on all its devices; an MLS group reaches one). - Groups get rows of their own in the Messages list, a New group button, an invitation card, members, add/remove/rename for admins, leave. - Every wallet publishes a KeyPackage (kind 30443), so it can be invited. src/mls.js is RFC 9420 for ciphersuite 0x0001 on the noble libs (no WebAssembly), with the extensions-draft pieces Marmot needs (app_data_dictionary, app_data_update, app_ephemeral, self_remove). src/marmot.js is the Marmot layer: components, the account identity proof, the 30443/444/445 event shapes and the group engine (publish-before-apply, retained epochs, same-epoch tie-break). State is per device, in IndexedDB (src/marmot-store.js). The whole stack is one lazy chunk (72 KB). Tested against the RFC 9420 vectors (tools/mls-vectors-test.js) and against the Rust reference, MDK's wn CLI, at the protocol level (tools/marmot-interop-test.js) and through the real UI (tools/marmot-chat-test.js). Not yet: attachments in these chats, push for group messages, the app-message witness boost in fork selection. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/marmot-store.js')
-rw-r--r--src/marmot-store.js74
1 files changed, 74 insertions, 0 deletions
diff --git a/src/marmot-store.js b/src/marmot-store.js
new file mode 100644
index 0000000..ac9b044
--- /dev/null
+++ b/src/marmot-store.js
@@ -0,0 +1,74 @@
+// Where White Noise (Marmot) state lives: IndexedDB, one record per group.
+//
+// MLS state is the opposite of everything else this wallet keeps: it cannot
+// be refetched or rederived (a lost ratchet is a lost group), it holds
+// Uint8Arrays by the dozen (structured clone stores them as-is, JSON would
+// triple them), and it is DEVICE state — two devices replaying one leaf's
+// ratchet would reuse keys. So it stays out of the localStorage feature
+// state and out of the cross-device sync, in a store of its own.
+//
+// Records are scoped by `scope` (wallet cache key + nostr identity), so two
+// wallets or two identities on one browser never read each other's groups.
+
+const DB = 'coinos-marmot';
+const STORE = 'kv';
+
+let dbP = null;
+function db() {
+ if (!dbP)
+ dbP = new Promise((resolve, reject) => {
+ if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable'));
+ const req = indexedDB.open(DB, 1);
+ req.onupgradeneeded = () => {
+ if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE);
+ };
+ req.onsuccess = () => {
+ // another tab upgrading or deleting the database must not be blocked
+ req.result.onversionchange = () => { req.result.close(); dbP = null; };
+ resolve(req.result);
+ };
+ req.onerror = () => { dbP = null; reject(req.error); };
+ });
+ return dbP;
+}
+
+async function tx(mode, fn) {
+ const d = await db();
+ return new Promise((resolve, reject) => {
+ const t = d.transaction(STORE, mode);
+ let out;
+ try { out = fn(t.objectStore(STORE)); } catch (e) { reject(e); return; }
+ t.oncomplete = () => resolve(out && 'result' in out ? out.result : out);
+ t.onerror = () => reject(t.error);
+ t.onabort = () => reject(t.error);
+ });
+}
+
+export function marmotStore(scope) {
+ const pre = scope + '|';
+ const range = () => IDBKeyRange.bound(pre, pre + '￿');
+ return {
+ // every record of this scope: Map(name -> value)
+ async all() {
+ const out = new Map();
+ await tx('readonly', (s) => {
+ const cur = s.openCursor(range());
+ cur.onsuccess = () => {
+ const c = cur.result;
+ if (!c) return;
+ out.set(String(c.key).slice(pre.length), c.value);
+ c.continue();
+ };
+ });
+ return out;
+ },
+ put: (name, value) => tx('readwrite', (s) => { s.put(value, pre + name); }),
+ del: (name) => tx('readwrite', (s) => { s.delete(pre + name); }),
+ wipe: () => tx('readwrite', (s) => { s.delete(range()); }),
+ };
+}
+
+// Drop every scope under a wallet (the account-wipe hook).
+export function wipeMarmot(walletKey) {
+ return tx('readwrite', (s) => { s.delete(IDBKeyRange.bound(walletKey, walletKey + '￿')); }).catch(() => {});
+}