aboutsummaryrefslogtreecommitdiffstats
path: root/src/dm-inbox.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/dm-inbox.js')
-rw-r--r--src/dm-inbox.js115
1 files changed, 115 insertions, 0 deletions
diff --git a/src/dm-inbox.js b/src/dm-inbox.js
new file mode 100644
index 0000000..a91a8e0
--- /dev/null
+++ b/src/dm-inbox.js
@@ -0,0 +1,115 @@
+// What the service worker needs to tell a friend's DM from a stranger's.
+//
+// A NIP-17 wrap is signed by a throwaway key and the sender's identity sits
+// under two layers of nip44, so no server can filter DMs by who sent them —
+// only the recipient's own key can open one. The notifier therefore ships the
+// wrap inside the push payload and the decision happens here, on the device.
+//
+// THREAT MODEL, stated plainly. This record holds the nostr identity secret,
+// so anything with same-origin script access or a stolen unlocked device can
+// read every DM this account can read. That is not a new exposure for a
+// seed-derived identity — localStorage already holds the seed that key comes
+// from — but it is a second copy, and it exists only to keep strangers from
+// buzzing the phone. A remote signer (bunker/extension) never hands over its
+// key: those wallets store no secret here and fall back to a generic
+// notification instead of a filtered one.
+
+import { unwrapDM } from './dm.js';
+
+const DB = 'coinos-dm-inbox';
+const STORE = 'inbox';
+const VERSION = 1;
+
+function openDb() {
+ return new Promise((resolve, reject) => {
+ if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable'));
+ const req = indexedDB.open(DB, VERSION);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+async function tx(mode, fn) {
+ const db = await openDb();
+ try {
+ return await new Promise((resolve, reject) => {
+ const t = db.transaction(STORE, mode);
+ const s = t.objectStore(STORE);
+ let out;
+ Promise.resolve(fn(s)).then((v) => { out = v; }).catch(reject);
+ t.oncomplete = () => resolve(out);
+ t.onerror = () => reject(t.error);
+ t.onabort = () => reject(t.error);
+ });
+ } finally { db.close(); }
+}
+
+// One record per wallet: { sk, pubkey, follows: [hex], names: { hex: name } }.
+// `sk` is null when the identity lives behind a remote signer.
+export async function saveInbox(walletKey, rec) {
+ return tx('readwrite', (s) => { s.put(rec, walletKey); });
+}
+export async function clearInbox(walletKey) {
+ return tx('readwrite', (s) => { s.delete(walletKey); });
+}
+export async function allInboxes() {
+ try {
+ return await tx('readonly', (s) => new Promise((res, rej) => {
+ const out = [];
+ const cur = s.openCursor();
+ cur.onsuccess = () => {
+ const c = cur.result;
+ if (!c) return res(out);
+ out.push(c.value);
+ c.continue();
+ };
+ cur.onerror = () => rej(cur.error);
+ }));
+ } catch { return []; }
+}
+
+const unhex = (h) => Uint8Array.from(h.match(/.{2}/g).map((b) => parseInt(b, 16)));
+
+// Open a wrap against every identity we hold a key for. Returns
+// { author, followed, name, mine } for the first one that opens it, or null
+// if none can — which includes every remote-signer wallet, so a null means
+// "we don't know", never "it's a stranger".
+export async function classifyDm(wrap, inboxes) {
+ for (const rec of inboxes) {
+ if (!rec || !rec.sk) continue;
+ let sk;
+ try { sk = unhex(rec.sk); } catch { continue; }
+ const opened = await unwrapDM(wrap, sk);
+ if (!opened) continue;
+ const author = opened.author;
+ return {
+ author,
+ mine: author === rec.pubkey,
+ followed: (rec.follows || []).includes(author),
+ // Someone you're already talking to is not a stranger, whether or not
+ // you ever followed them.
+ known: (rec.known || []).includes(author),
+ // No contact list published at all: there's nothing to filter against,
+ // and silence would be worse than noise.
+ hasList: !!rec.hasList,
+ name: (rec.names || {})[author] || null,
+ content: opened.rumor && opened.rumor.kind === 14 ? opened.rumor.content : '',
+ };
+ }
+ return null;
+}
+
+// Should this DM interrupt someone? Our own sent-copy never should — every
+// NIP-17 message is wrapped to the sender too, and being notified about what
+// you just typed is pure noise. Anything we couldn't open stays notifiable:
+// better a generic buzz than a silent drop.
+export function shouldNotifyDm(verdict) {
+ if (!verdict) return true;
+ if (verdict.mine) return false;
+ if (verdict.followed || verdict.known) return true;
+ return !verdict.hasList;
+}