clone

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

relay: signed notifications to the owner over NIP-17

Dami committed 2d1b7d0
The relay writes its owner a kind 14 rumour sealed and gift wrapped with
its identity key (NIP-59, NIP-44), stored here as the owner's inbox and
pushed best effort to the relays in the owner's kind 10050 through the
pull code's dial. Hooks: a report filed through either door, the daily
alarm when fuel turns low (once, then daily while low), a pull finishing,
and a notifytest method. policy.notify holds the three switches, all off.
The catch-all keep-for rule skips kind 1059 so the inbox survives it;
an explicit rule for 1059 still applies. Tests cover pages, the feed and
the wraps end to end, decrypting with the owner's key.
commit
2d1b7d0958187204b78651f78e8a980c7caa4fed
parent
4838498768235f7c167a6724deb63a2b4b91767e
author
Dami <dami.etoile@gmail.com>
8 files changed, +415 -6
Msrc/blossom.ts2+
Msrc/identity.ts8+
Msrc/manage.ts9+-
Asrc/notify.ts115++++++++++++++
Msrc/relay.ts29+++-
Msrc/roles.ts1+
Msrc/settings.ts8+
Atest/web.test.ts249++++++++++++++++++++++++++++++
M src/blossom.ts +2 -0
M src/identity.ts +8 -0
M src/manage.ts +8 -1
@@ -10,6 +10,7 @@ import { descriptor, type Blob } from "./blossom.ts";
1010 import { isReplaceable, isProtected, publicFields } from "./settings.ts";1111 import { checkPullURL } from "./pull.ts";1212 import { relaysFromList } from "./jobs.ts";13+import { notify, notifySettings } from "./notify.ts";1314 import { can, METHOD_ACTIONS } from "./roles.ts";1415 import { PRESETS, applyPreset } from "./presets.ts";1516
@@ -57,7 +58,7 @@ const METHODS = [
5758 "listblobs", "deleteblob",5859 "storagestats", "setretention", "listretention", "purgekind",5960 "deleterelay", "exportconfig", "importconfig", "resetrules", "listpresets", "applypreset",60- "pullfrom", "pullstatus", "listjobs", "addjob", "removejob", "runjob", "backfill", "transferowner",61+ "pullfrom", "pullstatus", "listjobs", "addjob", "removejob", "runjob", "backfill", "transferowner", "notifytest",6162 ];6263 6364 // validIP accepts an IPv4 or IPv6 address, nothing fancier: no ranges, no names.
@@ -133,6 +134,8 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
133134 if (patch.reads === "open" || patch.reads === "auth" || patch.reads === "members") clean.reads = patch.reads;134135 if (typeof patch.joinTerms === "string") clean.joinTerms = patch.joinTerms.slice(0, 20000);135136 if (typeof patch.directoryPublic === "boolean") clean.directoryPublic = patch.directoryPublic;137+ const notifyPatch = notifySettings(patch.notify, p.notify);138+ if (notifyPatch) clean.notify = notifyPatch;136139 for (const k of ["minPow", "maxFuture", "maxLimit", "maxSubs", "maxBlobMB", "eventsPerMinute", "reqsPerMinute"]) if (Number.isInteger(patch[k]) && (patch[k] as number) >= 0) clean[k] = patch[k];137140 if (typeof clean.maxBlobMB === "number") clean.maxBlobMB = Math.min(Math.max(clean.maxBlobMB as number, 1), 95);138141 if (typeof clean.eventsPerMinute === "number") clean.eventsPerMinute = Math.max(clean.eventsPerMinute as number, 1);
@@ -340,6 +343,10 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
340343 return reply({ result: s.listKinds("allow") });341344 case "listblockedkinds":342345 return reply({ result: s.listKinds("block") });346+ case "notifytest": {347+ const sent = await notify(relay, "test", `This is a test from ${relay.slug}. Notifications work: you will hear from the relay here about the things you switched on.`, "a test from " + relay.slug);348+ return reply({ result: { sent } });349+ }343350 case "resetrules":344351 s.resetRules();345352 await relay.publishMembership();
A src/notify.ts +115 -0
@@ -0,0 +1,115 @@
1+// Relay-signed notifications. The relay has a keypair, so it can write its2+// owner a NIP-17 private message: a kind 14 rumour, sealed and gift wrapped3+// with the relay's key (NIP-59, NIP-44). The wrap is stored here, which is4+// the owner's inbox on their own relay, and pushed to the relays in the5+// owner's kind 10050 when this relay holds one. No email, no webhook.6+import { now, tagValues, type Event } from "./event.ts";7+import { dial } from "./pull.ts";8+import type { Relay } from "./relay.ts";9+import type { FuelStatus } from "./fuel.ts";10+11+export type NotifyKind = "reports" | "fuel" | "jobs" | "test";12+export interface NotifySettings {13+ reports: boolean;14+ fuel: boolean;15+ jobs: boolean;16+}17+18+const KIND_DM = 14;19+export const KIND_WRAP = 1059;20+const PUSH_RELAYS = 3;21+const PUSH_TIMEOUT_MS = 4000;22+// Below this balance, while past an allowance, fuel counts as low.23+const LOW_SATS = 2000;24+25+// notifySettings validates a policy patch's notify object. Missing keys keep26+// their current value.27+export function notifySettings(raw: unknown, cur: NotifySettings): NotifySettings | null {28+ if (!raw || typeof raw !== "object") return null;29+ const r = raw as Record<string, unknown>;30+ const pick = (k: keyof NotifySettings) => (typeof r[k] === "boolean" ? (r[k] as boolean) : cur[k]);31+ return { reports: pick("reports"), fuel: pick("fuel"), jobs: pick("jobs") };32+}33+34+// notify sends the owner a message of the given kind if that kind is35+// switched on ("test" always sends). Returns whether a wrap was stored. A36+// leased relay has no owner to write to.37+export async function notify(relay: Relay, kind: NotifyKind, text: string, subject = "your relay"): Promise<boolean> {38+ const p = relay.settings.policy;39+ if (p.owner === "" || relay.settings.isLeased()) return false;40+ if (kind !== "test" && !p.notify[kind]) return false;41+ await relay.identity.ensure();42+ const t = now();43+ const wrap = relay.identity.wrap({ kind: KIND_DM, content: text, tags: [["p", p.owner], ["subject", subject]], created_at: t }, p.owner);44+ const err = relay.store.save(wrap, t);45+ if (err) return false;46+ relay.broadcast(wrap);47+ void pushToInbox(relay, wrap).catch(() => {});48+ return true;49+}50+51+// pushToInbox forwards the wrap to the owner's DM relays, best effort, a52+// few seconds each, never to this relay itself.53+async function pushToInbox(relay: Relay, wrap: Event): Promise<void> {54+ const rows = relay.store.query({ kinds: [10050], authors: [relay.settings.policy.owner], tags: {}, limit: 1 }, { pubkeys: [], all: true }, 1, now()).rows;55+ if (!rows[0]) return;56+ const list = JSON.parse(rows[0]) as Event;57+ const self = relay.slug + ".";58+ const urls = tagValues(list, "relay")59+ .map((u) => u.trim())60+ .filter((u) => {61+ try {62+ return /^wss?:$/.test(new URL(u).protocol) && !new URL(u).hostname.startsWith(self);63+ } catch {64+ return false;65+ }66+ })67+ .slice(0, PUSH_RELAYS);68+ await Promise.all(urls.map((u) => pushOne(relay, u, wrap).catch(() => {})));69+}70+71+function pushOne(relay: Relay, url: string, wrap: Event): Promise<void> {72+ return new Promise((resolve) => {73+ let ws: WebSocket | null = null;74+ const timer = setTimeout(done, PUSH_TIMEOUT_MS);75+ function done() {76+ clearTimeout(timer);77+ try {78+ ws?.close(1000, "done");79+ } catch {80+ /* already closed */81+ }82+ resolve();83+ }84+ dial(relay, url)85+ .then((sock) => {86+ ws = sock;87+ ws.addEventListener("message", (e) => {88+ try {89+ const m = JSON.parse(String(e.data)) as unknown[];90+ if (m[0] === "OK" && m[1] === wrap.id) done();91+ } catch {92+ /* not for us */93+ }94+ });95+ ws.addEventListener("close", done);96+ ws.send(JSON.stringify(["EVENT", wrap]));97+ })98+ .catch(done);99+ });100+}101+102+// fuelLow says whether the owner should hear about fuel: out of it, or past103+// an allowance with less than a small balance left.104+export function fuelLow(f: FuelStatus): boolean {105+ if (!f.enabled) return false;106+ if (f.outOfFuel) return true;107+ const over = f.eventBytes > f.freeEventBytes || f.mediaBytes > f.freeMediaBytes || f.activeMs > f.freeActiveMs || f.rowsWritten > f.freeRowsWritten;108+ return over && f.balanceMsats < LOW_SATS * 1000;109+}110+111+export function fuelText(relay: Relay, f: FuelStatus): string {112+ const sats = Math.floor(f.balanceMsats / 1000);113+ const state = f.outOfFuel ? "is out of fuel and read-only" : `is past its free allowance with ${sats} sats left`;114+ return `Your relay ${relay.slug} ${state}. Zap it at https://${relay.slug}.${relay.domain}/ to keep it writing.`;115+}
M src/relay.ts +24 -5
@@ -27,6 +27,7 @@ import { groupFacts, handleGroupEvent, isGroupManagement, isGroupState, isNIP43R
2727 import { KIND_GROUP_MEMBERS } from "./identity.ts";2828 import { SIGNER_JS } from "./gen/signer.ts";2929 import { isPagePath, pages } from "./pages.ts";30+import { notify, fuelLow, fuelText } from "./notify.ts";3031 import type { Blob } from "./blossom.ts";3132 3233 export interface Env {
@@ -451,12 +452,19 @@ export class Relay extends DurableObject<Env> {
451452 const fresh = await this.jobs();452453 const slot = fresh.findIndex((j) => j.id === job.id);453454 if (slot < 0) return fresh.some((j) => j.running || (j.nextRun > 0 && j.nextRun <= now()));455+ // A finished run is worth a word to the owner, when they asked for one.456+ const finish = (error: string) => {457+ finishRun(job, error, now());458+ const where = (job.kind === "push" ? "to " : "from ") + job.relays.join(", ");459+ const outcome = error ? `failed after ${job.rounds} rounds: ${error}` : job.kind === "push" ? `finished: ${job.sent} events sent${job.refused ? ", " + job.refused + " refused" : ""}` : `finished: ${job.stored} events${job.blobs ? " and " + job.blobs + " files" : ""}${job.skipped ? ", " + job.skipped + " skipped" : ""}`;460+ void notify(this, "jobs", `${job.label} ${where} ${outcome}.`, "jobs on " + this.slug);461+ };454462 if (r.error) {455463 job.failures++;456- if (job.failures >= 3) finishRun(job, r.error, now());464+ if (job.failures >= 3) finish(r.error);457465 } else {458466 job.failures = 0;459- if (!r.more) finishRun(job, "", now());467+ if (!r.more) finish("");460468 }461469 fresh[slot] = job;462470 await this.saveJobs(fresh);
@@ -882,7 +890,7 @@ export class Relay extends DurableObject<Env> {
882890 const r = await this.acceptReceipt(e, conn.host);883891 if (r) return r;884892 }885- if (e.kind === KIND_REPORT) return this.acceptReport(e);893+ if (e.kind === KIND_REPORT) return this.acceptReport(e, conn.host);886894 if (isGroupManagement(e.kind) && (hasTag(e, "h") || isNIP43Request(e.kind))) return this.acceptGroup(e, conn);887895 return this.accept(e, conn);888896 }
@@ -934,7 +942,7 @@ export class Relay extends DurableObject<Env> {
934942 935943 // acceptReport files a NIP-56 report in the moderation queue. It is never936944 // stored as an event or served: reports are for the owner, not the feed.937- private acceptReport(e: Event): { ok: boolean; msg: string; stored: boolean } {945+ private acceptReport(e: Event, host = ""): { ok: boolean; msg: string; stored: boolean } {938946 if (this.settings.policy.owner === "") return { ok: false, msg: this.settings.isLeased() ? "restricted: this temporary relay has no owner to report to" : "restricted: this relay is unclaimed", stored: false };939947 if (this.settings.isBanned(e.pubkey)) return { ok: false, msg: "blocked: this pubkey is banned from this relay", stored: false };940948 const p = tagValues(e, "p")[0] ?? "";
@@ -946,6 +954,8 @@ export class Relay extends DurableObject<Env> {
946954 `INSERT OR IGNORE INTO reports(id,reporter,target_pubkey,target_event,type,content,at) VALUES(?,?,?,?,?,?,?)`,947955 e.id, e.pubkey, p, et?.[1] ?? "", type.slice(0, 32), e.content.slice(0, 2000), e.created_at,948956 );957+ const where = host ? `https://${host}/#people` : "the People tab";958+ void notify(this, "reports", `New report on ${this.slug}: ${type || "report"} about ${p.slice(0, 8)}${et ? " (event " + et[1].slice(0, 8) + ")" : ""} by ${e.pubkey.slice(0, 8)}.${e.content ? " " + e.content.slice(0, 300) : ""} Review it at ${where}`, "a report on " + this.slug);949959 return { ok: true, msg: "info: report received", stored: false };950960 }951961
@@ -1012,6 +1022,15 @@ export class Relay extends DurableObject<Env> {
10121022 this.fuel.chargeStorage(t, this.eventBytes(), this.mediaBytes());10131023 this.store.drain();10141024 this.sweepRetention(t);1025+ // Fuel notice: once when it turns low, then once a day while it stays low.1026+ if (this.settings.policy.notify.fuel) {1027+ const low = fuelLow(this.fuelStatus());1028+ const last = (await this.ctx.storage.get<number>("fuel-low-at")) ?? 0;1029+ if (low && t - last >= 86400) {1030+ await this.ctx.storage.put("fuel-low-at", t);1031+ await notify(this, "fuel", fuelText(this, this.fuelStatus()), "fuel on " + this.slug);1032+ } else if (!low && last) await this.ctx.storage.delete("fuel-low-at");1033+ }10151034 const next = this.store.sweepExpired(t);10161035 let at = t + 86400;10171036 if (next > 0 && next < at) at = next;
@@ -1030,7 +1049,7 @@ export class Relay extends DurableObject<Env> {
10301049 const own = rules.filter((r) => r.kind !== null).map((r) => r.kind as number);10311050 for (const r of rules) {10321051 if (r.kind !== null) gone += this.store.purge(r.kind, t - r.days * 86400);1033- else gone += this.store.purge(null, t - r.days * 86400, [...own, ...this.store.kindStats().map((k) => k.kind).filter((k) => isReplaceable(k) || isProtected(k))]);1052+ else gone += this.store.purge(null, t - r.days * 86400, [...own, ...this.store.kindStats().map((k) => k.kind).filter((k) => isReplaceable(k) || isProtected(k) || k === 1059)]);10341053 }10351054 return gone;10361055 }
M src/roles.ts +1 -0
M src/settings.ts +8 -0
@@ -1,4 +1,5 @@
11 // Per-relay settings and policy, kept in the same SQLite database.2+import { notifySettings, type NotifySettings } from "./notify.ts";23 import type { Role } from "./roles.ts";34 45 // A lease makes an unclaimed relay usable for a while: open to everyone,
@@ -23,6 +24,7 @@ export interface Policy {
2324 tags: string[]; // short topic words2425 languageTags: string[]; // BCP-47, such as en or pt-BR2526 relayCountries: string[]; // ISO 3166-1 alpha-2, such as US27+ notify: NotifySettings; // relay-signed NIP-17 messages to the owner2628 writes: "open" | "allowlist" | "owner"; // who may publish2729 reads: "open" | "auth" | "members"; // whether REQ/COUNT need NIP-42, or membership2830 joinTerms: string; // shown before an invite is accepted (markdown-ish plain text)
@@ -49,6 +51,7 @@ export const DEFAULT_POLICY: Policy = {
4951 tags: [],5052 languageTags: [],5153 relayCountries: [],54+ notify: { reports: false, fuel: false, jobs: false },5255 writes: "open",5356 reads: "open",5457 joinTerms: "",
@@ -314,6 +317,8 @@ export class Settings {
314317 if (policy.writes === "open" || policy.writes === "allowlist" || policy.writes === "owner") clean.writes = policy.writes;315318 if (policy.reads === "open" || policy.reads === "auth" || policy.reads === "members") clean.reads = policy.reads;316319 if (typeof policy.directoryPublic === "boolean") clean.directoryPublic = policy.directoryPublic;320+ const notify = notifySettings(policy.notify, this.policy.notify);321+ if (notify) clean.notify = notify;317322 for (const k of ["minPow", "maxFuture", "maxLimit", "maxSubs", "maxBlobMB", "eventsPerMinute", "reqsPerMinute"] as const) if (Number.isInteger(policy[k]) && (policy[k] as number) >= 0) clean[k] = policy[k] as number;318323 this.update(clean);319324 for (const m of this.members()) if (m.role !== "owner") this.removeMember(m.pubkey);
@@ -442,6 +447,9 @@ export class Settings {
442447 const own = this.retention.get(kind);443448 if (own !== undefined) return own;444449 if (isReplaceable(kind)) return 0;450+ // Gift wraps are mail. "Keep everything else N days" never meant the451+ // owner's inbox; an explicit rule for 1059 still applies.452+ if (kind === 1059) return 0;445453 return this.retention.get(RETENTION_ANY) ?? 0;446454 }447455 }
A test/web.test.ts +249 -0
@@ -0,0 +1,249 @@
1+// Pages, the feed, and relay-signed notifications.2+import { SELF, env, runInDurableObject } from "cloudflare:test";3+import { describe, it, expect } from "vitest";4+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";5+import { getToken } from "nostr-tools/nip98";6+import { unwrapEvent } from "nostr-tools/nip59";7+import * as nip19 from "nostr-tools/nip19";8+import type { Relay } from "../src/relay.ts";9+10+const now = () => Math.floor(Date.now() / 1000);11+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);12+13+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {14+ const url = `http://${host}/`;15+ const payload = { method, params };16+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);17+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });18+ return { status: resp.status, ...(await resp.json<any>()) };19+}20+21+const get = async (host: string, path: string) => {22+ const r = await SELF.fetch(`http://${host}${path}`);23+ return { status: r.status, type: r.headers.get("content-type") ?? "", text: await r.text() };24+};25+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();26+27+class WS {28+ private queue: any[][] = [];29+ private waiters: ((m: any[]) => void)[] = [];30+ challenge = "";31+ constructor(public ws: WebSocket) {32+ ws.accept();33+ ws.addEventListener("message", (e) => {34+ const m = JSON.parse(e.data as string);35+ const w = this.waiters.shift();36+ if (w) w(m);37+ else this.queue.push(m);38+ });39+ }40+ static async connect(host: string) {41+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });42+ const c = new WS(resp.webSocket!);43+ const m = await c.expect("AUTH");44+ c.challenge = m[1];45+ return c;46+ }47+ send(...m: unknown[]) {48+ this.ws.send(JSON.stringify(m));49+ }50+ recv(): Promise<any[]> {51+ const m = this.queue.shift();52+ if (m) return Promise.resolve(m);53+ return new Promise((res) => this.waiters.push(res));54+ }55+ // recvOr resolves null when nothing arrives within ms.56+ recvOr(ms: number): Promise<any[] | null> {57+ return Promise.race([this.recv(), new Promise<null>((res) => setTimeout(() => res(null), ms))]);58+ }59+ async expect(type: string) {60+ const m = await this.recv();61+ expect(m[0], JSON.stringify(m)).toBe(type);62+ return m;63+ }64+ async ok(e: Event) {65+ this.send("EVENT", e);66+ const m = await this.expect("OK");67+ return { ok: m[2] as boolean, msg: m[3] as string };68+ }69+ async auth(sk: Uint8Array, host: string) {70+ this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));71+ const m = await this.expect("OK");72+ expect(m[2], m[3]).toBe(true);73+ }74+ async live(filter: unknown, id: string) {75+ this.send("REQ", id, filter);76+ const got: Event[] = [];77+ for (;;) {78+ const m = await this.recv();79+ if (m[0] === "EOSE" && m[1] === id) return got;80+ if (m[0] === "EVENT") got.push(m[2]);81+ else throw new Error(JSON.stringify(m));82+ }83+ }84+}85+86+describe("pages and feed", () => {87+ it("renders notes and articles with open graph tags, hides what must stay hidden", async () => {88+ const host = "site.bind.ws";89+ const owner = generateSecretKey();90+ const pk = getPublicKey(owner);91+ await rpc(host, owner, "claim");92+ await rpc(host, owner, "setpolicy", { name: "Alice's place" });93+ const c = await WS.connect(host);94+ expect((await c.ok(ev(owner, 0, JSON.stringify({ name: "alice", display_name: "Alice" })))).ok).toBe(true);95+ const note = ev(owner, 1, "Hello from my own relay\nWith a picture https://pics.example/cat.png and a link https://example.com/page.");96+ expect((await c.ok(note)).ok).toBe(true);97+ const article = ev(owner, 30023, "First paragraph of the essay.\n\nSecond paragraph, longer, with some thought in it.", [["d", "why-relays"], ["title", "Why relays"], ["summary", "A short case for owning one."], ["image", "https://pics.example/hero.jpg"]]);98+ expect((await c.ok(article)).ok).toBe(true);99+ const gone = ev(owner, 1, "to be deleted");100+ expect((await c.ok(gone)).ok).toBe(true);101+ expect((await c.ok(ev(owner, 5, "", [["e", gone.id]]))).ok).toBe(true);102+ const dm = ev(owner, 4, "secret", [["p", getPublicKey(generateSecretKey())]]);103+ expect((await c.ok(dm)).ok).toBe(true);104+105+ let r = await get(host, `/e/${note.id}`);106+ expect(r.status).toBe(200);107+ expect(r.type).toMatch(/text\/html/);108+ expect(r.text).toContain('<meta property="og:title" content="Hello from my own relay">');109+ expect(r.text).toContain('<meta property="og:image" content="https://pics.example/cat.png">');110+ expect(r.text).toContain('<meta name="twitter:card" content="summary_large_image">');111+ // The host is under the service domain, so the page addresses itself over https.112+ expect(r.text).toContain(`<link rel="canonical" href="https://${host}/e/${note.id}">`);113+ expect(r.text).toContain('<link rel="alternate" type="application/atom+xml"');114+ expect(r.text).toContain("<b>Alice</b>");115+ expect(r.text).toContain('<img src="https://pics.example/cat.png"');116+ expect(r.text).toContain('<a href="https://example.com/page" rel="noopener nofollow">');117+ expect(r.text).toContain("nostr:nevent1");118+ expect(r.text).not.toContain("<script");119+120+ r = await get(host, "/a/why-relays");121+ expect(r.status).toBe(200);122+ expect(r.text).toContain("<h1>Why relays</h1>");123+ expect(r.text).toContain('<meta property="og:description" content="A short case for owning one.">');124+ expect(r.text).toContain('<img class="hero" src="https://pics.example/hero.jpg"');125+ expect(r.text).toContain("<p>Second paragraph, longer, with some thought in it.</p>");126+ expect(r.text).toContain("nostr:naddr1");127+ r = await get(host, `/a/${nip19.npubEncode(pk)}/why-relays`);128+ expect(r.status).toBe(200);129+130+ expect((await get(host, `/e/${"0".repeat(64)}`)).status).toBe(404);131+ expect((await get(host, `/e/${gone.id}`)).status).toBe(404);132+ expect((await get(host, `/e/${dm.id}`)).status).toBe(404);133+ expect((await get(host, "/a/nope")).status).toBe(404);134+135+ // A note that references the article links to it here.136+ const ref = ev(owner, 1, "Read this: nostr:" + nip19.naddrEncode({ kind: 30023, pubkey: pk, identifier: "why-relays" }) + " by nostr:" + nip19.npubEncode(pk));137+ expect((await c.ok(ref)).ok).toBe(true);138+ r = await get(host, `/e/${ref.id}`);139+ expect(r.text).toContain('<a href="/a/why-relays">Why relays</a>');140+ expect(r.text).toContain("@Alice");141+142+ // The feed lists both, newest first, and filters by kind.143+ r = await get(host, "/feed.xml");144+ expect(r.status).toBe(200);145+ expect(r.type).toMatch(/application\/atom\+xml/);146+ expect(r.text.match(/<entry>/g)?.length).toBe(3);147+ expect(r.text).toContain("<title>Why relays</title>");148+ expect(r.text).toContain(`<link href="https://${host}/a/why-relays"/>`);149+ expect(r.text).toContain("<name>Alice</name>");150+ r = await get(host, "/feed.xml?kinds=30023");151+ expect(r.text.match(/<entry>/g)?.length).toBe(1);152+ r = await get(host, `/feed.xml?author=${"1".repeat(64)}`);153+ expect(r.text).not.toContain("<entry>");154+155+ // Members-only reads: nothing renders.156+ await rpc(host, owner, "setpolicy", { reads: "members" });157+ expect((await get(host, `/e/${note.id}`)).status).toBe(404);158+ expect((await get(host, "/a/why-relays")).status).toBe(404);159+ expect((await get(host, "/feed.xml")).status).toBe(404);160+ });161+162+ it("renders nothing on an unclaimed relay", async () => {163+ expect((await get("nobody.bind.ws", "/feed.xml")).status).toBe(404);164+ expect((await get("nobody.bind.ws", `/e/${"a".repeat(64)}`)).status).toBe(404);165+ });166+});167+168+describe("relay-signed notifications", () => {169+ it("wraps a report for the owner, who alone can read it", async () => {170+ const host = "inbox.bind.ws";171+ const owner = generateSecretKey();172+ const ownerPk = getPublicKey(owner);173+ const member = generateSecretKey();174+ const stranger = generateSecretKey();175+ await rpc(host, owner, "claim");176+ await rpc(host, owner, "setmember", getPublicKey(member), {});177+ expect((await rpc(host, owner, "getpolicy")).result.notify).toEqual({ reports: false, fuel: false, jobs: false });178+179+ // Off by default: a report stores nothing.180+ const s = await WS.connect(host);181+ expect((await s.ok(ev(stranger, 1984, "spam", [["p", getPublicKey(member), "spam"]]))).ok).toBe(true);182+ const o = await WS.connect(host);183+ await o.auth(owner, host);184+ expect(await o.live({ kinds: [1059], "#p": [ownerPk] }, "inbox")).toEqual([]);185+186+ const set = await rpc(host, owner, "setpolicy", { notify: { reports: true } });187+ expect(set.result.notify).toEqual({ reports: true, fuel: false, jobs: false });188+ const m = await WS.connect(host);189+ await m.auth(member, host);190+ expect(await m.live({ kinds: [1059] }, "mine")).toEqual([]);191+192+ expect((await s.ok(ev(stranger, 1984, "still spam", [["p", getPublicKey(member), "spam"]]))).ok).toBe(true);193+ const got = await o.expect("EVENT");194+ const wrap = got[2] as Event;195+ expect(wrap.kind).toBe(1059);196+ expect(wrap.tags).toContainEqual(["p", ownerPk]);197+ const rumor = unwrapEvent(wrap, owner);198+ expect(rumor.kind).toBe(14);199+ expect(rumor.pubkey).toBe((await info(host)).self);200+ expect(rumor.content).toMatch(/report/i);201+ expect(rumor.content).toContain("still spam");202+ expect(rumor.tags).toContainEqual(["subject", "a report on inbox"]);203+ expect(await m.recvOr(300)).toBeNull();204+205+ // Stored: the owner can fetch it later; nobody else can see it.206+ const o2 = await WS.connect(host);207+ await o2.auth(owner, host);208+ expect((await o2.live({ kinds: [1059] }, "again")).map((e) => e.id)).toEqual([wrap.id]);209+ const anon = await WS.connect(host);210+ anon.send("REQ", "anon", { kinds: [1059] });211+ const closed = await anon.expect("CLOSED");212+ expect(closed[2]).toMatch(/^auth-required/);213+214+ // The test button always sends; a member may not press it.215+ expect((await rpc(host, owner, "notifytest")).result).toEqual({ sent: true });216+ expect((await rpc(host, member, "notifytest")).status).toBe(403);217+ expect(unwrapEvent((await o.expect("EVENT"))[2] as Event, owner).content).toMatch(/test/i);218+219+ // The catch-all keep-for rule leaves the inbox alone.220+ await rpc(host, owner, "setretention", null, 1);221+ await runInDurableObject(env.RELAY.getByName("inbox"), async (r: Relay) => {222+ expect(r.settings.retentionDays(1059)).toBe(0);223+ expect(r.settings.retentionDays(1)).toBe(1);224+ });225+ });226+227+ it("says fuel is low once, then not again the same day", async () => {228+ const host = "lowfuel.bind.ws";229+ const owner = generateSecretKey();230+ await rpc(host, owner, "claim");231+ await rpc(host, owner, "setpolicy", { notify: { fuel: true } });232+ const stub = env.RELAY.getByName("lowfuel");233+ const wraps = (r: Relay) => r.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM events WHERE kind=1059`).one().n;234+ await runInDurableObject(stub, async (r: Relay) => {235+ await r.alarm();236+ expect(wraps(r)).toBe(0);237+ r.fuel.record(now(), { rowsWritten: 1_200_000 });238+ expect(r.fuelStatus().outOfFuel).toBe(true);239+ await r.alarm();240+ expect(wraps(r)).toBe(1);241+ await r.alarm();242+ expect(wraps(r)).toBe(1);243+ });244+ const o = await WS.connect(host);245+ await o.auth(owner, host);246+ const [wrap] = await o.live({ kinds: [1059] }, "fuel");247+ expect(unwrapEvent(wrap, owner).content).toMatch(/out of fuel/);248+ });249+});