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>
| M | docs/ | 2 | +- |
| M | package.json | 2 | +- |
| M | scripts/ | 16 | +- |
| A | scripts/ | 57 | ++++++ |
| A | scripts/ | 111 | +++++++++++ |
| A | scripts/ | 207 | ++++++++++++++++++++ |
| A | scripts/ | 306 | ++++++++++++++++++++++++++++++ |
| A | scripts/ | 95 | +++++++++ |
| A | scripts/ | 33 | +++ |
| R | scripts/ | 0 | |
| A | scripts/ | 47 | +++++ |
| A | scripts/ | 21 | ++ |
| A | scripts/ | 30 | +++ |
| A | scripts/ | 41 | ++++ |
| A | scripts/ | 27 | +++ |
| A | scripts/ | 131 | +++++++++++++ |
| A | scripts/ | 39 | ++++ |
| A | scripts/ | 19 | ++ |
| A | scripts/ | 47 | +++++ |
| M | scripts/ | 4 | +- |
| M | scripts/ | 26 | +-- |
| M | scripts/ | 10 | +- |
| R | scripts/ | 0 |
@@ -43,7 +43,7 @@ python3 scripts/observe-git-benchmark.py --out artifacts/git/baseline \
4343 -- ./scripts/git-performance.mjs4444 ```4545 46-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.46+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.4747 4848 ## Real Git repositories4949 @@ -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.@@ -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+});@@ -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+});@@ -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+}@@ -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+}@@ -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+});@@ -0,0 +1,33 @@
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-09 deletion", () => {7+ it("lets only the author delete, keeps deleted ids out, ignores strangers", async () => {8+ const sk = newKey();9+ const other = newKey();10+ const alice = await connect();11+ const stranger = await connect();12+ const carol = await connect();13+ const note = newEvent(sk, 1, "regret", [], now() - 100);14+ await alice.publish(note);15+16+ await stranger.publish(newEvent(other, 5, "", [["e", note.id]]));17+ expect((await carol.req({ ids: [note.id] })).events.length).toBe(1);18+19+ await alice.publish(newEvent(sk, 5, "", [["e", note.id], ["k", "1"]]));20+ expect((await carol.req({ ids: [note.id] })).events.length).toBe(0);21+ alice.send("EVENT", note);22+ expect(await alice.expectOK(note.id, false)).toMatch(/^blocked:/);23+ });24+25+ it("deletes addressable versions up to the request time via a tags", async () => {26+ const sk = newKey();27+ const c = await connect();28+ const t0 = now() - 100;29+ await c.publish(newEvent(sk, 30023, "v1", [["d", "x"]], t0));30+ await c.publish(newEvent(sk, 5, "", [["a", `30023:${pub(sk)}:x`]], t0 + 1));31+ expect((await c.req({ kinds: [30023], authors: [pub(sk)], "#d": ["x"] })).events.length).toBe(0);32+ });33+});No content changes.
@@ -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+});@@ -0,0 +1,21 @@
1+import { describe, it, expect } from "vitest";2+import { newEvent, newKey, now, sleep, sockets } from "./helpers.ts";3+4+const connect = sockets();5+6+describe("NIP-40 expiration", () => {7+ it("refuses expired events, serves unexpired ones, and stops serving after expiry", async () => {8+ const sk = newKey();9+ const c = await connect();10+ const dead = newEvent(sk, 1, "gone", [["expiration", String(now() - 1)]]);11+ c.send("EVENT", dead);12+ expect(await c.expectOK(dead.id, false)).toMatch(/expired/);13+ const soon = newEvent(sk, 1, "soon", [["expiration", String(now() + 2)]]);14+ await c.publish(soon);15+ expect((await c.req({ ids: [soon.id] })).events.length).toBe(1);16+ await sleep(2500);17+ expect((await c.req({ ids: [soon.id] })).events.length).toBe(0);18+ c.send("COUNT", "q", { ids: [soon.id] });19+ expect((await c.expect("COUNT"))[2].count).toBe(0);20+ });21+});@@ -0,0 +1,30 @@
1+import { describe, it, expect } from "vitest";2+import { newEvent, newKey, now, RELAY_URL, sockets } from "./helpers.ts";3+4+const connect = sockets();5+6+describe("NIP-42 AUTH", () => {7+ it("refuses bad challenges, wrong relays, stale timestamps, wrong kinds; accepts several keys", async () => {8+ const sk = newKey();9+ const c = await connect();10+ const bad = (tags: string[][], ts = now(), kind = 22242) => newEvent(sk, kind, "", tags, ts);11+ let e = bad([["relay", RELAY_URL], ["challenge", "nope"]]);12+ c.send("AUTH", e);13+ expect(await c.expectOK(e.id, false)).toMatch(/challenge/);14+ e = bad([["relay", "wss://elsewhere.example"], ["challenge", c.challenge]]);15+ c.send("AUTH", e);16+ expect(await c.expectOK(e.id, false)).toMatch(/relay/);17+ e = bad([["relay", RELAY_URL], ["challenge", c.challenge]], now() - 3600);18+ c.send("AUTH", e);19+ expect(await c.expectOK(e.id, false)).toMatch(/time/);20+ e = bad([["relay", RELAY_URL], ["challenge", c.challenge]], now(), 1);21+ c.send("AUTH", e);22+ await c.expectOK(e.id, false);23+ // kind 22242 sent as a plain EVENT is never accepted.24+ const asEvent = newEvent(sk, 22242, "", [["relay", RELAY_URL], ["challenge", c.challenge]]);25+ c.send("EVENT", asEvent);26+ expect(await c.expectOK(asEvent.id, false)).toMatch(/^(blocked|invalid):/);27+ await c.auth(sk);28+ await c.auth(newKey());29+ });30+});@@ -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+});@@ -0,0 +1,27 @@
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-50 search", () => {7+ it("matches words in content, case-insensitively, ignores extensions, is injection-safe, and matches live", async () => {8+ const sk = newKey();9+ const c = await connect();10+ const w = rand();11+ const texts = [`Purple ${w}elephants dance`, `${w}orange juice`, `the ${w}elephant in the room`];12+ for (const [i, t] of texts.entries()) await c.publish(newEvent(sk, 1, t, [], now() - 10 + i));13+ await c.publish(newEvent(sk, 4, `${w}elephant encrypted`, [["p", pub(sk)]], now() - 5));14+15+ let r = await c.req({ search: `${w}elephant`, authors: [pub(sk)] });16+ expect(r.events.map((e) => e.content)).toEqual([texts[2]]);17+ r = await c.req({ search: `PURPLE include:spam dance ${w}elephants`, authors: [pub(sk)] });18+ expect(r.events.map((e) => e.content)).toEqual([texts[0]]);19+ r = await c.req({ search: `${w}juice OR NOT "`, authors: [pub(sk)] });20+ expect(r.hints).toContain("finish");21+22+ c.send("REQ", "live", { search: `${w}banana`, authors: [pub(sk)] });23+ await c.drain();24+ await c.publish(newEvent(sk, 1, `a ${w}Banana split`));25+ await c.expect("EVENT");26+ });27+});@@ -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+}@@ -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+});@@ -0,0 +1,19 @@
1+import { describe, it, expect } from "vitest";2+import { newEvent, newKey, sockets } from "./helpers.ts";3+4+const connect = sockets();5+6+describe("NIP-70 protected events", () => {7+ it("accepts ['-'] events only from a connection authenticated as the author", async () => {8+ const sk = newKey();9+ const c = await connect();10+ const e = newEvent(sk, 1, "members only", [["-"]]);11+ c.send("EVENT", e);12+ expect(await c.expectOK(e.id, false)).toMatch(/^auth-required:/);13+ await c.auth(newKey());14+ c.send("EVENT", e);15+ await c.expectOK(e.id, false);16+ await c.auth(sk);17+ await c.publish(e);18+ });19+});@@ -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+});@@ -7,8 +7,8 @@ const root = resolve(import.meta.dirname, "../..");
77 const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");88 const artifactDir = resolve(process.env.CONFORMANCE_ARTIFACTS ?? resolve(root, "artifacts/conformance"), stamp);99 mkdirSync(artifactDir, { recursive: true });10-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" };11-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");10+const env = { ...process.env, RELAY_URL: process.env.RELAY_URL ?? "ws://127.0.0.1:7447" };11+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");1212 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"] });1313 process.stdout.write(result.stdout ?? "");1414 process.stderr.write(result.stderr ?? "");@@ -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)}`);@@ -1,18 +1,14 @@
11 import { defineConfig } from "vitest/config";22 import { resolve } from "node:path";33 4-const bindws = resolve(process.env.BINDWS_ROOT ?? "../bindws");5-const compatibilityNip11 = resolve(import.meta.dirname, "nip11.compat.test.ts");6-74 export default defineConfig({8- root: bindws,5+ root: resolve(import.meta.dirname, "../.."),96 test: {10- include: ["test/conformance/**/*.test.ts", compatibilityNip11],11- exclude: [resolve(bindws, "test/conformance/nip11.test.ts")],7+ include: ["scripts/conformance/**/*.test.ts"],128 environment: "node",139 testTimeout: 60_000,1410 hookTimeout: 60_000,1511 fileParallelism: false,16- globalSetup: [resolve(process.cwd(), "scripts/conformance/setup.ts")]12+ globalSetup: [resolve(import.meta.dirname, "setup.ts")]1713 }1814 });No content changes.