clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

test: the pure functions run on node in a unit project, the Durable Object tests in an object project, both under one vitest config

Dami committed ee65de5
commit
ee65de55396160acadb6f5cbe37ea472fb7c78ce
parent
9f31ca0021600323745bda45e5c5cf709e092296
author
Dami <dami.etoile@gmail.com>
34 files changed, +373 -351
Dtest/card.test.ts219------------------------------
Atest/helpers/qr.ts19+++
Rtest/adopt.test.ts → test/object/adopt.test.ts8+-
Rtest/audit.test.ts → test/object/audit.test.ts2+-
Rtest/blossom.test.ts → test/object/blossom.test.ts6+-
Atest/object/card.test.ts101++++++++++++++
Rtest/data.test.ts → test/object/data.test.ts8+-
Rtest/domains.test.ts → test/object/domains.test.ts6+-
Rtest/exposure.test.ts → test/object/exposure.test.ts10+-
Rtest/fork.test.ts → test/object/fork.test.ts6+-
Rtest/fuel.test.ts → test/object/fuel.test.ts21+--
Rtest/gate.test.ts → test/object/gate.test.ts6+-
Rtest/groups.test.ts → test/object/groups.test.ts4+-
Rtest/jobs.test.ts → test/object/jobs.test.ts8+-
Rtest/lease.test.ts → test/object/lease.test.ts10+-
Rtest/manage.test.ts → test/object/manage.test.ts4+-
Rtest/media.test.ts → test/object/media.test.ts6+-
Rtest/nip11.test.ts → test/object/nip11.test.ts2+-
Rtest/nip43.test.ts → test/object/nip43.test.ts4+-
Rtest/nip46.test.ts → test/object/nip46.test.ts4+-
Rtest/nip66.test.ts → test/object/nip66.test.ts2+-
Rtest/policy.test.ts → test/object/policy.test.ts4+-
Rtest/presets.test.ts → test/object/presets.test.ts4+-
Rtest/relay.test.ts → test/object/relay.test.ts8+-
Atest/object/selfhost.test.ts31++++
Rtest/succession.test.ts → test/object/succession.test.ts6+-
Rtest/sync.test.ts → test/object/sync.test.ts8+-
Rtest/tools.test.ts → test/object/tools.test.ts8+-
Rtest/views.test.ts → test/object/views.test.ts8+-
Rtest/web.test.ts → test/object/web.test.ts6+-
Atest/unit/bolt11.test.ts17++
Rtest/selfhost.test.ts → test/unit/edge.test.ts35+----
Atest/unit/qr.test.ts106+++++++++++++++
Mvitest.config.ts27++-
D test/card.test.ts +0 -219
@@ -1,219 +0,0 @@
1-// The relay card and the QR encoder behind it. The QR test reads a symbol2-// back: format bits, unmasked data, de-interleaved blocks, Reed-Solomon3-// syndromes, and the bytes themselves.4-import { SELF } from "cloudflare:test";5-import { describe, it, expect } from "vitest";6-import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";7-import { decode } from "nostr-tools/nip19";8-import jsQR from "jsqr";9-import { EC_M, MASKS, dataCodewords, dataOrder, encode, formatBits, gfMul, gfPow, layout, capacity } from "../src/qr.ts";10-import { rpc, get } from "./helpers/relay.ts";11-12-// scan rasterizes a module grid and reads it with jsQR, a decoder that13-// shares nothing with the encoder, so it proves a phone can read it.14-function scan(size: number, dark: (x: number, y: number) => boolean): string | undefined {15- const px = 4, margin = 4, n = size + margin * 2, w = n * px;16- const img = new Uint8ClampedArray(w * w * 4).fill(255);17- for (let y = 0; y < size; y++) {18- for (let x = 0; x < size; x++) {19- if (!dark(x, y)) continue;20- for (let dy = 0; dy < px; dy++) for (let dx = 0; dx < px; dx++) {21- const i = (((y + margin) * px + dy) * w + (x + margin) * px + dx) * 4;22- img[i] = img[i + 1] = img[i + 2] = 0;23- }24- }25- }26- return jsQR(img, w, w)?.data;27-}28-29-// readBack decodes a symbol without any error correction: it must be exact.30-function readBack(sym: ReturnType<typeof encode>): { mask: number; level: number; bytes: Uint8Array } {31- const { size, modules, version } = sym;32- const at = (x: number, y: number) => modules[y * size + x];33- // Format bits around the top-left finder, and the second copy split34- // between the other two; both must agree.35- const fbits: number[] = [];36- for (let i = 0; i < 6; i++) fbits.push(at(8, i));37- fbits.push(at(8, 7), at(8, 8), at(7, 8));38- for (let i = 9; i < 15; i++) fbits.push(at(14 - i, 8));39- const second: number[] = [];40- for (let i = 0; i < 8; i++) second.push(at(size - 1 - i, 8));41- for (let i = 8; i < 15; i++) second.push(at(8, size - 15 + i));42- expect(second).toEqual(fbits);43- let f = 0;44- fbits.forEach((b, i) => (f |= b << i));45- const mask = (f ^ 0x5412) >> 10 & 7;46- const level = (f ^ 0x5412) >> 13 & 3;47- expect(f).toBe(formatBits(mask));48- // Data modules in placement order, unmasked.49- const l = layout(version);50- const order = dataOrder(l);51- const bits = order.map(([x, y]) => at(x, y) ^ (MASKS[mask](x, y) ? 1 : 0));52- const [ec, g1, d1, g2, d2] = EC_M[version];53- const nblocks = g1 + g2;54- const total = dataCodewords(version) + ec * nblocks;55- const cw: number[] = [];56- for (let i = 0; i < total; i++) cw.push(parseInt(bits.slice(i * 8, i * 8 + 8).join(""), 2));57- // De-interleave.58- const lens = [...Array(g1).fill(d1), ...Array(g2).fill(d2)] as number[];59- const data: number[][] = lens.map(() => []);60- const ecs: number[][] = lens.map(() => []);61- let k = 0;62- for (let i = 0; i < Math.max(d1, d2); i++) for (let b = 0; b < nblocks; b++) if (i < lens[b]) data[b].push(cw[k++]);63- for (let i = 0; i < ec; i++) for (let b = 0; b < nblocks; b++) ecs[b].push(cw[k++]);64- expect(k).toBe(total);65- // Syndromes: the codeword polynomial vanishes at alpha^0 .. alpha^(ec-1).66- for (let b = 0; b < nblocks; b++) {67- const poly = [...data[b], ...ecs[b]];68- for (let i = 0; i < ec; i++) {69- let v = 0;70- const a = gfPow(2, i);71- for (const c of poly) v = gfMul(v, a) ^ c;72- expect(v, `block ${b} syndrome ${i}`).toBe(0);73- }74- }75- // Byte mode header and payload.76- const dbits: number[] = [];77- for (const block of data) for (const c of block) for (let i = 7; i >= 0; i--) dbits.push((c >> i) & 1);78- const mode = parseInt(dbits.slice(0, 4).join(""), 2);79- expect(mode).toBe(4);80- const cl = version < 10 ? 8 : 16;81- const len = parseInt(dbits.slice(4, 4 + cl).join(""), 2);82- const bytes = new Uint8Array(len);83- for (let i = 0; i < len; i++) bytes[i] = parseInt(dbits.slice(4 + cl + i * 8, 4 + cl + i * 8 + 8).join(""), 2);84- return { mask, level, bytes };85-}86-87-describe("qr encoder", () => {88- it("round-trips through every version class with valid format bits and zero syndromes", () => {89- const cases = [5, 14, 40, 100, 130, 213, 260, 400, 500, 666].map((n) => "x".repeat(n).replace(/x/g, () => String.fromCharCode(33 + Math.floor(Math.random() * 90))));90- cases.push("HELLO WORLD", "nostrconnect://" + "ab".repeat(32) + "?relay=wss%3A%2F%2Fearly-trout.bind.ws&secret=deadbeef&perms=sign_event%3A27235");91- const seen = new Set<number>();92- for (const text of cases) {93- const sym = encode(text);94- seen.add(sym.version);95- expect(sym.size).toBe(sym.version * 4 + 17);96- // Finder patterns are dark at their corners and cores.97- for (const [cx, cy] of [[0, 0], [sym.size - 7, 0], [0, sym.size - 7]]) {98- expect(sym.modules[cy * sym.size + cx]).toBe(1);99- expect(sym.modules[(cy + 3) * sym.size + cx + 3]).toBe(1);100- expect(sym.modules[(cy + 1) * sym.size + cx + 1]).toBe(0);101- }102- const back = readBack(sym);103- expect(back.level).toBe(0); // M104- expect(back.mask).toBe(sym.mask);105- expect(new TextDecoder().decode(back.bytes)).toBe(text);106- }107- expect(seen.size).toBeGreaterThan(6);108- expect(capacity(1)).toBe(14);109- expect(capacity(10)).toBe(213);110- expect(capacity(20)).toBe(666);111- expect(() => encode("y".repeat(667))).toThrow(/too long/);112- });113-114- it("scans with an independent decoder, which the read-back above cannot prove", () => {115- // The read-back shares the encoder's assumptions: transposed format bits116- // passed it for months while no phone could scan the result, and a117- // misplaced alignment pattern in version 10 hid behind error correction118- // at short lengths. jsQR is a different code base; every version at119- // full capacity goes through it.120- const uri = "nostrconnect://" + "ab".repeat(32) + "?relay=wss%3A%2F%2Fdami.bind.ws&secret=0123456789abcdef&perms=sign_event%3A27235%2Csign_event%3A9734&name=dami.bind.ws&url=https%3A%2F%2Fdami.bind.ws";121- const full = Array.from({ length: 20 }, (_, i) => Array.from({ length: capacity(i + 1) }, (_, j) => String.fromCharCode(48 + ((j * 7 + i) % 74))).join(""));122- for (const text of ["nostr:npub1abc", uri, ...full]) {123- const sym = encode(text);124- expect(scan(sym.size, (x, y) => sym.modules[y * sym.size + x] === 1), `version ${sym.version}`).toBe(text);125- }126- });127-});128-129-describe("relay card", () => {130- it("describes a claimed relay, hides the member count when the directory is private, and signs", async () => {131- const host = "cardy.bind.ws";132- const owner = generateSecretKey();133- const member = generateSecretKey();134- await rpc(host, owner, "claim");135- await rpc(host, owner, "setmember", getPublicKey(member), { name: "bob" });136- await rpc(host, owner, "setpolicy", { description: "A relay for people who like cards.", writes: "allowlist" });137-138- let r = await get(host, "/card.json");139- expect(r.status).toBe(200);140- expect(r.headers.get("cache-control")).toContain("max-age=300");141- let c: any = await r.json();142- expect(c.state).toBe("claimed");143- expect(c.name).toBe("cardy");144- expect(c.url).toBe("wss://cardy.bind.ws");145- expect(c.console).toBe("https://cardy.bind.ws/");146- expect(c.owner).toBe(getPublicKey(owner));147- expect(c.members).toBe(2);148- expect(c.writes).toBe("allowlist");149- expect(c.reads).toBe("open");150- expect(c.fuel).toBe("allowance");151- expect(c.self).toMatch(/^[0-9a-f]{64}$/);152- expect(c.signed_url).toBe("https://cardy.bind.ws/card.nostr");153- const prof = decode(c.nprofile);154- expect(prof.type).toBe("nprofile");155- expect((prof.data as any).pubkey).toBe(c.owner);156- expect((prof.data as any).relays).toEqual(["wss://cardy.bind.ws"]);157- const addr = decode(c.naddr);158- expect(addr.type).toBe("naddr");159- expect((addr.data as any).kind).toBe(39000);160- expect((addr.data as any).pubkey).toBe(c.self);161- expect((addr.data as any).identifier).toBe("cardy");162- expect((addr.data as any).relays).toEqual(["wss://cardy.bind.ws"]);163-164- await rpc(host, owner, "setpolicy", { directoryPublic: false });165- c = await (await get(host, "/card.json")).json();166- expect(c.members).toBeUndefined();167-168- const signed: any = await (await get(host, "/card.nostr")).json();169- expect(signed.kind).toBe(30078);170- expect(signed.pubkey).toBe(c.self);171- expect(signed.tags).toEqual([["d", "bind.ws/card"]]);172- expect(verifyEvent(signed)).toBe(true);173- expect(JSON.parse(signed.content).naddr).toBe(c.naddr);174-175- r = await get(host, "/card.svg");176- expect(r.status).toBe(200);177- expect(r.headers.get("content-type")).toContain("image/svg+xml");178- const svg = await r.text();179- expect(svg).toContain("cardy");180- expect(svg).toContain("people who like cards");181- expect(svg).toContain("members write");182- expect(svg).toContain("group naddr");183- expect(svg).toContain('<path d="M');184- // The QR on the card scans to the naddr: the card scales the modules185- // into a 150 unit box, so read the cells back from the path.186- const path = svg.match(/<g transform="translate\(418 58\)">.*?<path d="([^"]+)"/)![1];187- const cells = [...path.matchAll(/M([\d.]+) ([\d.]+)h([\d.]+)/g)].map((m) => ({ x: +m[1], y: +m[2], cell: +m[3] }));188- const size = encode(c.naddr).size;189- expect(cells.length).toBeGreaterThan(100);190- const dark = new Set(cells.map((k) => Math.round(k.x / k.cell) + "," + Math.round(k.y / k.cell)));191- expect(scan(size, (x, y) => dark.has(x + "," + y))).toBe(c.naddr);192- });193-194- it("says so on unclaimed and leased relays, and does not sign for them", async () => {195- const host = "nobody.bind.ws";196- const c: any = await (await get(host, "/card.json")).json();197- expect(c.state).toBe("unclaimed");198- expect(c.owner).toBeUndefined();199- expect(c.naddr).toBeUndefined();200- expect((await get(host, "/card.nostr")).status).toBe(404);201- const svg = await (await get(host, "/card.svg")).text();202- expect(svg).toContain("Nobody owns this relay yet");203- const l: any = await (await SELF.fetch("http://bind.ws/lease", { method: "POST", headers: { "cf-connecting-ip": "10.9.9.9" } })).json();204- const lc: any = await (await get(`${l.name}.bind.ws`, "/card.json")).json();205- expect(lc.state).toBe("leased");206- expect(lc.expires_at).toBe(l.expires_at);207- });208-209- it("serves any short text as a QR and caps it", async () => {210- const host = "nobody.bind.ws";211- let r = await get(host, "/qr.svg?text=" + encodeURIComponent("nostr:npub1abc"));212- expect(r.status).toBe(200);213- expect(r.headers.get("content-type")).toContain("image/svg+xml");214- expect(await r.text()).toContain("<path");215- expect((await get(host, "/qr.svg")).status).toBe(400);216- r = await get(host, "/qr.svg?text=" + "a".repeat(513));217- expect(r.status).toBe(413);218- });219-});
A test/helpers/qr.ts +19 -0
R test/adopt.test.ts → test/object/adopt.test.ts +4 -4
R test/audit.test.ts → test/object/audit.test.ts +1 -1
R test/blossom.test.ts → test/object/blossom.test.ts +3 -3
A test/object/card.test.ts +101 -0
@@ -0,0 +1,101 @@
1+// The relay card: the JSON, the nostr event, the SVG with a QR the phone2+// can read, and the caching. The encoder itself is test/unit/qr.test.ts.3+import { SELF } from "cloudflare:test";4+import { describe, it, expect } from "vitest";5+import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";6+import { decode } from "nostr-tools/nip19";7+import { encode } from "../../src/qr.ts";8+import { scan } from "../helpers/qr.ts";9+import { rpc, get } from "../helpers/relay.ts";10+11+describe("relay card", () => {12+ it("describes a claimed relay, hides the member count when the directory is private, and signs", async () => {13+ const host = "cardy.bind.ws";14+ const owner = generateSecretKey();15+ const member = generateSecretKey();16+ await rpc(host, owner, "claim");17+ await rpc(host, owner, "setmember", getPublicKey(member), { name: "bob" });18+ await rpc(host, owner, "setpolicy", { description: "A relay for people who like cards.", writes: "allowlist" });19+20+ let r = await get(host, "/card.json");21+ expect(r.status).toBe(200);22+ expect(r.headers.get("cache-control")).toContain("max-age=300");23+ let c: any = await r.json();24+ expect(c.state).toBe("claimed");25+ expect(c.name).toBe("cardy");26+ expect(c.url).toBe("wss://cardy.bind.ws");27+ expect(c.console).toBe("https://cardy.bind.ws/");28+ expect(c.owner).toBe(getPublicKey(owner));29+ expect(c.members).toBe(2);30+ expect(c.writes).toBe("allowlist");31+ expect(c.reads).toBe("open");32+ expect(c.fuel).toBe("allowance");33+ expect(c.self).toMatch(/^[0-9a-f]{64}$/);34+ expect(c.signed_url).toBe("https://cardy.bind.ws/card.nostr");35+ const prof = decode(c.nprofile);36+ expect(prof.type).toBe("nprofile");37+ expect((prof.data as any).pubkey).toBe(c.owner);38+ expect((prof.data as any).relays).toEqual(["wss://cardy.bind.ws"]);39+ const addr = decode(c.naddr);40+ expect(addr.type).toBe("naddr");41+ expect((addr.data as any).kind).toBe(39000);42+ expect((addr.data as any).pubkey).toBe(c.self);43+ expect((addr.data as any).identifier).toBe("cardy");44+ expect((addr.data as any).relays).toEqual(["wss://cardy.bind.ws"]);45+46+ await rpc(host, owner, "setpolicy", { directoryPublic: false });47+ c = await (await get(host, "/card.json")).json();48+ expect(c.members).toBeUndefined();49+50+ const signed: any = await (await get(host, "/card.nostr")).json();51+ expect(signed.kind).toBe(30078);52+ expect(signed.pubkey).toBe(c.self);53+ expect(signed.tags).toEqual([["d", "bind.ws/card"]]);54+ expect(verifyEvent(signed)).toBe(true);55+ expect(JSON.parse(signed.content).naddr).toBe(c.naddr);56+57+ r = await get(host, "/card.svg");58+ expect(r.status).toBe(200);59+ expect(r.headers.get("content-type")).toContain("image/svg+xml");60+ const svg = await r.text();61+ expect(svg).toContain("cardy");62+ expect(svg).toContain("people who like cards");63+ expect(svg).toContain("members write");64+ expect(svg).toContain("group naddr");65+ expect(svg).toContain('<path d="M');66+ // The QR on the card scans to the naddr: the card scales the modules67+ // into a 150 unit box, so read the cells back from the path.68+ const path = svg.match(/<g transform="translate\(418 58\)">.*?<path d="([^"]+)"/)![1];69+ const cells = [...path.matchAll(/M([\d.]+) ([\d.]+)h([\d.]+)/g)].map((m) => ({ x: +m[1], y: +m[2], cell: +m[3] }));70+ const size = encode(c.naddr).size;71+ expect(cells.length).toBeGreaterThan(100);72+ const dark = new Set(cells.map((k) => Math.round(k.x / k.cell) + "," + Math.round(k.y / k.cell)));73+ expect(scan(size, (x, y) => dark.has(x + "," + y))).toBe(c.naddr);74+ });75+76+ it("says so on unclaimed and leased relays, and does not sign for them", async () => {77+ const host = "nobody.bind.ws";78+ const c: any = await (await get(host, "/card.json")).json();79+ expect(c.state).toBe("unclaimed");80+ expect(c.owner).toBeUndefined();81+ expect(c.naddr).toBeUndefined();82+ expect((await get(host, "/card.nostr")).status).toBe(404);83+ const svg = await (await get(host, "/card.svg")).text();84+ expect(svg).toContain("Nobody owns this relay yet");85+ const l: any = await (await SELF.fetch("http://bind.ws/lease", { method: "POST", headers: { "cf-connecting-ip": "10.9.9.9" } })).json();86+ const lc: any = await (await get(`${l.name}.bind.ws`, "/card.json")).json();87+ expect(lc.state).toBe("leased");88+ expect(lc.expires_at).toBe(l.expires_at);89+ });90+91+ it("serves any short text as a QR and caps it", async () => {92+ const host = "nobody.bind.ws";93+ let r = await get(host, "/qr.svg?text=" + encodeURIComponent("nostr:npub1abc"));94+ expect(r.status).toBe(200);95+ expect(r.headers.get("content-type")).toContain("image/svg+xml");96+ expect(await r.text()).toContain("<path");97+ expect((await get(host, "/qr.svg")).status).toBe(400);98+ r = await get(host, "/qr.svg?text=" + "a".repeat(513));99+ expect(r.status).toBe(413);100+ });101+});
R test/data.test.ts → test/object/data.test.ts +4 -4
R test/domains.test.ts → test/object/domains.test.ts +3 -3
R test/exposure.test.ts → test/object/exposure.test.ts +5 -5
R test/fork.test.ts → test/object/fork.test.ts +3 -3
R test/fuel.test.ts → test/object/fuel.test.ts +3 -18
@@ -2,10 +2,9 @@ import { SELF, env, runInDurableObject } from "cloudflare:test";
22 import { describe, it, expect } from "vitest";33 import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";44 import { getToken } from "nostr-tools/nip98";5-import type { Relay } from "../src/relay.ts";6-import { bolt11Msats } from "../src/fuel.ts";7-import { now, ev, rpc } from "./helpers/relay.ts";8-import { WS } from "./helpers/ws.ts";5+import type { Relay } from "../../src/relay.ts";6+import { now, ev, rpc } from "../helpers/relay.ts";7+import { WS } from "../helpers/ws.ts";98 109 const SERVICE = "ab".repeat(32);1110 const provider = generateSecretKey();
@@ -45,20 +44,6 @@ function receipt(req: Event, bolt11: string, signer = provider, p = SERVICE) {
4544 return ev(signer, 9735, "", [["p", p], ["P", req.pubkey], ["bolt11", bolt11], ["description", JSON.stringify(req)]]);4645 }4746 48-describe("bolt11 amounts", () => {49- it("reads the human-readable amount", () => {50- expect(bolt11Msats("lnbc10n1abc")).toBe(1000);51- expect(bolt11Msats("lnbc10u1abc")).toBe(1_000_000);52- expect(bolt11Msats("lnbc2500u1abc")).toBe(250_000_000);53- expect(bolt11Msats("lnbc1m1abc")).toBe(100_000_000);54- expect(bolt11Msats("lnbc1abc")).toBe(0);55- expect(bolt11Msats("lnbc20p1abc")).toBe(2);56- expect(bolt11Msats("lnbc25p1abc")).toBe(0); // sub-msat precision is invalid57- expect(bolt11Msats("lntb500n1abc")).toBe(50_000);58- expect(bolt11Msats("nonsense")).toBe(0);59- });60-});61-6247 describe("fuel", () => {6348 it("issues invoices, credits valid receipts once, ignores forgeries, and lifts the out-of-fuel restriction", async () => {6449 const host = "fuel-a.bind.ws";
R test/gate.test.ts → test/object/gate.test.ts +3 -3
R test/groups.test.ts → test/object/groups.test.ts +2 -2
R test/jobs.test.ts → test/object/jobs.test.ts +4 -4
R test/lease.test.ts → test/object/lease.test.ts +5 -5
R test/manage.test.ts → test/object/manage.test.ts +2 -2
R test/media.test.ts → test/object/media.test.ts +3 -3
R test/nip11.test.ts → test/object/nip11.test.ts +1 -1
R test/nip43.test.ts → test/object/nip43.test.ts +2 -2
R test/nip46.test.ts → test/object/nip46.test.ts +2 -2
R test/nip66.test.ts → test/object/nip66.test.ts +1 -1
R test/policy.test.ts → test/object/policy.test.ts +2 -2
R test/presets.test.ts → test/object/presets.test.ts +2 -2
R test/relay.test.ts → test/object/relay.test.ts +4 -4
A test/object/selfhost.test.ts +31 -0
@@ -0,0 +1,31 @@
1+// A host without Cloudflare's edge (docs/16-hosting-without-cloudflare.md):2+// a proxy can ask which hostnames are ours before it fetches certificates3+// for them. The address and lease pieces are test/unit/edge.test.ts.4+import { SELF, env } from "cloudflare:test";5+import { describe, it, expect } from "vitest";6+import { hostnameKnown } from "../../src/edge.ts";7+8+describe("which hostnames are ours", () => {9+ it("knows the apex, valid and reserved names under it and mapped custom hostnames, and nothing else", async () => {10+ const e = { DOMAIN: "bind.ws", HOSTS: env.HOSTS };11+ await env.HOSTS.put("relay.example.org", "kitchen");12+ expect(await hostnameKnown(e, "bind.ws")).toBe(true);13+ expect(await hostnameKnown(e, "www.bind.ws")).toBe(true);14+ expect(await hostnameKnown(e, "Kitchen.bind.ws.")).toBe(true);15+ expect(await hostnameKnown(e, "api.bind.ws")).toBe(true);16+ expect(await hostnameKnown(e, "relay.example.org")).toBe(true);17+ expect(await hostnameKnown(e, "a.b.bind.ws")).toBe(false);18+ expect(await hostnameKnown(e, "-bad.bind.ws")).toBe(false);19+ expect(await hostnameKnown(e, "other.example.org")).toBe(false);20+ expect(await hostnameKnown(e, "")).toBe(false);21+ });22+23+ it("answers a proxy's question on any host with a status and no body", async () => {24+ const ask = async (domain: string) => (await SELF.fetch(`http://bind.ws/.well-known/bindws/hostname?domain=${encodeURIComponent(domain)}`)).status;25+ expect(await ask("kitchen.bind.ws")).toBe(200);26+ expect(await ask("bind.ws")).toBe(200);27+ expect(await ask("nope.example.org")).toBe(404);28+ const r = await SELF.fetch("http://anything.example/.well-known/bindws/hostname?domain=kitchen.bind.ws");29+ expect([r.status, await r.text()]).toEqual([200, ""]);30+ });31+});
R test/succession.test.ts → test/object/succession.test.ts +3 -3
R test/sync.test.ts → test/object/sync.test.ts +4 -4
R test/tools.test.ts → test/object/tools.test.ts +4 -4
R test/views.test.ts → test/object/views.test.ts +4 -4
R test/web.test.ts → test/object/web.test.ts +3 -3
A test/unit/bolt11.test.ts +17 -0
R test/selfhost.test.ts → test/unit/edge.test.ts +4 -31
@@ -1,10 +1,8 @@
1-// A host without Cloudflare's edge (docs/16-hosting-without-cloudflare.md):2-// the client's address comes from the proxy's header or not at all, the3-// lease door keeps its own count, and a proxy can ask which hostnames are4-// ours before it fetches certificates for them.5-import { SELF, env } from "cloudflare:test";1+// The edge without Cloudflare (docs/16-hosting-without-cloudflare.md): the2+// client's address comes from the proxy's header or not at all, and the3+// lease door keeps its own count when there are no rate limit bindings.64 import { describe, it, expect } from "vitest";7-import { clientIP, hostnameKnown, leaseAllowed, LEASES_PER_IP_MINUTE } from "../src/edge.ts";5+import { clientIP, leaseAllowed, LEASES_PER_IP_MINUTE } from "../../src/edge.ts";86 97 const req = (headers: Record<string, string>) => new Request("http://x/", { headers });108
@@ -36,28 +34,3 @@ describe("the lease door without rate limit bindings", () => {
3634 expect(calls).toEqual(["203.0.113.9", "all", "203.0.113.9", "all"]);3735 });3836 });39-40-describe("which hostnames are ours", () => {41- it("knows the apex, valid and reserved names under it and mapped custom hostnames, and nothing else", async () => {42- const e = { DOMAIN: "bind.ws", HOSTS: env.HOSTS };43- await env.HOSTS.put("relay.example.org", "kitchen");44- expect(await hostnameKnown(e, "bind.ws")).toBe(true);45- expect(await hostnameKnown(e, "www.bind.ws")).toBe(true);46- expect(await hostnameKnown(e, "Kitchen.bind.ws.")).toBe(true);47- expect(await hostnameKnown(e, "api.bind.ws")).toBe(true);48- expect(await hostnameKnown(e, "relay.example.org")).toBe(true);49- expect(await hostnameKnown(e, "a.b.bind.ws")).toBe(false);50- expect(await hostnameKnown(e, "-bad.bind.ws")).toBe(false);51- expect(await hostnameKnown(e, "other.example.org")).toBe(false);52- expect(await hostnameKnown(e, "")).toBe(false);53- });54-55- it("answers a proxy's question on any host with a status and no body", async () => {56- const ask = async (domain: string) => (await SELF.fetch(`http://bind.ws/.well-known/bindws/hostname?domain=${encodeURIComponent(domain)}`)).status;57- expect(await ask("kitchen.bind.ws")).toBe(200);58- expect(await ask("bind.ws")).toBe(200);59- expect(await ask("nope.example.org")).toBe(404);60- const r = await SELF.fetch("http://anything.example/.well-known/bindws/hostname?domain=kitchen.bind.ws");61- expect([r.status, await r.text()]).toEqual([200, ""]);62- });63-});
A test/unit/qr.test.ts +106 -0
@@ -0,0 +1,106 @@
1+// The QR encoder behind the relay card. A symbol is read back: format2+// bits, unmasked data, de-interleaved blocks, Reed-Solomon syndromes, and3+// the bytes themselves; then jsQR, which shares nothing with the encoder.4+import { describe, it, expect } from "vitest";5+import { EC_M, MASKS, dataCodewords, dataOrder, encode, formatBits, gfMul, gfPow, layout, capacity } from "../../src/qr.ts";6+import { scan } from "../helpers/qr.ts";7+8+// readBack decodes a symbol without any error correction: it must be exact.9+function readBack(sym: ReturnType<typeof encode>): { mask: number; level: number; bytes: Uint8Array } {10+ const { size, modules, version } = sym;11+ const at = (x: number, y: number) => modules[y * size + x];12+ // Format bits around the top-left finder, and the second copy split13+ // between the other two; both must agree.14+ const fbits: number[] = [];15+ for (let i = 0; i < 6; i++) fbits.push(at(8, i));16+ fbits.push(at(8, 7), at(8, 8), at(7, 8));17+ for (let i = 9; i < 15; i++) fbits.push(at(14 - i, 8));18+ const second: number[] = [];19+ for (let i = 0; i < 8; i++) second.push(at(size - 1 - i, 8));20+ for (let i = 8; i < 15; i++) second.push(at(8, size - 15 + i));21+ expect(second).toEqual(fbits);22+ let f = 0;23+ fbits.forEach((b, i) => (f |= b << i));24+ const mask = (f ^ 0x5412) >> 10 & 7;25+ const level = (f ^ 0x5412) >> 13 & 3;26+ expect(f).toBe(formatBits(mask));27+ // Data modules in placement order, unmasked.28+ const l = layout(version);29+ const order = dataOrder(l);30+ const bits = order.map(([x, y]) => at(x, y) ^ (MASKS[mask](x, y) ? 1 : 0));31+ const [ec, g1, d1, g2, d2] = EC_M[version];32+ const nblocks = g1 + g2;33+ const total = dataCodewords(version) + ec * nblocks;34+ const cw: number[] = [];35+ for (let i = 0; i < total; i++) cw.push(parseInt(bits.slice(i * 8, i * 8 + 8).join(""), 2));36+ // De-interleave.37+ const lens = [...Array(g1).fill(d1), ...Array(g2).fill(d2)] as number[];38+ const data: number[][] = lens.map(() => []);39+ const ecs: number[][] = lens.map(() => []);40+ let k = 0;41+ for (let i = 0; i < Math.max(d1, d2); i++) for (let b = 0; b < nblocks; b++) if (i < lens[b]) data[b].push(cw[k++]);42+ for (let i = 0; i < ec; i++) for (let b = 0; b < nblocks; b++) ecs[b].push(cw[k++]);43+ expect(k).toBe(total);44+ // Syndromes: the codeword polynomial vanishes at alpha^0 .. alpha^(ec-1).45+ for (let b = 0; b < nblocks; b++) {46+ const poly = [...data[b], ...ecs[b]];47+ for (let i = 0; i < ec; i++) {48+ let v = 0;49+ const a = gfPow(2, i);50+ for (const c of poly) v = gfMul(v, a) ^ c;51+ expect(v, `block ${b} syndrome ${i}`).toBe(0);52+ }53+ }54+ // Byte mode header and payload.55+ const dbits: number[] = [];56+ for (const block of data) for (const c of block) for (let i = 7; i >= 0; i--) dbits.push((c >> i) & 1);57+ const mode = parseInt(dbits.slice(0, 4).join(""), 2);58+ expect(mode).toBe(4);59+ const cl = version < 10 ? 8 : 16;60+ const len = parseInt(dbits.slice(4, 4 + cl).join(""), 2);61+ const bytes = new Uint8Array(len);62+ for (let i = 0; i < len; i++) bytes[i] = parseInt(dbits.slice(4 + cl + i * 8, 4 + cl + i * 8 + 8).join(""), 2);63+ return { mask, level, bytes };64+}65+66+describe("qr encoder", () => {67+ it("round-trips through every version class with valid format bits and zero syndromes", () => {68+ const cases = [5, 14, 40, 100, 130, 213, 260, 400, 500, 666].map((n) => "x".repeat(n).replace(/x/g, () => String.fromCharCode(33 + Math.floor(Math.random() * 90))));69+ cases.push("HELLO WORLD", "nostrconnect://" + "ab".repeat(32) + "?relay=wss%3A%2F%2Fearly-trout.bind.ws&secret=deadbeef&perms=sign_event%3A27235");70+ const seen = new Set<number>();71+ for (const text of cases) {72+ const sym = encode(text);73+ seen.add(sym.version);74+ expect(sym.size).toBe(sym.version * 4 + 17);75+ // Finder patterns are dark at their corners and cores.76+ for (const [cx, cy] of [[0, 0], [sym.size - 7, 0], [0, sym.size - 7]]) {77+ expect(sym.modules[cy * sym.size + cx]).toBe(1);78+ expect(sym.modules[(cy + 3) * sym.size + cx + 3]).toBe(1);79+ expect(sym.modules[(cy + 1) * sym.size + cx + 1]).toBe(0);80+ }81+ const back = readBack(sym);82+ expect(back.level).toBe(0); // M83+ expect(back.mask).toBe(sym.mask);84+ expect(new TextDecoder().decode(back.bytes)).toBe(text);85+ }86+ expect(seen.size).toBeGreaterThan(6);87+ expect(capacity(1)).toBe(14);88+ expect(capacity(10)).toBe(213);89+ expect(capacity(20)).toBe(666);90+ expect(() => encode("y".repeat(667))).toThrow(/too long/);91+ });92+93+ it("scans with an independent decoder, which the read-back above cannot prove", () => {94+ // The read-back shares the encoder's assumptions: transposed format bits95+ // passed it for months while no phone could scan the result, and a96+ // misplaced alignment pattern in version 10 hid behind error correction97+ // at short lengths. jsQR is a different code base; every version at98+ // full capacity goes through it.99+ const uri = "nostrconnect://" + "ab".repeat(32) + "?relay=wss%3A%2F%2Fdami.bind.ws&secret=0123456789abcdef&perms=sign_event%3A27235%2Csign_event%3A9734&name=dami.bind.ws&url=https%3A%2F%2Fdami.bind.ws";100+ const full = Array.from({ length: 20 }, (_, i) => Array.from({ length: capacity(i + 1) }, (_, j) => String.fromCharCode(48 + ((j * 7 + i) % 74))).join(""));101+ for (const text of ["nostr:npub1abc", uri, ...full]) {102+ const sym = encode(text);103+ expect(scan(sym.size, (x, y) => sym.modules[y * sym.size + x] === 1), `version ${sym.version}`).toBe(text);104+ }105+ });106+});
M vitest.config.ts +18 -9