aboutsummaryrefslogtreecommitdiffstats
path: root/src/dm.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 13:40:54 -0700
committerAdam Soltys <[email protected]>2026-08-02 13:40:54 -0700
commit95263c02a13a825a8d02dbe760f2a74de8f93f77 (patch)
tree1383d4ca48ec24eedb26fd7740f13e1295d55bdf /src/dm.js
parent6416bd5dd79c3bdde5dfa7e7ec95fc811fbcdb28 (diff)
downloadcoinosv3-95263c02a13a825a8d02dbe760f2a74de8f93f77.tar.gz
coinosv3-95263c02a13a825a8d02dbe760f2a74de8f93f77.zip
Chat v2: DMs, invites, and multi-community — coinos is the default, not the only
- NIP-17 private DMs (src/dm.js): kind-14 rumors in sender-signed seals under ephemeral wraps; interops with other nostr DM clients. Login signers gained encryptTo/decryptFrom so extensions and bunkers can seal to a peer; the inbox listens for both the login npub and the wallet key and publishes a kind-10050 DM-relay list for the wallet key when none exists. - Concord invites (CORD-05): mint shareable links (33301 bundle on relays, keys only in the URL fragment), join by pasting a link or opening v3.coinos.io/invite/…, and direct invites giftwrapped to an npub with the k-tag index hint. Bundles self-certify the founder before anything joins. - Multi-community: home view lists DM threads and communities; found your own community in-app (owner-signed genesis). coinos stays baked in as default. - Welcome DM: the names registrar DMs every first-time registrant from Adam's npub on claim (config: welcome{sk,message}; deduped in registrar state). Every new wallet auto-claims a payment address, so every new user gets one. - relay.coinos.io write policy now allows 33301/13302/13303. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/dm.js')
-rw-r--r--src/dm.js88
1 files changed, 88 insertions, 0 deletions
diff --git a/src/dm.js b/src/dm.js
new file mode 100644
index 0000000..70cf46b
--- /dev/null
+++ b/src/dm.js
@@ -0,0 +1,88 @@
+// NIP-17 private DMs: kind 14 rumor, kind 13 seal (sender-signed), kind 1059
+// wrap under a single-use ephemeral key to the recipient. Interops with
+// 0xchat/Amethyst/etc. Works with a raw secret key (Uint8Array) or a
+// nostr-login signer adapter carrying { pubkey, signEvent, encryptTo,
+// decryptFrom } — the seal is encrypted to the peer, so remote signers need
+// the peer variants (added alongside encryptSelf/decryptSelf).
+
+import { finalizeEvent, generateSecretKey, getPublicKey, getEventHash, verifyEvent } from 'nostr-tools/pure';
+import * as nip44 from 'nostr-tools/nip44';
+
+const now = () => Math.floor(Date.now() / 1000);
+// NIP-59: outer layers carry timestamps tweaked into the past two days so the
+// wrap's time reveals nothing; the rumor keeps the true time.
+const tweaked = () => now() - Math.floor(Math.random() * 2 * 24 * 3600);
+
+const asSigner = (skOrSigner) =>
+ skOrSigner instanceof Uint8Array
+ ? {
+ pubkey: getPublicKey(skOrSigner),
+ signEvent: async (e) => finalizeEvent(e, skOrSigner),
+ encryptTo: async (peer, txt) => nip44.encrypt(txt, nip44.getConversationKey(skOrSigner, peer)),
+ decryptFrom: async (peer, ct) => nip44.decrypt(ct, nip44.getConversationKey(skOrSigner, peer)),
+ }
+ : skOrSigner;
+
+// Build the wrap for one receiver (the recipient, or the sender's own copy).
+// Returns the kind 1059 event to publish at the receiver's inbox.
+// extraTags ride the outer wrap unencrypted — used only for the Direct
+// Invite index hint ["k","3313"] (CORD-05 §6); never put content there.
+export async function wrapDM(skOrSigner, receiverPk, rumor, extraTags = []) {
+ const signer = asSigner(skOrSigner);
+ const seal = await signer.signEvent({
+ kind: 13,
+ content: await signer.encryptTo(receiverPk, JSON.stringify(rumor)),
+ tags: [],
+ created_at: tweaked(),
+ });
+ const eph = generateSecretKey();
+ return finalizeEvent(
+ {
+ kind: 1059,
+ content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(eph, receiverPk)),
+ tags: [['p', receiverPk], ...extraTags],
+ created_at: tweaked(),
+ },
+ eph
+ );
+}
+
+// A DM to a peer: the kind 14 rumor (unsigned, true timestamp) plus wraps for
+// the recipient and for the sender's own inbox (the sent-copy).
+export async function makeDM(skOrSigner, peerPk, text) {
+ const signer = asSigner(skOrSigner);
+ const rumor = {
+ kind: 14,
+ pubkey: signer.pubkey,
+ content: text,
+ tags: [['p', peerPk]],
+ created_at: now(),
+ };
+ rumor.id = getEventHash(rumor);
+ return {
+ rumor,
+ toPeer: await wrapDM(signer, peerPk, rumor),
+ toSelf: await wrapDM(signer, signer.pubkey, rumor),
+ };
+}
+
+// Open an inbound kind 1059 addressed to us. Returns
+// { rumor, author, peer } or null (not ours / not a DM / bad layer).
+// `peer` is the conversation partner: the author for received messages,
+// the p-tagged recipient for our own sent-copies.
+export async function unwrapDM(wrap, skOrSigner) {
+ const signer = asSigner(skOrSigner);
+ try {
+ const seal = JSON.parse(await signer.decryptFrom(wrap.pubkey, wrap.content));
+ if (seal.kind !== 13 || !verifyEvent(seal)) return null;
+ const rumor = JSON.parse(await signer.decryptFrom(seal.pubkey, seal.content));
+ if (rumor.pubkey !== seal.pubkey) return null;
+ if (rumor.id !== getEventHash({ ...rumor, id: undefined })) return null;
+ if (rumor.kind !== 14) return { rumor, author: seal.pubkey, peer: null };
+ const to = rumor.tags?.find((t) => t[0] === 'p')?.[1];
+ const mine = rumor.pubkey === signer.pubkey;
+ return { rumor, author: seal.pubkey, peer: mine ? to : rumor.pubkey };
+ } catch {
+ return null;
+ }
+}