clone

tiny dependency-free nostr libraries for plain html, each under 5kb and named for an element. public domain

Add krypton, argon, neon and radon: keys, cache, readable nostr, profiles

Dami committed b3c4b24
commit
b3c4b24ad7866b28b68a83a5c670de8b7dc3f510
parent
31a637a3b00c74d7856e5540f8ed46dd84ac71be
author
Dami <dami.etoile@gmail.com>
11 files changed, +599 -0
Aargon/README.md35+++++++++++
Aargon/argon.js57++++++++++++++++++
Akrypton/README.md39++++++++++++
Akrypton/krypton.js96++++++++++++++++++++++++++++++
Aneon/README.md40+++++++++++++
Aneon/neon.js83++++++++++++++++++++++++++
Aradon/README.md36+++++++++++
Aradon/radon.js84++++++++++++++++++++++++++
Atest/argon.test.js41+++++++++++++
Atest/krypton.test.js44++++++++++++++
Atest/neon.test.js44++++++++++++++
A argon/README.md +35 -0
@@ -0,0 +1,35 @@
1+# 📦 argon.js - a relay in your browser2+3+argon.js is a miniature relay, in the way tinyrelay is a big one: an IndexedDB event store that understands replaceable, addressable, ephemeral and deletion events. Loaded alongside xenon.js, it answers every subscription from the cache first and stores every event that arrives or is published. Pages render instantly on reload and keep working offline.4+5+```html6+<script src="argon.js"></script>7+<script src="xenon.js"></script>8+9+<ol nxe-filter='{"kinds":[1],"limit":50}'></ol>10+```11+12+On reload the list fills from IndexedDB before any relay answers, and xenon drops the duplicates when they arrive.13+14+## Attributes15+16+| attribute | description |17+|---|---|18+| `nar-ignore` | Neither read from nor write to the cache for subscriptions on or under this element |19+20+`window.narCfg = {name}` picks the database name (default `argon`).21+22+## Behavior23+24+- Subscriptions receive cached events with `from == "argon"`, then an `nxe:eose` with `from == "argon"`.25+- Kinds 0, 3 and 10000-19999 keep the newest per author. Kinds 30000-39999 keep the newest per author and `d` tag.26+- Kinds 20000-29999 are never stored. Kind 5 removes the author's own events named by `e` tags.27+28+## API29+30+| function | returns |31+|---|---|32+| `argon.put(event)` | `true` if stored |33+| `argon.query(filters)` | matching events, newest first, honoring each filter's `limit` |34+| `argon.match(filter, event)` | NIP-01 filter test |35+| `argon.remove(id?)` | removes one record, or everything |
A argon/argon.js +57 -0
@@ -0,0 +1,57 @@
1+(()=>{2+ let g = globalThis, d = g.document3+ if (g.argon) return4+ let db5+ let done = (r)=>new Promise((res, rej)=>(r.onsuccess = ()=>res(r.result), r.onerror = ()=>rej(r.error)))6+ let store = async(mode)=>(await (db ||= new Promise((res, rej)=>{7+ let r = indexedDB.open(g.narCfg?.name ?? "argon", 1)8+ r.onupgradeneeded = ()=>r.result.createObjectStore("e", {keyPath:"k"}).createIndex("t", "e.created_at")9+ done(r).then(res, rej)10+ }))).transaction("e", mode).objectStore("e")11+ let key = (e)=>{12+ let k = e.kind, who = k + ":" + e.pubkey13+ if (k == 0 || k == 3 || k >= 1e4 && k < 2e4) return who14+ if (k >= 3e4 && k < 4e4) return who + ":" + (e.tags.find((t)=>t[0] == "d")?.[1] ?? "")15+ return e.id16+ }17+ let match = (f, e)=>(!f.ids || f.ids.includes(e.id))18+ && (!f.kinds || f.kinds.includes(e.kind))19+ && (!f.authors || f.authors.includes(e.pubkey))20+ && !(e.created_at < f.since || e.created_at > f.until)21+ && Object.keys(f).every((k)=>k[0] != "#" || e.tags.some((t)=>t[0] == k.slice(1) && f[k].includes(t[1])))22+ let put = async(e)=>{23+ if (e.kind >= 2e4 && e.kind < 3e4) return false24+ let s = await store("readwrite"), k = key(e), old = (await done(s.get(k)))?.e25+ if (old && (old.created_at > e.created_at || old.created_at == e.created_at && old.id <= e.id)) return false26+ if (e.kind == 5) for (let [t, v] of e.tags) if (t == "e" && (await done(s.get(v)))?.e.pubkey == e.pubkey) s.delete(v)27+ await done(s.put({k, e}))28+ return true29+ }30+ let query = async(filters)=>{31+ let out = new Map(), n = filters.map(()=>0), c = (await store("readonly")).index("t").openCursor(null, "prev")32+ return new Promise((res, rej)=>{33+ c.onerror = ()=>rej(c.error)34+ c.onsuccess = ()=>{35+ let cur = c.result, full = (f, i)=>f.limit != null && n[i] >= f.limit36+ if (!cur || filters.every(full)) return res([...out.values()])37+ let e = cur.value.e38+ filters.forEach((f, i)=>!full(f, i) && match(f, e) && out.set(e.id, e) && n[i]++)39+ cur.continue()40+ }41+ })42+ }43+ let remove = async(k)=>done((await store("readwrite"))[k ? "delete" : "clear"](k))44+ g.argon = {put, query, match, remove}45+ if (!d) return46+ let skip = (c)=>c.elt?.closest?.("[nar-ignore]")47+ d.addEventListener("nhy:config", (evt)=>{48+ let c = evt.detail.cfg49+ if (c.filters && !skip(c)) query(c.filters).then((es)=>{50+ if (evt.defaultPrevented) return51+ es.forEach((e)=>c.deliver(e, "argon"))52+ c.oneose?.("argon")53+ })54+ })55+ d.addEventListener("nhy:event", (evt)=>evt.detail.from != "argon" && !skip(evt.detail.cfg) && put(evt.detail.event))56+ d.addEventListener("nxe:publish", (evt)=>put(evt.detail.cfg.event))57+})()
A krypton/README.md +39 -0
@@ -0,0 +1,39 @@
1+# 🔑 krypton.js - keys & signatures2+3+krypton.js is a miniature @noble/secp256k1: BIP-340 Schnorr keys, signing and verification in about 4kb of BigInt and `crypto.subtle`. Loaded alongside xenon.js, it signs `nxe-publish` events when no NIP-07 extension is present, and verifies incoming events under `nkr-verify`. It also runs in Deno and other runtimes with Web Crypto, where it needs no document.4+5+```html6+<script src="krypton.js"></script>7+<script src="xenon.js"></script>8+9+<form nxe-publish><input name="content"><button>Post</button></form>10+<ol nxe-filter='{"kinds":[1]}' nkr-verify></ol>11+```12+13+The form publishes with a local key when there is no extension, and the list drops any event whose id or signature is invalid.14+15+## Attributes16+17+| attribute | description |18+|---|---|19+| `nkr-verify` | Verify every event for subscriptions on or under this element |20+21+`window.nkrCfg = {local:true}` always signs with the local key. `window.nkrCfg = {verify:true}` verifies every subscription.22+23+## API24+25+| function | returns |26+|---|---|27+| `krypton.generate()` | new secret key (hex) |28+| `krypton.pub(sk)` | x-only public key (hex) |29+| `krypton.id(event)` | event id (hex) |30+| `krypton.finish(event, sk)` | signed event with `pubkey`, `id`, `sig` |31+| `krypton.check(event)` | `true` if id and signature are valid |32+| `krypton.sign(msg, sk, aux?)` | 64-byte signature (`Uint8Array`) |33+| `krypton.verify(sig, msg, pk)` | `true` or `false` |34+| `krypton.key(sk?)` | local key: gets (creating one), sets, or with `null` replaces |35+| `krypton.signer` | NIP-07 shaped `{getPublicKey, signEvent}` using the local key |36+37+## Caveats38+39+BigInt arithmetic is not constant time, and the local key lives in `localStorage`. Use it for demos, throwaway or low-value keys. Prefer a NIP-07 extension for anything that matters. Tests check keys and signatures byte for byte against @noble/curves.
A krypton/krypton.js +96 -0
@@ -0,0 +1,96 @@
1+(()=>{2+ let g = globalThis3+ if (g.krypton) return4+ let P = 2n ** 256n - 0x1000003d1n, N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n5+ let G = [0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n, 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n]6+ let mod = (a, m = P)=>(a %= m) < 0n ? a + m : a7+ let inv = (a, m = P)=>{8+ let [r0, r1, s0, s1] = [mod(a, m), m, 1n, 0n]9+ while (r1){10+ let q = r0 / r1;11+ [r0, r1, s0, s1] = [r1, r0 - q * r1, s1, s0 - q * s1]12+ }13+ return mod(s0, m)14+ }15+ let pow = (b, e, r = 1n)=>{16+ for (b = mod(b); e; e >>= 1n, b = b * b % P) if (e & 1n) r = r * b % P17+ return r18+ }19+ let add = (p, q)=>{20+ if (!p || !q) return p || q21+ let [x1, y1] = p, [x2, y2] = q22+ if (x1 == x2 && y1 != y2) return null23+ let l = x1 == x2 ? 3n * x1 * x1 * inv(2n * y1) : (y2 - y1) * inv(x2 - x1), x3 = mod(l * l - x1 - x2)24+ return [x3, mod(l * (x1 - x3) - y1)]25+ }26+ let mul = (k, p = G, r = null)=>{27+ for (; k; k >>= 1n, p = add(p, p)) if (k & 1n) r = add(r, p)28+ return r29+ }30+ let lift = (x)=>{31+ let c = mod(x * x * x + 7n), y = pow(c, (P + 1n) / 4n)32+ if (x >= P || y * y % P != c) throw Error("bad point")33+ return [x, y & 1n ? P - y : y]34+ }35+ let hex = (b)=>[...b].map((x)=>x.toString(16).padStart(2, "0")).join("")36+ let bytes = (h)=>Uint8Array.from(h.match(/../g), (x)=>parseInt(x, 16))37+ let num = (b)=>BigInt("0x" + hex(b))38+ let b32 = (n)=>bytes(n.toString(16).padStart(64, "0"))39+ let cat = (...a)=>{40+ let o = new Uint8Array(a.reduce((s, x)=>s + x.length, 0)), i = 041+ for (let x of a) o.set(x, i), i += x.length42+ return o43+ }44+ let te = new TextEncoder()45+ let sha = async(...a)=>new Uint8Array(await crypto.subtle.digest("SHA-256", cat(...a)))46+ let tagged = async(tag, ...a)=>{47+ let t = await sha(te.encode(tag))48+ return num(await sha(t, t, ...a))49+ }50+ let sign = async(m, sk, aux = crypto.getRandomValues(new Uint8Array(32)))=>{51+ let d0 = num(sk)52+ if (!d0 || d0 >= N) throw Error("bad key")53+ let p = mul(d0), d = p[1] & 1n ? N - d0 : d0, px = b32(p[0])54+ let k0 = mod(await tagged("BIP0340/nonce", b32(d ^ await tagged("BIP0340/aux", aux)), px, m), N)55+ let R = mul(k0), k = R[1] & 1n ? N - k0 : k0, rx = b32(R[0])56+ let e = mod(await tagged("BIP0340/challenge", rx, px, m), N)57+ return cat(rx, b32(mod(k + e * d, N)))58+ }59+ let verify = async(sig, m, pk)=>{60+ try {61+ let r = num(sig.slice(0, 32)), s = num(sig.slice(32)), p = lift(num(pk))62+ if (sig.length != 64 || r >= P || s >= N) return false63+ let e = mod(await tagged("BIP0340/challenge", sig.slice(0, 32), pk, m), N)64+ let R = add(mul(s), mul(N - e, p))65+ return !!R && !(R[1] & 1n) && R[0] == r66+ } catch { return false }67+ }68+ let generate = (k)=>{69+ while (!k || num(k) >= N) k = crypto.getRandomValues(new Uint8Array(32))70+ return hex(k)71+ }72+ let pub = (sk)=>hex(b32(mul(num(bytes(sk)))[0]))73+ let shared = (sk, pk)=>hex(b32(mul(num(bytes(sk)), lift(num(bytes(pk))))[0]))74+ let id = async(e)=>hex(await sha(te.encode(JSON.stringify([0, e.pubkey, e.created_at, e.kind, e.tags, e.content]))))75+ let finish = async(e, sk)=>{76+ e = {...e, pubkey:pub(sk)}77+ e.id = await id(e)78+ e.sig = hex(await sign(bytes(e.id), bytes(sk)))79+ return e80+ }81+ let check = async(e)=>{82+ try { return e.id == await id(e) && verify(bytes(e.sig), bytes(e.id), bytes(e.pubkey)) } catch { return false }83+ }84+ let key = (k)=>{85+ let s = g.localStorage86+ if (k !== undefined) k ? s["nkr:key"] = k : delete s["nkr:key"]87+ return s["nkr:key"] ||= generate()88+ }89+ let signer = {getPublicKey:async()=>pub(key()), signEvent:(e)=>finish(e, key())}90+ g.krypton = {generate, pub, shared, id, sign, verify, finish, check, key, signer}91+ g.document?.addEventListener("nhy:config", (evt)=>{92+ let c = evt.detail.cfg93+ if (c.event && (g.nkrCfg?.local || !g.nostr)) c.sign = signer.signEvent94+ if (c.filters && (g.nkrCfg?.verify || c.elt?.closest("[nkr-verify]"))) c.verify = check95+ })96+})()
A neon/README.md +40 -0
@@ -0,0 +1,40 @@
1+# 🔗 neon.js - readable nostr2+3+neon.js is a miniature nip19 plus a content renderer. It encodes and decodes `npub`, `note`, `nprofile`, `nevent` and `naddr`, and loaded alongside xenon.js it gives templates linked, escaped content and friendly fields. Relative times stay fresh on their own.4+5+```html6+<ol nxe-filter='{"kinds":[1]}'>7+ <template>8+ <li><a href="https://njump.me/{npub}">{npub}</a> <time nne-ago="{created_at}">{ago}</time><p>{content}</p></li>9+ </template>10+</ol>11+```12+13+Each note shows its author's npub, a time like `5m` that keeps updating, and content with links, images and `nostr:` mentions.14+15+## Attributes16+17+| attribute | description |18+|---|---|19+| `nne-ago` | Unix time. Text becomes `now`, `5m`, `3h`, `2d` or a date, refreshed every 30s |20+21+`window.nneCfg = {link}` sets the prefix for `nostr:` links (default `https://njump.me/`).22+23+## Template fields24+25+| field | value |26+|---|---|27+| `{content}` | escaped content with links, images and `nostr:` mentions. npub mentions carry `nrn-profile` for radon.js |28+| `{npub}`, `{note}`, `{nevent}` | encoded ids |29+| `{naddr}` | for kinds 30000-39999 |30+| `{ago}` | relative time at render |31+32+## API33+34+| function | returns |35+|---|---|36+| `neon.encode(type, data)` | bech32 string. `data` is hex for `npub`, `nsec`, `note`, or an object for `nprofile`, `nevent`, `naddr` |37+| `neon.decode(str)` | `{type, data}`. Accepts a `nostr:` prefix |38+| `neon.rich(text)` | escaped, linked HTML |39+| `neon.ago(unix)` | short relative time |40+| `neon.esc(text)` | escaped HTML |
A neon/neon.js +83 -0
@@ -0,0 +1,83 @@
1+(()=>{2+ let g = globalThis, d = g.document3+ if (g.neon) return4+ let A = "qpzry9x8gf2tvdw0s3jn54khce6mua7l", te = new TextEncoder(), td = new TextDecoder()5+ let hex = (b)=>[...b].map((x)=>x.toString(16).padStart(2, "0")).join("")6+ let bytes = (h)=>h.match(/../g).map((x)=>parseInt(x, 16))7+ let poly = (v, c = 1)=>{8+ for (let x of v){9+ let b = c >> 2510+ c = (c & 0x1ffffff) << 5 ^ x11+ for (let i = 0; i < 5; i++) if (b >> i & 1) c ^= [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3][i]12+ }13+ return c14+ }15+ let hrp = (h)=>[...h].map((c)=>c.charCodeAt() >> 5).concat(0, [...h].map((c)=>c.charCodeAt() & 31))16+ let conv = (data, f, t, pad)=>{17+ let a = 0, b = 0, o = [], m = (1 << t) - 118+ for (let x of data){19+ a = (a << f | x) & 409520+ for (b += f; b >= t;) o.push(a >> (b -= t) & m)21+ }22+ if (pad && b) o.push(a << t - b & m)23+ if (!pad && (b >= f || a << t - b & m)) throw Error("bad padding")24+ return o25+ }26+ let tlv = (t, v)=>[t, v.length, ...v]27+ let encode = (type, data)=>{28+ let o = data29+ if (typeof data == "string") o = bytes(data)30+ else {31+ let u = (s)=>[...te.encode(s)], naddr = type == "naddr"32+ o = tlv(0, naddr ? u(data.identifier) : bytes(data.id ?? data.pubkey))33+ for (let r of data.relays ?? []) o.push(...tlv(1, u(r)))34+ let a = naddr ? data.pubkey : data.author, k = data.kind35+ if (a) o.push(...tlv(2, bytes(a)))36+ if (k != null) o.push(...tlv(3, [k >>> 24, k >> 16 & 255, k >> 8 & 255, k & 255]))37+ }38+ let w = conv(o, 8, 5, 1), p = poly([...hrp(type), ...w, 0, 0, 0, 0, 0, 0]) ^ 139+ for (let i = 0; i < 6; i++) w.push(p >> 5 * (5 - i) & 31)40+ return type + "1" + w.map((x)=>A[x]).join("")41+ }42+ let decode = (s)=>{43+ s = s.replace(/^nostr:/, "").toLowerCase()44+ let i = s.lastIndexOf("1"), type = s.slice(0, i), w = [...s.slice(i + 1)].map((c)=>A.indexOf(c))45+ if (i < 1 || w.length < 6 || w.includes(-1) || poly([...hrp(type), ...w]) != 1) throw Error("bad bech32")46+ let b = conv(w.slice(0, -6), 5, 8)47+ if (!/^n(profile|event|addr)$/.test(type)) return {type, data:hex(b)}48+ let data = {relays:[]}, naddr = type == "naddr"49+ for (let j = 0; j < b.length; j += 2 + b[j + 1]){50+ let t = b[j], v = b.slice(j + 2, j + 2 + b[j + 1]), str = td.decode(new Uint8Array(v))51+ if (t == 0) naddr ? data.identifier = str : data[type == "nevent" ? "id" : "pubkey"] = hex(v)52+ if (t == 1) data.relays.push(str)53+ if (t == 2) data[naddr ? "pubkey" : "author"] = hex(v)54+ if (t == 3) data.kind = v.reduce((a, x)=>a * 256 + x, 0)55+ }56+ return {type, data}57+ }58+ let esc = (s)=>String(s).replace(/[&<>"']/g, (c)=>`&#${c.charCodeAt()};`)59+ let link = (ref)=>(g.nneCfg?.link ?? "https://njump.me/") + ref60+ let rich = (s)=>esc(s).replace(/\bhttps?:\/\/[^\s<]*[^\s<.,:!?)]|nostr:(n(?:pub|profile|ote|event|addr)1[a-z0-9]+)/g, (m, ref)=>{61+ if (!ref) return /\.(png|jpe?g|gif|webp|avif)(\?|$)/i.test(m) ? `<img src="${m}" loading="lazy" alt="">` : `<a href="${m}" target="_blank" rel="noopener">${m}</a>`62+ let who = /^np/.test(ref) ? ` nrn-profile="${ref}" nrn-field="name"` : ""63+ return `<a href="${link(ref)}"${who}>${ref.slice(0, 12)}…</a>`64+ })65+ let ago = (t)=>{66+ let s = Date.now() / 1e3 - t67+ return s < 60 ? "now" : s < 3600 ? (s / 60 | 0) + "m" : s < 86400 ? (s / 3600 | 0) + "h" : s < 604800 ? (s / 86400 | 0) + "d" : new Date(t * 1e3).toLocaleDateString()68+ }69+ let tick = (n)=>n.querySelectorAll?.("[nne-ago]").forEach((e)=>e.textContent = ago(e.getAttribute("nne-ago")))70+ g.neon = {encode, decode, rich, ago, esc}71+ if (!d) return72+ d.addEventListener("nhy:event", (evt)=>{73+ let {event:e, fields:f} = evt.detail, k = e.kind74+ f.content = rich(e.content)75+ f.npub = encode("npub", e.pubkey)76+ f.note = encode("note", e.id)77+ f.nevent = encode("nevent", {id:e.id, author:e.pubkey, kind:k})78+ if (k >= 3e4 && k < 4e4) f.naddr = encode("naddr", {identifier:e.tags.find((t)=>t[0] == "d")?.[1] ?? "", pubkey:e.pubkey, kind:k})79+ f.ago = ago(e.created_at)80+ })81+ new MutationObserver((recs)=>recs.forEach((r)=>r.addedNodes.forEach(tick))).observe(d, {childList:1, subtree:1})82+ setInterval(()=>tick(d), 3e4)83+})()
A radon/README.md +36 -0
@@ -0,0 +1,36 @@
1+# 🪪 radon.js - names & faces2+3+radon.js is a miniature profile loader. Any element with `nrn-profile` gets its author's name, picture or other kind 0 fields filled in, with requests batched across the page and results cached. NIP-05 identifiers are checked, and newly seen or published profiles update every element at once.4+5+```html6+<span nrn-profile="npub1..." nrn-field="name">npub1...</span>7+<img nrn-profile="{pubkey}" nrn-field="picture">8+```9+10+The span's text becomes the author's name, and the image's `src` their picture.11+12+## Attributes13+14+| attribute | description |15+|---|---|16+| `nrn-profile` | Hex pubkey, or `npub`/`nprofile` when neon.js is loaded |17+| `nrn-field` | Kind 0 field to fill (on the element itself or descendants): `name`, `picture`, `about`, `nip05`, ... `img` gets `src`, others `textContent` |18+| `nrn-verified` | Set by radon when the profile's `nip05` checks out |19+| `nrn-ignore` | radon skips this element and its descendants |20+21+`name` falls back to `display_name`. Requests go to the element's relays from hydrogen (the closest `nhy-relay`, else `nhyCfg.relays`) plus `window.nrnCfg.relays` (default `wss://purplepag.es`).22+23+## Events24+25+| event | cancel to | detail |26+|---|---|---|27+| `nrn:profile` | skip filling this element | `pubkey`, `profile` |28+29+## API30+31+| function | returns |32+|---|---|33+| `radon.get(pubkey)` | promise of the profile (or `undefined`) |34+| `radon.cache` | profiles by hex pubkey |35+36+Requires xenon.js.
A radon/radon.js +84 -0
@@ -0,0 +1,84 @@
1+(()=>{2+ let d = document3+ if (d.__nrn_mo) return4+ let cache = {}, watch = {}, waits = {}, pending = new Set(), relays = new Set(), timer, nips = {}5+ let send = (el, type, detail)=>el.dispatchEvent(new CustomEvent("nrn:" + type, {detail, cancelable:1, bubbles:1, composed:1}))6+ let hexOf = (s)=>{7+ if (/^[0-9a-f]{64}$/.test(s)) return s8+ try { let x = neon.decode(s).data; return x.pubkey ?? x } catch {}9+ }10+ let nip05 = (id, pk)=>{11+ let [name, host] = id.includes("@") ? id.split("@") : ["_", id]12+ return nips[id + pk] ??= fetch(`https://${host}/.well-known/nostr.json?name=${encodeURIComponent(name)}`)13+ .then((r)=>r.json()).then((j)=>j.names?.[name] == pk, ()=>false)14+ }15+ let fill = (el, pk, p)=>{16+ if (!send(el, "profile", {pubkey:pk, profile:p})) return17+ for (let e of [el, ...el.querySelectorAll("[nrn-field]")]){18+ let f = e.getAttribute("nrn-field"), v = p[f]19+ if (e != el && e.closest("[nrn-profile]") != el || v == null || typeof v == "object") continue20+ e.matches("img") ? e.src = v : e.textContent = v21+ if (f == "nip05") nip05(v, pk).then((ok)=>el.toggleAttribute("nrn-verified", ok))22+ }23+ }24+ let update = (pk)=>{25+ watch[pk]?.forEach((el)=>el.isConnected ? fill(el, pk, cache[pk]) : watch[pk].delete(el))26+ waits[pk]?.forEach((r)=>r(cache[pk]))27+ delete waits[pk]28+ }29+ let ingest = (e)=>{30+ if (e.kind || cache[e.pubkey]?.created_at >= e.created_at) return31+ try {32+ let p = JSON.parse(e.content)33+ p.name = p.display_name || p.name || p.displayName34+ cache[e.pubkey] = {...p, created_at:e.created_at}35+ update(e.pubkey)36+ } catch {}37+ }38+ let flush = ()=>{39+ timer = 040+ let authors = [...pending], rs = [...relays, ...window.nrnCfg?.relays ?? ["wss://purplepag.es"]], eose = 041+ pending.clear()42+ relays.clear()43+ let end = ()=>{44+ c?.close()45+ authors.forEach((pk)=>waits[pk] && update(pk))46+ }47+ let c = hydrogen.req({48+ relays:rs,49+ filters:[{kinds:[0], authors}],50+ onevent:ingest,51+ oneose:(u)=>rs.includes(u) && ++eose == rs.length && end()52+ })53+ setTimeout(end, 8e3)54+ }55+ let want = (pk, el)=>{56+ pending.add(pk)57+ hydrogen.relays(el).forEach((u)=>relays.add(u))58+ timer ||= setTimeout(flush, 50)59+ }60+ let get = (pk)=>cache[pk = hexOf(pk)] ? Promise.resolve(cache[pk]) : new Promise((r)=>{61+ (waits[pk] ??= []).push(r)62+ want(pk)63+ })64+ let init = (el)=>{65+ let pk = hexOf(el.getAttribute("nrn-profile"))66+ if (el.__nrn == pk || !pk || el.closest("[nrn-ignore]")) return67+ watch[el.__nrn]?.delete(el)68+ el.__nrn = pk;69+ (watch[pk] ??= new Set()).add(el)70+ cache[pk] ? fill(el, pk, cache[pk]) : want(pk, el)71+ }72+ let process = (n)=>{73+ if (n.matches?.("[nrn-profile]")) init(n)74+ n.querySelectorAll?.("[nrn-profile]").forEach(init)75+ }76+ window.radon = {get, cache}77+ d.__nrn_mo = new MutationObserver((recs)=>recs.forEach((r)=>r.type == "attributes" ? init(r.target) : r.addedNodes.forEach(process)))78+ d.addEventListener("nhy:event", (evt)=>ingest(evt.detail.event))79+ d.addEventListener("nxe:publish", (evt)=>ingest(evt.detail.cfg.event))80+ d.addEventListener("DOMContentLoaded", ()=>{81+ d.__nrn_mo.observe(d.documentElement, {childList:1, subtree:1, attributes:1, attributeFilter:["nrn-profile"]})82+ process(d.body)83+ })84+})()
A test/argon.test.js +41 -0
@@ -0,0 +1,41 @@
1+import "npm:fake-indexeddb@6/auto";2+import { assert, assertEquals } from "jsr:@std/assert@1";3+import "../argon/argon.js";4+5+const { argon } = globalThis;6+const A = "a".repeat(64), B = "b".repeat(64);7+let n = 0;8+const ev = (o) => ({ id: (++n).toString(16).padStart(64, "0"), pubkey: A, kind: 1, created_at: 1000 + n, tags: [], content: "", sig: "", ...o });9+10+Deno.test("argon", async (t) => {11+ await argon.remove();12+13+ await t.step("stores and queries newest first with limits", async () => {14+ const es = [ev({}), ev({}), ev({ pubkey: B }), ev({ kind: 7, tags: [["e", "x"]] })];15+ for (const e of es) assert(await argon.put(e));16+ assertEquals((await argon.query([{ kinds: [1] }])).map((e) => e.id), [es[2].id, es[1].id, es[0].id]);17+ assertEquals((await argon.query([{ kinds: [1], authors: [A], limit: 1 }])).map((e) => e.id), [es[1].id]);18+ assertEquals((await argon.query([{ "#e": ["x"] }])).map((e) => e.id), [es[3].id]);19+ assertEquals((await argon.query([{ since: es[1].created_at, until: es[2].created_at }])).length, 2);20+ assertEquals((await argon.query([{ kinds: [7] }, { authors: [B] }])).length, 2);21+ });22+23+ await t.step("keeps only the newest replaceable and addressable events", async () => {24+ const old = ev({ kind: 0, content: "old" }), neu = ev({ kind: 0, content: "new" });25+ assert(await argon.put(neu));26+ assert(!(await argon.put(old)));27+ assertEquals((await argon.query([{ kinds: [0] }])).map((e) => e.content), ["new"]);28+ const a1 = ev({ kind: 30023, tags: [["d", "x"]] }), a2 = ev({ kind: 30023, tags: [["d", "x"]] }), a3 = ev({ kind: 30023, tags: [["d", "y"]] });29+ for (const e of [a1, a2, a3]) await argon.put(e);30+ assertEquals((await argon.query([{ kinds: [30023] }])).map((e) => e.id), [a3.id, a2.id]);31+ });32+33+ await t.step("skips ephemeral events and honors deletions from the author", async () => {34+ assert(!(await argon.put(ev({ kind: 20001 }))));35+ const mine = ev({}), theirs = ev({ pubkey: B });36+ await argon.put(mine); await argon.put(theirs);37+ await argon.put(ev({ kind: 5, tags: [["e", mine.id], ["e", theirs.id]] }));38+ const ids = (await argon.query([{ ids: [mine.id, theirs.id] }])).map((e) => e.id);39+ assertEquals(ids, [theirs.id]);40+ });41+});
A test/krypton.test.js +44 -0
@@ -0,0 +1,44 @@
1+import { assert, assertEquals } from "jsr:@std/assert@1";2+import { schnorr } from "npm:@noble/curves@1.9.7/secp256k1";3+import { finalizeEvent, verifyEvent } from "npm:nostr-tools@2.17.0/pure";4+import "../krypton/krypton.js";5+6+const { krypton } = globalThis;7+const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");8+const rand = (n = 32) => crypto.getRandomValues(new Uint8Array(n));9+10+Deno.test("public keys match @noble/curves", () => {11+ for (let i = 0; i < 20; i++) {12+ const sk = krypton.generate();13+ assertEquals(krypton.pub(sk), hex(schnorr.getPublicKey(sk)));14+ }15+});16+17+Deno.test("signatures match @noble/curves byte for byte", async () => {18+ for (let i = 0; i < 10; i++) {19+ const sk = rand(), m = rand(), aux = rand();20+ const ours = await krypton.sign(m, sk, aux);21+ assertEquals(hex(ours), hex(schnorr.sign(m, sk, aux)));22+ assert(await krypton.verify(ours, m, schnorr.getPublicKey(sk)));23+ }24+});25+26+Deno.test("verify rejects tampered signatures", async () => {27+ const sk = rand(), m = rand(), pk = schnorr.getPublicKey(sk);28+ const sig = schnorr.sign(m, sk);29+ assert(await krypton.verify(sig, m, pk));30+ sig[5] ^= 1;31+ assert(!(await krypton.verify(sig, m, pk)));32+ assert(!(await krypton.verify(sig.slice(0, 40), m, pk)));33+});34+35+Deno.test("events interoperate with nostr-tools", async () => {36+ const sk = krypton.generate();37+ const tmpl = { kind: 1, created_at: 1700000000, tags: [["t", "nobel"]], content: "hello \"nostr\" ✨" };38+ const ours = await krypton.finish(tmpl, sk);39+ assert(verifyEvent({ ...ours }));40+ const theirs = finalizeEvent({ ...tmpl }, Uint8Array.from(sk.match(/../g), (x) => parseInt(x, 16)));41+ assertEquals(ours.id, theirs.id);42+ assert(await krypton.check(theirs));43+ assert(!(await krypton.check({ ...theirs, content: "tampered" })));44+});
A test/neon.test.js +44 -0
@@ -0,0 +1,44 @@
1+import { assert, assertEquals, assertThrows } from "jsr:@std/assert@1";2+import * as nip19 from "npm:nostr-tools@2.17.0/nip19";3+import "../neon/neon.js";4+5+const { neon } = globalThis;6+const pk = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e";7+const id = "d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027";8+9+Deno.test("npub, nsec and note match nostr-tools", () => {10+ assertEquals(neon.encode("npub", pk), nip19.npubEncode(pk));11+ assertEquals(neon.encode("note", id), nip19.noteEncode(id));12+ assertEquals(neon.decode(nip19.npubEncode(pk)), { type: "npub", data: pk });13+ assertEquals(neon.decode("nostr:" + nip19.noteEncode(id)), { type: "note", data: id });14+});15+16+Deno.test("nprofile, nevent and naddr cross-decode with nostr-tools", () => {17+ const relays = ["wss://relay.example", "wss://r2.example/ünïcode"];18+ const np = nip19.nprofileEncode({ pubkey: pk, relays });19+ assertEquals(nip19.decode(neon.encode("nprofile", { pubkey: pk, relays })).data, { pubkey: pk, relays });20+ assertEquals(neon.decode(np), { type: "nprofile", data: { pubkey: pk, relays } });21+22+ const ev = { id, relays, author: pk, kind: 1 };23+ assertEquals(nip19.decode(neon.encode("nevent", ev)).data, ev);24+ assertEquals(neon.decode(nip19.neventEncode(ev)).data, ev);25+26+ const ad = { identifier: "my-article", pubkey: pk, kind: 30023, relays };27+ assertEquals(nip19.decode(neon.encode("naddr", ad)).data, ad);28+ assertEquals(neon.decode(nip19.naddrEncode(ad)).data, ad);29+});30+31+Deno.test("decode rejects bad checksums", () => {32+ const np = nip19.npubEncode(pk);33+ assertThrows(() => neon.decode(np.slice(0, -1) + (np.at(-1) == "q" ? "p" : "q")));34+});35+36+Deno.test("rich escapes and links", () => {37+ const npub = nip19.npubEncode(pk);38+ const html = neon.rich(`<b>hi</b> see https://ex.com/a?b=1&c=2. pic https://ex.com/p.png nostr:${npub}`);39+ assert(!html.includes("<b>"));40+ assert(html.includes(`<a href="https://ex.com/a?b=1&#38;c=2" target="_blank" rel="noopener">`));41+ assert(html.includes(`<img src="https://ex.com/p.png"`));42+ assert(html.includes(`nrn-profile="${npub}"`));43+ assert(!neon.rich(`"><script>`).includes("<script"));44+});