clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

vendor the conformance suite

Dami committed a24bb5c
Copy the black-box NIP and GRASP conformance cases and the NIP-77 client
into scripts/conformance so the suite runs from this checkout alone.
Setup provisions the tenant with the tiny CLI; the legacy claim path is
gone.
commit
a24bb5cd0b22c686bf0fde3d4fad9c16927b2e4b
parent
4eb2c99fd6cc23a5da8e2876035ad397dbe63151
author
Dami <dami.etoile@gmail.com>
23 files changed, +1232 -39
Mdocs/testing.md2+-
Mpackage.json2+-
Mscripts/conformance/README.md16+-
Ascripts/conformance/blossom.test.ts57++++++
Ascripts/conformance/grasp.test.ts111+++++++++++
Ascripts/conformance/helpers.ts207++++++++++++++++++++
Ascripts/conformance/negentropy.ts306++++++++++++++++++++++++++++++
Ascripts/conformance/nip01.test.ts95+++++++++
Ascripts/conformance/nip09.test.ts33+++
Rscripts/conformance/nip11.compat.test.ts → scripts/conformance/nip11.test.ts0
Ascripts/conformance/nip17.test.ts47+++++
Ascripts/conformance/nip40.test.ts21++
Ascripts/conformance/nip42.test.ts30+++
Ascripts/conformance/nip45.test.ts41++++
Ascripts/conformance/nip50.test.ts27+++
Ascripts/conformance/nip5a.test.ts131+++++++++++++
Ascripts/conformance/nip62.test.ts39++++
Ascripts/conformance/nip70.test.ts19++
Ascripts/conformance/nip77.test.ts47+++++
Mscripts/conformance/run.mjs4+-
Mscripts/conformance/setup.ts26+--
Mscripts/conformance/vitest.config.mts10+-
Rscripts/qa/nip46-bunker.cjs → scripts/qa/nip46-bunker.mjs0
M docs/testing.md +1 -1
M package.json +1 -1
M scripts/conformance/README.md +5 -11
@@ -1,19 +1,13 @@
1-# Conformance harness1+# Conformance suite22 3-Start `tiny` on a local URL, then run:3+Black-box NIP and GRASP tests that speak plain websocket and HTTP to a running relay. Start `tiny` on a local URL, then run:44 55 ```sh66 RELAY_URL=ws://127.0.0.1:7447 node scripts/conformance/run.mjs77 ```88 9-The harness executes the original bindws black-box files from `../bindws`10-without modifying that checkout. By default setup provisions a permanent11-tenant with `tiny tenant create --name NAME --owner PUBKEY --template default`.12-Set `TINY_BIN`, `TINY_TENANT`, or `TINY_TEMPLATE` to adapt the command. The13-owner key is deterministic unless `CLAIM_SK` is supplied.9+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.1410 15-For comparison only, `LEGACY_CLAIM=1` invokes bindws's old NIP-86 `claim`16-setup. This compatibility path is not a tiny requirement and does not skip or17-alter test assertions.11+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`.1812 19-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.13+The NIP-11 test does not require a numeric `limitation.max_limit` because self-hosted relays have no hosted quota.
A scripts/conformance/blossom.test.ts +57 -0
@@ -0,0 +1,57 @@
1+// Files, from outside: a Blossom upload, the download with and without a2+// range, the listing and the delete. The one door that touches the media3+// bucket, so a host whose bucket is not R2 (docs/16) is checked here.4+import { describe, it, expect } from "vitest";5+import { sha256 } from "@noble/hashes/sha2.js";6+import { newEvent, newKey, pub, now, rand, hex, HTTP_URL } from "./helpers.ts";7+8+// A Blossom token: kind 24242 with the verb and the hash, base64 in the header.9+function token(sk: Uint8Array, verb: "upload" | "get" | "list" | "delete", sha?: string): string {10+ const tags = [["t", verb], ...(sha ? [["x", sha]] : []), ["expiration", String(now() + 300)]];11+ return "Nostr " + Buffer.from(JSON.stringify(newEvent(sk, 24242, verb, tags))).toString("base64");12+}13+14+describe("Blossom media", () => {15+ it("uploads a file, serves it whole and by range, lists it, and deletes it", async () => {16+ const sk = newKey();17+ const body = new TextEncoder().encode(`a small file ${rand()} with enough bytes for a range`);18+ const sha = hex(sha256(body));19+20+ const put = await fetch(`${HTTP_URL}/upload`, { method: "PUT", headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" }, body });21+ expect(put.status, await put.clone().text()).toBe(200);22+ const desc: any = await put.json();23+ expect([desc.sha256, desc.size, desc.type]).toEqual([sha, body.length, "text/plain"]);24+ expect(desc.url).toMatch(new RegExp(`/${sha}\\.txt$`));25+26+ // The whole file, with its type and hash-derived etag.27+ const get = await fetch(`${HTTP_URL}/${sha}.txt`);28+ expect(get.status).toBe(200);29+ expect(get.headers.get("content-type")).toBe("text/plain");30+ expect(new Uint8Array(await get.arrayBuffer())).toEqual(body);31+32+ // A byte range: the second word.33+ const part = await fetch(`${HTTP_URL}/${sha}`, { headers: { range: "bytes=2-6" } });34+ expect(part.status).toBe(206);35+ expect(part.headers.get("content-range")).toBe(`bytes 2-6/${body.length}`);36+ expect(await part.text()).toBe("small");37+38+ // HEAD says it is there without a body.39+ const head = await fetch(`${HTTP_URL}/${sha}`, { method: "HEAD" });40+ expect(head.status).toBe(200);41+ expect(await head.text()).toBe("");42+43+ // The uploader's listing carries it.44+ const list = await fetch(`${HTTP_URL}/list/${pub(sk)}`, { headers: { authorization: token(sk, "list") } });45+ expect(list.status).toBe(200);46+ expect((await list.json<any>()).map((d: any) => d.sha256)).toContain(sha);47+48+ // The uploader deletes it; the same bytes can come back afterwards.49+ const del = await fetch(`${HTTP_URL}/${sha}`, { method: "DELETE", headers: { authorization: token(sk, "delete", sha) } });50+ expect(del.status).toBe(204);51+ expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(404);52+ const again = await fetch(`${HTTP_URL}/upload`, { method: "PUT", headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" }, body });53+ expect(again.status).toBe(200);54+ expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(200);55+ await fetch(`${HTTP_URL}/${sha}`, { method: "DELETE", headers: { authorization: token(sk, "delete", sha) } });56+ });57+});
A scripts/conformance/grasp.test.ts +111 -0
@@ -0,0 +1,111 @@
1+// GRASP-01 conformance uses the stock Git client against the public Smart HTTP door.2+import { execFile } from "node:child_process";3+import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";4+import { tmpdir } from "node:os";5+import { promisify } from "node:util";6+import { describe, expect, it } from "vitest";7+import { finalizeEvent, getPublicKey, generateSecretKey } from "nostr-tools/pure";8+import { Client, HTTP_URL, RELAY_URL, sleep, now } from "./helpers.ts";9+import { npubEncode } from "nostr-tools/nip19";10+11+const exec = promisify(execFile);12+const KIND_REPO = 30617;13+const KIND_REPO_STATE = 30618;14+15+async function git(cwd: string, ...args: string[]): Promise<string> {16+ const result = await exec("git", args, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });17+ return result.stdout.trim();18+}19+20+function repositoryPath(npub: string, identifier: string): string {21+ return `/${npub}/${encodeURIComponent(identifier)}.git`;22+}23+24+async function waitForRefs(url: string): Promise<void> {25+ for (let attempt = 0; attempt < 60; attempt++) {26+ const response = await fetch(`${url}/info/refs?service=git-upload-pack`);27+ const text = await response.text();28+ if (response.status === 200 && text.includes("# service=git-upload-pack")) return;29+ await sleep(250);30+ }31+ throw new Error("GRASP repository did not become available");32+}33+34+async function publish(c: Client, sk: Uint8Array, kind: number, tags: string[][], createdAt: number): Promise<void> {35+ const event = finalizeEvent({ kind, content: "", tags, created_at: createdAt }, sk);36+ await c.publish(event);37+}38+39+describe("GRASP-01 stock Git", () => {40+ it("clones, pushes an accepted state, fetches incrementally, and supports partial clones", async ({ skip }) => {41+ const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } });42+ const info: any = await infoResponse.json();43+ if (!info.supported_grasps?.includes("GRASP-01")) skip();44+45+ const root = await mkdtemp(`${tmpdir()}/tiny-grasp-`);46+ const clone = await mkdtemp(`${tmpdir()}/tiny-grasp-clone-`);47+ const partial = await mkdtemp(`${tmpdir()}/tiny-grasp-partial-`);48+ const sk = generateSecretKey();49+ const npub = npubEncode(getPublicKey(sk));50+ const identifier = `stock-${Math.random().toString(36).slice(2, 10)}`;51+ const path = repositoryPath(npub, identifier);52+ const repoURL = `${HTTP_URL}${path}`;53+ const relayURL = RELAY_URL;54+ const createdAt = now();55+ const c = await Client.connect(RELAY_URL);56+ try {57+ await git(root, "init", "--initial-branch=main");58+ await git(root, "config", "user.email", "grasp-conformance@example.com");59+ await git(root, "config", "user.name", "GRASP conformance");60+ await writeFile(`${root}/README.md`, "initial GRASP repository\n");61+ await git(root, "add", "README.md");62+ await git(root, "commit", "-m", "initial");63+ const first = await git(root, "rev-parse", "HEAD");64+ await git(root, "tag", "-a", "v1", "-m", "first release");65+ const tag = await git(root, "rev-parse", "refs/tags/v1^{tag}");66+67+ await publish(c, sk, KIND_REPO, [68+ ["d", identifier],69+ ["clone", repoURL],70+ ["relays", relayURL],71+ ["maintainers", getPublicKey(sk)],72+ ], createdAt);73+ await publish(c, sk, KIND_REPO_STATE, [74+ ["d", identifier],75+ ["HEAD", "ref: refs/heads/main"],76+ ["refs/heads/main", first],77+ ["refs/tags/v1", tag],78+ ], createdAt + 1);79+80+ await waitForRefs(repoURL);81+ await git(root, "push", repoURL, "HEAD:refs/heads/main", "refs/tags/v1:refs/tags/v1");82+ await git(clone, "clone", repoURL, ".");83+ expect(await git(clone, "rev-parse", "HEAD")).toBe(first);84+ expect(await git(clone, "describe", "--tags")).toBe("v1");85+ expect((await git(clone, "fsck", "--full"))).not.toContain("dangling");86+87+ await writeFile(`${root}/README.md`, "incremental GRASP repository\n");88+ await git(root, "add", "README.md");89+ await git(root, "commit", "-m", "incremental");90+ const second = await git(root, "rev-parse", "HEAD");91+ await publish(c, sk, KIND_REPO_STATE, [92+ ["d", identifier],93+ ["HEAD", "ref: refs/heads/main"],94+ ["refs/heads/main", second],95+ ["refs/tags/v1", tag],96+ ], createdAt + 2);97+ await git(root, "push", repoURL, "HEAD:refs/heads/main");98+ await git(clone, "fetch", repoURL, "refs/heads/main:refs/remotes/origin/main");99+ expect(await git(clone, "rev-parse", "refs/remotes/origin/main")).toBe(second);100+101+ await git(partial, "clone", "--filter=blob:none", "--no-checkout", repoURL, ".");102+ await git(partial, "fsck", "--full");103+ await git(partial, "checkout", "main");104+ expect(await readFile(`${partial}/README.md`, "utf8")).toBe("incremental GRASP repository\n");105+ } finally {106+ await publish(c, sk, 5, [["a", `${KIND_REPO}:${getPublicKey(sk)}:${identifier}`], ["a", `${KIND_REPO_STATE}:${getPublicKey(sk)}:${identifier}`]], createdAt + 3).catch(() => {});107+ c.close();108+ await Promise.all([rm(root, { recursive: true, force: true }), rm(clone, { recursive: true, force: true }), rm(partial, { recursive: true, force: true })]);109+ }110+ }, 120_000);111+});
A scripts/conformance/helpers.ts +207 -0
@@ -0,0 +1,207 @@
1+// Shared client for the black-box conformance suite. It speaks plain2+// NIP-01 over a websocket to whatever relay RELAY_URL names.3+import WebSocket from "ws";4+import { afterEach } from "vitest";5+import { finalizeEvent, generateSecretKey, getPublicKey, type Event as NostrEvent, type EventTemplate } from "nostr-tools/pure";6+import { sha256 } from "@noble/hashes/sha2.js";7+import { Negentropy, hexToBytes, type SyncItem } from "./negentropy.ts";8+9+export const RELAY_URL = process.env.RELAY_URL ?? "ws://127.0.0.1:7447";10+export const HTTP_URL = RELAY_URL.replace(/^ws/, "http");11+export const now = () => Math.floor(Date.now() / 1000);12+export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));13+14+export type Key = Uint8Array;15+export const newKey = (): Key => generateSecretKey();16+export const pub = (sk: Key) => getPublicKey(sk);17+18+export function newEvent(sk: Key, kind: number, content: string, tags: string[][] = [], created_at = now()): NostrEvent {19+ return finalizeEvent({ kind, content, tags, created_at }, sk);20+}21+22+export function difficulty(id: string): number {23+ let bits = 0;24+ for (const ch of id) {25+ const n = parseInt(ch, 16);26+ if (n === 0) {27+ bits += 4;28+ continue;29+ }30+ bits += Math.clz32(n) - 28;31+ break;32+ }33+ return bits;34+}35+36+export function mine(sk: Key, t: EventTemplate, bits: number): NostrEvent {37+ for (let n = 0; ; n++) {38+ const e = finalizeEvent({ ...t, tags: [["nonce", String(n), String(bits)]] }, sk);39+ if (difficulty(e.id) >= bits) return e;40+ }41+}42+43+export class Client {44+ private ws!: WebSocket;45+ private queue: any[][] = [];46+ private waiters: ((m: any[]) => void)[] = [];47+ challenge = "";48+ closed = false;49+50+ static async connect(url = RELAY_URL): Promise<Client> {51+ const c = new Client();52+ c.ws = new WebSocket(url);53+ // Listen before open: the relay's AUTH challenge can arrive in the same tick.54+ c.ws.on("message", (data) => {55+ const msg = JSON.parse(data.toString());56+ const w = c.waiters.shift();57+ if (w) w(msg);58+ else c.queue.push(msg);59+ });60+ c.ws.on("close", () => {61+ c.closed = true;62+ });63+ await new Promise<void>((resolve, reject) => {64+ c.ws.once("open", () => resolve());65+ c.ws.once("error", reject);66+ });67+ // NIP-42: every connection opens with a challenge.68+ const first = await c.recv();69+ if (first[0] !== "AUTH") throw new Error(`expected AUTH challenge, got ${JSON.stringify(first)}`);70+ c.challenge = first[1];71+ return c;72+ }73+74+ send(...msg: any[]) {75+ this.ws.send(JSON.stringify(msg));76+ }77+78+ recv(timeoutMs = 15_000): Promise<any[]> {79+ const m = this.queue.shift();80+ if (m) return Promise.resolve(m);81+ return new Promise((resolve, reject) => {82+ const waiter = (msg: any[]) => {83+ clearTimeout(t);84+ resolve(msg);85+ };86+ const t = setTimeout(() => {87+ this.waiters = this.waiters.filter((w) => w !== waiter);88+ reject(new Error("timed out waiting for a message"));89+ }, timeoutMs);90+ this.waiters.push(waiter);91+ });92+ }93+94+ async expect(type: string): Promise<any[]> {95+ const m = await this.recv();96+ if (m[0] !== type) throw new Error(`expected ${type}, got ${JSON.stringify(m)}`);97+ return m;98+ }99+100+ async expectOK(id: string, ok: boolean): Promise<string> {101+ const m = await this.expect("OK");102+ if (m[1] !== id || m[2] !== ok) throw new Error(`OK mismatch: want (${id},${ok}) got ${JSON.stringify(m)}`);103+ return m[3];104+ }105+106+ async expectClosed(prefix: string): Promise<void> {107+ const m = await this.expect("CLOSED");108+ if (!String(m[2]).startsWith(prefix)) throw new Error(`expected CLOSED ${prefix}..., got ${JSON.stringify(m)}`);109+ }110+111+ // drain reads until EOSE, returning the events seen and the EOSE hints.112+ async drain(): Promise<{ events: NostrEvent[]; hints: string[] }> {113+ const events: NostrEvent[] = [];114+ for (;;) {115+ const m = await this.recv();116+ if (m[0] === "EVENT") events.push(m[2]);117+ else if (m[0] === "EOSE") return { events, hints: Array.isArray(m[2]) ? m[2] : [] };118+ else throw new Error(`unexpected ${JSON.stringify(m)}`);119+ }120+ }121+122+ async req(filter: Record<string, unknown>, id = "q" + Math.random().toString(36).slice(2, 8)) {123+ this.send("REQ", id, filter);124+ const r = await this.drain();125+ this.send("CLOSE", id);126+ return r;127+ }128+129+ async publish(e: NostrEvent): Promise<string> {130+ this.send("EVENT", e);131+ return this.expectOK(e.id, true);132+ }133+134+ async expectNothing(ms = 400) {135+ try {136+ const m = await this.recv(ms);137+ throw new Error(`expected silence, got ${JSON.stringify(m)}`);138+ } catch (err) {139+ if (!(err instanceof Error) || !err.message.includes("timed out")) throw err;140+ }141+ }142+143+ async auth(sk: Key, relay = RELAY_URL) {144+ const e = newEvent(sk, 22242, "", [["relay", relay], ["challenge", this.challenge]]);145+ this.send("AUTH", e);146+ await this.expectOK(e.id, true);147+ }148+149+ // sync runs a full NIP-77 exchange for the filter and returns have/need.150+ async sync(items: SyncItem[], filter: Record<string, unknown>, id = "s1"): Promise<{ have: string[]; need: string[] }> {151+ const neg = new Negentropy(items, sha256);152+ let msg: Uint8Array | null = neg.initiate();153+ this.send("NEG-OPEN", id, filter, hex(msg));154+ const have: string[] = [];155+ const need: string[] = [];156+ for (;;) {157+ const m = await this.recv();158+ if (m[0] === "NEG-ERR") throw new Error(`NEG-ERR ${m[2]}`);159+ if (m[0] !== "NEG-MSG") throw new Error(`unexpected ${JSON.stringify(m)}`);160+ const r = neg.reconcile(hexToBytes(m[2]));161+ have.push(...r.have);162+ need.push(...r.need);163+ if (!r.reply) {164+ this.send("NEG-CLOSE", id);165+ return { have: have.sort(), need: need.sort() };166+ }167+ this.send("NEG-MSG", id, hex(r.reply));168+ }169+ }170+171+ close() {172+ this.ws.close();173+ }174+}175+176+export function hex(b: Uint8Array): string {177+ let s = "";178+ for (const x of b) s += x.toString(16).padStart(2, "0");179+ return s;180+}181+182+export function item(e: NostrEvent): SyncItem {183+ return { timestamp: e.created_at, id: hexToBytes(e.id) };184+}185+186+export function rand(): string {187+ return Math.random().toString(36).slice(2, 10);188+}189+190+// randHex returns n random lowercase hex characters.191+export function randHex(n = 64): string {192+ let s = "";193+ while (s.length < n) s += Math.floor(Math.random() * 16).toString(16);194+ return s;195+}196+197+// sockets returns a connect that keeps its clients and closes them after198+// each test.199+export function sockets() {200+ const open: Client[] = [];201+ afterEach(() => open.splice(0).forEach((c) => c.close()));202+ return async () => {203+ const c = await Client.connect();204+ open.push(c);205+ return c;206+ };207+}
A scripts/conformance/negentropy.ts +306 -0
@@ -0,0 +1,306 @@
1+// NIP-77 Negentropy v1: range-based set reconciliation used by the2+// conformance client. Both sides hold a sorted (timestamp,3+// id) list and exchange ranges carrying fingerprints or id lists until the4+// initiator knows exactly which ids each side is missing.5+6+export interface SyncItem {7+ timestamp: number; // unix seconds8+ id: Uint8Array; // 32 bytes9+}10+11+const VERSION = 0x61;12+const MODE_SKIP = 0n;13+const MODE_FP = 1n;14+const MODE_IDLIST = 2n;15+const INFINITY = (1n << 64n) - 1n;16+const BUCKETS = 16;17+export const FRAME_SIZE = 60_000;18+19+interface Bound {20+ ts: bigint;21+ id: Uint8Array; // 32 bytes, zero padded22+ len: number; // significant prefix bytes23+}24+25+const ZERO32 = new Uint8Array(32);26+27+function cmpBytes(a: Uint8Array, b: Uint8Array): number {28+ for (let i = 0; i < 32; i++) {29+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;30+ }31+ return 0;32+}33+34+export function itemLess(a: SyncItem, b: SyncItem): boolean {35+ if (a.timestamp !== b.timestamp) return a.timestamp < b.timestamp;36+ return cmpBytes(a.id, b.id) < 0;37+}38+39+export function hexToBytes(h: string): Uint8Array {40+ const out = new Uint8Array(h.length / 2);41+ for (let i = 0; i < out.length; i++) out[i] = parseInt(h.substr(i * 2, 2), 16);42+ return out;43+}44+45+export function bytesToHex(b: Uint8Array): string {46+ let s = "";47+ for (const x of b) s += x.toString(16).padStart(2, "0");48+ return s;49+}50+51+class Writer {52+ parts: number[] = [];53+ varint(v: bigint) {54+ const digits: number[] = [];55+ do {56+ digits.unshift(Number(v & 0x7fn));57+ v >>= 7n;58+ } while (v > 0n);59+ for (let i = 0; i < digits.length - 1; i++) digits[i] |= 0x80;60+ this.parts.push(...digits);61+ }62+ bytes(b: Uint8Array) {63+ for (const x of b) this.parts.push(x);64+ }65+ get length() {66+ return this.parts.length;67+ }68+ toBytes() {69+ return new Uint8Array(this.parts);70+ }71+}72+73+class Reader {74+ pos = 0;75+ constructor(private buf: Uint8Array) {}76+ get remaining() {77+ return this.buf.length - this.pos;78+ }79+ varint(): bigint {80+ let v = 0n;81+ for (let i = 0; ; i++) {82+ if (this.pos >= this.buf.length || i === 10) throw new Error("bad varint");83+ const b = this.buf[this.pos++];84+ v = (v << 7n) | BigInt(b & 0x7f);85+ if ((b & 0x80) === 0) return v;86+ }87+ }88+ bytes(n: number): Uint8Array {89+ if (this.remaining < n) throw new Error("truncated message");90+ const out = this.buf.subarray(this.pos, this.pos + n);91+ this.pos += n;92+ return out;93+ }94+}95+96+export class Negentropy {97+ items: SyncItem[];98+ initiator = false;99+ frame = FRAME_SIZE;100+ private lastIn = 0n;101+ private lastOut = 0n;102+103+ constructor(items: SyncItem[], private sha256: (b: Uint8Array) => Uint8Array) {104+ this.items = [...items].sort((a, b) => (itemLess(a, b) ? -1 : itemLess(b, a) ? 1 : 0));105+ }106+107+ initiate(): Uint8Array {108+ this.initiator = true;109+ this.lastOut = 0n;110+ const w = new Writer();111+ w.parts.push(VERSION);112+ this.splitRange(0, this.items.length, { ts: INFINITY, id: ZERO32, len: 0 }, w);113+ return w.toBytes();114+ }115+116+ // Returns the reply (null when an initiator is done) and, for the117+ // initiator, the ids only we have and the ids only the peer has.118+ reconcile(msg: Uint8Array): { reply: Uint8Array | null; have: string[]; need: string[] } {119+ const have: string[] = [];120+ const need: string[] = [];121+ if (msg.length === 0) throw new Error("empty message");122+ if (msg[0] !== VERSION) {123+ if (this.initiator) throw new Error("peer does not support protocol v1");124+ return { reply: new Uint8Array([VERSION]), have, need };125+ }126+ const r = new Reader(msg.subarray(1));127+ this.lastIn = 0n;128+ this.lastOut = 0n;129+ const out = new Writer();130+ out.parts.push(VERSION);131+ let prevBound: Bound = { ts: 0n, id: ZERO32, len: 0 };132+ let prevIndex = 0;133+ let pendingSkip = false;134+ const flushSkip = () => {135+ if (pendingSkip) {136+ pendingSkip = false;137+ this.writeBound(out, prevBound);138+ out.varint(MODE_SKIP);139+ }140+ };141+142+ while (r.remaining > 0) {143+ let bound = this.readBound(r);144+ const mode = r.varint();145+ const lower = prevIndex;146+ let upper = this.findUpper(lower, bound);147+148+ if (mode === MODE_SKIP) {149+ pendingSkip = true;150+ } else if (mode === MODE_FP) {151+ const theirs = r.bytes(16);152+ const ours = this.fingerprint(lower, upper);153+ let same = true;154+ for (let i = 0; i < 16; i++) if (theirs[i] !== ours[i]) same = false;155+ if (same) pendingSkip = true;156+ else {157+ flushSkip();158+ this.splitRange(lower, upper, bound, out);159+ }160+ } else if (mode === MODE_IDLIST) {161+ const count = Number(r.varint());162+ const theirs = new Map<string, true>();163+ for (let i = 0; i < count; i++) theirs.set(bytesToHex(r.bytes(32)), true);164+ if (this.initiator) {165+ pendingSkip = true;166+ for (let i = lower; i < upper; i++) {167+ const h = bytesToHex(this.items[i].id);168+ if (theirs.has(h)) theirs.delete(h);169+ else have.push(h);170+ }171+ for (const h of theirs.keys()) need.push(h);172+ } else {173+ flushSkip();174+ let end = bound;175+ const ids: number[] = [];176+ let cnt = 0;177+ for (let i = lower; i < upper; i++) {178+ if (out.length + ids.length > this.frame) {179+ end = { ts: BigInt(this.items[i].timestamp), id: this.items[i].id, len: 32 };180+ upper = i;181+ break;182+ }183+ ids.push(...this.items[i].id);184+ cnt++;185+ }186+ this.writeBound(out, end);187+ out.varint(MODE_IDLIST);188+ out.varint(BigInt(cnt));189+ out.parts.push(...ids);190+ bound = end;191+ }192+ } else {193+ throw new Error("unknown range mode");194+ }195+ prevIndex = upper;196+ prevBound = bound;197+198+ if (out.length > this.frame) {199+ flushSkip();200+ this.writeBound(out, { ts: INFINITY, id: ZERO32, len: 0 });201+ out.varint(MODE_FP);202+ out.bytes(this.fingerprint(upper, this.items.length));203+ break;204+ }205+ }206+ if (this.initiator && out.length === 1) return { reply: null, have, need };207+ return { reply: out.toBytes(), have, need };208+ }209+210+ private splitRange(lower: number, upper: number, bound: Bound, out: Writer) {211+ const count = upper - lower;212+ if (count < BUCKETS * 2) {213+ this.writeBound(out, bound);214+ out.varint(MODE_IDLIST);215+ out.varint(BigInt(count));216+ for (let i = lower; i < upper; i++) out.bytes(this.items[i].id);217+ return;218+ }219+ const per = Math.floor(count / BUCKETS);220+ const extra = count % BUCKETS;221+ let cur = lower;222+ for (let i = 0; i < BUCKETS; i++) {223+ const size = per + (i < extra ? 1 : 0);224+ const fp = this.fingerprint(cur, cur + size);225+ cur += size;226+ const next = cur < upper ? minimalBound(this.items[cur - 1], this.items[cur]) : bound;227+ this.writeBound(out, next);228+ out.varint(MODE_FP);229+ out.bytes(fp);230+ }231+ }232+233+ fingerprint(lower: number, upper: number): Uint8Array {234+ const sum = new Uint8Array(32);235+ for (let i = lower; i < upper; i++) {236+ let carry = 0;237+ const id = this.items[i].id;238+ for (let j = 0; j < 32; j++) {239+ const v = sum[j] + id[j] + carry;240+ sum[j] = v & 0xff;241+ carry = v >> 8;242+ }243+ }244+ const w = new Writer();245+ w.bytes(sum);246+ w.varint(BigInt(upper - lower));247+ return this.sha256(w.toBytes()).subarray(0, 16);248+ }249+250+ private findUpper(lower: number, b: Bound): number {251+ let lo = lower;252+ let hi = this.items.length;253+ while (lo < hi) {254+ const mid = (lo + hi) >> 1;255+ const it = this.items[mid];256+ const ts = BigInt(it.timestamp);257+ const below = ts !== b.ts ? ts < b.ts : cmpBytes(it.id, b.id) < 0;258+ if (below) lo = mid + 1;259+ else hi = mid;260+ }261+ return lo;262+ }263+264+ private writeBound(w: Writer, b: Bound) {265+ if (b.ts === INFINITY) {266+ this.lastOut = INFINITY;267+ w.varint(0n);268+ } else {269+ w.varint(b.ts - this.lastOut + 1n);270+ this.lastOut = b.ts;271+ }272+ w.varint(BigInt(b.len));273+ w.bytes(b.id.subarray(0, b.len));274+ }275+276+ private readBound(r: Reader): Bound {277+ const v = r.varint();278+ let ts: bigint;279+ if (v === 0n) ts = INFINITY;280+ else {281+ ts = this.lastIn + v - 1n;282+ if (ts < this.lastIn || ts > INFINITY) ts = INFINITY;283+ }284+ this.lastIn = ts;285+ const len = Number(r.varint());286+ if (len > 32) throw new Error("bound id prefix too long");287+ const id = new Uint8Array(32);288+ id.set(r.bytes(len));289+ return { ts, id, len };290+ }291+}292+293+export function minimalBound(prev: SyncItem, cur: SyncItem): Bound {294+ if (cur.timestamp !== prev.timestamp) return { ts: BigInt(cur.timestamp), id: ZERO32, len: 0 };295+ let shared = 0;296+ while (shared < 32 && prev.id[shared] === cur.id[shared]) shared++;297+ const id = new Uint8Array(32);298+ id.set(cur.id.subarray(0, shared + 1));299+ return { ts: BigInt(cur.timestamp), id, len: shared + 1 };300+}301+302+export function encodeVarint(v: bigint): Uint8Array {303+ const w = new Writer();304+ w.varint(v);305+ return w.toBytes();306+}
A scripts/conformance/nip01.test.ts +95 -0
@@ -0,0 +1,95 @@
1+import { describe, it, expect } from "vitest";2+import { newEvent, newKey, pub, now, rand, sockets } from "./helpers.ts";3+4+const connect = sockets();5+6+describe("NIP-01 core", () => {7+ it("publishes, pushes live, dedups, rejects tampering, queries, replaces, limits, closes", async () => {8+ const sk = newKey();9+ const alice = await connect();10+ const bob = await connect();11+ const tag = rand();12+ const t0 = now() - 1000;13+14+ // Bob subscribes before anything exists: immediate EOSE.15+ bob.send("REQ", "notes", { kinds: [1], "#t": [tag] });16+ await bob.expect("EOSE");17+18+ const note = newEvent(sk, 1, "hello <world> & friends", [["t", tag]], t0);19+ await alice.publish(note);20+ const live = await bob.expect("EVENT");21+ expect(live[1]).toBe("notes");22+ expect(live[2].id).toBe(note.id);23+ expect(live[2].content).toBe(note.content);24+25+ // Duplicate acknowledged, not re-broadcast.26+ alice.send("EVENT", note);27+ expect(await alice.expectOK(note.id, true)).toMatch(/^duplicate:/);28+ await bob.expectNothing();29+30+ // Tampered content is refused with an invalid: prefix.31+ alice.send("EVENT", { ...note, content: "tampered" });32+ expect(await alice.expectOK(note.id, false)).toMatch(/^invalid:/);33+34+ // Stored query by tag and author.35+ const carol = await connect();36+ let r = await carol.req({ "#t": [tag], authors: [pub(sk)] });37+ expect(r.events.map((e) => e.id)).toEqual([note.id]);38+39+ // Replaceable: newest wins, stale refused.40+ const meta1 = newEvent(sk, 0, '{"name":"v1"}', [], t0 + 1);41+ const meta2 = newEvent(sk, 0, '{"name":"v2"}', [], t0 + 2);42+ await alice.publish(meta2);43+ alice.send("EVENT", meta1);44+ expect(await alice.expectOK(meta1.id, false)).toMatch(/newer/);45+ r = await carol.req({ kinds: [0], authors: [pub(sk)] });46+ expect(r.events.map((e) => e.id)).toEqual([meta2.id]);47+48+ // Addressable: same d replaces, different d coexists.49+ const a1 = newEvent(sk, 30023, "draft", [["d", "post"]], t0 + 3);50+ const a2 = newEvent(sk, 30023, "final", [["d", "post"]], t0 + 4);51+ const a3 = newEvent(sk, 30023, "other", [["d", "other"]], t0 + 4);52+ for (const e of [a1, a2, a3]) await alice.publish(e);53+ r = await carol.req({ kinds: [30023], authors: [pub(sk)] });54+ expect(r.events.map((e) => e.id).sort()).toEqual([a2.id, a3.id].sort());55+56+ // Limit, newest-first ordering, and NIP-67 hints.57+ for (let i = 0; i < 5; i++) await alice.publish(newEvent(sk, 1, "n" + i, [["t", tag]], t0 + 10 + i));58+ r = await carol.req({ kinds: [1], "#t": [tag], limit: 2 });59+ expect(r.events.length).toBe(2);60+ expect(r.events[0].created_at).toBe(t0 + 14);61+ expect(r.hints).toContain("more");62+ r = await carol.req({ kinds: [1], "#t": [tag] });63+ expect(r.events.length).toBe(6);64+ expect(r.hints).toContain("finish");65+66+ // Bob's live sub saw the five notes; CLOSE stops delivery.67+ for (let i = 0; i < 5; i++) await bob.expect("EVENT");68+ bob.send("CLOSE", "notes");69+ await alice.publish(newEvent(sk, 1, "after close", [["t", tag]], t0 + 20));70+ await bob.expectNothing();71+ });72+73+ it("does not store ephemeral events but does broadcast them", async () => {74+ const a = await connect();75+ const b = await connect();76+ const sk = newKey();77+ b.send("REQ", "eph", { kinds: [20001], authors: [pub(sk)] });78+ await b.expect("EOSE");79+ await a.publish(newEvent(sk, 20001, "poof"));80+ await b.expect("EVENT");81+ const c = await connect();82+ const r = await c.req({ kinds: [20001], authors: [pub(sk)] });83+ expect(r.events).toEqual([]);84+ });85+86+ it("answers bad input with NOTICE or CLOSED and keeps the connection", async () => {87+ const c = await connect();88+ c.send("BOGUS", 1);89+ expect((await c.expect("NOTICE"))[1]).toMatch(/^error:/);90+ c.send("REQ", "x", { kinds: "not-a-list" });91+ await c.expectClosed("invalid:");92+ c.send("REQ", "still-alive", { kinds: [1], limit: 0 });93+ await c.expect("EOSE");94+ });95+});
A scripts/conformance/nip09.test.ts +33 -0
R scripts/conformance/nip11.compat.test.ts → scripts/conformance/nip11.test.ts +0 -0

No content changes.

A scripts/conformance/nip17.test.ts +47 -0
@@ -0,0 +1,47 @@
1+import { describe, it, expect } from "vitest";2+import { newEvent, newKey, pub, now, sockets } from "./helpers.ts";3+4+const connect = sockets();5+6+describe("NIP-17 / NIP-59 private kinds", () => {7+ it("serves gift wraps only to their recipients, stored and live, and lets recipients delete them", async () => {8+ const sender = newKey();9+ const recipient = newKey();10+ const stranger = newKey();11+ const s = await connect();12+ const wrap = newEvent(sender, 1059, "ciphertext", [["p", pub(recipient)]], now() - 100);13+ await s.publish(wrap);14+15+ const x = await connect();16+ x.send("REQ", "dm", { kinds: [1059], "#p": [pub(recipient)] });17+ await x.expectClosed("auth-required:");18+ let r = await x.req({ "#p": [pub(recipient)] });19+ expect(r.events).toEqual([]);20+ expect(r.hints).toContain("auth");21+ x.send("COUNT", "n", { "#p": [pub(recipient)] });22+ expect((await x.expect("COUNT"))[2].count).toBe(0);23+24+ await x.auth(stranger);25+ r = await x.req({ kinds: [1059], "#p": [pub(recipient)] });26+ expect(r.events).toEqual([]);27+ x.send("REQ", "live", { kinds: [1059], "#p": [pub(recipient)] });28+ await x.expect("EOSE");29+ await s.publish(newEvent(sender, 1059, "ciphertext2", [["p", pub(recipient)]], now() - 99));30+ await x.expectNothing();31+32+ const rc = await connect();33+ await rc.auth(recipient);34+ r = await rc.req({ kinds: [1059], "#p": [pub(recipient)] });35+ expect(r.events.length).toBe(2);36+ rc.send("REQ", "live", { kinds: [1059], "#p": [pub(recipient)] });37+ await rc.drain();38+ const wrap3 = newEvent(sender, 1059, "ciphertext3", [["p", pub(recipient)]], now() - 98);39+ await s.publish(wrap3);40+ expect((await rc.expect("EVENT"))[2].id).toBe(wrap3.id);41+42+ await rc.publish(newEvent(recipient, 5, "", [["e", wrap3.id]]));43+ expect((await rc.req({ ids: [wrap3.id] })).events).toEqual([]);44+ s.send("EVENT", wrap3);45+ expect(await s.expectOK(wrap3.id, false)).toMatch(/^blocked:/);46+ });47+});
A scripts/conformance/nip40.test.ts +21 -0
A scripts/conformance/nip42.test.ts +30 -0
A scripts/conformance/nip45.test.ts +41 -0
@@ -0,0 +1,41 @@
1+import { describe, it, expect } from "vitest";2+import { newEvent, newKey, pub, now, randHex, sockets } from "./helpers.ts";3+4+const connect = sockets();5+6+describe("NIP-45 COUNT", () => {7+ it("counts across OR'd filters without double counting, and adds an hll sketch for tag filters", async () => {8+ const sk = newKey();9+ const c = await connect();10+ const target = randHex();11+ for (let i = 0; i < 3; i++) await c.publish(newEvent(sk, 1, "n", [["t", target]], now() - 10 + i));12+ await c.publish(newEvent(sk, 7, "+", [["e", target]], now() - 5));13+14+ c.send("COUNT", "n", { kinds: [1], authors: [pub(sk)] }, { kinds: [7], authors: [pub(sk)] });15+ expect((await c.expect("COUNT"))[2].count).toBe(4);16+ c.send("COUNT", "n", { kinds: [1], authors: [pub(sk)] }, { "#t": [target], authors: [pub(sk)] });17+ expect((await c.expect("COUNT"))[2].count).toBe(3);18+19+ c.send("COUNT", "r", { kinds: [7], "#e": [target] });20+ const res = (await c.expect("COUNT"))[2];21+ expect(res.count).toBe(1);22+ expect(res.hll).toMatch(/^[0-9a-f]{512}$/);23+ // Register: offset = nibble 32 of the target + 8; byte[offset] indexes,24+ // zero run after it plus one is the value.25+ const offset = parseInt(target[32], 16) + 8;26+ const pk = pub(sk);27+ const regIndex = parseInt(pk.substr(offset * 2, 2), 16);28+ const rest = pk.slice((offset + 1) * 2);29+ let zeros = 0;30+ for (const ch of rest) {31+ const n = parseInt(ch, 16);32+ if (n === 0) {33+ zeros += 4;34+ continue;35+ }36+ zeros += Math.clz32(n) - 28;37+ break;38+ }39+ expect(parseInt(res.hll.substr(regIndex * 2, 2), 16)).toBe(zeros + 1);40+ });41+});
A scripts/conformance/nip50.test.ts +27 -0
A scripts/conformance/nip5a.test.ts +131 -0
@@ -0,0 +1,131 @@
1+// NIP-5A from outside the relay: discover the host forms from NIP-11, upload2+// the files through Blossom, publish signed manifests, and fetch each site3+// through its canonical single-label hostname.4+import { describe, it, expect } from "vitest";5+import { sha256 } from "@noble/hashes/sha2.js";6+import { newEvent, newKey, pub, hex, HTTP_URL, RELAY_URL, now, sleep, Client } from "./helpers.ts";7+import { npubEncode } from "nostr-tools/nip19";8+9+function blossomToken(sk: Uint8Array, action: string, hash?: string): string {10+ const tags = [["t", action], ...(hash ? [["x", hash]] : []), ["expiration", String(now() + 300)]];11+ return "Nostr " + Buffer.from(JSON.stringify(newEvent(sk, 24242, action, tags))).toString("base64");12+}13+14+async function upload(sk: Uint8Array, body: string, type: string): Promise<string> {15+ const bytes = new TextEncoder().encode(body);16+ const hash = hex(sha256(bytes));17+ const response = await fetch(`${HTTP_URL}/upload`, {18+ method: "PUT",19+ headers: { authorization: blossomToken(sk, "upload", hash), "content-type": type },20+ body: bytes,21+ });22+ expect(response.status, await response.clone().text()).toBe(200);23+ const descriptor: any = await response.json();24+ expect(descriptor.sha256).toBe(hash);25+ return hash;26+}27+28+function siteHash(paths: string[][]): string {29+ const lines = paths.map((t) => `${t[2]} ${t[1]}\n`).sort().join("");30+ return hex(sha256(new TextEncoder().encode(lines)));31+}32+33+function discoveredHost(template: string, replacements: Record<string, string>): string {34+ let value = template;35+ for (const [key, replacement] of Object.entries(replacements)) value = value.replace(`<${key}>`, replacement);36+ return new URL(value).hostname;37+}38+39+// siteFetch keeps Wrangler runs local while using the canonical hostname40+// when RELAY_URL points at a public relay.41+function siteFetch(host: string, path: string, init: RequestInit = {}) {42+ const endpoint = new URL(HTTP_URL);43+ const local = endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1" || endpoint.hostname === "[::1]" || endpoint.hostname === "::1";44+ endpoint.hostname = local ? `${host.split(".")[0]}.localhost` : host;45+ endpoint.pathname = path;46+ return fetch(endpoint, init);47+}48+49+async function waitForSite(host: string): Promise<void> {50+ for (let attempt = 0; attempt < 20; attempt++) {51+ const response = await siteFetch(host, "/");52+ if (response.status === 200) { await response.arrayBuffer(); return; }53+ await response.arrayBuffer();54+ await sleep(100);55+ }56+ throw new Error(`site index did not become visible at ${host}`);57+}58+59+describe("NIP-5A static websites", () => {60+ it("discovers canonical labels, serves root/named/snapshot sites, and applies fallbacks", async () => {61+ const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } });62+ expect(infoResponse.status).toBe(200);63+ const doc: any = await infoResponse.json();64+ expect(doc.nsites).toMatchObject({ kinds: [15128, 35128, 5128] });65+ expect(typeof doc.nsites.root).toBe("string");66+ expect(typeof doc.nsites.named).toBe("string");67+ expect(typeof doc.nsites.snapshot).toBe("string");68+69+ const sk = newKey();70+ const index = "<!doctype html><title>root</title>";71+ const blogIndex = "<!doctype html><title>blog</title>";72+ const notFound = "<!doctype html><title>missing</title>";73+ const indexHash = await upload(sk, index, "text/html; charset=utf-8");74+ const blogHash = await upload(sk, blogIndex, "text/html; charset=utf-8");75+ const notFoundHash = await upload(sk, notFound, "text/html; charset=utf-8");76+ const paths = [["path", "/index.html", indexHash], ["path", "/blog/index.html", blogHash], ["path", "/404.html", notFoundHash]];77+ const aggregate = siteHash(paths);78+ const c = await Client.connect(RELAY_URL);79+ try {80+ const root = newEvent(sk, 15128, "", paths);81+ await c.publish(root);82+ const named = newEvent(sk, 35128, "", [...paths, ["d", "demo-site"]]);83+ await c.publish(named);84+ const snapshot = newEvent(sk, 5128, "", [...paths, ["x", aggregate, "aggregate"], ["a", `15128:${pub(sk)}:`]]);85+ await c.publish(snapshot);86+87+ const replacements = { npub: npubEncode(pub(sk)) };88+ const rootHost = discoveredHost(doc.nsites.root, replacements);89+ const namedHost = discoveredHost(doc.nsites.named, { pubkeyB36: base36(pub(sk)), dTag: "demo-site" });90+ const snapshotHost = discoveredHost(doc.nsites.snapshot, { snapshotIdB36: base36(snapshot.id) });91+ // The event acknowledgement and KV index update are separate async92+ // tasks on some deployments, so wait for each discovered label rather93+ // than relying on a fixed delay.94+ for (const host of [rootHost, namedHost, snapshotHost]) await waitForSite(host);95+96+ for (const host of [rootHost, namedHost, snapshotHost]) {97+ const response = await siteFetch(host, "/");98+ expect(response.status, host).toBe(200);99+ expect(await response.text(), host).toBe(index);100+ expect(response.headers.get("content-type")).toContain("text/html");101+ expect(response.headers.get("etag")).toBe(`"${indexHash}"`);102+ expect(response.headers.get("cache-control")).toContain("public");103+ expect(response.headers.get("cache-control")).toContain("no-transform");104+ const head = await siteFetch(host, "/index.html", { method: "HEAD" });105+ expect(head.status).toBe(200);106+ expect(head.headers.get("content-length")).toBe(String(new TextEncoder().encode(index).length));107+108+ const blog = await siteFetch(host, "/blog/");109+ expect(blog.status).toBe(200);110+ expect(await blog.text()).toBe(blogIndex);111+ const fallback = await siteFetch(host, "/does-not-exist.txt");112+ expect(fallback.status).toBe(404);113+ expect(await fallback.text()).toBe(notFound);114+ const redirect = await siteFetch(host, "/blog", { redirect: "manual" });115+ expect(redirect.status).toBe(308);116+ expect(new URL(redirect.headers.get("location")!).pathname).toBe("/blog/");117+ }118+119+ const notFoundSite = await siteFetch(rootHost, "/no-extension", { redirect: "manual" });120+ expect(notFoundSite.status).toBe(308);121+ const missingManifest = await siteFetch(discoveredHost(doc.nsites.root, { npub: npubEncode(pub(newKey())) }), "/");122+ expect(missingManifest.status).toBe(404);123+ } finally {124+ c.close();125+ }126+ });127+});128+129+function base36(hexKey: string): string {130+ return BigInt("0x" + hexKey).toString(36).padStart(50, "0");131+}
A scripts/conformance/nip62.test.ts +39 -0
@@ -0,0 +1,39 @@
1+import { describe, it, expect } from "vitest";2+import { newEvent, newKey, pub, now, RELAY_URL, sockets } from "./helpers.ts";3+4+const connect = sockets();5+6+describe("NIP-62 vanish", () => {7+ it("deletes everything from the pubkey up to the request, plus gift wraps to it, and blocks resurrection", async () => {8+ const sk = newKey();9+ const other = newKey();10+ const c = await connect();11+ const t0 = now() - 100;12+ const note = newEvent(sk, 1, "regret", [], t0);13+ const meta = newEvent(sk, 0, "{}", [], t0);14+ const wrap = newEvent(other, 1059, "dm", [["p", pub(sk)]], t0);15+ const keep = newEvent(other, 1, "unrelated", [], t0);16+ for (const e of [note, meta, wrap, keep]) await c.publish(e);17+18+ let v = newEvent(sk, 62, "", [["relay", "wss://other.example"]], t0 + 1);19+ c.send("EVENT", v);20+ await c.expectOK(v.id, false);21+ expect((await c.req({ authors: [pub(sk)] })).events.length).toBe(2);22+23+ v = newEvent(sk, 62, "bye", [["relay", RELAY_URL]], t0 + 1);24+ await c.publish(v);25+ expect((await c.req({ authors: [pub(sk)] })).events).toEqual([]);26+ expect((await c.req({ ids: [keep.id] })).events.length).toBe(1);27+ const rc = await connect();28+ await rc.auth(sk);29+ expect((await rc.req({ kinds: [1059], "#p": [pub(sk)] })).events).toEqual([]);30+31+ c.send("EVENT", note);32+ expect(await c.expectOK(note.id, false)).toMatch(/^blocked:/);33+ await c.publish(newEvent(sk, 1, "fresh start", [], t0 + 2));34+35+ v = newEvent(other, 62, "", [["relay", "ALL_RELAYS"]], t0 + 3);36+ await c.publish(v);37+ expect((await c.req({ authors: [pub(other)] })).events).toEqual([]);38+ });39+});
A scripts/conformance/nip70.test.ts +19 -0
A scripts/conformance/nip77.test.ts +47 -0
@@ -0,0 +1,47 @@
1+import { describe, it, expect } from "vitest";2+import { newEvent, newKey, pub, now, item, sockets } from "./helpers.ts";3+4+const connect = sockets();5+6+describe("NIP-77 negentropy", () => {7+ it("reconciles a client set against the relay's, respecting visibility", async () => {8+ const sk = newKey();9+ const recipient = newKey();10+ const c = await connect();11+ const t0 = now() - 1000;12+ const relayHas = [];13+ for (let i = 0; i < 100; i++) relayHas.push(await (async () => {14+ const e = newEvent(sk, 1, String(i), [], t0 + Math.floor(i / 3));15+ await c.publish(e);16+ return e;17+ })());18+ const local = [];19+ for (let i = 0; i < 5; i++) local.push(newEvent(sk, 1, "local " + i, [], t0 + 10 + i));20+ const wrap = newEvent(sk, 1059, "dm", [["p", pub(recipient)]], t0 + 5);21+ await c.publish(wrap);22+23+ const clientItems = [...relayHas.slice(0, 60), ...local].map(item);24+ const { have, need } = await c.sync(clientItems, { kinds: [1], authors: [pub(sk)] });25+ expect(have).toEqual(local.map((e) => e.id).sort());26+ expect(need).toEqual(relayHas.slice(60).map((e) => e.id).sort());27+28+ // Strangers never learn the gift wrap; the recipient does.29+ let r = await c.sync(relayHas.map(item), { authors: [pub(sk)] });30+ expect(r.need).toEqual([]);31+ const rc = await connect();32+ await rc.auth(recipient);33+ r = await rc.sync(relayHas.map(item), { authors: [pub(sk)] });34+ expect(r.need).toEqual([wrap.id]);35+36+ // Error paths.37+ c.send("NEG-MSG", "nope", "61");38+ expect((await c.expect("NEG-ERR"))[2]).toMatch(/^closed:/);39+ c.send("NEG-OPEN", "bad", {}, "zz");40+ expect((await c.expect("NEG-ERR"))[2]).toMatch(/^invalid:/);41+ const x = await connect();42+ x.send("NEG-OPEN", "dm", { kinds: [1059] }, "61");43+ expect((await x.expect("NEG-ERR"))[2]).toMatch(/^auth-required:/);44+ x.send("NEG-OPEN", "v", { kinds: [1], authors: [pub(sk)] }, "62");45+ expect((await x.expect("NEG-MSG"))[2]).toBe("61");46+ });47+});
M scripts/conformance/run.mjs +2 -2
M scripts/conformance/setup.ts +9 -17
@@ -1,8 +1,9 @@
1+// Global setup: provision the tenant under test with the tiny CLI so the2+// conformance suite writes as its owner. Set TINY_PREPROVISIONED=1 to skip.13 import { execFileSync } from "node:child_process";2-import { getPublicKey, finalizeEvent } from "nostr-tools/pure";3-import { getToken } from "nostr-tools/nip98";4+import { getPublicKey } from "nostr-tools/pure";45 import { sha256 } from "@noble/hashes/sha2.js";5-import { hexToBytes } from "../../../bindws/src/negentropy.ts";6+import { hexToBytes } from "@noble/hashes/utils.js";67 78 const relayURL = process.env.RELAY_URL ?? "ws://127.0.0.1:7447";89 const httpURL = relayURL.replace(/^ws/, "http").replace(/\/$/, "") + "/";
@@ -10,26 +11,17 @@ const owner = process.env.CLAIM_SK
1011 ? hexToBytes(process.env.CLAIM_SK)1112 : sha256(new TextEncoder().encode("tiny conformance owner"));1213 13-function provisionWithTiny() {14+function provision() {1415 const bin = process.env.TINY_BIN ?? "tiny";1516 const name = process.env.TINY_TENANT ?? new URL(httpURL).hostname.split(".")[0];1617 const template = process.env.TINY_TEMPLATE ?? "default";17- execFileSync(bin, ["tenant", "create", "--name", name, "--owner", getPublicKey(owner), "--template", template], { stdio: "inherit" });18-}19-20-async function provisionLegacy() {21- const payload = { method: "claim", params: [] };22- const body = JSON.stringify(payload);23- const token = await getToken(httpURL, "POST", (event) => finalizeEvent(event, owner), true, payload);24- const response = await fetch(httpURL, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body });25- const json: any = await response.json();26- if (!json.result?.claimed) throw new Error(`legacy claim failed: ${response.status} ${JSON.stringify(json)}`);27- console.warn("conformance: LEGACY_CLAIM=1 uses bindws claim compatibility");18+ const args = ["tenant", "create", "--name", name, "--owner", getPublicKey(owner), "--template", template];19+ if (process.env.TINY_DATA_DIR) args.push("--data-dir", process.env.TINY_DATA_DIR);20+ execFileSync(bin, args, { stdio: "inherit" });2821 }2922 3023 export default async function setup() {31- if (process.env.LEGACY_CLAIM === "1") await provisionLegacy();32- else if (process.env.TINY_PREPROVISIONED !== "1") provisionWithTiny();24+ if (process.env.TINY_PREPROVISIONED !== "1") provision();3325 const response = await fetch(httpURL, { headers: { accept: "application/nostr+json" } });3426 if (!response.ok) throw new Error(`relay did not become ready at ${httpURL}: ${response.status}`);3527 console.log(`conformance tenant ready at ${relayURL} as ${getPublicKey(owner)}`);
M scripts/conformance/vitest.config.mts +3 -7
R scripts/qa/nip46-bunker.cjs → scripts/qa/nip46-bunker.mjs +0 -0

No content changes.