- commit
- 13c1ad60e0b34b2f41850aa23b942c2a20eab456
- parent
- c7bc31752bd3034d2c671beaf68d1d50acbe4d2a
- author
- Dami <dami.etoile@gmail.com>
| A | gold/ | 44 | +++++ |
| A | gold/ | 57 | +++++++ |
| A | mercury/ | 49 | ++++++ |
| A | mercury/ | 64 | ++++++++ |
| A | test/ | 210 | ++++++++++++++++++++++++++ |
| A | test/ | 247 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,44 @@
1+# 🥇 gold.js - relay AUTH2+3+gold.js is a miniature NIP-42 client: it answers a relay's `AUTH` challenge with a signed kind 22242 event, so members-only relays let you read and publish. When a relay refuses a subscription or an event with `auth-required:`, gold authenticates once and re-sends it, and xenon never sees the refusal. It composes through xenon's `nhy:message` event and the `nhy:config` signing hook, so krypton.js, chlorine.js or a NIP-07 extension signs the AUTH event just as it signs posts.4+5+```html6+<script src="krypton.js"></script>7+<script src="gold.js"></script>8+<script src="xenon.js"></script>9+10+<ol nhy-relay="wss://private.example" nxe-filter='{"kinds":[1]}'></ol>11+<form nhy-relay="wss://private.example" nxe-publish><input name="content"><button>Post</button></form>12+```13+14+The relay refuses the list's subscription until you prove who you are, so gold signs its challenge and asks again, and the notes appear.15+16+## Attributes17+18+| attribute | description |19+|---|---|20+| `nau-eager` | On `<html>` or `<body>`: authenticate as soon as a relay sends a challenge, not only when it refuses something |21+| `nau-relays` | On any element: space-separated relays that may receive AUTH. Others are never answered. Default any relay that asks |22+23+`window.nauCfg = {eager, relays}` sets the same options from script.24+25+## Events26+27+Dispatched on `document`.28+29+| event | cancel to | detail |30+|---|---|---|31+| `nau:auth` | skip authenticating (the refusal then reaches xenon) | `url`, `challenge`, `cfg` with the unsigned `event`, before signing. To choose a different signer, set `cfg.sign` in `nhy:config` like every nobel signer |32+| `nau:authed` | | `url`, `pubkey` once the relay says OK |33+| `nau:error` | | `url`, `error`: refused, cancelled, no challenge within 3 s, or (from `gold.auth`) a relay not in `nau-relays` |34+35+## API36+37+| function | returns |38+|---|---|39+| `gold.auth(url)` | Promise of your pubkey once authenticated on that relay's current connection (connects if needed) |40+| `gold.state[url]` | `{challenge, pubkey, ...}` for the current connection. A reconnect starts fresh |41+42+Each connection gets at most one AUTH, and each refused subscription or event is retried once. If the relay still refuses, the refusal reaches xenon, so the publish reports `ok:false`. Load gold.js before xenon.js.43+44+Privacy: gold is lazy by default and only authenticates when a relay refuses something. Each AUTH tells that relay which pubkey is connecting, so eager mode tells every relay you connect to. Use `nau-relays` to limit which relays get it.@@ -0,0 +1,57 @@
1+(()=>{2+ let doc = document, w = window3+ if (w.gold) return4+ let state = {}5+ let send = (type, detail)=>doc.dispatchEvent(new CustomEvent("nau:" + type, {detail, cancelable:1, bubbles:1, composed:1}))6+ let has = (a)=>doc.documentElement.hasAttribute(a) || doc.body?.hasAttribute(a)7+ let allowed = (url, l = doc.querySelector("[nau-relays]")?.getAttribute("nau-relays").split(/\s+/) ?? w.nauCfg?.relays)=>!l || l.includes(url)8+ let conn = (url, r)=>{9+ let c = state[url]10+ return c?.ws == r.ws ? c : state[url] = {ws:r.ws, tried:new Set(), oks:new Map(), wait:[]}11+ }12+ let auth = (url)=>{13+ let r = hydrogen.relay(url), c = conn(url, r)14+ return c.p ??= (async()=>{15+ if (!allowed(url)) throw Error("relay not allowed")16+ if (!c.challenge) await new Promise((res, rej)=>{17+ c.wait.push(res)18+ setTimeout(()=>rej(Error("no challenge")), 3e3)19+ })20+ let challenge = c.challenge21+ let cfg = {elt:doc, relays:[url], event:{kind:22242, created_at:Date.now() / 1e3 | 0, tags:[["relay", url], ["challenge", challenge]], content:""}}22+ let e = send("auth", {url, challenge, cfg}) && await hydrogen.sign(cfg)23+ if (!e) throw Error("cancelled")24+ let [ok, msg] = await new Promise((res)=>{25+ c.oks.set(e.id, res)26+ setTimeout(()=>res([0, "timeout"]), 8e3)27+ r.send(["AUTH", e])28+ })29+ if (!ok) throw Error(msg)30+ send("authed", {url, pubkey:c.pubkey = e.pubkey})31+ return e.pubkey32+ })().catch((error)=>{33+ send("error", {url, error})34+ throw error35+ })36+ }37+ doc.addEventListener("nhy:message", (evt)=>{38+ let {url, relay:r, msg:[t, a, b, m]} = evt.detail, c = conn(url, r), done = c.oks.get(a)39+ if (t == "AUTH"){40+ c.challenge = a41+ c.wait.splice(0).forEach((f)=>f())42+ if ((w.nauCfg?.eager || has("nau-eager")) && allowed(url)) auth(url).catch(()=>{})43+ }44+ if (t == "OK" && done){45+ evt.preventDefault()46+ c.oks.delete(a)47+ done([b, m])48+ }49+ let s = t == "CLOSED" ? r.subs.get(a) : t == "OK" && !b && r.oks.get(a)50+ if (s && /^auth-required:/.test(t == "OK" ? m : b) && allowed(url) && !c.tried.has(t + a)){51+ c.tried.add(t + a)52+ evt.preventDefault()53+ auth(url).catch(()=>{}).then(()=>t == "OK" ? r.oks.has(a) && r.send(["EVENT", s.event]) : r.subs.has(a) && r.send(["REQ", a, ...s.filters]))54+ }55+ })56+ w.gold = {auth, state}57+})()@@ -0,0 +1,49 @@
1+# ☿️ mercury.js - signed HTTP2+3+mercury.js is a miniature rexi.js with a signature on every request: each call carries a NIP-98 `Authorization` header over its URL, method and body hash, and Blossom helpers sign kind 24242 tokens to upload, list and delete blobs. It composes with fixi.js through `fx:config`, so any fixi request under `nhg-sign` is signed, and with every nobel signer through `nhy:config`, so krypton.js, chlorine.js or a NIP-07 extension does the signing. It also runs in Deno, where it signs with `globalThis.nostr` or krypton.js.4+5+```html6+<script src="fixi.js"></script>7+<script src="krypton.js"></script>8+<script src="mercury.js"></script>9+10+<button fx-action="/api/like" fx-method="post" nhg-sign>Like</button>11+<input type="file" onchange="mercury.upload('https://blossom.example', this.files[0]).then((b)=>console.log(b.url))">12+```13+14+The button's request reaches `/api/like` with a NIP-98 header proving who clicked it, and the file input stores the chosen file on a Blossom server and logs its URL.15+16+## Attributes17+18+| attribute | description |19+|---|---|20+| `nhg-sign` | fixi requests from this element or its descendants are NIP-98 signed |21+22+`window.nhgCfg = {ttl}` sets how long Blossom tokens stay valid (300 seconds).23+24+## Events25+26+Dispatched on the element involved (`opts.elt`), else `document`, or `globalThis` outside a browser.27+28+| event | cancel to | detail |29+|---|---|---|30+| `nhy:config` | | `cfg` (`elt`, `event`, `sign`): the family signing hook, where signers set `cfg.sign` |31+| `nhg:sign` | abort the request (rejects with `AbortError`) | `cfg`, just before `cfg.sign(cfg.event)` |32+| `nhg:error` | | `error` from signing, the network, or an error status (verbs and Blossom helpers) |33+34+## API35+36+| function | returns |37+|---|---|38+| `mercury.get/head/post/put/patch/del(url, body?, opts?)` | Promise of the `Response`, with `.json()` and `.text()`. Rejects on non-2xx with `status` and `response` |39+| `mercury.fetch(url, init?)` | Promise of the `Response` of a NIP-98 signed `fetch`, whatever its status |40+| `mercury.upload(server, blob, {media}?)` | Blossom blob descriptor `{url, sha256, size, type, uploaded}`, via `PUT /upload` or, with `media`, `PUT /media` |41+| `mercury.list(server, pubkey)` | descriptors from `GET /list/<pubkey>` |42+| `mercury.remove(server, sha256)` | `true` after `DELETE /<sha256>` |43+| `mercury.sign(event, elt?)` | `"Nostr <base64>"` for any unsigned event, through the signing hook |44+45+Like rexi, `get`, `head` and `del` put `body` in the query string, and the others send plain objects and arrays as JSON, forms as `FormData`, and anything `fetch` accepts as is. `opts` are `fetch` options plus `elt`, the element whose events and signer apply. Relative URLs resolve against the page before signing. A request that already has an `Authorization` header is sent unchanged. Blossom options also take `server`, the domain in the token's `server` tag (the server URL's host).46+47+## Notes48+49+Every request is a new signature, so a NIP-07 extension or a remote signer may prompt or take a moment each time. The body is serialized once before signing, `FormData` included, and those exact bytes are hashed into the `payload` tag and sent, so servers can always check it. Streams are read into memory first. NIP-98 tokens show the full URL, including the query, to anyone who sees the header, and are good for 60 seconds on most servers. Tested against nostr-tools' NIP-98 validator, a minimal Blossom server, fixi in Chromium, and tinyrelay's NIP-86 management API and Blossom store.@@ -0,0 +1,64 @@
1+(()=>{2+ let g = globalThis, d = g.document, CT = "content-type"3+ if (g.mercury) return4+ let send = (el, type, detail)=>(el ?? d ?? g).dispatchEvent(new CustomEvent(type, {detail, cancelable:1, bubbles:1, composed:1}))5+ let hex = (b)=>[...new Uint8Array(b)].map((x)=>x.toString(16).padStart(2, "0")).join("")6+ let sha = async(b)=>hex(await crypto.subtle.digest("SHA-256", b))7+ let fail = (r)=>{8+ if (!r.ok) throw Object.assign(Error(`${r.status} ${r.headers.get("x-reason") ?? r.statusText}`), {status:r.status, response:r})9+ return r10+ }11+ let sign = async(event, elt)=>{12+ let cfg = {elt, event:{created_at:Date.now() / 1e3 | 0, content:"", ...event}, sign:(e)=>(g.nostr ?? g.krypton.signer).signEvent(e)}13+ send(elt, "nhy:config", {cfg})14+ if (!send(elt, "nhg:sign", {cfg})) throw new DOMException("nhg:sign cancelled", "AbortError")15+ return "Nostr " + btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(await cfg.sign(cfg.event)))))16+ }17+ let guard = async(elt, f)=>{18+ try { return await f() } catch(error) {19+ send(elt, "nhg:error", {error})20+ throw error21+ }22+ }23+ let hfetch = async(url, o = {}, f = fetch)=>{24+ url = new URL(url, g.location?.href).href25+ let m = (o.method ?? "GET").toUpperCase(), h = new Headers(o.headers), tags = [["u", url], ["method", m]], r, t26+ if (o.body != null){27+ r = new Response(o.body)28+ o = {...o, body:await r.arrayBuffer()}29+ if ((t = r.headers.get(CT)) && !h.has(CT)) h.set(CT, t)30+ tags.push(["payload", await sha(o.body)])31+ }32+ if (!h.has("authorization")) h.set("authorization", await sign({kind:27235, tags}, o.elt))33+ return f(url, {...o, method:m, headers:h})34+ }35+ let verb = (m)=>(url, body, o = {})=>{36+ let q = /GET|HEAD|DEL/.test(m)37+ if (body != null){38+ if (q) url += (/\?/.test(url) ? "&" : "?") + new URLSearchParams(body), body = null39+ else if (body.constructor == Object || Array.isArray(body)) body = JSON.stringify(body), o = {...o, headers:{"content-type":"application/json", ...o.headers}}40+ else if (body.tagName == "FORM") body = new FormData(body)41+ }42+ let p = guard(o.elt, ()=>hfetch(url, {...o, method:m, body}).then(fail))43+ p.json = ()=>p.then((r)=>r.json())44+ p.text = ()=>p.then((r)=>r.text())45+ return p46+ }47+ let blossom = (server, path, t, x, o = {})=>guard(o.elt, async()=>{48+ let u = new URL(path, server.replace(/\/?$/, "/")), tags = [["t", t], ["expiration", "" + ((Date.now() / 1e3 | 0) + (g.nhgCfg?.ttl ?? 300))], ["server", o.server ?? u.hostname]]49+ if (x) tags.push(["x", x])50+ let a = await sign({kind:24242, content:`${t} ${x || "blobs"}`, tags}, o.elt)51+ return fail(await fetch(u, {...o, headers:{...o.headers, authorization:a}}))52+ })53+ let upload = async(server, blob, o = {})=>{54+ let body = await blob.arrayBuffer(), x = await sha(body), t = o.media ? "media" : "upload"55+ return (await blossom(server, t, t, x, {...o, method:"PUT", body, headers:{"content-type":blob.type || "application/octet-stream", "x-sha-256":x}})).json()56+ }57+ let remove = (server, x, o)=>blossom(server, x, "delete", x, {...o, method:"DELETE"}).then(()=>true)58+ let list = (server, pubkey, o)=>blossom(server, "list/" + pubkey, "list", 0, o).then((r)=>r.json())59+ g.mercury = {fetch:(url, o)=>guard(o?.elt, ()=>hfetch(url, o)), sign, upload, remove, list, get:verb("GET"), head:verb("HEAD"), post:verb("POST"), put:verb("PUT"), patch:verb("PATCH"), del:verb("DELETE")}60+ d?.addEventListener("fx:config", (e)=>{61+ let el = e.target, c = e.detail.cfg, f = c.fetch62+ if (el.closest?.("[nhg-sign]")) c.fetch = (url, o)=>guard(el, ()=>hfetch(url, {...o, elt:el}, f))63+ })64+})()@@ -0,0 +1,210 @@
1+// NIP-42 against a members-only tinyrelay tenant (ws://localhost:7447, container noble-tiny), in headless Chromium.2+import { assert, assertEquals } from "jsr:@std/assert@1";3+import { chromium } from "npm:playwright@1.58";4+import { serve } from "../server.ts";5+import "../krypton/krypton.js";6+7+const { krypton } = globalThis;8+const opts = { sanitizeResources: false, sanitizeOps: false };9+const TINY = Deno.env.get("NAU_TINY") ?? "ws://localhost:7447";10+const CONTAINER = Deno.env.get("NAU_CONTAINER") ?? "noble-tiny";11+12+async function chromiumPath() {13+ if (Deno.env.get("CHROMIUM")) return Deno.env.get("CHROMIUM");14+ const cache = `${Deno.env.get("HOME")}/Library/Caches/ms-playwright`;15+ try {16+ const dirs = [...Deno.readDirSync(cache)].map((d) => d.name).filter((n) => n.startsWith("chromium_headless_shell-")).sort();17+ const dir = `${cache}/${dirs.at(-1)}`;18+ const arch = [...Deno.readDirSync(dir)][0].name;19+ return `${dir}/${arch}/chrome-headless-shell`;20+ } catch { return undefined; }21+}22+23+// A fresh private tenant (owner writes, members read) owned by a new krypton key. Skipped without the container.24+const owner = krypton.generate(), ownerPk = krypton.pub(owner);25+const name = `gold${Date.now().toString(36)}`;26+const made = await new Deno.Command("docker", { args: ["exec", CONTAINER, "tiny", "tenant", "create", "--name", name, "--owner", ownerPk, "--template", "private"] })27+ .output().then((o) => o.success, () => false);28+const relay = `${TINY}/r/${name}`;29+30+// Seed one note as the owner (tinyrelay accepts owner writes without AUTH; only reads need it).31+async function seed(content) {32+ const ws = new WebSocket(relay), e = await krypton.finish({ kind: 1, created_at: Date.now() / 1e3 | 0, tags: [], content }, owner);33+ await new Promise((r) => ws.onopen = r);34+ const ok = new Promise((r) => ws.onmessage = (m) => JSON.parse(m.data)[0] === "OK" && r(JSON.parse(m.data)[2]));35+ ws.send(JSON.stringify(["EVENT", e]));36+ assert(await ok, "seed accepted");37+ ws.close();38+}39+40+const page = (key, cfg = {}) => `<!doctype html><script>41+localStorage["nkr:key"] = ${JSON.stringify(key)}42+window.nauCfg = ${JSON.stringify(cfg)}43+window.__log = []; window.__wire = []44+for (const t of ["auth", "authed", "error"]) document.addEventListener("nau:" + t, (e) => __log.push([t, e.detail.pubkey ?? e.detail.error?.message ?? e.detail.challenge]))45+document.addEventListener("nhy:message", (e) => __wire.push(e.detail.msg.slice(0, 2).concat(e.detail.msg[0] == "OK" ? e.detail.msg.slice(2) : e.detail.msg[0] == "CLOSED" ? [] : [])))46+document.addEventListener("nxe:published", (e) => window.__results = e.detail.cfg.results)47+</script>48+<script src="/krypton/krypton.js"></script><script src="/gold/gold.js"></script><script src="/hydrogen/hydrogen.js"></script><script src="/xenon/xenon.js"></script>49+<body>50+<ol id="feed" nhy-relay="${relay}" nxe-filter='{"kinds":[1],"limit":10}'><template><li>{content}</li></template></ol>51+<form id="post" nhy-relay="${relay}" nxe-publish nxe-tags='[["-"]]'><input name="content"><button>Post</button></form>`;52+53+Deno.test({ name: "gold: NIP-42 on a members-only tinyrelay tenant", ignore: !made, ...opts }, async (t) => {54+ await seed("seeded before auth");55+ const server = serve(0);56+ const base = `http://localhost:${server.addr.port}`;57+ const browser = await chromium.launch({ executablePath: await chromiumPath(), args: ["--disable-features=LocalNetworkAccessChecks"] });58+ const errors = [];59+ const open = async (html) => {60+ const p = await browser.newPage();61+ p.on("pageerror", (e) => errors.push(e.message));62+ await p.route(`${base}/t.html`, (r) => r.fulfill({ contentType: "text/html", body: html }));63+ await p.goto(`${base}/t.html`);64+ return p;65+ };66+ try {67+ let p;68+ await t.step("owner: subscription is re-sent after automatic AUTH and receives events", async () => {69+ p = await open(page(owner));70+ await p.waitForFunction(() => /seeded before auth/.test(document.querySelector("#feed").textContent), null, { timeout: 8000 });71+ const log = await p.evaluate(() => __log);72+ assertEquals(log.map((l) => l[0]), ["auth", "authed"]);73+ assertEquals(log[1][1], ownerPk);74+ const wire = await p.evaluate(() => __wire);75+ assert(wire.some((m) => m[0] == "CLOSED"), "relay first refused the REQ");76+ });77+78+ await t.step("owner: protected publish (NIP-70) succeeds and appears live", async () => {79+ await p.fill("#post input", "protected hello");80+ await p.click("#post button");81+ await p.waitForFunction(() => window.__results, null, { timeout: 8000 });82+ const res = await p.evaluate(() => __results);83+ assertEquals(res.map((r) => r.ok), [true]);84+ await p.waitForFunction(() => /protected hello/.test(document.querySelector("#feed").textContent), null, { timeout: 5000 });85+ assertEquals((await p.evaluate(() => __log)).length, 2, "no second AUTH");86+ assertEquals(await p.evaluate((u) => gold.state[u].pubkey, relay), ownerPk);87+ await p.close();88+ });89+90+ await t.step("protected publish before any read needs AUTH, and waits for it", async () => {91+ const p = await open(page(owner).replace(/<ol[\s\S]*?<\/ol>/, ""));92+ await p.fill("#post input", "publish-first");93+ await p.click("#post button");94+ await p.waitForFunction(() => window.__results, null, { timeout: 8000 });95+ assertEquals((await p.evaluate(() => __results)).map((r) => r.ok), [true]);96+ const wire = await p.evaluate(() => __wire.filter((m) => m[0] == "OK"));97+ assert(/^auth-required:/.test(wire[0][3]), "first OK was auth-required: " + JSON.stringify(wire));98+ await p.close();99+ });100+101+ await t.step("non-member: AUTH once, then clean failures without loops", async () => {102+ const p = await open(page(krypton.generate()));103+ await p.fill("#post input", "intruder");104+ await p.click("#post button");105+ await p.waitForFunction(() => window.__results, null, { timeout: 8000 });106+ await p.waitForTimeout(1000);107+ const [res, log, wire, feed] = await p.evaluate(() => [__results, __log, __wire, document.querySelector("#feed").textContent]);108+ assertEquals(res.map((r) => r.ok), [false]);109+ assert(/^blocked:/.test(res[0].msg), res[0].msg);110+ assertEquals(log.map((l) => l[0]), ["auth", "authed"]);111+ assertEquals(wire.filter((m) => m[0] == "CLOSED").length, 2, "one auth-required, one restricted");112+ assertEquals(wire.filter((m) => m[0] == "OK").length, 2, "AUTH ok + the refusal (tinyrelay checks write policy first)");113+ assertEquals(feed.trim(), "");114+ await p.close();115+ });116+117+ await t.step("still refused after AUTH: one retry, then xenon reports the failure", async () => {118+ // AUTH as someone else, so the owner's protected event stays auth-required after the retry.119+ const other = JSON.stringify(krypton.generate());120+ const p = await open(page(owner).replace("<body>", `<script>document.addEventListener("nhy:config", (e) => e.detail.cfg.event?.kind == 22242 && (e.detail.cfg.sign = (ev) => krypton.finish(ev, ${other})))</script><body>`));121+ await p.fill("#post input", "wrong identity");122+ await p.click("#post button");123+ await p.waitForFunction(() => window.__results, null, { timeout: 8000 });124+ await p.waitForTimeout(1000);125+ const [res, log, wire] = await p.evaluate(() => [__results, __log, __wire]);126+ assertEquals(res.map((r) => r.ok), [false]);127+ assert(/^auth-required:/.test(res[0].msg), res[0].msg);128+ assertEquals(log.map((l) => l[0]), ["auth", "authed"]);129+ assertEquals(wire.filter((m) => m[0] == "OK" && !m[2]).length, 2, "refused, retried once, refused");130+ assertEquals(wire.filter((m) => m[0] == "CLOSED").length, 2, "REQ refused, retried once, restricted");131+ await p.close();132+ });133+134+ await t.step("allowlist: a relay not on nau relays never gets AUTH", async () => {135+ const p = await open(page(owner, { relays: ["wss://elsewhere.example"] }));136+ await p.waitForTimeout(1500);137+ assertEquals(await p.evaluate(() => __log), []);138+ assertEquals(await p.evaluate(() => document.querySelector("#feed").textContent.trim()), "");139+ await p.close();140+ });141+142+ await t.step("eager: authenticates on connect, before any refusal", async () => {143+ const p = await open(page(owner, { eager: true }).replace(/<ol[\s\S]*?<\/ol>/, "") + `<script>addEventListener("load", () => hydrogen.relay(${JSON.stringify(relay)}))</script>`);144+ await p.waitForFunction(() => __log.some((l) => l[0] == "authed"), null, { timeout: 8000 });145+ assertEquals(await p.evaluate(() => __wire.filter((m) => m[0] == "CLOSED" || m[0] == "OK" && !m[2]).length), 0);146+ await p.close();147+ });148+149+ await t.step("gold.auth(url) on demand returns the pubkey", async () => {150+ const p = await open(page(owner).replace(/<ol[\s\S]*?<\/ol>/, ""));151+ assertEquals(await p.evaluate((u) => gold.auth(u), relay), ownerPk);152+ assertEquals(await p.evaluate((u) => gold.auth(u), relay), ownerPk, "cached per connection");153+ assertEquals((await p.evaluate(() => __log)).length, 2);154+ await p.close();155+ });156+157+ await t.step("cancelling nau:auth stops signing and reports nau:error", async () => {158+ const p = await open(page(owner).replace("<body>", `<script>document.addEventListener("nau:auth", (e) => e.preventDefault())</script><body>`));159+ await p.waitForFunction(() => __log.some((l) => l[0] == "error"), null, { timeout: 8000 });160+ await p.waitForTimeout(500);161+ assertEquals((await p.evaluate(() => __log)).map((l) => l[0]), ["auth", "error"]);162+ await p.close();163+ });164+165+ assertEquals(errors, []);166+ } finally {167+ await browser.close();168+ await server.shutdown();169+ }170+});171+172+// Some relays only send the challenge after refusing; gold waits briefly for it. A scripted relay, no docker needed.173+Deno.test({ name: "gold: challenge that arrives after the auth-required refusal", ...opts }, async () => {174+ let authed = null, auths = 0;175+ const fake = Deno.serve({ port: 0, onListen() {} }, (req) => {176+ const { socket: ws, response } = Deno.upgradeWebSocket(req);177+ const send = (m) => ws.send(JSON.stringify(m));178+ ws.onmessage = async (m) => {179+ const [t, a] = JSON.parse(m.data);180+ if (t == "REQ" && !authed) {181+ send(["CLOSED", a, "auth-required: who are you"]);182+ setTimeout(() => send(["AUTH", "late-challenge"]), 300);183+ } else if (t == "REQ") send(["EVENT", a, await krypton.finish({ kind: 1, created_at: 1, tags: [], content: "for " + authed }, owner)]);184+ else if (t == "AUTH") {185+ auths++;186+ const ok = await krypton.check(a) && a.kind == 22242 && a.tags.some((x) => x[0] == "challenge" && x[1] == "late-challenge");187+ if (ok) authed = a.pubkey;188+ send(["OK", a.id, ok, ok ? "" : "invalid"]);189+ }190+ };191+ return response;192+ });193+ const server = serve(0), base = `http://localhost:${server.addr.port}`, url = `ws://localhost:${fake.addr.port}`;194+ const browser = await chromium.launch({ executablePath: await chromiumPath(), args: ["--disable-features=LocalNetworkAccessChecks"] });195+ const errors = [];196+ try {197+ const p = await browser.newPage();198+ p.on("pageerror", (e) => errors.push(e.message));199+ await p.route(`${base}/t.html`, (r) => r.fulfill({ contentType: "text/html", body: page(owner).replaceAll(relay, url) }));200+ await p.goto(`${base}/t.html`);201+ await p.waitForFunction((pk) => document.querySelector("#feed").textContent.includes("for " + pk), ownerPk, { timeout: 5000 });202+ assertEquals(auths, 1);203+ assertEquals(await p.evaluate((u) => [gold.state[u].challenge, gold.state[u].pubkey], url), ["late-challenge", ownerPk]);204+ assertEquals(errors, []);205+ } finally {206+ await browser.close();207+ await server.shutdown();208+ await fake.shutdown();209+ }210+});@@ -0,0 +1,247 @@
1+// mercury.js against nostr-tools' NIP-98 validator, a minimal Blossom server, fixi in Chromium,2+// and (with NHG_TINY and NHG_KEY set) a tinyrelay tenant's NIP-86 endpoint.3+import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1";4+import * as nip98 from "npm:nostr-tools@2.17.0/nip98";5+import { verifyEvent } from "npm:nostr-tools@2.17.0/pure";6+import { chromium } from "npm:playwright@1.58";7+import "../krypton/krypton.js";8+import "../mercury/mercury.js";9+10+const { krypton, mercury } = globalThis;11+const root = new URL("../", import.meta.url);12+const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");13+const sha = async (b) => hex(await crypto.subtle.digest("SHA-256", b));14+const unpack = (h) => JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(h.replace(/^Nostr /, "")), (c) => c.charCodeAt(0))));15+const tag = (e, k) => e.tags.find((t) => t[0] === k)?.[1];16+17+const sk = krypton.generate();18+krypton.key(sk);19+const pk = krypton.pub(sk);20+21+// NIP-98: nostr-tools checks signature, kind, u, method and a 60s created_at window; we check the payload hash22+// against the raw bytes, and nostr-tools' own payload check for JSON bodies.23+async function nip98Check(req, body) {24+ const h = req.headers.get("authorization");25+ await nip98.validateToken(h, req.url, req.method);26+ const e = await nip98.unpackEventFromToken(h);27+ assert(Math.abs(e.created_at - Date.now() / 1e3) < 60, "created_at window");28+ const p = tag(e, "payload");29+ if (body.byteLength || p) assertEquals(p, await sha(body), "payload hash");30+ if (/json/.test(req.headers.get("content-type"))) await nip98.validateEvent(e, req.url, req.method, JSON.parse(new TextDecoder().decode(body)));31+ return e;32+}33+34+const blobs = new Map();35+async function blossomCheck(req, t, x) {36+ const e = unpack(req.headers.get("authorization") ?? "");37+ assert(verifyEvent(e), "signature");38+ assertEquals(e.kind, 24242);39+ assertEquals(tag(e, "t"), t);40+ assert(+tag(e, "expiration") > Date.now() / 1e3, "expiration");41+ assertEquals(tag(e, "server"), new URL(req.url).hostname);42+ if (x) assert(e.tags.some((g) => g[0] === "x" && g[1] === x), "x tag");43+ return e;44+}45+46+let fixi;47+const page = `<!doctype html><script src="/fixi.js"></script><script src="/krypton/krypton.js"></script><script src="/mercury/mercury.js"></script>48+<button id=x fx-action="/api/x" fx-method="post" nhg-sign fx-target="#out" fx-swap="innerHTML" name=n value=1>x</button>49+<form fx-action="/api/x?y=2" fx-method="put" nhg-sign fx-target="#out" fx-swap="innerHTML"><input name=msg value="hé"><button id=f>f</button></form>50+<div nhg-sign><button id=g fx-action="/api/x?q=1" fx-target="#out" fx-swap="innerHTML">g</button></div>51+<button id=u fx-action="/api/x" fx-method="post" fx-target="#out" fx-swap="innerHTML">u</button>52+<div id=out></div>`;53+54+async function handler(req) {55+ const u = new URL(req.url), body = await req.arrayBuffer();56+ try {57+ if (u.pathname === "/") return new Response(page, { headers: { "content-type": "text/html; charset=utf-8" } });58+ if (u.pathname === "/fixi.js") return new Response(fixi, { headers: { "content-type": "text/javascript" } });59+ if (/^\/(krypton|mercury)\//.test(u.pathname)) return new Response(await Deno.readFile(new URL("." + u.pathname, root)), { headers: { "content-type": "text/javascript" } });60+ if (u.pathname.startsWith("/api/")) {61+ const e = await nip98Check(req, body);62+ const form = /multipart|urlencoded/.test(req.headers.get("content-type")) ? Object.fromEntries(await new Request(req.url, { method: "POST", headers: req.headers, body }).formData()) : undefined;63+ return Response.json({ pubkey: e.pubkey, method: req.method, u: tag(e, "u"), payload: tag(e, "payload") ?? null, type: req.headers.get("content-type"), form, text: form ? undefined : new TextDecoder().decode(body) });64+ }65+ const [, a, b] = u.pathname.split("/");66+ if (req.method === "PUT" && (a === "upload" || a === "media")) {67+ const x = await sha(body);68+ assertEquals(req.headers.get("x-sha-256"), x);69+ const e = await blossomCheck(req, a, x);70+ const d = { url: `${u.origin}/${x}`, sha256: x, size: body.byteLength, type: req.headers.get("content-type"), uploaded: Date.now() / 1e3 | 0 };71+ blobs.set(x, { d, body, owner: e.pubkey });72+ return Response.json(d);73+ }74+ if (req.method === "GET" && a === "list") {75+ await blossomCheck(req, "list");76+ return Response.json([...blobs.values()].filter((v) => v.owner === b).map((v) => v.d));77+ }78+ if (req.method === "DELETE") {79+ const e = await blossomCheck(req, "delete", a);80+ if (blobs.get(a)?.owner !== e.pubkey) return new Response("not yours", { status: 403, headers: { "x-reason": "not yours" } });81+ blobs.delete(a);82+ return new Response(null, { status: 204 });83+ }84+ if (req.method === "GET" && blobs.has(a)) return new Response(blobs.get(a).body);85+ return new Response("not found", { status: 404 });86+ } catch (err) {87+ return new Response(err.message, { status: 401, headers: { "x-reason": err.message } });88+ }89+}90+91+const opts = { sanitizeResources: false, sanitizeOps: false };92+93+Deno.test({ name: "mercury: NIP-98 requests validate with nostr-tools", ...opts }, async (t) => {94+ const server = Deno.serve({ port: 0, hostname: "127.0.0.1", onListen() {} }, handler);95+ const base = `http://127.0.0.1:${server.addr.port}`;96+ try {97+ await t.step("post JSON: u, method, payload match; nostr-tools' JSON payload check passes", async () => {98+ const r = await mercury.post(`${base}/api/a`, { hello: "wörld", n: [1, 2] }).json();99+ assertEquals(r.pubkey, pk);100+ assertEquals(r.method, "POST");101+ assertEquals(r.u, `${base}/api/a`);102+ assertEquals(r.type, "application/json");103+ assertEquals(JSON.parse(r.text), { hello: "wörld", n: [1, 2] });104+ assertEquals(r.payload, await sha(new TextEncoder().encode(JSON.stringify({ hello: "wörld", n: [1, 2] }))));105+ });106+107+ await t.step("get puts the body in the signed query string and has no payload", async () => {108+ const r = await mercury.get(`${base}/api/b?x=1`, { q: "a b" }).json();109+ assertEquals(r.u, `${base}/api/b?x=1&q=a+b`);110+ assertEquals(r.payload, null);111+ assertEquals((await mercury.del(`${base}/api/b`).json()).method, "DELETE");112+ });113+114+ await t.step("string, Blob, bytes, URLSearchParams and FormData bodies hash the bytes sent", async () => {115+ assertEquals((await mercury.put(`${base}/api/c`, "plain").json()).text, "plain");116+ assertEquals((await mercury.patch(`${base}/api/c`, new Blob([new Uint8Array([1, 2, 3])], { type: "x/y" })).json()).type, "x/y");117+ await mercury.post(`${base}/api/c`, new Uint8Array([0, 255]));118+ assertEquals((await mercury.post(`${base}/api/c`, new URLSearchParams({ a: "1" })).json()).form, { a: "1" });119+ const fd = new FormData();120+ fd.append("a", "1");121+ fd.append("f", new File(["file!"], "f.txt"));122+ const r = await mercury.post(`${base}/api/c`, fd).json();123+ assert(/^multipart\/form-data; boundary=/.test(r.type));124+ assertEquals(r.form.a, "1");125+ });126+127+ await t.step("fetch returns the Response; verbs throw on errors with status", async () => {128+ const r = await mercury.fetch(`${base}/api/d`, { method: "post", body: "x", headers: { authorization: "Nostr e30=" } });129+ assertEquals(r.status, 401, "an existing Authorization header is left alone");130+ assertEquals((await (await mercury.fetch(`${base}/api/d`)).json()).method, "GET");131+ const err = await assertRejects(() => mercury.get(`${base}/nope`));132+ assertEquals(err.status, 404);133+ });134+135+ await t.step("nhy:config can swap the signer; cancelling nhg:sign aborts and fires nhg:error", async () => {136+ const other = krypton.generate();137+ const swap = (e) => e.detail.cfg.sign = (ev) => krypton.finish(ev, other);138+ globalThis.addEventListener("nhy:config", swap);139+ assertEquals((await mercury.post(`${base}/api/e`, "x").json()).pubkey, krypton.pub(other));140+ globalThis.removeEventListener("nhy:config", swap);141+ const seen = [];142+ const cancel = (e) => (seen.push(e.detail.cfg.event.kind), e.preventDefault());143+ const onerr = (e) => seen.push(e.detail.error.name);144+ globalThis.addEventListener("nhg:sign", cancel);145+ globalThis.addEventListener("nhg:error", onerr);146+ await assertRejects(() => mercury.get(`${base}/api/e`), DOMException);147+ globalThis.removeEventListener("nhg:sign", cancel);148+ globalThis.removeEventListener("nhg:error", onerr);149+ assertEquals(seen, [27235, "AbortError"]);150+ });151+152+ await t.step("Blossom upload, media, list and remove", async () => {153+ const blob = new Blob(["hello blossom"], { type: "text/plain" });154+ const d = await mercury.upload(base, blob);155+ assertEquals(d.sha256, await sha(await blob.arrayBuffer()));156+ assertEquals(d.size, 13);157+ assertEquals(d.type, "text/plain");158+ assertEquals(await (await fetch(d.url)).text(), "hello blossom");159+ const m = await mercury.upload(base + "/", new Blob([new Uint8Array([9])]), { media: true });160+ assertEquals(m.type, "application/octet-stream");161+ assertEquals((await mercury.list(base, pk)).map((x) => x.sha256).sort(), [d.sha256, m.sha256].sort());162+ assertEquals(await mercury.remove(base, d.sha256), true);163+ assertEquals((await mercury.list(base, pk)).length, 1);164+ const err = await assertRejects(() => mercury.remove(base, "00".repeat(32)));165+ assertEquals(err.status, 403);166+ assertEquals(err.message, "403 not yours");167+ });168+ } finally {169+ await server.shutdown();170+ }171+});172+173+// Use CHROMIUM if set, else the newest headless shell already in the Playwright cache.174+async function chromiumPath() {175+ if (Deno.env.get("CHROMIUM")) return Deno.env.get("CHROMIUM");176+ const cache = `${Deno.env.get("HOME")}/Library/Caches/ms-playwright`;177+ try {178+ const dirs = [...Deno.readDirSync(cache)].map((d) => d.name).filter((n) => n.startsWith("chromium_headless_shell-")).sort();179+ const dir = `${cache}/${dirs.at(-1)}`;180+ const arch = [...Deno.readDirSync(dir)][0].name;181+ return `${dir}/${arch}/chrome-headless-shell`;182+ } catch { return undefined; }183+}184+185+Deno.test({ name: "mercury: fixi requests under nhg-sign carry NIP-98", ...opts }, async (t) => {186+ fixi = await (await fetch("https://raw.githubusercontent.com/bigskysoftware/fixi/master/fixi.js")).text();187+ const server = Deno.serve({ port: 0, hostname: "127.0.0.1", onListen() {} }, handler);188+ const base = `http://127.0.0.1:${server.addr.port}`;189+ const browser = await chromium.launch({ executablePath: await chromiumPath(), args: ["--disable-features=LocalNetworkAccessChecks"] });190+ const p = await browser.newPage();191+ const errors = [];192+ p.on("pageerror", (e) => errors.push(e.message));193+ const click = async (sel) => {194+ await p.evaluate(() => document.querySelector("#out").textContent = "");195+ await p.click(sel);196+ await p.waitForFunction(() => document.querySelector("#out").textContent);197+ const s = await p.textContent("#out");198+ try { return JSON.parse(s); } catch { return s; }199+ };200+ try {201+ await p.goto(base + "/");202+ const me = await p.evaluate(() => krypton.pub(krypton.key()));203+ await t.step("a POST button signs its multipart body", async () => {204+ const r = await click("#x");205+ assertEquals([r.pubkey, r.method, r.u, r.form], [me, "POST", `${base}/api/x`, { n: "1" }]);206+ assert(r.payload);207+ });208+ await t.step("a PUT form signs, with UTF-8 fields", async () => {209+ const r = await click("#f");210+ assertEquals([r.method, r.u, r.form], ["PUT", `${base}/api/x?y=2`, { msg: "hé" }]);211+ });212+ await t.step("nhg-sign on an ancestor signs a GET with the query in u", async () => {213+ const r = await click("#g");214+ assertEquals([r.method, r.u, r.payload], ["GET", `${base}/api/x?q=1`, null]);215+ });216+ await t.step("without nhg-sign the request is not signed", async () => {217+ assertEquals(await click("#u"), "Missing token");218+ });219+ await t.step("Blossom upload from the page", async () => {220+ const d = await p.evaluate(async () => await mercury.upload(location.origin, new Blob(["from page"], { type: "text/plain" })));221+ assertEquals(d.size, 9);222+ });223+ assertEquals(errors, []);224+ } finally {225+ await browser.close();226+ await server.shutdown();227+ }228+});229+230+// NHG_TINY=http://localhost:7447/r/<tenant> NHG_KEY=<owner secret hex>231+const tiny = Deno.env.get("NHG_TINY"), tinyKey = Deno.env.get("NHG_KEY");232+Deno.test({ name: "mercury: tinyrelay NIP-86 and Blossom", ignore: !tiny || !tinyKey, ...opts }, async () => {233+ krypton.key(tinyKey);234+ try {235+ const rpc = (method, params = []) => mercury.post(tiny, { method, params }, { headers: { "content-type": "application/nostr+json+rpc" } }).json();236+ const r = await rpc("supportedmethods");237+ assert(Array.isArray(r.result) && r.result.includes("banpubkey"), JSON.stringify(r)); // the owner's full method list238+ assert(Array.isArray((await rpc("listbannedpubkeys")).result));239+ // Path-routed tenants scope Blossom tokens to their site domain, <name>.localhost.240+ const server = new URL(tiny).pathname.split("/")[2] + ".localhost";241+ const d = await mercury.upload(tiny, new Blob([`mercury ${Date.now()}`], { type: "text/plain" }), { server });242+ assertEquals(d.sha256.length, 64);243+ assert(await mercury.remove(tiny, d.sha256, { server }));244+ } finally {245+ krypton.key(sk);246+ }247+});