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.3import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1";4import * as nip98 from "npm:nostr-tools@2.17.0/nip98";5import { verifyEvent } from "npm:nostr-tools@2.17.0/pure";6import { chromium } from "npm:playwright@1.58";7import "../helium/helium.js";8import "../mercury/mercury.js";910const { helium, mercury } = globalThis;11const root = new URL("../", import.meta.url);12const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");13const sha = async (b) => hex(await crypto.subtle.digest("SHA-256", b));14const unpack = (h) => JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(h.replace(/^Nostr /, "")), (c) => c.charCodeAt(0))));15const tag = (e, k) => e.tags.find((t) => t[0] === k)?.[1];1617const sk = helium.generate();18helium.key(sk);19const pk = helium.pub(sk);2021// 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.23async 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}3334const blobs = new Map();35async 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}4546let fixi;47const page = `<!doctype html><script src="/fixi.js"></script><script src="/helium/helium.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>`;5354async 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 (/^\/(helium|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}9091const opts = { sanitizeResources: false, sanitizeOps: false };9293Deno.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 });106107 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 });113114 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 });126127 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 });134135 await t.step("nhy:config can swap the signer; cancelling nhg:sign aborts and fires nhg:error", async () => {136 const other = helium.generate();137 const swap = (e) => e.detail.cfg.sign = (ev) => helium.finish(ev, other);138 globalThis.addEventListener("nhy:config", swap);139 assertEquals((await mercury.post(`${base}/api/e`, "x").json()).pubkey, helium.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 });151152 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});172173// Use CHROMIUM if set, else the newest headless shell already in the Playwright cache.174async 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}184185Deno.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(() => helium.pub(helium.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});229230// NHG_TINY=http://localhost:7447/r/<tenant> NHG_KEY=<owner secret hex>231const tiny = Deno.env.get("NHG_TINY"), tinyKey = Deno.env.get("NHG_KEY");232Deno.test({ name: "mercury: tinyrelay NIP-86 and Blossom", ignore: !tiny || !tinyKey, ...opts }, async () => {233 helium.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 helium.key(sk);246 }247});