1// NIP-42 against a members-only tinyrelay tenant (ws://localhost:7447, container noble-tiny), in headless Chromium.2import { assert, assertEquals } from "jsr:@std/assert@1";3import { chromium } from "npm:playwright@1.58";4import { serve } from "../server.ts";5import "../helium/helium.js";67const { helium } = globalThis;8const opts = { sanitizeResources: false, sanitizeOps: false };9const TINY = Deno.env.get("NAU_TINY") ?? "ws://localhost:7447";10const CONTAINER = Deno.env.get("NAU_CONTAINER") ?? "noble-tiny";1112async 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}2223// A fresh private tenant (owner writes, members read) owned by a new helium key. Skipped without the container.24const owner = helium.generate(), ownerPk = helium.pub(owner);25const name = `gold${Date.now().toString(36)}`;26const 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);28const relay = `${TINY}/r/${name}`;2930// Seed one note as the owner (tinyrelay accepts owner writes without AUTH; only reads need it).31async function seed(content) {32 const ws = new WebSocket(relay), e = await helium.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}3940const page = (key, cfg = {}) => `<!doctype html><script>41localStorage["nhe:key"] = ${JSON.stringify(key)}42window.nauCfg = ${JSON.stringify(cfg)}43window.__log = []; window.__wire = []44for (const t of ["auth", "authed", "error"]) document.addEventListener("nau:" + t, (e) => __log.push([t, e.detail.pubkey ?? e.detail.error?.message ?? e.detail.challenge]))45document.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" ? [] : [])))46document.addEventListener("nxe:published", (e) => window.__results = e.detail.cfg.results)47</script>48<script src="/helium/helium.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>`;5253Deno.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 });7778 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 });8990 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 });100101 await t.step("non-member: AUTH once, then clean failures without loops", async () => {102 const p = await open(page(helium.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 });116117 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(helium.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) => helium.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 });133134 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 });141142 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 });148149 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 });156157 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 });164165 assertEquals(errors, []);166 } finally {167 await browser.close();168 await server.shutdown();169 }170});171172// Some relays only send the challenge after refusing; gold waits briefly for it. A scripted relay, no docker needed.173Deno.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 helium.finish({ kind: 1, created_at: 1, tags: [], content: "for " + authed }, owner)]);184 else if (t == "AUTH") {185 auths++;186 const ok = await helium.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});