| 1 | diff --git a/docs/testing.md b/docs/testing.md |
| 2 | index 3857d2f..2e884b0 100644 |
| 3 | --- a/docs/testing.md |
| 4 | +++ b/docs/testing.md |
| 5 | @@ -43,7 +43,7 @@ python3 scripts/observe-git-benchmark.py --out artifacts/git/baseline \ |
| 6 | -- ./scripts/git-performance.mjs |
| 7 | ``` |
| 8 | |
| 9 | -The conformance harness runs the copied bindws-compatible cases against a running local daemon and preserves stdout, stderr, status, and environment artifacts. Start the daemon, then run `node scripts/conformance/run.mjs`; the NIP-11 compatibility assertion reflects the quota-free self-hosted contract. |
| 10 | +The [conformance suite](../scripts/conformance/README.md) runs black-box NIP and GRASP cases against a running local daemon and preserves stdout, stderr, status, and environment artifacts. Start the daemon, then run `node scripts/conformance/run.mjs`. To try the Bunker URL sign-in without a phone, run `node scripts/qa/nip46-bunker.mjs` and paste the printed URL into the sign-in page. |
| 11 | |
| 12 | ## Real Git repositories |
| 13 | |
| 14 | diff --git a/package.json b/package.json |
| 15 | index 9773144..42ec24c 100644 |
| 16 | --- a/package.json |
| 17 | +++ b/package.json |
| 18 | @@ -1,5 +1,5 @@ |
| 19 | { |
| 20 | - "name": "tiny-conformance", |
| 21 | + "name": "tiny", |
| 22 | "private": true, |
| 23 | "type": "module", |
| 24 | "scripts": { |
| 25 | diff --git a/scripts/conformance/README.md b/scripts/conformance/README.md |
| 26 | index 26c2668..eb9a778 100644 |
| 27 | --- a/scripts/conformance/README.md |
| 28 | +++ b/scripts/conformance/README.md |
| 29 | @@ -1,19 +1,13 @@ |
| 30 | -# Conformance harness |
| 31 | +# Conformance suite |
| 32 | |
| 33 | -Start `tiny` on a local URL, then run: |
| 34 | +Black-box NIP and GRASP tests that speak plain websocket and HTTP to a running relay. Start `tiny` on a local URL, then run: |
| 35 | |
| 36 | ```sh |
| 37 | RELAY_URL=ws://127.0.0.1:7447 node scripts/conformance/run.mjs |
| 38 | ``` |
| 39 | |
| 40 | -The harness executes the original bindws black-box files from `../bindws` |
| 41 | -without modifying that checkout. By default setup provisions a permanent |
| 42 | -tenant with `tiny tenant create --name NAME --owner PUBKEY --template default`. |
| 43 | -Set `TINY_BIN`, `TINY_TENANT`, or `TINY_TEMPLATE` to adapt the command. The |
| 44 | -owner key is deterministic unless `CLAIM_SK` is supplied. |
| 45 | +Setup provisions a tenant with `tiny tenant create --name NAME --owner PUBKEY --template default` before the tests start. Set `TINY_BIN`, `TINY_TENANT`, `TINY_TEMPLATE`, or `TINY_DATA_DIR` to adapt that command, or `TINY_PREPROVISIONED=1` to skip it. The owner key is deterministic unless `CLAIM_SK` is supplied. |
| 46 | |
| 47 | -For comparison only, `LEGACY_CLAIM=1` invokes bindws's old NIP-86 `claim` |
| 48 | -setup. This compatibility path is not a tiny requirement and does not skip or |
| 49 | -alter test assertions. |
| 50 | +Each run preserves `environment.txt`, `stdout.log`, `stderr.log`, and `status` under `artifacts/conformance/<UTC-stamp>/`. Set `CONFORMANCE_ARTIFACTS` to choose another destination. Pass extra vitest arguments after the script name to select files, for example `node scripts/conformance/run.mjs nip01`. |
| 51 | |
| 52 | -Each run preserves `environment.txt`, `stdout.log`, `stderr.log`, and `status` under `artifacts/conformance/<UTC-stamp>/`. Set `CONFORMANCE_ARTIFACTS` to choose another destination. The source conformance files remain unchanged. The hosted NIP-11 file is replaced in this harness by `nip11.compat.test.ts`, which preserves every assertion except the numeric `limitation.max_limit` requirement because self-hosted relays have no hosted quota. Other self-hosting differences belong in separately named compatibility tests. |
| 53 | +The NIP-11 test does not require a numeric `limitation.max_limit` because self-hosted relays have no hosted quota. |
| 54 | diff --git a/scripts/conformance/blossom.test.ts b/scripts/conformance/blossom.test.ts |
| 55 | new file mode 100644 |
| 56 | index 0000000..b6e39c1 |
| 57 | --- /dev/null |
| 58 | +++ b/scripts/conformance/blossom.test.ts |
| 59 | @@ -0,0 +1,57 @@ |
| 60 | +// Files, from outside: a Blossom upload, the download with and without a |
| 61 | +// range, the listing and the delete. The one door that touches the media |
| 62 | +// bucket, so a host whose bucket is not R2 (docs/16) is checked here. |
| 63 | +import { describe, it, expect } from "vitest"; |
| 64 | +import { sha256 } from "@noble/hashes/sha2.js"; |
| 65 | +import { newEvent, newKey, pub, now, rand, hex, HTTP_URL } from "./helpers.ts"; |
| 66 | + |
| 67 | +// A Blossom token: kind 24242 with the verb and the hash, base64 in the header. |
| 68 | +function token(sk: Uint8Array, verb: "upload" | "get" | "list" | "delete", sha?: string): string { |
| 69 | + const tags = [["t", verb], ...(sha ? [["x", sha]] : []), ["expiration", String(now() + 300)]]; |
| 70 | + return "Nostr " + Buffer.from(JSON.stringify(newEvent(sk, 24242, verb, tags))).toString("base64"); |
| 71 | +} |
| 72 | + |
| 73 | +describe("Blossom media", () => { |
| 74 | + it("uploads a file, serves it whole and by range, lists it, and deletes it", async () => { |
| 75 | + const sk = newKey(); |
| 76 | + const body = new TextEncoder().encode(`a small file ${rand()} with enough bytes for a range`); |
| 77 | + const sha = hex(sha256(body)); |
| 78 | + |
| 79 | + const put = await fetch(`${HTTP_URL}/upload`, { method: "PUT", headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" }, body }); |
| 80 | + expect(put.status, await put.clone().text()).toBe(200); |
| 81 | + const desc: any = await put.json(); |
| 82 | + expect([desc.sha256, desc.size, desc.type]).toEqual([sha, body.length, "text/plain"]); |
| 83 | + expect(desc.url).toMatch(new RegExp(`/${sha}\\.txt$`)); |
| 84 | + |
| 85 | + // The whole file, with its type and hash-derived etag. |
| 86 | + const get = await fetch(`${HTTP_URL}/${sha}.txt`); |
| 87 | + expect(get.status).toBe(200); |
| 88 | + expect(get.headers.get("content-type")).toBe("text/plain"); |
| 89 | + expect(new Uint8Array(await get.arrayBuffer())).toEqual(body); |
| 90 | + |
| 91 | + // A byte range: the second word. |
| 92 | + const part = await fetch(`${HTTP_URL}/${sha}`, { headers: { range: "bytes=2-6" } }); |
| 93 | + expect(part.status).toBe(206); |
| 94 | + expect(part.headers.get("content-range")).toBe(`bytes 2-6/${body.length}`); |
| 95 | + expect(await part.text()).toBe("small"); |
| 96 | + |
| 97 | + // HEAD says it is there without a body. |
| 98 | + const head = await fetch(`${HTTP_URL}/${sha}`, { method: "HEAD" }); |
| 99 | + expect(head.status).toBe(200); |
| 100 | + expect(await head.text()).toBe(""); |
| 101 | + |
| 102 | + // The uploader's listing carries it. |
| 103 | + const list = await fetch(`${HTTP_URL}/list/${pub(sk)}`, { headers: { authorization: token(sk, "list") } }); |
| 104 | + expect(list.status).toBe(200); |
| 105 | + expect((await list.json<any>()).map((d: any) => d.sha256)).toContain(sha); |
| 106 | + |
| 107 | + // The uploader deletes it; the same bytes can come back afterwards. |
| 108 | + const del = await fetch(`${HTTP_URL}/${sha}`, { method: "DELETE", headers: { authorization: token(sk, "delete", sha) } }); |
| 109 | + expect(del.status).toBe(204); |
| 110 | + expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(404); |
| 111 | + const again = await fetch(`${HTTP_URL}/upload`, { method: "PUT", headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" }, body }); |
| 112 | + expect(again.status).toBe(200); |
| 113 | + expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(200); |
| 114 | + await fetch(`${HTTP_URL}/${sha}`, { method: "DELETE", headers: { authorization: token(sk, "delete", sha) } }); |
| 115 | + }); |
| 116 | +}); |
| 117 | diff --git a/scripts/conformance/grasp.test.ts b/scripts/conformance/grasp.test.ts |
| 118 | new file mode 100644 |
| 119 | index 0000000..900bda3 |
| 120 | --- /dev/null |
| 121 | +++ b/scripts/conformance/grasp.test.ts |
| 122 | @@ -0,0 +1,111 @@ |
| 123 | +// GRASP-01 conformance uses the stock Git client against the public Smart HTTP door. |
| 124 | +import { execFile } from "node:child_process"; |
| 125 | +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; |
| 126 | +import { tmpdir } from "node:os"; |
| 127 | +import { promisify } from "node:util"; |
| 128 | +import { describe, expect, it } from "vitest"; |
| 129 | +import { finalizeEvent, getPublicKey, generateSecretKey } from "nostr-tools/pure"; |
| 130 | +import { Client, HTTP_URL, RELAY_URL, sleep, now } from "./helpers.ts"; |
| 131 | +import { npubEncode } from "nostr-tools/nip19"; |
| 132 | + |
| 133 | +const exec = promisify(execFile); |
| 134 | +const KIND_REPO = 30617; |
| 135 | +const KIND_REPO_STATE = 30618; |
| 136 | + |
| 137 | +async function git(cwd: string, ...args: string[]): Promise<string> { |
| 138 | + const result = await exec("git", args, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); |
| 139 | + return result.stdout.trim(); |
| 140 | +} |
| 141 | + |
| 142 | +function repositoryPath(npub: string, identifier: string): string { |
| 143 | + return `/${npub}/${encodeURIComponent(identifier)}.git`; |
| 144 | +} |
| 145 | + |
| 146 | +async function waitForRefs(url: string): Promise<void> { |
| 147 | + for (let attempt = 0; attempt < 60; attempt++) { |
| 148 | + const response = await fetch(`${url}/info/refs?service=git-upload-pack`); |
| 149 | + const text = await response.text(); |
| 150 | + if (response.status === 200 && text.includes("# service=git-upload-pack")) return; |
| 151 | + await sleep(250); |
| 152 | + } |
| 153 | + throw new Error("GRASP repository did not become available"); |
| 154 | +} |
| 155 | + |
| 156 | +async function publish(c: Client, sk: Uint8Array, kind: number, tags: string[][], createdAt: number): Promise<void> { |
| 157 | + const event = finalizeEvent({ kind, content: "", tags, created_at: createdAt }, sk); |
| 158 | + await c.publish(event); |
| 159 | +} |
| 160 | + |
| 161 | +describe("GRASP-01 stock Git", () => { |
| 162 | + it("clones, pushes an accepted state, fetches incrementally, and supports partial clones", async ({ skip }) => { |
| 163 | + const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } }); |
| 164 | + const info: any = await infoResponse.json(); |
| 165 | + if (!info.supported_grasps?.includes("GRASP-01")) skip(); |
| 166 | + |
| 167 | + const root = await mkdtemp(`${tmpdir()}/tiny-grasp-`); |
| 168 | + const clone = await mkdtemp(`${tmpdir()}/tiny-grasp-clone-`); |
| 169 | + const partial = await mkdtemp(`${tmpdir()}/tiny-grasp-partial-`); |
| 170 | + const sk = generateSecretKey(); |
| 171 | + const npub = npubEncode(getPublicKey(sk)); |
| 172 | + const identifier = `stock-${Math.random().toString(36).slice(2, 10)}`; |
| 173 | + const path = repositoryPath(npub, identifier); |
| 174 | + const repoURL = `${HTTP_URL}${path}`; |
| 175 | + const relayURL = RELAY_URL; |
| 176 | + const createdAt = now(); |
| 177 | + const c = await Client.connect(RELAY_URL); |
| 178 | + try { |
| 179 | + await git(root, "init", "--initial-branch=main"); |
| 180 | + await git(root, "config", "user.email", "grasp-conformance@example.com"); |
| 181 | + await git(root, "config", "user.name", "GRASP conformance"); |
| 182 | + await writeFile(`${root}/README.md`, "initial GRASP repository\n"); |
| 183 | + await git(root, "add", "README.md"); |
| 184 | + await git(root, "commit", "-m", "initial"); |
| 185 | + const first = await git(root, "rev-parse", "HEAD"); |
| 186 | + await git(root, "tag", "-a", "v1", "-m", "first release"); |
| 187 | + const tag = await git(root, "rev-parse", "refs/tags/v1^{tag}"); |
| 188 | + |
| 189 | + await publish(c, sk, KIND_REPO, [ |
| 190 | + ["d", identifier], |
| 191 | + ["clone", repoURL], |
| 192 | + ["relays", relayURL], |
| 193 | + ["maintainers", getPublicKey(sk)], |
| 194 | + ], createdAt); |
| 195 | + await publish(c, sk, KIND_REPO_STATE, [ |
| 196 | + ["d", identifier], |
| 197 | + ["HEAD", "ref: refs/heads/main"], |
| 198 | + ["refs/heads/main", first], |
| 199 | + ["refs/tags/v1", tag], |
| 200 | + ], createdAt + 1); |
| 201 | + |
| 202 | + await waitForRefs(repoURL); |
| 203 | + await git(root, "push", repoURL, "HEAD:refs/heads/main", "refs/tags/v1:refs/tags/v1"); |
| 204 | + await git(clone, "clone", repoURL, "."); |
| 205 | + expect(await git(clone, "rev-parse", "HEAD")).toBe(first); |
| 206 | + expect(await git(clone, "describe", "--tags")).toBe("v1"); |
| 207 | + expect((await git(clone, "fsck", "--full"))).not.toContain("dangling"); |
| 208 | + |
| 209 | + await writeFile(`${root}/README.md`, "incremental GRASP repository\n"); |
| 210 | + await git(root, "add", "README.md"); |
| 211 | + await git(root, "commit", "-m", "incremental"); |
| 212 | + const second = await git(root, "rev-parse", "HEAD"); |
| 213 | + await publish(c, sk, KIND_REPO_STATE, [ |
| 214 | + ["d", identifier], |
| 215 | + ["HEAD", "ref: refs/heads/main"], |
| 216 | + ["refs/heads/main", second], |
| 217 | + ["refs/tags/v1", tag], |
| 218 | + ], createdAt + 2); |
| 219 | + await git(root, "push", repoURL, "HEAD:refs/heads/main"); |
| 220 | + await git(clone, "fetch", repoURL, "refs/heads/main:refs/remotes/origin/main"); |
| 221 | + expect(await git(clone, "rev-parse", "refs/remotes/origin/main")).toBe(second); |
| 222 | + |
| 223 | + await git(partial, "clone", "--filter=blob:none", "--no-checkout", repoURL, "."); |
| 224 | + await git(partial, "fsck", "--full"); |
| 225 | + await git(partial, "checkout", "main"); |
| 226 | + expect(await readFile(`${partial}/README.md`, "utf8")).toBe("incremental GRASP repository\n"); |
| 227 | + } finally { |
| 228 | + await publish(c, sk, 5, [["a", `${KIND_REPO}:${getPublicKey(sk)}:${identifier}`], ["a", `${KIND_REPO_STATE}:${getPublicKey(sk)}:${identifier}`]], createdAt + 3).catch(() => {}); |
| 229 | + c.close(); |
| 230 | + await Promise.all([rm(root, { recursive: true, force: true }), rm(clone, { recursive: true, force: true }), rm(partial, { recursive: true, force: true })]); |
| 231 | + } |
| 232 | + }, 120_000); |
| 233 | +}); |
| 234 | diff --git a/scripts/conformance/helpers.ts b/scripts/conformance/helpers.ts |
| 235 | new file mode 100644 |
| 236 | index 0000000..8044c10 |
| 237 | --- /dev/null |
| 238 | +++ b/scripts/conformance/helpers.ts |
| 239 | @@ -0,0 +1,207 @@ |
| 240 | +// Shared client for the black-box conformance suite. It speaks plain |
| 241 | +// NIP-01 over a websocket to whatever relay RELAY_URL names. |
| 242 | +import WebSocket from "ws"; |
| 243 | +import { afterEach } from "vitest"; |
| 244 | +import { finalizeEvent, generateSecretKey, getPublicKey, type Event as NostrEvent, type EventTemplate } from "nostr-tools/pure"; |
| 245 | +import { sha256 } from "@noble/hashes/sha2.js"; |
| 246 | +import { Negentropy, hexToBytes, type SyncItem } from "./negentropy.ts"; |
| 247 | + |
| 248 | +export const RELAY_URL = process.env.RELAY_URL ?? "ws://127.0.0.1:7447"; |
| 249 | +export const HTTP_URL = RELAY_URL.replace(/^ws/, "http"); |
| 250 | +export const now = () => Math.floor(Date.now() / 1000); |
| 251 | +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); |
| 252 | + |
| 253 | +export type Key = Uint8Array; |
| 254 | +export const newKey = (): Key => generateSecretKey(); |
| 255 | +export const pub = (sk: Key) => getPublicKey(sk); |
| 256 | + |
| 257 | +export function newEvent(sk: Key, kind: number, content: string, tags: string[][] = [], created_at = now()): NostrEvent { |
| 258 | + return finalizeEvent({ kind, content, tags, created_at }, sk); |
| 259 | +} |
| 260 | + |
| 261 | +export function difficulty(id: string): number { |
| 262 | + let bits = 0; |
| 263 | + for (const ch of id) { |
| 264 | + const n = parseInt(ch, 16); |
| 265 | + if (n === 0) { |
| 266 | + bits += 4; |
| 267 | + continue; |
| 268 | + } |
| 269 | + bits += Math.clz32(n) - 28; |
| 270 | + break; |
| 271 | + } |
| 272 | + return bits; |
| 273 | +} |
| 274 | + |
| 275 | +export function mine(sk: Key, t: EventTemplate, bits: number): NostrEvent { |
| 276 | + for (let n = 0; ; n++) { |
| 277 | + const e = finalizeEvent({ ...t, tags: [["nonce", String(n), String(bits)]] }, sk); |
| 278 | + if (difficulty(e.id) >= bits) return e; |
| 279 | + } |
| 280 | +} |
| 281 | + |
| 282 | +export class Client { |
| 283 | + private ws!: WebSocket; |
| 284 | + private queue: any[][] = []; |
| 285 | + private waiters: ((m: any[]) => void)[] = []; |
| 286 | + challenge = ""; |
| 287 | + closed = false; |
| 288 | + |
| 289 | + static async connect(url = RELAY_URL): Promise<Client> { |
| 290 | + const c = new Client(); |
| 291 | + c.ws = new WebSocket(url); |
| 292 | + // Listen before open: the relay's AUTH challenge can arrive in the same tick. |
| 293 | + c.ws.on("message", (data) => { |
| 294 | + const msg = JSON.parse(data.toString()); |
| 295 | + const w = c.waiters.shift(); |
| 296 | + if (w) w(msg); |
| 297 | + else c.queue.push(msg); |
| 298 | + }); |
| 299 | + c.ws.on("close", () => { |
| 300 | + c.closed = true; |
| 301 | + }); |
| 302 | + await new Promise<void>((resolve, reject) => { |
| 303 | + c.ws.once("open", () => resolve()); |
| 304 | + c.ws.once("error", reject); |
| 305 | + }); |
| 306 | + // NIP-42: every connection opens with a challenge. |
| 307 | + const first = await c.recv(); |
| 308 | + if (first[0] !== "AUTH") throw new Error(`expected AUTH challenge, got ${JSON.stringify(first)}`); |
| 309 | + c.challenge = first[1]; |
| 310 | + return c; |
| 311 | + } |
| 312 | + |
| 313 | + send(...msg: any[]) { |
| 314 | + this.ws.send(JSON.stringify(msg)); |
| 315 | + } |
| 316 | + |
| 317 | + recv(timeoutMs = 15_000): Promise<any[]> { |
| 318 | + const m = this.queue.shift(); |
| 319 | + if (m) return Promise.resolve(m); |
| 320 | + return new Promise((resolve, reject) => { |
| 321 | + const waiter = (msg: any[]) => { |
| 322 | + clearTimeout(t); |
| 323 | + resolve(msg); |
| 324 | + }; |
| 325 | + const t = setTimeout(() => { |
| 326 | + this.waiters = this.waiters.filter((w) => w !== waiter); |
| 327 | + reject(new Error("timed out waiting for a message")); |
| 328 | + }, timeoutMs); |
| 329 | + this.waiters.push(waiter); |
| 330 | + }); |
| 331 | + } |
| 332 | + |
| 333 | + async expect(type: string): Promise<any[]> { |
| 334 | + const m = await this.recv(); |
| 335 | + if (m[0] !== type) throw new Error(`expected ${type}, got ${JSON.stringify(m)}`); |
| 336 | + return m; |
| 337 | + } |
| 338 | + |
| 339 | + async expectOK(id: string, ok: boolean): Promise<string> { |
| 340 | + const m = await this.expect("OK"); |
| 341 | + if (m[1] !== id || m[2] !== ok) throw new Error(`OK mismatch: want (${id},${ok}) got ${JSON.stringify(m)}`); |
| 342 | + return m[3]; |
| 343 | + } |
| 344 | + |
| 345 | + async expectClosed(prefix: string): Promise<void> { |
| 346 | + const m = await this.expect("CLOSED"); |
| 347 | + if (!String(m[2]).startsWith(prefix)) throw new Error(`expected CLOSED ${prefix}..., got ${JSON.stringify(m)}`); |
| 348 | + } |
| 349 | + |
| 350 | + // drain reads until EOSE, returning the events seen and the EOSE hints. |
| 351 | + async drain(): Promise<{ events: NostrEvent[]; hints: string[] }> { |
| 352 | + const events: NostrEvent[] = []; |
| 353 | + for (;;) { |
| 354 | + const m = await this.recv(); |
| 355 | + if (m[0] === "EVENT") events.push(m[2]); |
| 356 | + else if (m[0] === "EOSE") return { events, hints: Array.isArray(m[2]) ? m[2] : [] }; |
| 357 | + else throw new Error(`unexpected ${JSON.stringify(m)}`); |
| 358 | + } |
| 359 | + } |
| 360 | + |
| 361 | + async req(filter: Record<string, unknown>, id = "q" + Math.random().toString(36).slice(2, 8)) { |
| 362 | + this.send("REQ", id, filter); |
| 363 | + const r = await this.drain(); |
| 364 | + this.send("CLOSE", id); |
| 365 | + return r; |
| 366 | + } |
| 367 | + |
| 368 | + async publish(e: NostrEvent): Promise<string> { |
| 369 | + this.send("EVENT", e); |
| 370 | + return this.expectOK(e.id, true); |
| 371 | + } |
| 372 | + |
| 373 | + async expectNothing(ms = 400) { |
| 374 | + try { |
| 375 | + const m = await this.recv(ms); |
| 376 | + throw new Error(`expected silence, got ${JSON.stringify(m)}`); |
| 377 | + } catch (err) { |
| 378 | + if (!(err instanceof Error) || !err.message.includes("timed out")) throw err; |
| 379 | + } |
| 380 | + } |
| 381 | + |
| 382 | + async auth(sk: Key, relay = RELAY_URL) { |
| 383 | + const e = newEvent(sk, 22242, "", [["relay", relay], ["challenge", this.challenge]]); |
| 384 | + this.send("AUTH", e); |
| 385 | + await this.expectOK(e.id, true); |
| 386 | + } |
| 387 | + |
| 388 | + // sync runs a full NIP-77 exchange for the filter and returns have/need. |
| 389 | + async sync(items: SyncItem[], filter: Record<string, unknown>, id = "s1"): Promise<{ have: string[]; need: string[] }> { |
| 390 | + const neg = new Negentropy(items, sha256); |
| 391 | + let msg: Uint8Array | null = neg.initiate(); |
| 392 | + this.send("NEG-OPEN", id, filter, hex(msg)); |
| 393 | + const have: string[] = []; |
| 394 | + const need: string[] = []; |
| 395 | + for (;;) { |
| 396 | + const m = await this.recv(); |
| 397 | + if (m[0] === "NEG-ERR") throw new Error(`NEG-ERR ${m[2]}`); |
| 398 | + if (m[0] !== "NEG-MSG") throw new Error(`unexpected ${JSON.stringify(m)}`); |
| 399 | + const r = neg.reconcile(hexToBytes(m[2])); |
| 400 | + have.push(...r.have); |
| 401 | + need.push(...r.need); |
| 402 | + if (!r.reply) { |
| 403 | + this.send("NEG-CLOSE", id); |
| 404 | + return { have: have.sort(), need: need.sort() }; |
| 405 | + } |
| 406 | + this.send("NEG-MSG", id, hex(r.reply)); |
| 407 | + } |
| 408 | + } |
| 409 | + |
| 410 | + close() { |
| 411 | + this.ws.close(); |
| 412 | + } |
| 413 | +} |
| 414 | + |
| 415 | +export function hex(b: Uint8Array): string { |
| 416 | + let s = ""; |
| 417 | + for (const x of b) s += x.toString(16).padStart(2, "0"); |
| 418 | + return s; |
| 419 | +} |
| 420 | + |
| 421 | +export function item(e: NostrEvent): SyncItem { |
| 422 | + return { timestamp: e.created_at, id: hexToBytes(e.id) }; |
| 423 | +} |
| 424 | + |
| 425 | +export function rand(): string { |
| 426 | + return Math.random().toString(36).slice(2, 10); |
| 427 | +} |
| 428 | + |
| 429 | +// randHex returns n random lowercase hex characters. |
| 430 | +export function randHex(n = 64): string { |
| 431 | + let s = ""; |
| 432 | + while (s.length < n) s += Math.floor(Math.random() * 16).toString(16); |
| 433 | + return s; |
| 434 | +} |
| 435 | + |
| 436 | +// sockets returns a connect that keeps its clients and closes them after |
| 437 | +// each test. |
| 438 | +export function sockets() { |
| 439 | + const open: Client[] = []; |
| 440 | + afterEach(() => open.splice(0).forEach((c) => c.close())); |
| 441 | + return async () => { |
| 442 | + const c = await Client.connect(); |
| 443 | + open.push(c); |
| 444 | + return c; |
| 445 | + }; |
| 446 | +} |
| 447 | diff --git a/scripts/conformance/negentropy.ts b/scripts/conformance/negentropy.ts |
| 448 | new file mode 100644 |
| 449 | index 0000000..482a218 |
| 450 | --- /dev/null |
| 451 | +++ b/scripts/conformance/negentropy.ts |
| 452 | @@ -0,0 +1,306 @@ |
| 453 | +// NIP-77 Negentropy v1: range-based set reconciliation used by the |
| 454 | +// conformance client. Both sides hold a sorted (timestamp, |
| 455 | +// id) list and exchange ranges carrying fingerprints or id lists until the |
| 456 | +// initiator knows exactly which ids each side is missing. |
| 457 | + |
| 458 | +export interface SyncItem { |
| 459 | + timestamp: number; // unix seconds |
| 460 | + id: Uint8Array; // 32 bytes |
| 461 | +} |
| 462 | + |
| 463 | +const VERSION = 0x61; |
| 464 | +const MODE_SKIP = 0n; |
| 465 | +const MODE_FP = 1n; |
| 466 | +const MODE_IDLIST = 2n; |
| 467 | +const INFINITY = (1n << 64n) - 1n; |
| 468 | +const BUCKETS = 16; |
| 469 | +export const FRAME_SIZE = 60_000; |
| 470 | + |
| 471 | +interface Bound { |
| 472 | + ts: bigint; |
| 473 | + id: Uint8Array; // 32 bytes, zero padded |
| 474 | + len: number; // significant prefix bytes |
| 475 | +} |
| 476 | + |
| 477 | +const ZERO32 = new Uint8Array(32); |
| 478 | + |
| 479 | +function cmpBytes(a: Uint8Array, b: Uint8Array): number { |
| 480 | + for (let i = 0; i < 32; i++) { |
| 481 | + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; |
| 482 | + } |
| 483 | + return 0; |
| 484 | +} |
| 485 | + |
| 486 | +export function itemLess(a: SyncItem, b: SyncItem): boolean { |
| 487 | + if (a.timestamp !== b.timestamp) return a.timestamp < b.timestamp; |
| 488 | + return cmpBytes(a.id, b.id) < 0; |
| 489 | +} |
| 490 | + |
| 491 | +export function hexToBytes(h: string): Uint8Array { |
| 492 | + const out = new Uint8Array(h.length / 2); |
| 493 | + for (let i = 0; i < out.length; i++) out[i] = parseInt(h.substr(i * 2, 2), 16); |
| 494 | + return out; |
| 495 | +} |
| 496 | + |
| 497 | +export function bytesToHex(b: Uint8Array): string { |
| 498 | + let s = ""; |
| 499 | + for (const x of b) s += x.toString(16).padStart(2, "0"); |
| 500 | + return s; |
| 501 | +} |
| 502 | + |
| 503 | +class Writer { |
| 504 | + parts: number[] = []; |
| 505 | + varint(v: bigint) { |
| 506 | + const digits: number[] = []; |
| 507 | + do { |
| 508 | + digits.unshift(Number(v & 0x7fn)); |
| 509 | + v >>= 7n; |
| 510 | + } while (v > 0n); |
| 511 | + for (let i = 0; i < digits.length - 1; i++) digits[i] |= 0x80; |
| 512 | + this.parts.push(...digits); |
| 513 | + } |
| 514 | + bytes(b: Uint8Array) { |
| 515 | + for (const x of b) this.parts.push(x); |
| 516 | + } |
| 517 | + get length() { |
| 518 | + return this.parts.length; |
| 519 | + } |
| 520 | + toBytes() { |
| 521 | + return new Uint8Array(this.parts); |
| 522 | + } |
| 523 | +} |
| 524 | + |
| 525 | +class Reader { |
| 526 | + pos = 0; |
| 527 | + constructor(private buf: Uint8Array) {} |
| 528 | + get remaining() { |
| 529 | + return this.buf.length - this.pos; |
| 530 | + } |
| 531 | + varint(): bigint { |
| 532 | + let v = 0n; |
| 533 | + for (let i = 0; ; i++) { |
| 534 | + if (this.pos >= this.buf.length || i === 10) throw new Error("bad varint"); |
| 535 | + const b = this.buf[this.pos++]; |
| 536 | + v = (v << 7n) | BigInt(b & 0x7f); |
| 537 | + if ((b & 0x80) === 0) return v; |
| 538 | + } |
| 539 | + } |
| 540 | + bytes(n: number): Uint8Array { |
| 541 | + if (this.remaining < n) throw new Error("truncated message"); |
| 542 | + const out = this.buf.subarray(this.pos, this.pos + n); |
| 543 | + this.pos += n; |
| 544 | + return out; |
| 545 | + } |
| 546 | +} |
| 547 | + |
| 548 | +export class Negentropy { |
| 549 | + items: SyncItem[]; |
| 550 | + initiator = false; |
| 551 | + frame = FRAME_SIZE; |
| 552 | + private lastIn = 0n; |
| 553 | + private lastOut = 0n; |
| 554 | + |
| 555 | + constructor(items: SyncItem[], private sha256: (b: Uint8Array) => Uint8Array) { |
| 556 | + this.items = [...items].sort((a, b) => (itemLess(a, b) ? -1 : itemLess(b, a) ? 1 : 0)); |
| 557 | + } |
| 558 | + |
| 559 | + initiate(): Uint8Array { |
| 560 | + this.initiator = true; |
| 561 | + this.lastOut = 0n; |
| 562 | + const w = new Writer(); |
| 563 | + w.parts.push(VERSION); |
| 564 | + this.splitRange(0, this.items.length, { ts: INFINITY, id: ZERO32, len: 0 }, w); |
| 565 | + return w.toBytes(); |
| 566 | + } |
| 567 | + |
| 568 | + // Returns the reply (null when an initiator is done) and, for the |
| 569 | + // initiator, the ids only we have and the ids only the peer has. |
| 570 | + reconcile(msg: Uint8Array): { reply: Uint8Array | null; have: string[]; need: string[] } { |
| 571 | + const have: string[] = []; |
| 572 | + const need: string[] = []; |
| 573 | + if (msg.length === 0) throw new Error("empty message"); |
| 574 | + if (msg[0] !== VERSION) { |
| 575 | + if (this.initiator) throw new Error("peer does not support protocol v1"); |
| 576 | + return { reply: new Uint8Array([VERSION]), have, need }; |
| 577 | + } |
| 578 | + const r = new Reader(msg.subarray(1)); |
| 579 | + this.lastIn = 0n; |
| 580 | + this.lastOut = 0n; |
| 581 | + const out = new Writer(); |
| 582 | + out.parts.push(VERSION); |
| 583 | + let prevBound: Bound = { ts: 0n, id: ZERO32, len: 0 }; |
| 584 | + let prevIndex = 0; |
| 585 | + let pendingSkip = false; |
| 586 | + const flushSkip = () => { |
| 587 | + if (pendingSkip) { |
| 588 | + pendingSkip = false; |
| 589 | + this.writeBound(out, prevBound); |
| 590 | + out.varint(MODE_SKIP); |
| 591 | + } |
| 592 | + }; |
| 593 | + |
| 594 | + while (r.remaining > 0) { |
| 595 | + let bound = this.readBound(r); |
| 596 | + const mode = r.varint(); |
| 597 | + const lower = prevIndex; |
| 598 | + let upper = this.findUpper(lower, bound); |
| 599 | + |
| 600 | + if (mode === MODE_SKIP) { |
| 601 | + pendingSkip = true; |
| 602 | + } else if (mode === MODE_FP) { |
| 603 | + const theirs = r.bytes(16); |
| 604 | + const ours = this.fingerprint(lower, upper); |
| 605 | + let same = true; |
| 606 | + for (let i = 0; i < 16; i++) if (theirs[i] !== ours[i]) same = false; |
| 607 | + if (same) pendingSkip = true; |
| 608 | + else { |
| 609 | + flushSkip(); |
| 610 | + this.splitRange(lower, upper, bound, out); |
| 611 | + } |
| 612 | + } else if (mode === MODE_IDLIST) { |
| 613 | + const count = Number(r.varint()); |
| 614 | + const theirs = new Map<string, true>(); |
| 615 | + for (let i = 0; i < count; i++) theirs.set(bytesToHex(r.bytes(32)), true); |
| 616 | + if (this.initiator) { |
| 617 | + pendingSkip = true; |
| 618 | + for (let i = lower; i < upper; i++) { |
| 619 | + const h = bytesToHex(this.items[i].id); |
| 620 | + if (theirs.has(h)) theirs.delete(h); |
| 621 | + else have.push(h); |
| 622 | + } |
| 623 | + for (const h of theirs.keys()) need.push(h); |
| 624 | + } else { |
| 625 | + flushSkip(); |
| 626 | + let end = bound; |
| 627 | + const ids: number[] = []; |
| 628 | + let cnt = 0; |
| 629 | + for (let i = lower; i < upper; i++) { |
| 630 | + if (out.length + ids.length > this.frame) { |
| 631 | + end = { ts: BigInt(this.items[i].timestamp), id: this.items[i].id, len: 32 }; |
| 632 | + upper = i; |
| 633 | + break; |
| 634 | + } |
| 635 | + ids.push(...this.items[i].id); |
| 636 | + cnt++; |
| 637 | + } |
| 638 | + this.writeBound(out, end); |
| 639 | + out.varint(MODE_IDLIST); |
| 640 | + out.varint(BigInt(cnt)); |
| 641 | + out.parts.push(...ids); |
| 642 | + bound = end; |
| 643 | + } |
| 644 | + } else { |
| 645 | + throw new Error("unknown range mode"); |
| 646 | + } |
| 647 | + prevIndex = upper; |
| 648 | + prevBound = bound; |
| 649 | + |
| 650 | + if (out.length > this.frame) { |
| 651 | + flushSkip(); |
| 652 | + this.writeBound(out, { ts: INFINITY, id: ZERO32, len: 0 }); |
| 653 | + out.varint(MODE_FP); |
| 654 | + out.bytes(this.fingerprint(upper, this.items.length)); |
| 655 | + break; |
| 656 | + } |
| 657 | + } |
| 658 | + if (this.initiator && out.length === 1) return { reply: null, have, need }; |
| 659 | + return { reply: out.toBytes(), have, need }; |
| 660 | + } |
| 661 | + |
| 662 | + private splitRange(lower: number, upper: number, bound: Bound, out: Writer) { |
| 663 | + const count = upper - lower; |
| 664 | + if (count < BUCKETS * 2) { |
| 665 | + this.writeBound(out, bound); |
| 666 | + out.varint(MODE_IDLIST); |
| 667 | + out.varint(BigInt(count)); |
| 668 | + for (let i = lower; i < upper; i++) out.bytes(this.items[i].id); |
| 669 | + return; |
| 670 | + } |
| 671 | + const per = Math.floor(count / BUCKETS); |
| 672 | + const extra = count % BUCKETS; |
| 673 | + let cur = lower; |
| 674 | + for (let i = 0; i < BUCKETS; i++) { |
| 675 | + const size = per + (i < extra ? 1 : 0); |
| 676 | + const fp = this.fingerprint(cur, cur + size); |
| 677 | + cur += size; |
| 678 | + const next = cur < upper ? minimalBound(this.items[cur - 1], this.items[cur]) : bound; |
| 679 | + this.writeBound(out, next); |
| 680 | + out.varint(MODE_FP); |
| 681 | + out.bytes(fp); |
| 682 | + } |
| 683 | + } |
| 684 | + |
| 685 | + fingerprint(lower: number, upper: number): Uint8Array { |
| 686 | + const sum = new Uint8Array(32); |
| 687 | + for (let i = lower; i < upper; i++) { |
| 688 | + let carry = 0; |
| 689 | + const id = this.items[i].id; |
| 690 | + for (let j = 0; j < 32; j++) { |
| 691 | + const v = sum[j] + id[j] + carry; |
| 692 | + sum[j] = v & 0xff; |
| 693 | + carry = v >> 8; |
| 694 | + } |
| 695 | + } |
| 696 | + const w = new Writer(); |
| 697 | + w.bytes(sum); |
| 698 | + w.varint(BigInt(upper - lower)); |
| 699 | + return this.sha256(w.toBytes()).subarray(0, 16); |
| 700 | + } |
| 701 | + |
| 702 | + private findUpper(lower: number, b: Bound): number { |
| 703 | + let lo = lower; |
| 704 | + let hi = this.items.length; |
| 705 | + while (lo < hi) { |
| 706 | + const mid = (lo + hi) >> 1; |
| 707 | + const it = this.items[mid]; |
| 708 | + const ts = BigInt(it.timestamp); |
| 709 | + const below = ts !== b.ts ? ts < b.ts : cmpBytes(it.id, b.id) < 0; |
| 710 | + if (below) lo = mid + 1; |
| 711 | + else hi = mid; |
| 712 | + } |
| 713 | + return lo; |
| 714 | + } |
| 715 | + |
| 716 | + private writeBound(w: Writer, b: Bound) { |
| 717 | + if (b.ts === INFINITY) { |
| 718 | + this.lastOut = INFINITY; |
| 719 | + w.varint(0n); |
| 720 | + } else { |
| 721 | + w.varint(b.ts - this.lastOut + 1n); |
| 722 | + this.lastOut = b.ts; |
| 723 | + } |
| 724 | + w.varint(BigInt(b.len)); |
| 725 | + w.bytes(b.id.subarray(0, b.len)); |
| 726 | + } |
| 727 | + |
| 728 | + private readBound(r: Reader): Bound { |
| 729 | + const v = r.varint(); |
| 730 | + let ts: bigint; |
| 731 | + if (v === 0n) ts = INFINITY; |
| 732 | + else { |
| 733 | + ts = this.lastIn + v - 1n; |
| 734 | + if (ts < this.lastIn || ts > INFINITY) ts = INFINITY; |
| 735 | + } |
| 736 | + this.lastIn = ts; |
| 737 | + const len = Number(r.varint()); |
| 738 | + if (len > 32) throw new Error("bound id prefix too long"); |
| 739 | + const id = new Uint8Array(32); |
| 740 | + id.set(r.bytes(len)); |
| 741 | + return { ts, id, len }; |
| 742 | + } |
| 743 | +} |
| 744 | + |
| 745 | +export function minimalBound(prev: SyncItem, cur: SyncItem): Bound { |
| 746 | + if (cur.timestamp !== prev.timestamp) return { ts: BigInt(cur.timestamp), id: ZERO32, len: 0 }; |
| 747 | + let shared = 0; |
| 748 | + while (shared < 32 && prev.id[shared] === cur.id[shared]) shared++; |
| 749 | + const id = new Uint8Array(32); |
| 750 | + id.set(cur.id.subarray(0, shared + 1)); |
| 751 | + return { ts: BigInt(cur.timestamp), id, len: shared + 1 }; |
| 752 | +} |
| 753 | + |
| 754 | +export function encodeVarint(v: bigint): Uint8Array { |
| 755 | + const w = new Writer(); |
| 756 | + w.varint(v); |
| 757 | + return w.toBytes(); |
| 758 | +} |
| 759 | diff --git a/scripts/conformance/nip01.test.ts b/scripts/conformance/nip01.test.ts |
| 760 | new file mode 100644 |
| 761 | index 0000000..2756294 |
| 762 | --- /dev/null |
| 763 | +++ b/scripts/conformance/nip01.test.ts |
| 764 | @@ -0,0 +1,95 @@ |
| 765 | +import { describe, it, expect } from "vitest"; |
| 766 | +import { newEvent, newKey, pub, now, rand, sockets } from "./helpers.ts"; |
| 767 | + |
| 768 | +const connect = sockets(); |
| 769 | + |
| 770 | +describe("NIP-01 core", () => { |
| 771 | + it("publishes, pushes live, dedups, rejects tampering, queries, replaces, limits, closes", async () => { |
| 772 | + const sk = newKey(); |
| 773 | + const alice = await connect(); |
| 774 | + const bob = await connect(); |
| 775 | + const tag = rand(); |
| 776 | + const t0 = now() - 1000; |
| 777 | + |
| 778 | + // Bob subscribes before anything exists: immediate EOSE. |
| 779 | + bob.send("REQ", "notes", { kinds: [1], "#t": [tag] }); |
| 780 | + await bob.expect("EOSE"); |
| 781 | + |
| 782 | + const note = newEvent(sk, 1, "hello <world> & friends", [["t", tag]], t0); |
| 783 | + await alice.publish(note); |
| 784 | + const live = await bob.expect("EVENT"); |
| 785 | + expect(live[1]).toBe("notes"); |
| 786 | + expect(live[2].id).toBe(note.id); |
| 787 | + expect(live[2].content).toBe(note.content); |
| 788 | + |
| 789 | + // Duplicate acknowledged, not re-broadcast. |
| 790 | + alice.send("EVENT", note); |
| 791 | + expect(await alice.expectOK(note.id, true)).toMatch(/^duplicate:/); |
| 792 | + await bob.expectNothing(); |
| 793 | + |
| 794 | + // Tampered content is refused with an invalid: prefix. |
| 795 | + alice.send("EVENT", { ...note, content: "tampered" }); |
| 796 | + expect(await alice.expectOK(note.id, false)).toMatch(/^invalid:/); |
| 797 | + |
| 798 | + // Stored query by tag and author. |
| 799 | + const carol = await connect(); |
| 800 | + let r = await carol.req({ "#t": [tag], authors: [pub(sk)] }); |
| 801 | + expect(r.events.map((e) => e.id)).toEqual([note.id]); |
| 802 | + |
| 803 | + // Replaceable: newest wins, stale refused. |
| 804 | + const meta1 = newEvent(sk, 0, '{"name":"v1"}', [], t0 + 1); |
| 805 | + const meta2 = newEvent(sk, 0, '{"name":"v2"}', [], t0 + 2); |
| 806 | + await alice.publish(meta2); |
| 807 | + alice.send("EVENT", meta1); |
| 808 | + expect(await alice.expectOK(meta1.id, false)).toMatch(/newer/); |
| 809 | + r = await carol.req({ kinds: [0], authors: [pub(sk)] }); |
| 810 | + expect(r.events.map((e) => e.id)).toEqual([meta2.id]); |
| 811 | + |
| 812 | + // Addressable: same d replaces, different d coexists. |
| 813 | + const a1 = newEvent(sk, 30023, "draft", [["d", "post"]], t0 + 3); |
| 814 | + const a2 = newEvent(sk, 30023, "final", [["d", "post"]], t0 + 4); |
| 815 | + const a3 = newEvent(sk, 30023, "other", [["d", "other"]], t0 + 4); |
| 816 | + for (const e of [a1, a2, a3]) await alice.publish(e); |
| 817 | + r = await carol.req({ kinds: [30023], authors: [pub(sk)] }); |
| 818 | + expect(r.events.map((e) => e.id).sort()).toEqual([a2.id, a3.id].sort()); |
| 819 | + |
| 820 | + // Limit, newest-first ordering, and NIP-67 hints. |
| 821 | + for (let i = 0; i < 5; i++) await alice.publish(newEvent(sk, 1, "n" + i, [["t", tag]], t0 + 10 + i)); |
| 822 | + r = await carol.req({ kinds: [1], "#t": [tag], limit: 2 }); |
| 823 | + expect(r.events.length).toBe(2); |
| 824 | + expect(r.events[0].created_at).toBe(t0 + 14); |
| 825 | + expect(r.hints).toContain("more"); |
| 826 | + r = await carol.req({ kinds: [1], "#t": [tag] }); |
| 827 | + expect(r.events.length).toBe(6); |
| 828 | + expect(r.hints).toContain("finish"); |
| 829 | + |
| 830 | + // Bob's live sub saw the five notes; CLOSE stops delivery. |
| 831 | + for (let i = 0; i < 5; i++) await bob.expect("EVENT"); |
| 832 | + bob.send("CLOSE", "notes"); |
| 833 | + await alice.publish(newEvent(sk, 1, "after close", [["t", tag]], t0 + 20)); |
| 834 | + await bob.expectNothing(); |
| 835 | + }); |
| 836 | + |
| 837 | + it("does not store ephemeral events but does broadcast them", async () => { |
| 838 | + const a = await connect(); |
| 839 | + const b = await connect(); |
| 840 | + const sk = newKey(); |
| 841 | + b.send("REQ", "eph", { kinds: [20001], authors: [pub(sk)] }); |
| 842 | + await b.expect("EOSE"); |
| 843 | + await a.publish(newEvent(sk, 20001, "poof")); |
| 844 | + await b.expect("EVENT"); |
| 845 | + const c = await connect(); |
| 846 | + const r = await c.req({ kinds: [20001], authors: [pub(sk)] }); |
| 847 | + expect(r.events).toEqual([]); |
| 848 | + }); |
| 849 | + |
| 850 | + it("answers bad input with NOTICE or CLOSED and keeps the connection", async () => { |
| 851 | + const c = await connect(); |
| 852 | + c.send("BOGUS", 1); |
| 853 | + expect((await c.expect("NOTICE"))[1]).toMatch(/^error:/); |
| 854 | + c.send("REQ", "x", { kinds: "not-a-list" }); |
| 855 | + await c.expectClosed("invalid:"); |
| 856 | + c.send("REQ", "still-alive", { kinds: [1], limit: 0 }); |
| 857 | + await c.expect("EOSE"); |
| 858 | + }); |
| 859 | +}); |
| 860 | diff --git a/scripts/conformance/nip09.test.ts b/scripts/conformance/nip09.test.ts |
| 861 | new file mode 100644 |
| 862 | index 0000000..b53495d |
| 863 | --- /dev/null |
| 864 | +++ b/scripts/conformance/nip09.test.ts |
| 865 | @@ -0,0 +1,33 @@ |
| 866 | +import { describe, it, expect } from "vitest"; |
| 867 | +import { newEvent, newKey, pub, now, sockets } from "./helpers.ts"; |
| 868 | + |
| 869 | +const connect = sockets(); |
| 870 | + |
| 871 | +describe("NIP-09 deletion", () => { |
| 872 | + it("lets only the author delete, keeps deleted ids out, ignores strangers", async () => { |
| 873 | + const sk = newKey(); |
| 874 | + const other = newKey(); |
| 875 | + const alice = await connect(); |
| 876 | + const stranger = await connect(); |
| 877 | + const carol = await connect(); |
| 878 | + const note = newEvent(sk, 1, "regret", [], now() - 100); |
| 879 | + await alice.publish(note); |
| 880 | + |
| 881 | + await stranger.publish(newEvent(other, 5, "", [["e", note.id]])); |
| 882 | + expect((await carol.req({ ids: [note.id] })).events.length).toBe(1); |
| 883 | + |
| 884 | + await alice.publish(newEvent(sk, 5, "", [["e", note.id], ["k", "1"]])); |
| 885 | + expect((await carol.req({ ids: [note.id] })).events.length).toBe(0); |
| 886 | + alice.send("EVENT", note); |
| 887 | + expect(await alice.expectOK(note.id, false)).toMatch(/^blocked:/); |
| 888 | + }); |
| 889 | + |
| 890 | + it("deletes addressable versions up to the request time via a tags", async () => { |
| 891 | + const sk = newKey(); |
| 892 | + const c = await connect(); |
| 893 | + const t0 = now() - 100; |
| 894 | + await c.publish(newEvent(sk, 30023, "v1", [["d", "x"]], t0)); |
| 895 | + await c.publish(newEvent(sk, 5, "", [["a", `30023:${pub(sk)}:x`]], t0 + 1)); |
| 896 | + expect((await c.req({ kinds: [30023], authors: [pub(sk)], "#d": ["x"] })).events.length).toBe(0); |
| 897 | + }); |
| 898 | +}); |
| 899 | diff --git a/scripts/conformance/nip11.compat.test.ts b/scripts/conformance/nip11.compat.test.ts |
| 900 | deleted file mode 100644 |
| 901 | index 9fa976b..0000000 |
| 902 | --- a/scripts/conformance/nip11.compat.test.ts |
| 903 | +++ /dev/null |
| 904 | @@ -1,17 +0,0 @@ |
| 905 | -import { describe, expect, it } from "vitest"; |
| 906 | - |
| 907 | -const relayURL = process.env.RELAY_URL ?? "ws://127.0.0.1:7447"; |
| 908 | -const httpURL = relayURL.replace(/^ws/, "http").replace(/\/$/, "") + "/"; |
| 909 | - |
| 910 | -describe("NIP-11 self-hosted compatibility", () => { |
| 911 | - it("preserves the information assertions while omitting hosted quota", async () => { |
| 912 | - const response = await fetch(httpURL, { headers: { Accept: "application/nostr+json" } }); |
| 913 | - expect(response.status).toBe(200); |
| 914 | - expect(response.headers.get("content-type")).toContain("application/nostr+json"); |
| 915 | - const document: any = await response.json(); |
| 916 | - expect(typeof document.name).toBe("string"); |
| 917 | - for (const nip of [1, 9, 11, 40, 42, 45, 50, 62, 67, 70, 77]) expect(document.supported_nips).toContain(nip); |
| 918 | - expect(document.limitation.max_subid_length).toBeGreaterThanOrEqual(64); |
| 919 | - expect(document.limitation.max_limit).toBeUndefined(); |
| 920 | - }); |
| 921 | -}); |
| 922 | diff --git a/scripts/conformance/nip11.test.ts b/scripts/conformance/nip11.test.ts |
| 923 | new file mode 100644 |
| 924 | index 0000000..9fa976b |
| 925 | --- /dev/null |
| 926 | +++ b/scripts/conformance/nip11.test.ts |
| 927 | @@ -0,0 +1,17 @@ |
| 928 | +import { describe, expect, it } from "vitest"; |
| 929 | + |
| 930 | +const relayURL = process.env.RELAY_URL ?? "ws://127.0.0.1:7447"; |
| 931 | +const httpURL = relayURL.replace(/^ws/, "http").replace(/\/$/, "") + "/"; |
| 932 | + |
| 933 | +describe("NIP-11 self-hosted compatibility", () => { |
| 934 | + it("preserves the information assertions while omitting hosted quota", async () => { |
| 935 | + const response = await fetch(httpURL, { headers: { Accept: "application/nostr+json" } }); |
| 936 | + expect(response.status).toBe(200); |
| 937 | + expect(response.headers.get("content-type")).toContain("application/nostr+json"); |
| 938 | + const document: any = await response.json(); |
| 939 | + expect(typeof document.name).toBe("string"); |
| 940 | + for (const nip of [1, 9, 11, 40, 42, 45, 50, 62, 67, 70, 77]) expect(document.supported_nips).toContain(nip); |
| 941 | + expect(document.limitation.max_subid_length).toBeGreaterThanOrEqual(64); |
| 942 | + expect(document.limitation.max_limit).toBeUndefined(); |
| 943 | + }); |
| 944 | +}); |
| 945 | diff --git a/scripts/conformance/nip17.test.ts b/scripts/conformance/nip17.test.ts |
| 946 | new file mode 100644 |
| 947 | index 0000000..be83f5c |
| 948 | --- /dev/null |
| 949 | +++ b/scripts/conformance/nip17.test.ts |
| 950 | @@ -0,0 +1,47 @@ |
| 951 | +import { describe, it, expect } from "vitest"; |
| 952 | +import { newEvent, newKey, pub, now, sockets } from "./helpers.ts"; |
| 953 | + |
| 954 | +const connect = sockets(); |
| 955 | + |
| 956 | +describe("NIP-17 / NIP-59 private kinds", () => { |
| 957 | + it("serves gift wraps only to their recipients, stored and live, and lets recipients delete them", async () => { |
| 958 | + const sender = newKey(); |
| 959 | + const recipient = newKey(); |
| 960 | + const stranger = newKey(); |
| 961 | + const s = await connect(); |
| 962 | + const wrap = newEvent(sender, 1059, "ciphertext", [["p", pub(recipient)]], now() - 100); |
| 963 | + await s.publish(wrap); |
| 964 | + |
| 965 | + const x = await connect(); |
| 966 | + x.send("REQ", "dm", { kinds: [1059], "#p": [pub(recipient)] }); |
| 967 | + await x.expectClosed("auth-required:"); |
| 968 | + let r = await x.req({ "#p": [pub(recipient)] }); |
| 969 | + expect(r.events).toEqual([]); |
| 970 | + expect(r.hints).toContain("auth"); |
| 971 | + x.send("COUNT", "n", { "#p": [pub(recipient)] }); |
| 972 | + expect((await x.expect("COUNT"))[2].count).toBe(0); |
| 973 | + |
| 974 | + await x.auth(stranger); |
| 975 | + r = await x.req({ kinds: [1059], "#p": [pub(recipient)] }); |
| 976 | + expect(r.events).toEqual([]); |
| 977 | + x.send("REQ", "live", { kinds: [1059], "#p": [pub(recipient)] }); |
| 978 | + await x.expect("EOSE"); |
| 979 | + await s.publish(newEvent(sender, 1059, "ciphertext2", [["p", pub(recipient)]], now() - 99)); |
| 980 | + await x.expectNothing(); |
| 981 | + |
| 982 | + const rc = await connect(); |
| 983 | + await rc.auth(recipient); |
| 984 | + r = await rc.req({ kinds: [1059], "#p": [pub(recipient)] }); |
| 985 | + expect(r.events.length).toBe(2); |
| 986 | + rc.send("REQ", "live", { kinds: [1059], "#p": [pub(recipient)] }); |
| 987 | + await rc.drain(); |
| 988 | + const wrap3 = newEvent(sender, 1059, "ciphertext3", [["p", pub(recipient)]], now() - 98); |
| 989 | + await s.publish(wrap3); |
| 990 | + expect((await rc.expect("EVENT"))[2].id).toBe(wrap3.id); |
| 991 | + |
| 992 | + await rc.publish(newEvent(recipient, 5, "", [["e", wrap3.id]])); |
| 993 | + expect((await rc.req({ ids: [wrap3.id] })).events).toEqual([]); |
| 994 | + s.send("EVENT", wrap3); |
| 995 | + expect(await s.expectOK(wrap3.id, false)).toMatch(/^blocked:/); |
| 996 | + }); |
| 997 | +}); |
| 998 | diff --git a/scripts/conformance/nip40.test.ts b/scripts/conformance/nip40.test.ts |
| 999 | new file mode 100644 |
| 1000 | index 0000000..f5a30b1 |
| 1001 | --- /dev/null |
| 1002 | +++ b/scripts/conformance/nip40.test.ts |
| 1003 | @@ -0,0 +1,21 @@ |
| 1004 | +import { describe, it, expect } from "vitest"; |
| 1005 | +import { newEvent, newKey, now, sleep, sockets } from "./helpers.ts"; |
| 1006 | + |
| 1007 | +const connect = sockets(); |
| 1008 | + |
| 1009 | +describe("NIP-40 expiration", () => { |
| 1010 | + it("refuses expired events, serves unexpired ones, and stops serving after expiry", async () => { |
| 1011 | + const sk = newKey(); |
| 1012 | + const c = await connect(); |
| 1013 | + const dead = newEvent(sk, 1, "gone", [["expiration", String(now() - 1)]]); |
| 1014 | + c.send("EVENT", dead); |
| 1015 | + expect(await c.expectOK(dead.id, false)).toMatch(/expired/); |
| 1016 | + const soon = newEvent(sk, 1, "soon", [["expiration", String(now() + 2)]]); |
| 1017 | + await c.publish(soon); |
| 1018 | + expect((await c.req({ ids: [soon.id] })).events.length).toBe(1); |
| 1019 | + await sleep(2500); |
| 1020 | + expect((await c.req({ ids: [soon.id] })).events.length).toBe(0); |
| 1021 | + c.send("COUNT", "q", { ids: [soon.id] }); |
| 1022 | + expect((await c.expect("COUNT"))[2].count).toBe(0); |
| 1023 | + }); |
| 1024 | +}); |
| 1025 | diff --git a/scripts/conformance/nip42.test.ts b/scripts/conformance/nip42.test.ts |
| 1026 | new file mode 100644 |
| 1027 | index 0000000..af37608 |
| 1028 | --- /dev/null |
| 1029 | +++ b/scripts/conformance/nip42.test.ts |
| 1030 | @@ -0,0 +1,30 @@ |
| 1031 | +import { describe, it, expect } from "vitest"; |
| 1032 | +import { newEvent, newKey, now, RELAY_URL, sockets } from "./helpers.ts"; |
| 1033 | + |
| 1034 | +const connect = sockets(); |
| 1035 | + |
| 1036 | +describe("NIP-42 AUTH", () => { |
| 1037 | + it("refuses bad challenges, wrong relays, stale timestamps, wrong kinds; accepts several keys", async () => { |
| 1038 | + const sk = newKey(); |
| 1039 | + const c = await connect(); |
| 1040 | + const bad = (tags: string[][], ts = now(), kind = 22242) => newEvent(sk, kind, "", tags, ts); |
| 1041 | + let e = bad([["relay", RELAY_URL], ["challenge", "nope"]]); |
| 1042 | + c.send("AUTH", e); |
| 1043 | + expect(await c.expectOK(e.id, false)).toMatch(/challenge/); |
| 1044 | + e = bad([["relay", "wss://elsewhere.example"], ["challenge", c.challenge]]); |
| 1045 | + c.send("AUTH", e); |
| 1046 | + expect(await c.expectOK(e.id, false)).toMatch(/relay/); |
| 1047 | + e = bad([["relay", RELAY_URL], ["challenge", c.challenge]], now() - 3600); |
| 1048 | + c.send("AUTH", e); |
| 1049 | + expect(await c.expectOK(e.id, false)).toMatch(/time/); |
| 1050 | + e = bad([["relay", RELAY_URL], ["challenge", c.challenge]], now(), 1); |
| 1051 | + c.send("AUTH", e); |
| 1052 | + await c.expectOK(e.id, false); |
| 1053 | + // kind 22242 sent as a plain EVENT is never accepted. |
| 1054 | + const asEvent = newEvent(sk, 22242, "", [["relay", RELAY_URL], ["challenge", c.challenge]]); |
| 1055 | + c.send("EVENT", asEvent); |
| 1056 | + expect(await c.expectOK(asEvent.id, false)).toMatch(/^(blocked|invalid):/); |
| 1057 | + await c.auth(sk); |
| 1058 | + await c.auth(newKey()); |
| 1059 | + }); |
| 1060 | +}); |
| 1061 | diff --git a/scripts/conformance/nip45.test.ts b/scripts/conformance/nip45.test.ts |
| 1062 | new file mode 100644 |
| 1063 | index 0000000..598c1f0 |
| 1064 | --- /dev/null |
| 1065 | +++ b/scripts/conformance/nip45.test.ts |
| 1066 | @@ -0,0 +1,41 @@ |
| 1067 | +import { describe, it, expect } from "vitest"; |
| 1068 | +import { newEvent, newKey, pub, now, randHex, sockets } from "./helpers.ts"; |
| 1069 | + |
| 1070 | +const connect = sockets(); |
| 1071 | + |
| 1072 | +describe("NIP-45 COUNT", () => { |
| 1073 | + it("counts across OR'd filters without double counting, and adds an hll sketch for tag filters", async () => { |
| 1074 | + const sk = newKey(); |
| 1075 | + const c = await connect(); |
| 1076 | + const target = randHex(); |
| 1077 | + for (let i = 0; i < 3; i++) await c.publish(newEvent(sk, 1, "n", [["t", target]], now() - 10 + i)); |
| 1078 | + await c.publish(newEvent(sk, 7, "+", [["e", target]], now() - 5)); |
| 1079 | + |
| 1080 | + c.send("COUNT", "n", { kinds: [1], authors: [pub(sk)] }, { kinds: [7], authors: [pub(sk)] }); |
| 1081 | + expect((await c.expect("COUNT"))[2].count).toBe(4); |
| 1082 | + c.send("COUNT", "n", { kinds: [1], authors: [pub(sk)] }, { "#t": [target], authors: [pub(sk)] }); |
| 1083 | + expect((await c.expect("COUNT"))[2].count).toBe(3); |
| 1084 | + |
| 1085 | + c.send("COUNT", "r", { kinds: [7], "#e": [target] }); |
| 1086 | + const res = (await c.expect("COUNT"))[2]; |
| 1087 | + expect(res.count).toBe(1); |
| 1088 | + expect(res.hll).toMatch(/^[0-9a-f]{512}$/); |
| 1089 | + // Register: offset = nibble 32 of the target + 8; byte[offset] indexes, |
| 1090 | + // zero run after it plus one is the value. |
| 1091 | + const offset = parseInt(target[32], 16) + 8; |
| 1092 | + const pk = pub(sk); |
| 1093 | + const regIndex = parseInt(pk.substr(offset * 2, 2), 16); |
| 1094 | + const rest = pk.slice((offset + 1) * 2); |
| 1095 | + let zeros = 0; |
| 1096 | + for (const ch of rest) { |
| 1097 | + const n = parseInt(ch, 16); |
| 1098 | + if (n === 0) { |
| 1099 | + zeros += 4; |
| 1100 | + continue; |
| 1101 | + } |
| 1102 | + zeros += Math.clz32(n) - 28; |
| 1103 | + break; |
| 1104 | + } |
| 1105 | + expect(parseInt(res.hll.substr(regIndex * 2, 2), 16)).toBe(zeros + 1); |
| 1106 | + }); |
| 1107 | +}); |
| 1108 | diff --git a/scripts/conformance/nip50.test.ts b/scripts/conformance/nip50.test.ts |
| 1109 | new file mode 100644 |
| 1110 | index 0000000..01b4929 |
| 1111 | --- /dev/null |
| 1112 | +++ b/scripts/conformance/nip50.test.ts |
| 1113 | @@ -0,0 +1,27 @@ |
| 1114 | +import { describe, it, expect } from "vitest"; |
| 1115 | +import { newEvent, newKey, pub, now, rand, sockets } from "./helpers.ts"; |
| 1116 | + |
| 1117 | +const connect = sockets(); |
| 1118 | + |
| 1119 | +describe("NIP-50 search", () => { |
| 1120 | + it("matches words in content, case-insensitively, ignores extensions, is injection-safe, and matches live", async () => { |
| 1121 | + const sk = newKey(); |
| 1122 | + const c = await connect(); |
| 1123 | + const w = rand(); |
| 1124 | + const texts = [`Purple ${w}elephants dance`, `${w}orange juice`, `the ${w}elephant in the room`]; |
| 1125 | + for (const [i, t] of texts.entries()) await c.publish(newEvent(sk, 1, t, [], now() - 10 + i)); |
| 1126 | + await c.publish(newEvent(sk, 4, `${w}elephant encrypted`, [["p", pub(sk)]], now() - 5)); |
| 1127 | + |
| 1128 | + let r = await c.req({ search: `${w}elephant`, authors: [pub(sk)] }); |
| 1129 | + expect(r.events.map((e) => e.content)).toEqual([texts[2]]); |
| 1130 | + r = await c.req({ search: `PURPLE include:spam dance ${w}elephants`, authors: [pub(sk)] }); |
| 1131 | + expect(r.events.map((e) => e.content)).toEqual([texts[0]]); |
| 1132 | + r = await c.req({ search: `${w}juice OR NOT "`, authors: [pub(sk)] }); |
| 1133 | + expect(r.hints).toContain("finish"); |
| 1134 | + |
| 1135 | + c.send("REQ", "live", { search: `${w}banana`, authors: [pub(sk)] }); |
| 1136 | + await c.drain(); |
| 1137 | + await c.publish(newEvent(sk, 1, `a ${w}Banana split`)); |
| 1138 | + await c.expect("EVENT"); |
| 1139 | + }); |
| 1140 | +}); |
| 1141 | diff --git a/scripts/conformance/nip5a.test.ts b/scripts/conformance/nip5a.test.ts |
| 1142 | new file mode 100644 |
| 1143 | index 0000000..6af4f85 |
| 1144 | --- /dev/null |
| 1145 | +++ b/scripts/conformance/nip5a.test.ts |
| 1146 | @@ -0,0 +1,131 @@ |
| 1147 | +// NIP-5A from outside the relay: discover the host forms from NIP-11, upload |
| 1148 | +// the files through Blossom, publish signed manifests, and fetch each site |
| 1149 | +// through its canonical single-label hostname. |
| 1150 | +import { describe, it, expect } from "vitest"; |
| 1151 | +import { sha256 } from "@noble/hashes/sha2.js"; |
| 1152 | +import { newEvent, newKey, pub, hex, HTTP_URL, RELAY_URL, now, sleep, Client } from "./helpers.ts"; |
| 1153 | +import { npubEncode } from "nostr-tools/nip19"; |
| 1154 | + |
| 1155 | +function blossomToken(sk: Uint8Array, action: string, hash?: string): string { |
| 1156 | + const tags = [["t", action], ...(hash ? [["x", hash]] : []), ["expiration", String(now() + 300)]]; |
| 1157 | + return "Nostr " + Buffer.from(JSON.stringify(newEvent(sk, 24242, action, tags))).toString("base64"); |
| 1158 | +} |
| 1159 | + |
| 1160 | +async function upload(sk: Uint8Array, body: string, type: string): Promise<string> { |
| 1161 | + const bytes = new TextEncoder().encode(body); |
| 1162 | + const hash = hex(sha256(bytes)); |
| 1163 | + const response = await fetch(`${HTTP_URL}/upload`, { |
| 1164 | + method: "PUT", |
| 1165 | + headers: { authorization: blossomToken(sk, "upload", hash), "content-type": type }, |
| 1166 | + body: bytes, |
| 1167 | + }); |
| 1168 | + expect(response.status, await response.clone().text()).toBe(200); |
| 1169 | + const descriptor: any = await response.json(); |
| 1170 | + expect(descriptor.sha256).toBe(hash); |
| 1171 | + return hash; |
| 1172 | +} |
| 1173 | + |
| 1174 | +function siteHash(paths: string[][]): string { |
| 1175 | + const lines = paths.map((t) => `${t[2]} ${t[1]}\n`).sort().join(""); |
| 1176 | + return hex(sha256(new TextEncoder().encode(lines))); |
| 1177 | +} |
| 1178 | + |
| 1179 | +function discoveredHost(template: string, replacements: Record<string, string>): string { |
| 1180 | + let value = template; |
| 1181 | + for (const [key, replacement] of Object.entries(replacements)) value = value.replace(`<${key}>`, replacement); |
| 1182 | + return new URL(value).hostname; |
| 1183 | +} |
| 1184 | + |
| 1185 | +// siteFetch keeps Wrangler runs local while using the canonical hostname |
| 1186 | +// when RELAY_URL points at a public relay. |
| 1187 | +function siteFetch(host: string, path: string, init: RequestInit = {}) { |
| 1188 | + const endpoint = new URL(HTTP_URL); |
| 1189 | + const local = endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1" || endpoint.hostname === "[::1]" || endpoint.hostname === "::1"; |
| 1190 | + endpoint.hostname = local ? `${host.split(".")[0]}.localhost` : host; |
| 1191 | + endpoint.pathname = path; |
| 1192 | + return fetch(endpoint, init); |
| 1193 | +} |
| 1194 | + |
| 1195 | +async function waitForSite(host: string): Promise<void> { |
| 1196 | + for (let attempt = 0; attempt < 20; attempt++) { |
| 1197 | + const response = await siteFetch(host, "/"); |
| 1198 | + if (response.status === 200) { await response.arrayBuffer(); return; } |
| 1199 | + await response.arrayBuffer(); |
| 1200 | + await sleep(100); |
| 1201 | + } |
| 1202 | + throw new Error(`site index did not become visible at ${host}`); |
| 1203 | +} |
| 1204 | + |
| 1205 | +describe("NIP-5A static websites", () => { |
| 1206 | + it("discovers canonical labels, serves root/named/snapshot sites, and applies fallbacks", async () => { |
| 1207 | + const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } }); |
| 1208 | + expect(infoResponse.status).toBe(200); |
| 1209 | + const doc: any = await infoResponse.json(); |
| 1210 | + expect(doc.nsites).toMatchObject({ kinds: [15128, 35128, 5128] }); |
| 1211 | + expect(typeof doc.nsites.root).toBe("string"); |
| 1212 | + expect(typeof doc.nsites.named).toBe("string"); |
| 1213 | + expect(typeof doc.nsites.snapshot).toBe("string"); |
| 1214 | + |
| 1215 | + const sk = newKey(); |
| 1216 | + const index = "<!doctype html><title>root</title>"; |
| 1217 | + const blogIndex = "<!doctype html><title>blog</title>"; |
| 1218 | + const notFound = "<!doctype html><title>missing</title>"; |
| 1219 | + const indexHash = await upload(sk, index, "text/html; charset=utf-8"); |
| 1220 | + const blogHash = await upload(sk, blogIndex, "text/html; charset=utf-8"); |
| 1221 | + const notFoundHash = await upload(sk, notFound, "text/html; charset=utf-8"); |
| 1222 | + const paths = [["path", "/index.html", indexHash], ["path", "/blog/index.html", blogHash], ["path", "/404.html", notFoundHash]]; |
| 1223 | + const aggregate = siteHash(paths); |
| 1224 | + const c = await Client.connect(RELAY_URL); |
| 1225 | + try { |
| 1226 | + const root = newEvent(sk, 15128, "", paths); |
| 1227 | + await c.publish(root); |
| 1228 | + const named = newEvent(sk, 35128, "", [...paths, ["d", "demo-site"]]); |
| 1229 | + await c.publish(named); |
| 1230 | + const snapshot = newEvent(sk, 5128, "", [...paths, ["x", aggregate, "aggregate"], ["a", `15128:${pub(sk)}:`]]); |
| 1231 | + await c.publish(snapshot); |
| 1232 | + |
| 1233 | + const replacements = { npub: npubEncode(pub(sk)) }; |
| 1234 | + const rootHost = discoveredHost(doc.nsites.root, replacements); |
| 1235 | + const namedHost = discoveredHost(doc.nsites.named, { pubkeyB36: base36(pub(sk)), dTag: "demo-site" }); |
| 1236 | + const snapshotHost = discoveredHost(doc.nsites.snapshot, { snapshotIdB36: base36(snapshot.id) }); |
| 1237 | + // The event acknowledgement and KV index update are separate async |
| 1238 | + // tasks on some deployments, so wait for each discovered label rather |
| 1239 | + // than relying on a fixed delay. |
| 1240 | + for (const host of [rootHost, namedHost, snapshotHost]) await waitForSite(host); |
| 1241 | + |
| 1242 | + for (const host of [rootHost, namedHost, snapshotHost]) { |
| 1243 | + const response = await siteFetch(host, "/"); |
| 1244 | + expect(response.status, host).toBe(200); |
| 1245 | + expect(await response.text(), host).toBe(index); |
| 1246 | + expect(response.headers.get("content-type")).toContain("text/html"); |
| 1247 | + expect(response.headers.get("etag")).toBe(`"${indexHash}"`); |
| 1248 | + expect(response.headers.get("cache-control")).toContain("public"); |
| 1249 | + expect(response.headers.get("cache-control")).toContain("no-transform"); |
| 1250 | + const head = await siteFetch(host, "/index.html", { method: "HEAD" }); |
| 1251 | + expect(head.status).toBe(200); |
| 1252 | + expect(head.headers.get("content-length")).toBe(String(new TextEncoder().encode(index).length)); |
| 1253 | + |
| 1254 | + const blog = await siteFetch(host, "/blog/"); |
| 1255 | + expect(blog.status).toBe(200); |
| 1256 | + expect(await blog.text()).toBe(blogIndex); |
| 1257 | + const fallback = await siteFetch(host, "/does-not-exist.txt"); |
| 1258 | + expect(fallback.status).toBe(404); |
| 1259 | + expect(await fallback.text()).toBe(notFound); |
| 1260 | + const redirect = await siteFetch(host, "/blog", { redirect: "manual" }); |
| 1261 | + expect(redirect.status).toBe(308); |
| 1262 | + expect(new URL(redirect.headers.get("location")!).pathname).toBe("/blog/"); |
| 1263 | + } |
| 1264 | + |
| 1265 | + const notFoundSite = await siteFetch(rootHost, "/no-extension", { redirect: "manual" }); |
| 1266 | + expect(notFoundSite.status).toBe(308); |
| 1267 | + const missingManifest = await siteFetch(discoveredHost(doc.nsites.root, { npub: npubEncode(pub(newKey())) }), "/"); |
| 1268 | + expect(missingManifest.status).toBe(404); |
| 1269 | + } finally { |
| 1270 | + c.close(); |
| 1271 | + } |
| 1272 | + }); |
| 1273 | +}); |
| 1274 | + |
| 1275 | +function base36(hexKey: string): string { |
| 1276 | + return BigInt("0x" + hexKey).toString(36).padStart(50, "0"); |
| 1277 | +} |
| 1278 | diff --git a/scripts/conformance/nip62.test.ts b/scripts/conformance/nip62.test.ts |
| 1279 | new file mode 100644 |
| 1280 | index 0000000..d20669b |
| 1281 | --- /dev/null |
| 1282 | +++ b/scripts/conformance/nip62.test.ts |
| 1283 | @@ -0,0 +1,39 @@ |
| 1284 | +import { describe, it, expect } from "vitest"; |
| 1285 | +import { newEvent, newKey, pub, now, RELAY_URL, sockets } from "./helpers.ts"; |
| 1286 | + |
| 1287 | +const connect = sockets(); |
| 1288 | + |
| 1289 | +describe("NIP-62 vanish", () => { |
| 1290 | + it("deletes everything from the pubkey up to the request, plus gift wraps to it, and blocks resurrection", async () => { |
| 1291 | + const sk = newKey(); |
| 1292 | + const other = newKey(); |
| 1293 | + const c = await connect(); |
| 1294 | + const t0 = now() - 100; |
| 1295 | + const note = newEvent(sk, 1, "regret", [], t0); |
| 1296 | + const meta = newEvent(sk, 0, "{}", [], t0); |
| 1297 | + const wrap = newEvent(other, 1059, "dm", [["p", pub(sk)]], t0); |
| 1298 | + const keep = newEvent(other, 1, "unrelated", [], t0); |
| 1299 | + for (const e of [note, meta, wrap, keep]) await c.publish(e); |
| 1300 | + |
| 1301 | + let v = newEvent(sk, 62, "", [["relay", "wss://other.example"]], t0 + 1); |
| 1302 | + c.send("EVENT", v); |
| 1303 | + await c.expectOK(v.id, false); |
| 1304 | + expect((await c.req({ authors: [pub(sk)] })).events.length).toBe(2); |
| 1305 | + |
| 1306 | + v = newEvent(sk, 62, "bye", [["relay", RELAY_URL]], t0 + 1); |
| 1307 | + await c.publish(v); |
| 1308 | + expect((await c.req({ authors: [pub(sk)] })).events).toEqual([]); |
| 1309 | + expect((await c.req({ ids: [keep.id] })).events.length).toBe(1); |
| 1310 | + const rc = await connect(); |
| 1311 | + await rc.auth(sk); |
| 1312 | + expect((await rc.req({ kinds: [1059], "#p": [pub(sk)] })).events).toEqual([]); |
| 1313 | + |
| 1314 | + c.send("EVENT", note); |
| 1315 | + expect(await c.expectOK(note.id, false)).toMatch(/^blocked:/); |
| 1316 | + await c.publish(newEvent(sk, 1, "fresh start", [], t0 + 2)); |
| 1317 | + |
| 1318 | + v = newEvent(other, 62, "", [["relay", "ALL_RELAYS"]], t0 + 3); |
| 1319 | + await c.publish(v); |
| 1320 | + expect((await c.req({ authors: [pub(other)] })).events).toEqual([]); |
| 1321 | + }); |
| 1322 | +}); |
| 1323 | diff --git a/scripts/conformance/nip70.test.ts b/scripts/conformance/nip70.test.ts |
| 1324 | new file mode 100644 |
| 1325 | index 0000000..6f01909 |
| 1326 | --- /dev/null |
| 1327 | +++ b/scripts/conformance/nip70.test.ts |
| 1328 | @@ -0,0 +1,19 @@ |
| 1329 | +import { describe, it, expect } from "vitest"; |
| 1330 | +import { newEvent, newKey, sockets } from "./helpers.ts"; |
| 1331 | + |
| 1332 | +const connect = sockets(); |
| 1333 | + |
| 1334 | +describe("NIP-70 protected events", () => { |
| 1335 | + it("accepts ['-'] events only from a connection authenticated as the author", async () => { |
| 1336 | + const sk = newKey(); |
| 1337 | + const c = await connect(); |
| 1338 | + const e = newEvent(sk, 1, "members only", [["-"]]); |
| 1339 | + c.send("EVENT", e); |
| 1340 | + expect(await c.expectOK(e.id, false)).toMatch(/^auth-required:/); |
| 1341 | + await c.auth(newKey()); |
| 1342 | + c.send("EVENT", e); |
| 1343 | + await c.expectOK(e.id, false); |
| 1344 | + await c.auth(sk); |
| 1345 | + await c.publish(e); |
| 1346 | + }); |
| 1347 | +}); |
| 1348 | diff --git a/scripts/conformance/nip77.test.ts b/scripts/conformance/nip77.test.ts |
| 1349 | new file mode 100644 |
| 1350 | index 0000000..1a0afa5 |
| 1351 | --- /dev/null |
| 1352 | +++ b/scripts/conformance/nip77.test.ts |
| 1353 | @@ -0,0 +1,47 @@ |
| 1354 | +import { describe, it, expect } from "vitest"; |
| 1355 | +import { newEvent, newKey, pub, now, item, sockets } from "./helpers.ts"; |
| 1356 | + |
| 1357 | +const connect = sockets(); |
| 1358 | + |
| 1359 | +describe("NIP-77 negentropy", () => { |
| 1360 | + it("reconciles a client set against the relay's, respecting visibility", async () => { |
| 1361 | + const sk = newKey(); |
| 1362 | + const recipient = newKey(); |
| 1363 | + const c = await connect(); |
| 1364 | + const t0 = now() - 1000; |
| 1365 | + const relayHas = []; |
| 1366 | + for (let i = 0; i < 100; i++) relayHas.push(await (async () => { |
| 1367 | + const e = newEvent(sk, 1, String(i), [], t0 + Math.floor(i / 3)); |
| 1368 | + await c.publish(e); |
| 1369 | + return e; |
| 1370 | + })()); |
| 1371 | + const local = []; |
| 1372 | + for (let i = 0; i < 5; i++) local.push(newEvent(sk, 1, "local " + i, [], t0 + 10 + i)); |
| 1373 | + const wrap = newEvent(sk, 1059, "dm", [["p", pub(recipient)]], t0 + 5); |
| 1374 | + await c.publish(wrap); |
| 1375 | + |
| 1376 | + const clientItems = [...relayHas.slice(0, 60), ...local].map(item); |
| 1377 | + const { have, need } = await c.sync(clientItems, { kinds: [1], authors: [pub(sk)] }); |
| 1378 | + expect(have).toEqual(local.map((e) => e.id).sort()); |
| 1379 | + expect(need).toEqual(relayHas.slice(60).map((e) => e.id).sort()); |
| 1380 | + |
| 1381 | + // Strangers never learn the gift wrap; the recipient does. |
| 1382 | + let r = await c.sync(relayHas.map(item), { authors: [pub(sk)] }); |
| 1383 | + expect(r.need).toEqual([]); |
| 1384 | + const rc = await connect(); |
| 1385 | + await rc.auth(recipient); |
| 1386 | + r = await rc.sync(relayHas.map(item), { authors: [pub(sk)] }); |
| 1387 | + expect(r.need).toEqual([wrap.id]); |
| 1388 | + |
| 1389 | + // Error paths. |
| 1390 | + c.send("NEG-MSG", "nope", "61"); |
| 1391 | + expect((await c.expect("NEG-ERR"))[2]).toMatch(/^closed:/); |
| 1392 | + c.send("NEG-OPEN", "bad", {}, "zz"); |
| 1393 | + expect((await c.expect("NEG-ERR"))[2]).toMatch(/^invalid:/); |
| 1394 | + const x = await connect(); |
| 1395 | + x.send("NEG-OPEN", "dm", { kinds: [1059] }, "61"); |
| 1396 | + expect((await x.expect("NEG-ERR"))[2]).toMatch(/^auth-required:/); |
| 1397 | + x.send("NEG-OPEN", "v", { kinds: [1], authors: [pub(sk)] }, "62"); |
| 1398 | + expect((await x.expect("NEG-MSG"))[2]).toBe("61"); |
| 1399 | + }); |
| 1400 | +}); |
| 1401 | diff --git a/scripts/conformance/run.mjs b/scripts/conformance/run.mjs |
| 1402 | index 4cb29e6..8455047 100644 |
| 1403 | --- a/scripts/conformance/run.mjs |
| 1404 | +++ b/scripts/conformance/run.mjs |
| 1405 | @@ -7,8 +7,8 @@ const root = resolve(import.meta.dirname, "../.."); |
| 1406 | const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); |
| 1407 | const artifactDir = resolve(process.env.CONFORMANCE_ARTIFACTS ?? resolve(root, "artifacts/conformance"), stamp); |
| 1408 | mkdirSync(artifactDir, { recursive: true }); |
| 1409 | -const env = { ...process.env, BINDWS_ROOT: process.env.BINDWS_ROOT ?? resolve(root, "../bindws"), RELAY_URL: process.env.RELAY_URL ?? "ws://127.0.0.1:7447" }; |
| 1410 | -writeFileSync(resolve(artifactDir, "environment.txt"), Object.entries({ RELAY_URL: env.RELAY_URL, BINDWS_ROOT: env.BINDWS_ROOT, NODE: process.version, PLATFORM: process.platform, ARCH: process.arch }).map(([key, value]) => `${key}=${value}`).join("\n") + "\n"); |
| 1411 | +const env = { ...process.env, RELAY_URL: process.env.RELAY_URL ?? "ws://127.0.0.1:7447" }; |
| 1412 | +writeFileSync(resolve(artifactDir, "environment.txt"), Object.entries({ RELAY_URL: env.RELAY_URL, NODE: process.version, PLATFORM: process.platform, ARCH: process.arch }).map(([key, value]) => `${key}=${value}`).join("\n") + "\n"); |
| 1413 | const result = spawnSync(process.execPath, [resolve(root, "node_modules/vitest/vitest.mjs"), "run", "--config", resolve(root, "scripts/conformance/vitest.config.mts"), ...process.argv.slice(2)], { cwd: root, env, encoding: "utf8", stdio: ["inherit", "pipe", "pipe"] }); |
| 1414 | process.stdout.write(result.stdout ?? ""); |
| 1415 | process.stderr.write(result.stderr ?? ""); |
| 1416 | diff --git a/scripts/conformance/setup.ts b/scripts/conformance/setup.ts |
| 1417 | index de9fdfc..4847385 100644 |
| 1418 | --- a/scripts/conformance/setup.ts |
| 1419 | +++ b/scripts/conformance/setup.ts |
| 1420 | @@ -1,8 +1,9 @@ |
| 1421 | +// Global setup: provision the tenant under test with the tiny CLI so the |
| 1422 | +// conformance suite writes as its owner. Set TINY_PREPROVISIONED=1 to skip. |
| 1423 | import { execFileSync } from "node:child_process"; |
| 1424 | -import { getPublicKey, finalizeEvent } from "nostr-tools/pure"; |
| 1425 | -import { getToken } from "nostr-tools/nip98"; |
| 1426 | +import { getPublicKey } from "nostr-tools/pure"; |
| 1427 | import { sha256 } from "@noble/hashes/sha2.js"; |
| 1428 | -import { hexToBytes } from "../../../bindws/src/negentropy.ts"; |
| 1429 | +import { hexToBytes } from "@noble/hashes/utils.js"; |
| 1430 | |
| 1431 | const relayURL = process.env.RELAY_URL ?? "ws://127.0.0.1:7447"; |
| 1432 | const httpURL = relayURL.replace(/^ws/, "http").replace(/\/$/, "") + "/"; |
| 1433 | @@ -10,26 +11,17 @@ const owner = process.env.CLAIM_SK |
| 1434 | ? hexToBytes(process.env.CLAIM_SK) |
| 1435 | : sha256(new TextEncoder().encode("tiny conformance owner")); |
| 1436 | |
| 1437 | -function provisionWithTiny() { |
| 1438 | +function provision() { |
| 1439 | const bin = process.env.TINY_BIN ?? "tiny"; |
| 1440 | const name = process.env.TINY_TENANT ?? new URL(httpURL).hostname.split(".")[0]; |
| 1441 | const template = process.env.TINY_TEMPLATE ?? "default"; |
| 1442 | - execFileSync(bin, ["tenant", "create", "--name", name, "--owner", getPublicKey(owner), "--template", template], { stdio: "inherit" }); |
| 1443 | -} |
| 1444 | - |
| 1445 | -async function provisionLegacy() { |
| 1446 | - const payload = { method: "claim", params: [] }; |
| 1447 | - const body = JSON.stringify(payload); |
| 1448 | - const token = await getToken(httpURL, "POST", (event) => finalizeEvent(event, owner), true, payload); |
| 1449 | - const response = await fetch(httpURL, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body }); |
| 1450 | - const json: any = await response.json(); |
| 1451 | - if (!json.result?.claimed) throw new Error(`legacy claim failed: ${response.status} ${JSON.stringify(json)}`); |
| 1452 | - console.warn("conformance: LEGACY_CLAIM=1 uses bindws claim compatibility"); |
| 1453 | + const args = ["tenant", "create", "--name", name, "--owner", getPublicKey(owner), "--template", template]; |
| 1454 | + if (process.env.TINY_DATA_DIR) args.push("--data-dir", process.env.TINY_DATA_DIR); |
| 1455 | + execFileSync(bin, args, { stdio: "inherit" }); |
| 1456 | } |
| 1457 | |
| 1458 | export default async function setup() { |
| 1459 | - if (process.env.LEGACY_CLAIM === "1") await provisionLegacy(); |
| 1460 | - else if (process.env.TINY_PREPROVISIONED !== "1") provisionWithTiny(); |
| 1461 | + if (process.env.TINY_PREPROVISIONED !== "1") provision(); |
| 1462 | const response = await fetch(httpURL, { headers: { accept: "application/nostr+json" } }); |
| 1463 | if (!response.ok) throw new Error(`relay did not become ready at ${httpURL}: ${response.status}`); |
| 1464 | console.log(`conformance tenant ready at ${relayURL} as ${getPublicKey(owner)}`); |
| 1465 | diff --git a/scripts/conformance/vitest.config.mts b/scripts/conformance/vitest.config.mts |
| 1466 | index 02b6350..a6c83ad 100644 |
| 1467 | --- a/scripts/conformance/vitest.config.mts |
| 1468 | +++ b/scripts/conformance/vitest.config.mts |
| 1469 | @@ -1,18 +1,14 @@ |
| 1470 | import { defineConfig } from "vitest/config"; |
| 1471 | import { resolve } from "node:path"; |
| 1472 | |
| 1473 | -const bindws = resolve(process.env.BINDWS_ROOT ?? "../bindws"); |
| 1474 | -const compatibilityNip11 = resolve(import.meta.dirname, "nip11.compat.test.ts"); |
| 1475 | - |
| 1476 | export default defineConfig({ |
| 1477 | - root: bindws, |
| 1478 | + root: resolve(import.meta.dirname, "../.."), |
| 1479 | test: { |
| 1480 | - include: ["test/conformance/**/*.test.ts", compatibilityNip11], |
| 1481 | - exclude: [resolve(bindws, "test/conformance/nip11.test.ts")], |
| 1482 | + include: ["scripts/conformance/**/*.test.ts"], |
| 1483 | environment: "node", |
| 1484 | testTimeout: 60_000, |
| 1485 | hookTimeout: 60_000, |
| 1486 | fileParallelism: false, |
| 1487 | - globalSetup: [resolve(process.cwd(), "scripts/conformance/setup.ts")] |
| 1488 | + globalSetup: [resolve(import.meta.dirname, "setup.ts")] |
| 1489 | } |
| 1490 | }); |
| 1491 | diff --git a/scripts/qa/nip46-bunker.cjs b/scripts/qa/nip46-bunker.cjs |
| 1492 | deleted file mode 100644 |
| 1493 | index 558a038..0000000 |
| 1494 | --- a/scripts/qa/nip46-bunker.cjs |
| 1495 | +++ /dev/null |
| 1496 | @@ -1,9 +0,0 @@ |
| 1497 | -const path=require('path');const {createRequire}=require('module');const deps=createRequire(path.resolve(__dirname,'../../../bindws/node_modules/'));const {WebSocketServer,WebSocket}=deps('ws'); global.WebSocket=WebSocket; |
| 1498 | -const {generateSecretKey,getPublicKey,finalizeEvent}=deps('nostr-tools'); const nip44=require(path.join(path.dirname(deps.resolve('nostr-tools')),'nip44.js')); |
| 1499 | -const wss=new WebSocketServer({port:8789}); const clients=new Map(),events=[]; const bunkerSK=generateSecretKey(), bunkerPK=getPublicKey(bunkerSK), userSK=generateSecretKey(), userPK=getPublicKey(userSK); |
| 1500 | -function match(ev,f){if(f.kinds&&!f.kinds.includes(ev.kind))return false;if(f.authors&&!f.authors.includes(ev.pubkey))return false;for(const [k,vals] of Object.entries(f))if(k[0]==='#'&&!ev.tags.some(t=>t[0]===k.slice(1)&&vals.includes(t[1])))return false;return true} |
| 1501 | -wss.on('connection',ws=>{const subs=new Map();clients.set(ws,subs);ws.on('message',raw=>{let m;try{m=JSON.parse(raw)}catch{return}if(m[0]==='REQ'){const [id,...fs]=m.slice(1);subs.set(id,fs);for(const ev of events)if(fs.some(f=>match(ev,f)))ws.send(JSON.stringify(['EVENT',id,ev]));ws.send(JSON.stringify(['EOSE',id]));}else if(m[0]==='EVENT'){const ev=m[1];events.push(ev);for(const [peer,ps] of clients)for(const [id,fs] of ps)if(fs.some(f=>match(ev,f)))peer.send(JSON.stringify(['EVENT',id,ev]));ws.send(JSON.stringify(['OK',ev.id,true,'']))}});ws.on('close',()=>clients.delete(ws));}); |
| 1502 | -function publish(obj,clientPK){const conv=nip44.getConversationKey(bunkerSK,clientPK);const ev=finalizeEvent({kind:24133,tags:[['p',clientPK]],content:nip44.encrypt(JSON.stringify(obj),conv),created_at:Math.floor(Date.now()/1000)},bunkerSK);events.push(ev);for(const [peer,ps] of clients)for(const [id,fs] of ps)if(fs.some(f=>match(ev,f)))peer.send(JSON.stringify(['EVENT',id,ev]));} |
| 1503 | -setInterval(()=>{for(const ev of events){if(ev.kind!==24133||ev.pubkey===bunkerPK||ev._handled)continue;ev._handled=true;try{const clientPK=ev.pubkey,conv=nip44.getConversationKey(bunkerSK,clientPK),req=JSON.parse(nip44.decrypt(ev.content,conv));let result='';if(req.method==='connect')result='ack';else if(req.method==='ping')result='pong';else if(req.method==='get_public_key')result=userPK;else if(req.method==='sign_event'){const x=JSON.parse(req.params[0]);result=JSON.stringify(finalizeEvent(x,userSK));}publish({id:req.id,result},clientPK);}catch(e){console.error('handler',e.message)}}},50); |
| 1504 | -console.log(`bunker://?relay=ws%3A%2F%2F127.0.0.1%3A8789&pubkey=${bunkerPK}`); // UI parser expects pubkey in host; print corrected below |
| 1505 | -console.log(`bunker://${bunkerPK}?relay=ws%3A%2F%2F127.0.0.1%3A8789`); |
| 1506 | diff --git a/scripts/qa/nip46-bunker.mjs b/scripts/qa/nip46-bunker.mjs |
| 1507 | new file mode 100644 |
| 1508 | index 0000000..558a038 |
| 1509 | --- /dev/null |
| 1510 | +++ b/scripts/qa/nip46-bunker.mjs |
| 1511 | @@ -0,0 +1,9 @@ |
| 1512 | +const path=require('path');const {createRequire}=require('module');const deps=createRequire(path.resolve(__dirname,'../../../bindws/node_modules/'));const {WebSocketServer,WebSocket}=deps('ws'); global.WebSocket=WebSocket; |
| 1513 | +const {generateSecretKey,getPublicKey,finalizeEvent}=deps('nostr-tools'); const nip44=require(path.join(path.dirname(deps.resolve('nostr-tools')),'nip44.js')); |
| 1514 | +const wss=new WebSocketServer({port:8789}); const clients=new Map(),events=[]; const bunkerSK=generateSecretKey(), bunkerPK=getPublicKey(bunkerSK), userSK=generateSecretKey(), userPK=getPublicKey(userSK); |
| 1515 | +function match(ev,f){if(f.kinds&&!f.kinds.includes(ev.kind))return false;if(f.authors&&!f.authors.includes(ev.pubkey))return false;for(const [k,vals] of Object.entries(f))if(k[0]==='#'&&!ev.tags.some(t=>t[0]===k.slice(1)&&vals.includes(t[1])))return false;return true} |
| 1516 | +wss.on('connection',ws=>{const subs=new Map();clients.set(ws,subs);ws.on('message',raw=>{let m;try{m=JSON.parse(raw)}catch{return}if(m[0]==='REQ'){const [id,...fs]=m.slice(1);subs.set(id,fs);for(const ev of events)if(fs.some(f=>match(ev,f)))ws.send(JSON.stringify(['EVENT',id,ev]));ws.send(JSON.stringify(['EOSE',id]));}else if(m[0]==='EVENT'){const ev=m[1];events.push(ev);for(const [peer,ps] of clients)for(const [id,fs] of ps)if(fs.some(f=>match(ev,f)))peer.send(JSON.stringify(['EVENT',id,ev]));ws.send(JSON.stringify(['OK',ev.id,true,'']))}});ws.on('close',()=>clients.delete(ws));}); |
| 1517 | +function publish(obj,clientPK){const conv=nip44.getConversationKey(bunkerSK,clientPK);const ev=finalizeEvent({kind:24133,tags:[['p',clientPK]],content:nip44.encrypt(JSON.stringify(obj),conv),created_at:Math.floor(Date.now()/1000)},bunkerSK);events.push(ev);for(const [peer,ps] of clients)for(const [id,fs] of ps)if(fs.some(f=>match(ev,f)))peer.send(JSON.stringify(['EVENT',id,ev]));} |
| 1518 | +setInterval(()=>{for(const ev of events){if(ev.kind!==24133||ev.pubkey===bunkerPK||ev._handled)continue;ev._handled=true;try{const clientPK=ev.pubkey,conv=nip44.getConversationKey(bunkerSK,clientPK),req=JSON.parse(nip44.decrypt(ev.content,conv));let result='';if(req.method==='connect')result='ack';else if(req.method==='ping')result='pong';else if(req.method==='get_public_key')result=userPK;else if(req.method==='sign_event'){const x=JSON.parse(req.params[0]);result=JSON.stringify(finalizeEvent(x,userSK));}publish({id:req.id,result},clientPK);}catch(e){console.error('handler',e.message)}}},50); |
| 1519 | +console.log(`bunker://?relay=ws%3A%2F%2F127.0.0.1%3A8789&pubkey=${bunkerPK}`); // UI parser expects pubkey in host; print corrected below |
| 1520 | +console.log(`bunker://${bunkerPK}?relay=ws%3A%2F%2F127.0.0.1%3A8789`); |