1// NIP-46 against a real `nak bunker` on the dev relay, plus a scripted signer for auth_url.2import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1";3import { chromium } from "npm:playwright@1.58";4import { serve } from "../server.ts";5import "../helium/helium.js";6import "../fluorine/fluorine.js";78localStorage.removeItem("ncl:session");9await import("../chlorine/chlorine.js");10const { helium, fluorine, chlorine } = globalThis;11const opts = { sanitizeResources: false, sanitizeOps: false };1213const server = serve(0);14const base = `http://localhost:${server.addr.port}`;15const relay = Deno.env.get("NCL_RELAY") ?? `ws://localhost:${server.addr.port}/relay`;16const cfg = await Deno.makeTempDir();17const sk = helium.generate(), user = helium.pub(sk);1819function spawn(...args) {20 const p = new Deno.Command("nak", { args: ["--config-path", cfg, ...args], stdout: "piped", stderr: "piped" }).spawn();21 let out = "";22 const pump = async (r) => { for await (const c of r.pipeThrough(new TextDecoderStream())) out += c; };23 pump(p.stdout);24 pump(p.stderr);25 return { p, out: () => out.replace(/\x1b\[[0-9;]*m/g, "") };26}27const until = async (f, ms = 1e4) => {28 for (const t = Date.now(); Date.now() - t < ms; await new Promise((r) => setTimeout(r, 50))) if (f()) return f();29 throw Error("timed out");30};31const once = (type) => new Promise((r) => globalThis.addEventListener("ncl:" + type, (e) => r(e.detail), { once: true }));3233// Tests that need nak are skipped when it is not installed.34const hasNak = await (async () => new Deno.Command("nak", { args: ["--version"] }).output())().then(() => true, () => false);35const bunker = hasNak && spawn("bunker", "--sec", sk, "-s", "s3cret", relay);36const uri = hasNak && await until(() => /bunker:\/\/\S+/.exec(bunker.out())?.[0]);37await new Promise((r) => setTimeout(r, 500));3839Deno.test({ name: "chlorine connects to nak bunker via bunker://", ignore: !hasNak, ...opts }, async () => {40 const previous = { getPublicKey: () => "prev" };41 globalThis.nostr = previous;42 const connected = once("connected");43 assertEquals(await chlorine.connect(uri), user);44 assertEquals((await connected).pubkey, user);45 assertEquals(globalThis.nostr, chlorine.signer);46 assertEquals(chlorine.previous, previous);47 assertEquals(await nostr.getPublicKey(), user);48 assertEquals(await chlorine.rpc("get_public_key"), user);49 assertEquals(await chlorine.rpc("ping"), "pong");50 assertEquals(JSON.parse(localStorage["ncl:session"]).user, user);5152 const e = await nostr.signEvent({ kind: 1, created_at: Math.floor(Date.now() / 1e3), tags: [["t", "nobel"]], content: "hi from chlorine" });53 assertEquals(e.pubkey, user);54 assertEquals(e.content, "hi from chlorine");55 assert(await helium.check(e));5657 const other = helium.generate(), otherPk = helium.pub(other);58 const ct = await nostr.nip44.encrypt(otherPk, "secret ⚡ text");59 assertEquals(await fluorine.decrypt(ct, await fluorine.key(other, user)), "secret ⚡ text");60 assertEquals(await nostr.nip44.decrypt(otherPk, ct), "secret ⚡ text");61 const back = await fluorine.encrypt("reply", await fluorine.key(other, user));62 assertEquals(await nostr.nip44.decrypt(otherPk, back), "reply");6364 const gone = once("disconnected");65 chlorine.disconnect();66 await gone;67 assertEquals(globalThis.nostr, previous);68 assertEquals(localStorage.getItem("ncl:session"), null);69 assertEquals(chlorine.session, null);70});7172Deno.test({ name: "chlorine rejects a wrong secret", ignore: !hasNak, ...opts }, async () => {73 const bad = uri.replace(/secret=\w+/, "secret=nope");74 await assertRejects(() => chlorine.connect(bad), Error, "unauthorized");75 assertEquals(chlorine.session, null);76});7778Deno.test({ name: "chlorine nostrconnect:// flow with nak bunker connect", ignore: !hasNak, ...opts }, async () => {79 const { uri: nc, done } = chlorine.listen({ relays: [relay], name: "chlorine test", perms: "sign_event:1" });80 assert(nc.startsWith("nostrconnect://" + chlorine.session.pk + "?relay="));81 const c = spawn("bunker", "connect", nc);82 assertEquals((await c.p.status).code, 0, c.out());83 assertEquals(await done, user);84 assertEquals(chlorine.session.remote, user);85 const e = await nostr.signEvent({ kind: 1, content: "via nostrconnect" });86 assert(await helium.check(e));87 assertEquals(e.pubkey, user);88 chlorine.disconnect();89});9091Deno.test({ name: "chlorine waits through auth_url and emits ncl:auth", ...opts }, async () => {92 // A scripted remote signer whose user key differs from its own key.93 const rsk = helium.generate(), rpk = helium.pub(rsk), usk = helium.generate();94 const ws = new WebSocket(relay);95 await new Promise((r) => ws.onopen = r);96 ws.send(JSON.stringify(["REQ", "s", { kinds: [24133], "#p": [rpk], limit: 0 }]));97 ws.onmessage = async (m) => {98 const [t, , e] = JSON.parse(m.data);99 if (t != "EVENT") return;100 const k = await fluorine.key(rsk, e.pubkey), req = JSON.parse(await fluorine.decrypt(e.content, k));101 const reply = async (o) => ws.send(JSON.stringify(["EVENT", await helium.finish({ kind: 24133, created_at: Math.floor(Date.now() / 1e3), tags: [["p", e.pubkey]], content: await fluorine.encrypt(JSON.stringify({ id: req.id, ...o }), k) }, rsk)]));102 if (req.method == "connect") return reply({ result: req.params[1] == "s3" ? "ack" : "", error: req.params[1] == "s3" ? undefined : "bad secret" });103 if (req.method == "get_public_key") return reply({ result: helium.pub(usk) });104 if (req.method == "sign_event") {105 await reply({ result: "auth_url", error: "https://signer.example/approve" });106 await new Promise((r) => setTimeout(r, 300));107 return reply({ result: JSON.stringify(await helium.finish(JSON.parse(req.params[0]), usk)) });108 }109 };110 assertEquals(await chlorine.connect(`bunker://${rpk}?relay=${encodeURIComponent(relay)}&secret=s3`), helium.pub(usk));111 const auth = once("auth");112 const e = await nostr.signEvent({ kind: 1, content: "needs approval" });113 assertEquals((await auth).url, "https://signer.example/approve");114 assertEquals(e.pubkey, helium.pub(usk));115 assert(await helium.check(e));116 chlorine.disconnect();117 ws.close();118});119120async function chromiumPath() {121 if (Deno.env.get("CHROMIUM")) return Deno.env.get("CHROMIUM");122 const cache = `${Deno.env.get("HOME")}/Library/Caches/ms-playwright`;123 try {124 const dirs = [...Deno.readDirSync(cache)].map((d) => d.name).filter((n) => n.startsWith("chromium_headless_shell-")).sort();125 const dir = `${cache}/${dirs.at(-1)}`;126 return `${dir}/${[...Deno.readDirSync(dir)][0].name}/chrome-headless-shell`;127 } catch { return undefined; }128}129130Deno.test({ name: "chlorine in the browser: form, xenon publish, reload, nostrconnect", ignore: !hasNak, ...opts }, async (t) => {131 const browser = await chromium.launch({ executablePath: await chromiumPath(), args: ["--disable-features=LocalNetworkAccessChecks"] });132 const page = await browser.newPage(), errors = [];133 page.on("pageerror", (e) => errors.push(e.message));134 const html = `<!doctype html><title>chlorine page</title>135 <script src="/helium/helium.js"></script><script src="/fluorine/fluorine.js"></script>136 <script src="/chlorine/chlorine.js"></script><script src="/hydrogen/hydrogen.js"></script><script src="/xenon/xenon.js"></script>137 <form id="login" ncl-connect><input name="bunker"><button>Connect</button></form>138 <p ncl-relay="${relay}"><a id="nc" ncl-nostrconnect ncl-name="page">scan</a></p>139 <form id="post" nhy-relay="${relay}" nxe-publish><input name="content"><button>Post</button></form>140 <button id="out" ncl-disconnect>Log out</button>`;141 await page.route(base + "/page.html", (r) => r.fulfill({ contentType: "text/html", body: html }));142 try {143 await page.goto(base + "/page.html");144 await t.step("an ncl-nostrconnect link gets a nostrconnect:// href", async () => {145 assert((await page.getAttribute("#nc", "href")).startsWith("nostrconnect://"));146 });147 await t.step("the ncl-connect form connects and xenon publishes through the bunker", async () => {148 await page.fill("#login input", `bunker://${user}?relay=${encodeURIComponent(relay)}&secret=s3cret`);149 await page.click("#login button");150 await page.waitForFunction((u) => document.documentElement.getAttribute("ncl-connected") == u, user);151 const published = page.evaluate(() => new Promise((r) => document.addEventListener("nxe:published", (e) => r(e.detail.cfg), { once: true })));152 await page.fill("#post input", "posted via bunker");153 await page.click("#post button");154 const cfg = await published;155 assertEquals(cfg.event.pubkey, user);156 assert(cfg.results.every((r) => r.ok), JSON.stringify(cfg.results));157 });158 await t.step("a reload restores the session without a handshake", async () => {159 await page.reload();160 assertEquals(await page.evaluate(() => document.documentElement.getAttribute("ncl-connected")), user);161 assertEquals(await page.evaluate(() => document.querySelector("#nc").getAttribute("href")), null);162 const e = await page.evaluate(() => nostr.signEvent({ kind: 1, content: "after reload" }));163 assert(await helium.check(e));164 });165 await t.step("ncl-disconnect logs out, then nostrconnect logs back in", async () => {166 await page.click("#out");167 assertEquals(await page.evaluate(() => [document.documentElement.hasAttribute("ncl-connected"), !!window.nostr, localStorage["ncl:session"]]), [false, false, undefined]);168 await page.reload();169 const nc = await page.getAttribute("#nc", "href");170 assert(nc.includes("name=page"));171 const c = spawn("bunker", "connect", nc);172 assertEquals((await c.p.status).code, 0, c.out());173 await page.waitForFunction((u) => document.documentElement.getAttribute("ncl-connected") == u, user);174 });175 assertEquals(errors, []);176 } finally {177 await browser.close();178 }179});180181Deno.test({ name: "cleanup", ...opts }, async () => {182 if (bunker) bunker.p.kill(), await bunker.p.status;183 await server.shutdown();184 await Deno.remove(cfg, { recursive: true });185});