12// Seed a deterministic, public corpus for browser UX fuzzing.3// The script is intentionally self-contained so it can be run against a4// disposable daemon without changing a checkout or a user's home directory.5import { execFile } from "node:child_process";6import { mkdir, writeFile, readFile } from "node:fs/promises";7import { join, resolve } from "node:path";8import { promisify } from "node:util";9import { createHash } from "node:crypto";10import WebSocket from "ws";11import { finalizeEvent, getPublicKey } from "nostr-tools/pure";12import { getToken } from "nostr-tools/nip98";13import { npubEncode } from "nostr-tools/nip19";1415const exec = promisify(execFile);16const args = process.argv.slice(2);17const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback;18const http = (option("--relay", "http://127.0.0.1:18447")).replace(/^ws/, "http").replace(/\/$/, "");19const wsURL = http.replace(/^http/, "ws");20const dataDir = resolve(option("--data", "/tmp/tinyrelay-ux-20260908"));21const outDir = resolve(option("--out", "output/playwright/ux"));22const secret = Uint8Array.from(Buffer.from("fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd", "hex"));23const owner = getPublicKey(secret);24const now = () => Math.floor(Date.now() / 1000);25const json = value => JSON.stringify(value);2627// This is printed before network or filesystem work so browser runners can28// discover the fixture identity even if a later seed operation fails.29console.log(json({ fixture: "tinyrelay-ux", owner, npub: npubEncode(owner), relay: http, dataDir, out: join(outDir, "fixture.json") }));3031async function rpc(method, params = []) {32 const payload = { method, params };33 const token = await getToken(http, "POST", event => finalizeEvent(event, secret), true, payload);34 const response = await fetch(http, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: json(payload) });35 const result = await response.json();36 if (!response.ok || result.error) throw Error(`${method}: ${json(result)}`);37 return result.result;38}3940class Client {41 constructor(url) {42 this.ws = new WebSocket(url); this.waiters = new Map(); this.events = new Map();43 this.open = new Promise((resolve, reject) => { this.ws.once("open", resolve); this.ws.once("error", reject); });44 this.ws.on("message", raw => { let m; try { m = JSON.parse(raw); } catch { return; }45 if (m[0] === "AUTH") return;46 const waiter = this.waiters.get(m[1]); if (!waiter) return;47 if (m[0] === "OK") {48 const reason = String(m[3] || "publish rejected");49 // Re-seeding an existing fixture is successful when the relay already50 // has this event, or has a newer replaceable event for the same d tag.51 waiter.done(m[2] || /^(duplicate|invalid: a newer version)/.test(reason) ? null : Error(reason), m);52 }53 else if (m[0] === "EOSE") waiter.done(null, waiter.items);54 else if (m[0] === "EVENT") waiter.items.push(m[2]);55 else if (m[0] === "CLOSED") waiter.done(Error(m[2]));56 });57 }58 async publish(event) { await this.open; return new Promise((resolve, reject) => {59 const finish = (e, value) => { clearTimeout(timer); this.waiters.delete(event.id); e ? reject(e) : resolve(value); };60 const timer = setTimeout(() => finish(Error("publish timeout")), 30000);61 this.waiters.set(event.id, { done: finish, items: [] }); this.ws.send(json(["EVENT", event]));62 }); }63 close() { this.ws.close(); }64}6566function event(kind, content, tags = [], createdAt = now()) { return finalizeEvent({ kind, content, tags, created_at: createdAt }, secret); }67async function upload(body, type, label) {68 const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body);69 const hash = createHash("sha256").update(bytes).digest("hex");70 const authEvent = finalizeEvent({ kind: 24242, created_at: now(), content: "upload", tags: [["t", "upload"], ["x", hash], ["expiration", String(now() + 300)]] }, secret);71 const token = "Nostr " + Buffer.from(json(authEvent)).toString("base64");72 const response = await fetch(http + "/upload", { method: "PUT", headers: { authorization: token, "content-type": type }, body: bytes });73 const result = await response.json().catch(() => ({}));74 if (!response.ok) throw Error(`upload ${label}: ${response.status} ${json(result)}`);75 return { label, sha256: hash, type, size: bytes.length, url: result.url || `${http}/${hash}`, response: result };76}77async function git(cwd, ...command) { return (await exec("git", ["-C", cwd, ...command], { encoding: "utf8", maxBuffer: 4 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "tinyrelay UX fixture", GIT_AUTHOR_EMAIL: "ux-fixture@example.invalid", GIT_COMMITTER_NAME: "tinyrelay UX fixture", GIT_COMMITTER_EMAIL: "ux-fixture@example.invalid" } })).stdout.trim(); }7879await mkdir(dataDir, { recursive: true }); await mkdir(outDir, { recursive: true });80const policy = await rpc("getpolicy");81policy.features = { ...policy.features, files: true, sites: { ...(policy.features?.sites || {}), enabled: true, mirror: true }, grasp: true, grasp02: true, grasp03: true, grasp05: true, grasp06: true };82await rpc("setpolicy", [policy]);83const relay = new Client(wsURL); await relay.open;84// Keep event IDs stable across reruns so the relay's normal event dedupe makes85// this safe to invoke repeatedly against the same disposable daemon.86const created = Number(option("--created-at", "1788876000"));87if (!Number.isSafeInteger(created) || created < 1) throw Error("--created-at must be a positive integer");88const notes = [89 event(1, "UX fixture note: <script>alert('xss')</script> & \"quoted\" — emoji 🧪\n\nhttps://example.com/a?x=1&y=2\n\n" + "long text ".repeat(120), [["t", "ux-fixture"], ["d", "note-edge"]], created),90 event(1, "Short note with [markdown](https://example.com) and a bare https://example.org/path#fragment", [["t", "ux-fixture"], ["t", "links"]], created + 1),91];92const articles = [93 event(30023, "# Article heading\n\nBody containing <b>escaped markup</b>, `code`, and a very long paragraph.\n\n" + "article words ".repeat(180), [["d", "ux-long-article"], ["title", "A long article & edge cases"], ["summary", "Summary with <angle> & ampersand"], ["published_at", String(created)]], created + 2),94 event(30023, "Second revision with the same identifier", [["d", "ux-revision"], ["title", "Revision"]], created + 3),95];96for (const e of [...notes, ...articles]) await relay.publish(e);9798const files = [];99files.push(await upload("plain text with <escaped> & unicode café\n".repeat(20), "text/plain; charset=utf-8", "text"));100files.push(await upload("# Markdown fixture\n\n[link](https://example.com)\n\n<script>ignored</script>", "text/markdown; charset=utf-8", "markdown"));101files.push(await upload("<!doctype html><meta charset=utf-8><title>UX HTML</title><p>fixture & markup</p>", "text/html; charset=utf-8", "html"));102files.push(await upload(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), "image/png", "image"));103104const repo = join(dataDir, "repo"); await mkdir(repo, { recursive: true });105await git(repo, "init", "-b", "main");106const entries = new Map([107 ["README UX fixture.md", "UX fixture repository\nPaths and refs contain awkward URL characters.\n"],108 ["nested dir/space # hash/percent%/question?.txt", "nested edge path\n"],109 ["nested dir/Unicode café/emoji 🧪.md", "unicode path content\n"],110 ["web/<escaped>.html", "<!doctype html><title>fixture</title><script>ignored</script>"],111]);112for (const [path, content] of entries) { const full = join(repo, path); await mkdir(join(full, ".."), { recursive: true }); await writeFile(full, content); }113await git(repo, "add", ".");114try { await git(repo, "commit", "-m", "UX fixture paths"); } catch (error) { if (!String(error.stdout).includes("nothing to commit")) throw error; }115const mergeBase = await git(repo, "rev-parse", "HEAD");116await writeFile(join(repo, "README UX fixture.md"), "UX fixture repository\nPaths and refs contain awkward URL characters.\nPR fixture change.\n");117await git(repo, "add", "."); await git(repo, "commit", "-m", "PR fixture change");118const pullTip = await git(repo, "rev-parse", "HEAD");119await git(repo, "tag", "-f", "release/1.0"); await git(repo, "branch", "-f", "feature/café");120// GRASP identifiers are intentionally conservative; awkward characters live121// in the display name and tracked paths, where viewers must still URL-escape.122const repoId = "ux-edge-repo"; const cloneURL = `${http}/${npubEncode(owner)}/${encodeURIComponent(repoId)}.git`;123const refs = (await git(repo, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags")).split("\n").filter(Boolean).map(line => line.split(" "));124const announcement = event(30617, "", [["d", repoId], ["name", "UX space/#/?/% café"], ["description", "Repository used by browser navigation fuzzing"], ["clone", cloneURL], ["relays", wsURL], ["maintainers", owner]]);125await relay.publish(announcement);126const state = event(30618, "", [["d", repoId], ["HEAD", "ref: refs/heads/main"], ...refs.map(([ref, oid]) => [ref, oid])]);127await relay.publish(state);128// Seed NIP-34 collaboration roots and their signed thread events so browser129// checks cover issue filtering, PR detail rendering and status transitions.130const coordinate = `30617:${owner}:${repoId}`;131const issue = event(1621, "Fixture issue body with a [link](https://example.com) and escaped <markup>.", [["a", coordinate], ["subject", "Fixture issue: awkward paths"], ["t", "bug"], ["t", "ux"]], created + 4);132const pull = event(1618, "Fixture pull request body", [["a", coordinate], ["subject", "Fixture PR: add nested path"], ["t", "enhancement"], ["c", pullTip], ["clone", cloneURL], ["merge-base", mergeBase]], created + 5);133const patch = event(1617, "diff --git a/README UX fixture.md b/README UX fixture.md\n@@\n+fixture change\n", [["a", coordinate], ["e", pull.id, "", "root"], ["E", pull.id, "", owner], ["K", "1618"], ["P", owner]], created + 6);134const reply = event(1111, "Signed fixture reply", [["a", coordinate], ["E", issue.id, "", owner], ["K", "1621"], ["P", owner], ["e", issue.id, "", owner], ["k", "1621"], ["p", owner]], created + 7);135const closed = event(1632, "", [["a", coordinate], ["e", issue.id, "", "root"], ["p", owner]], created + 8);136for (const collaboration of [issue, pull, patch, reply, closed]) await relay.publish(collaboration);137try { await git(repo, "remote", "set-url", "origin", cloneURL); } catch { await git(repo, "remote", "add", "origin", cloneURL); }138await git(repo, "push", "origin", "--all"); await git(repo, "push", "origin", "--tags");139const manifest = { schema: 1, fixture: "tinyrelay-ux", relay: http, owner, npub: npubEncode(owner), dataDir, notes: notes.map(e => ({ id: e.id, kind: e.kind, tags: e.tags })), articles: articles.map(e => ({ id: e.id, tags: e.tags })), collaboration: [issue, pull, patch, reply, closed].map(e => ({ id: e.id, kind: e.kind, tags: e.tags })), files, repository: { id: repoId, clone: cloneURL, path: repo, refs } };140await writeFile(join(outDir, "fixture.json"), json(manifest) + "\n");141relay.close(); console.log(json({ ok: true, manifest: join(outDir, "fixture.json"), notes: notes.length, articles: articles.length, files: files.length, repository: repoId }));