- commit
- 2341c31ced0f31119eeba4466840c2e271163a1c
- parent
- 453e022c841649228706b37f68322aa3c639b667
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 31 | ++++++++++++++--- |
| M | src/ | 10 | ++++-- |
| M | src/ | 5 | ++- |
| M | src/ | 17 | +++++++-- |
| M | src/ | 9 | +++-- |
| M | src/ | 11 | +++--- |
| M | src/ | 6 | +++- |
| M | src/ | 55 | +++++++++++++++++++++++------- |
| M | src/ | 4 | +- |
| M | test/ | 12 | ++++++- |
| M | test/ | 19 | ++++++++++- |
| M | test/ | 3 | +- |
@@ -10,12 +10,15 @@
1010 // HEAD /upload t=upload; X-SHA-256, X-Content-Type, X-Content-Length (BUD-06)1111 // PUT /mirror t=upload, x=<sha256>; body {url} -> descriptor (BUD-04)1212 // PUT /report body: kind 1984 with x tags (BUD-09)13-// GET /<sha256>[.ext] public14-// HEAD /<sha256>[.ext] public13+// GET /<sha256>[.ext] the read rule; t=get or NIP-98 when reads are not open14+// HEAD /<sha256>[.ext] same1515 // DELETE /<sha256> t=delete, x=<sha256>; uploader or owner16-// GET /list/<pubkey> public16+// GET /list/<pubkey> the read rule; t=list or NIP-98 when reads are not open17+//18+// Files follow the relay's read rule (Settings.mayRead), the same gate as19+// events: on a members-only relay a blob is a member's, not a public URL.1720 import { sha256 } from "@noble/hashes/sha2.js";18-import { verifyBlossom } from "./auth.ts";21+import { verifyBlossom, whoAsks, denyStatus } from "./auth.ts";1922 export { verifyBlossom } from "./auth.ts";2023 import { tagValues, validate, type Event } from "./event.ts";2124 import { bytesToHex } from "./negentropy.ts";@@ -249,6 +252,10 @@ export async function blossom(relay: Relay, req: Request): Promise<Response> {
249252 if (bad) return reason(bad, 400);250253 if (e.kind !== KIND_REPORT) return reason("invalid: report must be kind 1984", 400);251254 if (Math.abs(now - e.created_at) > 3600) return reason("invalid: report is too old or from the future", 400);255+ // The reporter must be someone the read rule admits, before any hash is256+ // looked up: whether this relay holds a file is a member's to know.257+ const gate = relay.settings.mayRead([e.pubkey]);258+ if (gate) return reason(gate, denyStatus(gate));252259 const r = fileReport(relay, e);253260 if (typeof r === "string") return reason(r, r.startsWith("not found") ? 404 : r.startsWith("invalid") ? 400 : 403);254261 return json({ ok: true, filed: r });@@ -257,11 +264,16 @@ export async function blossom(relay: Relay, req: Request): Promise<Response> {
257264 if (req.method === "GET" && url.pathname.startsWith("/list/")) {258265 const pk = url.pathname.slice(6);259266 if (!/^[0-9a-f]{64}$/.test(pk)) return reason("invalid: bad pubkey", 400);267+ const who = whoAsks(req, "", "list");268+ if (typeof who === "string") return reason(who, 401);269+ const gate = relay.settings.mayRead(who.pubkeys);270+ if (gate) return reason(gate, denyStatus(gate));260271 const rows = sql.exec<Blob>(`SELECT * FROM blobs WHERE uploader=? ORDER BY uploaded DESC LIMIT 500`, pk).toArray();261272 return json(rows.map((b) => descriptor(host, b)));262273 }263274 264- const m = SHA_RE.exec(url.pathname);275+ // /nip96/<sha> is the same door (nip96.ts hands it over unchanged).276+ const m = SHA_RE.exec(url.pathname.startsWith("/nip96/") ? url.pathname.slice("/nip96".length) : url.pathname);265277 if (!m) return reason("not found", 404);266278 const sha = m[1];267279 const blob = sql.exec<Blob>(`SELECT * FROM blobs WHERE sha256=?`, sha).toArray()[0];@@ -277,12 +289,19 @@ export async function blossom(relay: Relay, req: Request): Promise<Response> {
277289 }278290 279291 if (req.method !== "GET" && req.method !== "HEAD") return reason("method not allowed", 405);292+ // The read rule, before the lookup: a stranger learns nothing, not even293+ // whether the hash is here. A gated answer is not for shared caches.294+ const open = relay.settings.policy.reads === "open";295+ const who = whoAsks(req, "", "get", sha);296+ if (typeof who === "string") return reason(who, 401);297+ const gate = relay.settings.mayRead(who.pubkeys);298+ if (gate) return reason(gate, denyStatus(gate));280299 if (!blob) return reason("not found", 404);281300 const headers: Record<string, string> = {282301 "content-type": blob.type,283302 "content-length": String(blob.size),284303 "accept-ranges": "bytes",285- "cache-control": "public, max-age=31536000, immutable",304+ "cache-control": open ? "public, max-age=31536000, immutable" : "private, no-store",286305 "access-control-allow-origin": "*",287306 "x-content-type-options": "nosniff",288307 "content-security-policy": "default-src 'none'",@@ -12,6 +12,7 @@ import { svg as qrSVG, encode as qrEncode } from "./qr.ts";
1212 import { KIND_GROUP_METADATA } from "./identity.ts";1313 import { escapeHTML } from "./ui.ts";1414 import type { Relay } from "./relay.ts";15+import { whoAsks } from "./auth.ts";1516 1617 export const KIND_APP_DATA = 30078;1718 export const CARD_D = "bind.ws/card";@@ -38,7 +39,8 @@ export interface Card {
3839 image?: string;3940 }4041 41-export function cardData(relay: Relay, host: string): Card {42+// caller is the pubkeys the request proved, for the directory switch.43+export function cardData(relay: Relay, host: string, caller: string[] = []): Card {4244 const p = relay.settings.policy;4345 const s = relay.settings;4446 const url = relay.relayURL(host);@@ -65,7 +67,7 @@ export function cardData(relay: Relay, host: string): Card {
6567 card.self = self;6668 card.naddr = naddrEncode({ kind: KIND_GROUP_METADATA, pubkey: self, identifier: relay.slug, relays: [url] });6769 }68- if (p.directoryPublic) card.members = s.members().length;70+ if (s.mayList(caller) === "") card.members = s.members().length;6971 return card;7072 }7173 @@ -80,7 +82,9 @@ export function card(relay: Relay, req: Request): Response {
8082 if (bytes.length > QR_MAX_BYTES) return json({ error: `invalid: at most ${QR_MAX_BYTES} bytes` }, 413);8183 return new Response(qrSVG(bytes, { margin: 2 }), { headers: headers("image/svg+xml") });8284 }83- const data = cardData(relay, url.host);85+ const who = whoAsks(req, "", null);86+ if (typeof who === "string") return json({ error: who }, 401);87+ const data = cardData(relay, url.host, who.pubkeys);8488 if (url.pathname === "/card.json") return json(data);8589 if (url.pathname === "/card.nostr") {8690 if (data.state !== "claimed") return json({ error: "restricted: this relay has no owner to sign for it" }, 404);@@ -10,8 +10,9 @@ export const KIND_AUTH = 22242;
1010 export const isEphemeral = (k: number) => k >= 20000 && k < 30000;1111 export const isReplaceable = (k: number) => k === 0 || k === 3 || (k >= 10000 && k < 20000);1212 export const isAddressable = (k: number) => k >= 30000 && k < 40000;13-// Only served to the parties involved (NIP-04 DMs, NIP-59 gift wraps), per NIP-17.14-export const isPrivate = (k: number) => k === 4 || k === 1059 || k === 21059;13+// Only served to the parties involved: NIP-04 DMs and NIP-59 gift wraps per14+// NIP-17, and NIP-46 signer traffic, which is ephemeral and addressed by p.15+export const isPrivate = (k: number) => k === 4 || k === 1059 || k === 21059 || k === 24133;1516 1617 export const now = () => Math.floor(Date.now() / 1000);1718 @@ -8,7 +8,7 @@ import { descriptor, type Blob } from "./blossom.ts";
88 import { blockedWords, gateFields, isWriteRule, viewFields } from "./settings.ts";99 import { isReplaceable, isProtected, publicFields, dumpFields } from "./settings.ts";1010 import { checkPullURL } from "./pull.ts";11-import { viewsSummary } from "./views.ts";11+import { VIEWS, viewsSummary } from "./views.ts";1212 import { MAX_PINS } from "./groups.ts";1313 import { relaysFromList } from "./jobs.ts";1414 import { notify, notifySettings } from "./notify.ts";@@ -108,7 +108,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
108108 case "stats": {109109 const d = new Date(now() * 1000);110110 const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;111- return reply({ result: { ...relay.store.stats(), kinds: relay.store.kinds(monthStart), connections: relay.connections(), name: relay.slug, owner: p.owner, fuel: relay.fuelStatus() } });111+ return reply({ result: { ...relay.store.stats(), kinds: relay.store.kinds(monthStart), connections: relay.connections(), name: relay.slug, owner: p.owner, fuel: relay.fuelStatus(), credits: relay.fuel.recentCredits() } });112112 }113113 case "getpolicy":114114 return reply({ result: p });@@ -130,9 +130,15 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
130130 if (typeof clean.maxLimit === "number") clean.maxLimit = Math.min(Math.max(clean.maxLimit as number, 1), 5000);131131 if (typeof clean.maxSubs === "number") clean.maxSubs = Math.min(Math.max(clean.maxSubs as number, 1), 200);132132 s.update(clean);133+ // A read rule that tightened ends the subscriptions it no longer admits.134+ relay.enforceReads();133135 await relay.publishMembership();134- // A view switched off is taken down now; one switched on is published now.135- if (patch.views && typeof patch.views === "object") for (const name of Object.keys(patch.views as object)) await relay.publishView(name);136+ // A view switched off is taken down now; one switched on is published137+ // now, and every view is republished when the rules changed, since138+ // the read rule and the directory decide each one's audience.139+ const names = patch.views && typeof patch.views === "object" ? Object.keys(patch.views as object) : [];140+ if (clean.reads !== undefined || clean.directoryPublic !== undefined) for (const v of VIEWS) if (!names.includes(v.name)) names.push(v.name);141+ for (const name of names) await relay.publishView(name);136142 return reply({ result: s.policy });137143 }138144 case "listviews":@@ -417,6 +423,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
417423 }418424 const err = applyPreset(s, preset.name);419425 if (err) return reply({ error: err }, 400);426+ relay.enforceReads();420427 for (const j of await relay.jobs()) if (j.label === "replica") await relay.removeJob(j.id);421428 let job = null;422429 if (source) {@@ -425,6 +432,8 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
425432 job = r;426433 }427434 await relay.publishMembership();435+ // The read rule and the directory moved, so every view's audience may have.436+ for (const v of VIEWS) await relay.publishView(v.name);428437 return reply({ result: { ...s.policy, job } });429438 }430439 case "forkrelay": {@@ -4,11 +4,14 @@
44 // or clear names from the console.55 import type { Settings } from "./settings.ts";66 7-// nip05Document answers /.well-known/nostr.json?name=<name>.8-export function nip05Document(settings: Settings, name: string | null, relayURL: string): Record<string, unknown> {7+// nip05Document answers /.well-known/nostr.json?name=<name>. A lookup by8+// name answers for anyone: the member put that address in their own profile.9+// Without a name the document is the directory, so it is empty unless the10+// caller may list the members.11+export function nip05Document(settings: Settings, name: string | null, relayURL: string, caller: string[] = []): Record<string, unknown> {912 const names: Record<string, string> = {};1013 const relays: Record<string, string[]> = {};11- const rows = name === null ? settings.members().filter((m) => m.name) : [settings.memberByName(name)].filter((m) => m !== null);14+ const rows = name === null ? (settings.mayList(caller) ? [] : settings.members().filter((m) => m.name)) : [settings.memberByName(name)].filter((m) => m !== null);1215 for (const m of rows) {1316 names[m!.name!] = m!.pubkey;1417 relays[m!.pubkey] = [relayURL];@@ -43,13 +43,10 @@ export async function nip96(relay: Relay, req: Request): Promise<Response> {
4343 });4444 }4545 46- // Downloads are the Blossom path with a different prefix.46+ // Downloads are the Blossom door under a different prefix; the request47+ // goes over unchanged so a signature over this URL still fits.4748 const m = SHA_RE.exec(url.pathname);48- if (m && (req.method === "GET" || req.method === "HEAD")) {49- const target = new URL(req.url);50- target.pathname = url.pathname.slice("/nip96".length);51- return blossom(relay, new Request(target.toString(), req));52- }49+ if (m && (req.method === "GET" || req.method === "HEAD")) return blossom(relay, req);5350 5451 const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, "");5552 if (typeof auth === "string") return fail(auth, 401);@@ -66,6 +63,8 @@ export async function nip96(relay: Relay, req: Request): Promise<Response> {
6663 if (url.pathname !== "/nip96") return fail("not found", 404);6764 6865 if (req.method === "GET") {66+ const gate = relay.settings.mayRead([auth.pubkey]);67+ if (gate) return fail(gate, 403);6968 const count = Math.max(1, Math.min(MAX_PAGE, Number(url.searchParams.get("count")) || 20));7069 const page = Math.max(0, Math.floor(Number(url.searchParams.get("page")) || 0));7170 const total = relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM blobs WHERE uploader=?`, auth.pubkey).one().n;@@ -217,7 +217,11 @@ async function fetchEvents(relay: Relay, sock: Socket, ids: string[], job: PullJ
217217 // copyBlobs brings over a batch of the other relay's files. Returns true218218 // when more remain.219219 async function copyBlobs(relay: Relay, srcName: string, job: PullJob): Promise<boolean> {220- const rows: Blob[] = await relay.relays.getByName(srcName).listBlobs();220+ // The source applies its read rule to the copier, who has proved no key221+ // (the pull never authenticates), so files come across from an open relay222+ // only, the same as its events.223+ const rows: Blob[] | string = await relay.relays.getByName(srcName).listBlobs([]);224+ if (typeof rows === "string") throw new Error("files refused: " + rows);221225 let n = 0;222226 for (const b of rows) {223227 if (relay.sql.exec(`SELECT 1 FROM blobs WHERE sha256=?`, b.sha256).toArray().length) continue;@@ -23,7 +23,7 @@ import { KIND_GROUP_PINS } from "./identity.ts";
2323 import { DIGEST_DAYS, digestText } from "./notify.ts";2424 import { claimFromProfile, nip05Document } from "./nip05.ts";2525 import { checkInvite, claimInvite, inviteCreator, invitePage, termsPage } from "./invites.ts";26-import { verifyNIP98 } from "./manage.ts";26+import { verifyNIP98, whoAsks } from "./auth.ts";2727 import { FAVICON_SVG } from "./ui.ts";2828 import type { PullFilter, PullJob, PullResult } from "./pull.ts";2929 import { leaseDays, leaseNames, validName } from "./names.ts";@@ -419,6 +419,7 @@ export class Relay extends DurableObject<Env> {
419419 if (!s.authed.includes(pubkey)) continue;420420 for (const id of Object.keys(s.subs)) this.send(ws, "CLOSED", id, reason);421421 s.subs = {};422+ this.syncs.delete(ws);422423 this.persist(ws, s);423424 if (close) {424425 try {@@ -430,6 +431,26 @@ export class Relay extends DurableObject<Env> {
430431 }431432 }432433 434+ // enforceReads runs after the read rule changed: every open subscription435+ // is judged again and the ones the rule no longer admits are closed, so a436+ // relay that turned members-only stops feeding the sockets it let in437+ // while it was open. Sync sessions go with them.438+ enforceReads() {439+ for (const ws of this.ctx.getWebSockets()) {440+ const s = this.state(ws);441+ let changed = false;442+ for (const [id, filters] of Object.entries(s.subs)) {443+ const { reason } = this.allowFilters(s, filters);444+ if (!reason) continue;445+ this.send(ws, "CLOSED", id, reason);446+ delete s.subs[id];447+ changed = true;448+ }449+ if (this.settings.mayRead(s.authed)) this.syncs.delete(ws);450+ if (changed) this.persist(ws, s);451+ }452+ }453+433454 // virtualConn represents an HTTP caller as if it had authenticated over the socket.434455 virtualConn(host: string, pubkey: string, ip = "unknown"): ConnState {435456 void this.ownerSeen(pubkey);@@ -521,8 +542,11 @@ export class Relay extends DurableObject<Env> {
521542 this.sql.exec(`DELETE FROM blobs WHERE sha256=?`, sha);522543 }523544 524- // listBlobs is for another relay on this host pulling our files.525- listBlobs(): Blob[] {545+ // listBlobs is for another relay on this host pulling our files. The546+ // caller says which pubkeys it has proved; the read rule decides.547+ listBlobs(pubkeys: string[]): Blob[] | string {548+ const gate = this.settings.mayRead(pubkeys);549+ if (gate) return gate;526550 return this.sql.exec<Blob>(`SELECT * FROM blobs ORDER BY uploaded`).toArray();527551 }528552 @@ -946,7 +970,7 @@ export class Relay extends DurableObject<Env> {
946970 // events or files. The page, NIP-11 and management stay reachable, so947971 // an owner who blocked their own address can undo it.948972 const upgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";949- const door = upgrade || (req.method === "POST" && (url.pathname === "/events" || url.pathname === "/query" || url.pathname === "/count" || url.pathname === "/api/invites/claim" || url.pathname === "/fuel/invoice")) || url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname);973+ const door = upgrade || (req.method === "POST" && (url.pathname === "/events" || url.pathname === "/query" || url.pathname === "/count" || url.pathname === "/api/invites/claim" || url.pathname === "/fuel/invoice")) || url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname === "/report" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname);950974 if (door && this.settings.isIPBlocked(clientIP(req))) {951975 const msg = "blocked: this address is blocked from this relay";952976 return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "content-type": "application/json", "x-reason": msg, "access-control-allow-origin": "*" } });@@ -960,11 +984,16 @@ export class Relay extends DurableObject<Env> {
960984 if (req.method === "POST" && req.headers.get("content-type")?.includes("application/nostr+json+rpc")) return manage(this, req);961985 if (req.method === "POST" && (url.pathname === "/events" || url.pathname === "/query" || url.pathname === "/count")) return bridge(this, req);962986 if (url.pathname === "/.well-known/nostr.json") {963- return Response.json(nip05Document(this.settings, url.searchParams.get("name"), this.relayURL(url.host)), { headers: { "access-control-allow-origin": "*" } });987+ const who = whoAsks(req, "", null);988+ if (typeof who === "string") return Response.json({ error: who }, { status: 401, headers: { "access-control-allow-origin": "*" } });989+ return Response.json(nip05Document(this.settings, url.searchParams.get("name"), this.relayURL(url.host), who.pubkeys), { headers: { "access-control-allow-origin": "*" } });964990 }965991 if (url.pathname === "/people" && req.method === "GET") {966992 const p = this.settings.policy;967- const people = p.owner && p.directoryPublic ? this.settings.members().map((m) => ({ pubkey: m.pubkey, role: m.role, name: m.name })) : [];993+ const who = whoAsks(req, "", null);994+ if (typeof who === "string") return Response.json({ error: who }, { status: 401, headers: { "access-control-allow-origin": "*" } });995+ const listed = p.owner !== "" && this.settings.mayList(who.pubkeys) === "";996+ const people = listed ? this.settings.members().map((m) => ({ pubkey: m.pubkey, role: m.role, name: m.name })) : [];968997 return Response.json({ public: p.directoryPublic, self: this.identity.pubkey, host: url.host, people }, { headers: { "access-control-allow-origin": "*" } });969998 }970999 if (url.pathname === "/terms" && req.method === "GET") {@@ -987,7 +1016,9 @@ export class Relay extends DurableObject<Env> {
9871016 if (url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname === "/report" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname)) return blossom(this, req);9881017 if (url.pathname === "/.well-known/nostr/nip96.json" || url.pathname === "/nip96" || url.pathname.startsWith("/nip96/")) return nip96(this, req);9891018 if (url.pathname === "/fuel" && req.method === "GET") {990- return Response.json({ ...this.fuelStatus(), credits: this.fuel.recentCredits() }, { headers: { "access-control-allow-origin": "*" } });1019+ // Meters and prices for anyone who might top up; who paid is the1020+ // owner's, in the stats method.1021+ return Response.json(this.fuelStatus(), { headers: { "access-control-allow-origin": "*" } });9911022 }9921023 if (url.pathname === "/fuel/invoice" && req.method === "POST") return this.fuelInvoice(req);9931024 if (url.pathname === "/card.json" || url.pathname === "/card.nostr" || url.pathname === "/card.svg" || url.pathname === "/qr.svg") return card(this, req);@@ -1491,13 +1522,13 @@ export class Relay extends DurableObject<Env> {
14911522 // allowFilters returns a CLOSED reason, or "" plus whether an EOSE "auth"14921523 // hint applies because private kinds were silently filtered out.14931524 allowFilters(s: ConnState, filters: Filter[]): { reason: string; authHint: boolean } {1494- // A subscription to NIP-46 traffic alone is served under any read1495- // policy: see accept for why the relay carries it.1525+ // A subscription to NIP-46 traffic alone may be opened under any read1526+ // policy, so a signer can subscribe before its AUTH lands; the traffic1527+ // itself is a private kind, delivered only to its parties (canSee).14961528 if (filters.length > 0 && filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === KIND_NOSTR_CONNECT)) return { reason: "", authHint: false };14971529 const authed = s.authed.length > 0;1498- const reads = this.settings.policy.reads;1499- if (reads !== "open" && !authed) return { reason: "auth-required: this relay requires authentication", authHint: false };1500- if (reads === "members" && !s.authed.some((pk) => this.settings.isAllowed(pk))) return { reason: "restricted: this relay only serves its members", authHint: false };1530+ const gate = this.settings.mayRead(s.authed);1531+ if (gate) return { reason: gate, authHint: false };15011532 if (authed) return { reason: "", authHint: false };15021533 let authHint = false;15031534 for (const f of filters) {@@ -174,7 +174,7 @@ const moderation: View = {
174174 name: "moderation",175175 trigger: "daily",176176 about: "this month's moderation counts, no ids",177- audience: () => "public",177+ audience: byReads,178178 fold(relay) {179179 const d = new Date(now() * 1000);180180 const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;@@ -212,7 +212,7 @@ const zaps: View = {
212212 name: "zaps",213213 trigger: "hourly",214214 about: "zap totals for the top notes and authors here",215- audience: () => "public",215+ audience: byReads,216216 fingerprint(relay) {217217 const r = relay.sql.exec<{ n: number; m: number | null }>(`SELECT count(*) AS n, max(created_at) AS m FROM events WHERE kind=9735`).one();218218 return `${r.n}:${r.m ?? 0}`;@@ -37,6 +37,14 @@ async function claim(host: string, sk: Uint8Array) {
3737 expect(r.status).toBe(200);3838 }3939 40+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {41+ const url = `http://${host}/`;42+ const payload = { method, params };43+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);44+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });45+ return { status: resp.status, ...(await resp.json<any>()) };46+}47+4048 async function ws(host: string) {4149 const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });4250 const sock = resp.webSocket!;@@ -133,7 +141,9 @@ describe("fuel", () => {
133141 expect(r.msg).toMatch(/^duplicate:/);134142 status = await (await SELF.fetch(`http://${host}/fuel`)).json();135143 expect(status.creditedMsats).toBe(1_000_000);136- expect(status.credits[0].payer).toBe(getPublicKey(payer));144+ // Who paid is the owner's to see, not the public meter's.145+ expect(status.credits).toBeUndefined();146+ expect((await rpc(host, owner, "stats")).result.credits[0].payer).toBe(getPublicKey(payer));137147 expect(status.outOfFuel).toBe(false);138148 expect((await c.ok(ev(owner, 1, "fueled"))).ok).toBe(true);139149 @@ -29,12 +29,20 @@ class WS {
2929 else this.queue.push(m);3030 });3131 }32+ challenge = "";3233 static async connect(host: string) {3334 const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });3435 const c = new WS(resp.webSocket!);35- await c.expect("AUTH");36+ c.challenge = (await c.expect("AUTH"))[1];3637 return c;3738 }39+ // auth proves a key over the socket: 24133 is a private kind, delivered40+ // only to a socket that has proved it is the sender or the recipient.41+ async auth(sk: Uint8Array, host: string) {42+ this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));43+ const m = await this.expect("OK");44+ expect(m[2], m[3]).toBe(true);45+ }3846 send(...m: unknown[]) {3947 this.ws.send(JSON.stringify(m));4048 }@@ -72,13 +80,22 @@ describe("NIP-46 transport", () => {
7280 const client = generateSecretKey();7381 const listener = await WS.connect(host);7482 expect(await listener.open("nc", { kinds: [24133], "#p": [getPublicKey(signerKey)] })).toBe("");83+ // A bystander with the same subscription, who has not proved the key.84+ const bystander = await WS.connect(host);85+ expect(await bystander.open("nc", { kinds: [24133], "#p": [getPublicKey(signerKey)] })).toBe("");7586 7687 const sender = await WS.connect(host);88+ const early = ev(client, 24133, "before auth", [["p", getPublicKey(signerKey)]]);89+ expect(await sender.ok(early)).toEqual({ ok: true, msg: "" });90+ // Nothing reaches a socket that has not authenticated as a party.91+ await listener.auth(signerKey, host);7792 const req = ev(client, 24133, "ciphertext", [["p", getPublicKey(signerKey)]]);7893 expect(await sender.ok(req)).toEqual({ ok: true, msg: "" });7994 const got = await listener.expect("EVENT");8095 expect(got[1]).toBe("nc");8196 expect(got[2].id).toBe(req.id);97+ // The bystander saw neither; a later frame proves the queue is empty.98+ expect(await bystander.open("probe", { kinds: [24133] })).toBe("");8299 // Anything else is still refused while unclaimed, and the request left no trace.83100 expect((await sender.ok(ev(client, 1, "hello"))).msg).toMatch(/unclaimed/);84101 const later = await WS.connect(host);@@ -179,8 +179,9 @@ describe("policy", () => {
179179 expect((await c.ok(weak)).msg).toMatch(/^pow:/);180180 await rpc(host, owner, "setpolicy", { minPow: 0 });181181 182- // reads need auth182+ // reads need auth: tightening the rule closes the subscription still open183183 await rpc(host, owner, "setpolicy", { reads: "auth" });184+ expect((await c.expect("CLOSED"))[2]).toMatch(/^auth-required:/);184185 expect((await c.req({ kinds: [1] })).closed).toMatch(/^auth-required:/);185186 await c.auth(stranger, host);186187 expect((await c.req({ kinds: [1] })).events.length).toBeGreaterThan(0);