tinyrelaycommit

Commit a24bb5cd0b22

browse files at this commit | history

1diff --git a/docs/testing.md b/docs/testing.md
2index 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
14diff --git a/package.json b/package.json
15index 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": {
25diff --git a/scripts/conformance/README.md b/scripts/conformance/README.md
26index 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.
54diff --git a/scripts/conformance/blossom.test.ts b/scripts/conformance/blossom.test.ts
55new file mode 100644
56index 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+});
117diff --git a/scripts/conformance/grasp.test.ts b/scripts/conformance/grasp.test.ts
118new file mode 100644
119index 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+});
234diff --git a/scripts/conformance/helpers.ts b/scripts/conformance/helpers.ts
235new file mode 100644
236index 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+}
447diff --git a/scripts/conformance/negentropy.ts b/scripts/conformance/negentropy.ts
448new file mode 100644
449index 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+}
759diff --git a/scripts/conformance/nip01.test.ts b/scripts/conformance/nip01.test.ts
760new file mode 100644
761index 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+});
860diff --git a/scripts/conformance/nip09.test.ts b/scripts/conformance/nip09.test.ts
861new file mode 100644
862index 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+});
899diff --git a/scripts/conformance/nip11.compat.test.ts b/scripts/conformance/nip11.compat.test.ts
900deleted file mode 100644
901index 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-});
922diff --git a/scripts/conformance/nip11.test.ts b/scripts/conformance/nip11.test.ts
923new file mode 100644
924index 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+});
945diff --git a/scripts/conformance/nip17.test.ts b/scripts/conformance/nip17.test.ts
946new file mode 100644
947index 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+});
998diff --git a/scripts/conformance/nip40.test.ts b/scripts/conformance/nip40.test.ts
999new file mode 100644
1000index 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+});
1025diff --git a/scripts/conformance/nip42.test.ts b/scripts/conformance/nip42.test.ts
1026new file mode 100644
1027index 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+});
1061diff --git a/scripts/conformance/nip45.test.ts b/scripts/conformance/nip45.test.ts
1062new file mode 100644
1063index 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+});
1108diff --git a/scripts/conformance/nip50.test.ts b/scripts/conformance/nip50.test.ts
1109new file mode 100644
1110index 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+});
1141diff --git a/scripts/conformance/nip5a.test.ts b/scripts/conformance/nip5a.test.ts
1142new file mode 100644
1143index 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+}
1278diff --git a/scripts/conformance/nip62.test.ts b/scripts/conformance/nip62.test.ts
1279new file mode 100644
1280index 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+});
1323diff --git a/scripts/conformance/nip70.test.ts b/scripts/conformance/nip70.test.ts
1324new file mode 100644
1325index 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+});
1348diff --git a/scripts/conformance/nip77.test.ts b/scripts/conformance/nip77.test.ts
1349new file mode 100644
1350index 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+});
1401diff --git a/scripts/conformance/run.mjs b/scripts/conformance/run.mjs
1402index 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 ?? "");
1416diff --git a/scripts/conformance/setup.ts b/scripts/conformance/setup.ts
1417index 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)}`);
1465diff --git a/scripts/conformance/vitest.config.mts b/scripts/conformance/vitest.config.mts
1466index 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 });
1491diff --git a/scripts/qa/nip46-bunker.cjs b/scripts/qa/nip46-bunker.cjs
1492deleted file mode 100644
1493index 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`);
1506diff --git a/scripts/qa/nip46-bunker.mjs b/scripts/qa/nip46-bunker.mjs
1507new file mode 100644
1508index 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`);

About

ownerc01503e8035f
visibilitypublic
heada24bb5cd0b22

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs