| 1 | #!/usr/bin/env node |
| 2 | // Seed a deterministic, public corpus for browser UX fuzzing. |
| 3 | // The script is intentionally self-contained so it can be run against a |
| 4 | // disposable daemon without changing a checkout or a user's home directory. |
| 5 | import { execFile } from "node:child_process" ; |
| 6 | import { mkdir, writeFile, readFile } from "node:fs/promises" ; |
| 7 | import { join, resolve } from "node:path" ; |
| 8 | import { promisify } from "node:util" ; |
| 9 | import { createHash } from "node:crypto" ; |
| 10 | import WebSocket from "ws" ; |
| 11 | import { finalizeEvent, getPublicKey } from "nostr-tools/pure" ; |
| 12 | import { getToken } from "nostr-tools/nip98" ; |
| 13 | import { npubEncode } from "nostr-tools/nip19" ; |
| 14 | |
| 15 | const exec = promisify(execFile); |
| 16 | const args = process.argv.slice(2); |
| 17 | const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback; |
| 18 | const http = (option("--relay" , "http://127.0.0.1:18447" )).replace(/^ws/, "http" ).replace(/\/$/, "" ); |
| 19 | const wsURL = http.replace(/^http/, "ws" ); |
| 20 | const dataDir = resolve(option("--data" , "/tmp/tinyrelay-ux-20260908" )); |
| 21 | const outDir = resolve(option("--out" , "output/playwright/ux" )); |
| 22 | const secret = Uint8Array.from(Buffer.from("fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd" , "hex" )); |
| 23 | const owner = getPublicKey(secret); |
| 24 | const now = () => Math.floor(Date.now() / 1000); |
| 25 | const json = value => JSON.stringify(value); |
| 26 | |
| 27 | // This is printed before network or filesystem work so browser runners can |
| 28 | // discover the fixture identity even if a later seed operation fails. |
| 29 | console.log(json({ fixture: "tinyrelay-ux" , owner, npub: npubEncode(owner), relay: http, dataDir, out: join(outDir, "fixture.json" ) })); |
| 30 | |
| 31 | async 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 | } |
| 39 | |
| 40 | class 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 already |
| 50 | // 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 | } |
| 65 | |
| 66 | function event(kind, content, tags = [], createdAt = now()) { return finalizeEvent({ kind, content, tags, created_at: createdAt }, secret); } |
| 67 | async 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 | } |
| 77 | async 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(); } |
| 78 | |
| 79 | await mkdir(dataDir, { recursive: true }); await mkdir(outDir, { recursive: true }); |
| 80 | const policy = await rpc("getpolicy" ); |
| 81 | policy.features = { ...policy.features, files: true, sites: { ...(policy.features?.sites || {}), enabled: true, mirror: true }, grasp: true, grasp02: true, grasp03: true, grasp05: true, grasp06: true }; |
| 82 | await rpc("setpolicy" , [policy]); |
| 83 | const relay = new Client(wsURL); await relay.open; |
| 84 | // Keep event IDs stable across reruns so the relay's normal event dedupe makes |
| 85 | // this safe to invoke repeatedly against the same disposable daemon. |
| 86 | const created = Number(option("--created-at" , "1788876000" )); |
| 87 | if (!Number.isSafeInteger(created) || created < 1) throw Error("--created-at must be a positive integer" ); |
| 88 | const 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 | ]; |
| 92 | const 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 | ]; |
| 96 | for (const e of [...notes, ...articles]) await relay.publish(e); |
| 97 | |
| 98 | const files = []; |
| 99 | files.push(await upload("plain text with <escaped> & unicode café\n" .repeat(20), "text/plain; charset=utf-8" , "text" )); |
| 100 | files.push(await upload("# Markdown fixture\n\n[link](https://example.com)\n\n<script>ignored</script>" , "text/markdown; charset=utf-8" , "markdown" )); |
| 101 | files.push(await upload("<!doctype html><meta charset=utf-8><title>UX HTML</title><p>fixture & markup</p>" , "text/html; charset=utf-8" , "html" )); |
| 102 | files.push(await upload(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" , "base64" ), "image/png" , "image" )); |
| 103 | |
| 104 | const repo = join(dataDir, "repo" ); await mkdir(repo, { recursive: true }); |
| 105 | await git(repo, "init" , "-b" , "main" ); |
| 106 | const 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 | ]); |
| 112 | for (const [path, content] of entries) { const full = join(repo, path); await mkdir(join(full, ".." ), { recursive: true }); await writeFile(full, content); } |
| 113 | await git(repo, "add" , "." ); |
| 114 | try { await git(repo, "commit" , "-m" , "UX fixture paths" ); } catch (error) { if (!String(error.stdout).includes("nothing to commit" )) throw error; } |
| 115 | const mergeBase = await git(repo, "rev-parse" , "HEAD" ); |
| 116 | await writeFile(join(repo, "README UX fixture.md" ), "UX fixture repository\nPaths and refs contain awkward URL characters.\nPR fixture change.\n" ); |
| 117 | await git(repo, "add" , "." ); await git(repo, "commit" , "-m" , "PR fixture change" ); |
| 118 | const pullTip = await git(repo, "rev-parse" , "HEAD" ); |
| 119 | await git(repo, "tag" , "-f" , "release/1.0" ); await git(repo, "branch" , "-f" , "feature/café" ); |
| 120 | // GRASP identifiers are intentionally conservative; awkward characters live |
| 121 | // in the display name and tracked paths, where viewers must still URL-escape. |
| 122 | const repoId = "ux-edge-repo" ; const cloneURL = `${http}/${npubEncode(owner)}/${encodeURIComponent(repoId)}.git` ; |
| 123 | const refs = (await git(repo, "for-each-ref" , "--format=%(refname) %(objectname)" , "refs/heads" , "refs/tags" )).split("\n" ).filter(Boolean).map(line => line.split(" " )); |
| 124 | const announcement = event(30617, "" , [["d" , repoId], ["name" , "UX space/#/?/% café" ], ["description" , "Repository used by browser navigation fuzzing" ], ["clone" , cloneURL], ["relays" , wsURL], ["maintainers" , owner]]); |
| 125 | await relay.publish(announcement); |
| 126 | const state = event(30618, "" , [["d" , repoId], ["HEAD" , "ref: refs/heads/main" ], ...refs.map(([ref, oid]) => [ref, oid])]); |
| 127 | await relay.publish(state); |
| 128 | // Seed NIP-34 collaboration roots and their signed thread events so browser |
| 129 | // checks cover issue filtering, PR detail rendering and status transitions. |
| 130 | const coordinate = `30617:${owner}:${repoId}` ; |
| 131 | const 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); |
| 132 | const 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); |
| 133 | const 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); |
| 134 | const 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); |
| 135 | const closed = event(1632, "" , [["a" , coordinate], ["e" , issue.id, "" , "root" ], ["p" , owner]], created + 8); |
| 136 | for (const collaboration of [issue, pull, patch, reply, closed]) await relay.publish(collaboration); |
| 137 | try { await git(repo, "remote" , "set-url" , "origin" , cloneURL); } catch { await git(repo, "remote" , "add" , "origin" , cloneURL); } |
| 138 | await git(repo, "push" , "origin" , "--all" ); await git(repo, "push" , "origin" , "--tags" ); |
| 139 | const 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 } }; |
| 140 | await writeFile(join(outDir, "fixture.json" ), json(manifest) + "\n" ); |
| 141 | relay.close(); console.log(json({ ok: true, manifest: join(outDir, "fixture.json" ), notes: notes.length, articles: articles.length, files: files.length, repository: repoId })); |