clone

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

relay: scheduled dumps of every event to R2, kept for a few runs

Dami committed 1d5fa50
A policy switch, off, daily or weekly, has the alarm write a JSONL of the
whole event set under <name>/dumps/<date>.jsonl, streamed by sequence into a
multipart upload so a large relay never holds its history in memory. The
newest dumpsKeep files stay, the rest go. Dumps count as media for fuel.
listdumps, deletedump and dumpnow under the storage action; GET /dumps/<name>
needs a NIP-98 signature with that action, so a private relay's history is
never a public URL.
commit
1d5fa50155aef9308b6d17520970ac7efa6f134a
parent
d0d257967a6d69a4de5e4546ee0b7cafccbadb72
author
Dami <dami.etoile@gmail.com>
7 files changed, +327 -6
Asrc/dumps.ts122+++++++++++++++++++++++
Msrc/manage.ts17+++-
Msrc/relay.ts14+++-
Msrc/roles.ts2+-
Msrc/settings.ts17+++-
Msrc/store.ts5+
Atest/data.test.ts156++++++++++++++++++++++++++++++
A src/dumps.ts +122 -0
@@ -0,0 +1,122 @@
1+// Dumps: the relay's whole event set as JSONL in R2, written by the alarm on2+// the schedule the owner picks, kept for a few runs, and downloadable with a3+// signature. The way out that needs no other relay.4+//5+// The file is streamed in pages by sequence into an R2 multipart upload, so6+// a large relay never holds its history in memory. Every part but the last7+// is exactly PART bytes, which is what R2 requires.8+import { verifyNIP98 } from "./manage.ts";9+import { can } from "./roles.ts";10+import type { Relay } from "./relay.ts";11+12+export type Dump = { name: string; bytes: number; events: number; at: number };13+14+const PAGE = 500;15+const PART = 8 * 1024 * 1024;16+export const DUMP_NAME_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/;17+18+export function dumpName(t: number): string {19+ return new Date(t * 1000).toISOString().slice(0, 10) + ".jsonl";20+}21+export function dumpKey(slug: string, name: string): string {22+ return `${slug}/dumps/${name}`;23+}24+export function dumpBytes(sql: SqlStorage): number {25+ return sql.exec<{ n: number | null }>(`SELECT sum(bytes) AS n FROM dumps`).one().n ?? 0;26+}27+export function listDumps(sql: SqlStorage): Dump[] {28+ return sql.exec<Dump>(`SELECT * FROM dumps ORDER BY at DESC`).toArray();29+}30+31+// dumpDue says whether the schedule owes a dump: daily means one a day,32+// weekly one every seven, with an hour of slack so a tick that comes a33+// little early still counts.34+export function dumpDue(relay: Relay, t: number): boolean {35+ const p = relay.settings.policy;36+ if (p.dumps === "off" || p.owner === "") return false;37+ const last = relay.sql.exec<{ at: number | null }>(`SELECT max(at) AS at FROM dumps`).one().at;38+ if (last === null) return true;39+ const period = p.dumps === "daily" ? 86400 : 7 * 86400;40+ return t - last >= period - 3600;41+}42+43+// writeDump writes today's file, replacing one of the same name, records it44+// and drops the oldest beyond the keep count.45+export async function writeDump(relay: Relay, t: number): Promise<Dump> {46+ const name = dumpName(t);47+ const key = dumpKey(relay.slug, name);48+ const enc = new TextEncoder();49+ const upload = await relay.media.createMultipartUpload(key, { httpMetadata: { contentType: "application/x-ndjson" } });50+ const parts: R2UploadedPart[] = [];51+ let pending = new Uint8Array(0);52+ let total = 0;53+ let events = 0;54+ let seq = 0;55+ const append = (a: Uint8Array, b: Uint8Array) => {56+ const out = new Uint8Array(a.length + b.length);57+ out.set(a);58+ out.set(b, a.length);59+ return out;60+ };61+ try {62+ for (;;) {63+ const page = relay.store.dumpPage(seq, PAGE);64+ if (page.length === 0) break;65+ const chunk = enc.encode(page.map((r) => r.raw + "\n").join(""));66+ pending = append(pending, chunk);67+ events += page.length;68+ seq = page[page.length - 1].seq;69+ while (pending.length >= PART) {70+ parts.push(await upload.uploadPart(parts.length + 1, pending.slice(0, PART)));71+ total += PART;72+ pending = pending.slice(PART);73+ }74+ }75+ parts.push(await upload.uploadPart(parts.length + 1, pending));76+ total += pending.length;77+ await upload.complete(parts);78+ } catch (err) {79+ await upload.abort().catch(() => {});80+ throw err;81+ }82+ relay.sql.exec(`INSERT INTO dumps(name,bytes,events,at) VALUES(?,?,?,?) ON CONFLICT(name) DO UPDATE SET bytes=excluded.bytes, events=excluded.events, at=excluded.at`, name, total, events, t);83+ relay.meterBytes(0, total);84+ await rotateDumps(relay);85+ return { name, bytes: total, events, at: t };86+}87+88+// rotateDumps keeps the newest `dumpsKeep` files.89+export async function rotateDumps(relay: Relay) {90+ const keep = Math.max(1, relay.settings.policy.dumpsKeep);91+ const old = listDumps(relay.sql).slice(keep);92+ for (const d of old) await deleteDump(relay, d.name);93+}94+95+export async function deleteDump(relay: Relay, name: string): Promise<boolean> {96+ await relay.media.delete(dumpKey(relay.slug, name));97+ return relay.sql.exec(`DELETE FROM dumps WHERE name=?`, name).rowsWritten > 0;98+}99+100+// dumpDownload serves GET /dumps/<name> to a NIP-98 signer with the storage101+// action. A private relay's history is never a public URL.102+export async function dumpDownload(relay: Relay, req: Request): Promise<Response> {103+ const json = (b: unknown, status: number) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*" } });104+ const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, "");105+ if (typeof auth === "string") return json({ error: auth }, 401);106+ if (!can(relay.settings.roleOf(auth.pubkey), "storage")) return json({ error: "restricted: not the relay owner" }, 403);107+ const name = new URL(req.url).pathname.slice("/dumps/".length);108+ if (!DUMP_NAME_RE.test(name)) return json({ error: "invalid: not a dump name" }, 400);109+ const row = relay.sql.exec<Dump>(`SELECT * FROM dumps WHERE name=?`, name).toArray()[0];110+ const obj = row ? await relay.media.get(dumpKey(relay.slug, name)) : null;111+ if (!row || !obj) return json({ error: "not found" }, 404);112+ relay.meterBytes(0, obj.size);113+ return new Response(obj.body, {114+ headers: {115+ "content-type": "application/x-ndjson",116+ "content-length": String(obj.size),117+ "content-disposition": `attachment; filename="${relay.slug}-${name}"`,118+ "cache-control": "private, no-store",119+ "access-control-allow-origin": "*",120+ },121+ });122+}
M src/manage.ts +14 -3
@@ -7,10 +7,11 @@ import { bytesToHex } from "./negentropy.ts";
77 import type { Relay } from "./relay.ts";88 import { listInvites, mintInvite, revokeInvite } from "./invites.ts";99 import { descriptor, type Blob } from "./blossom.ts";10-import { isReplaceable, isProtected, publicFields } from "./settings.ts";10+import { isReplaceable, isProtected, publicFields, dumpFields } from "./settings.ts";1111 import { checkPullURL } from "./pull.ts";1212 import { relaysFromList } from "./jobs.ts";1313 import { notify, notifySettings } from "./notify.ts";14+import { DUMP_NAME_RE, deleteDump, dumpBytes, listDumps, writeDump } from "./dumps.ts";1415 import { can, METHOD_ACTIONS } from "./roles.ts";1516 import { PRESETS, applyPreset } from "./presets.ts";1617
@@ -59,6 +60,7 @@ const METHODS = [
5960 "storagestats", "setretention", "listretention", "purgekind",6061 "deleterelay", "exportconfig", "importconfig", "resetrules", "listpresets", "applypreset",6162 "pullfrom", "pullstatus", "listjobs", "addjob", "removejob", "runjob", "backfill", "transferowner", "notifytest",63+ "listdumps", "deletedump", "dumpnow",6264 ];6365 6466 // validIP accepts an IPv4 or IPv6 address, nothing fancier: no ranges, no names.
@@ -129,7 +131,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
129131 const patch = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};130132 const clean: Record<string, unknown> = {};131133 for (const k of ["name", "description", "icon", "contact"]) if (typeof patch[k] === "string") clean[k] = (patch[k] as string).slice(0, 2000);132- Object.assign(clean, publicFields(patch));134+ Object.assign(clean, publicFields(patch), dumpFields(patch));133135 if (patch.writes === "open" || patch.writes === "allowlist" || patch.writes === "owner") clean.writes = patch.writes;134136 if (patch.reads === "open" || patch.reads === "auth" || patch.reads === "members") clean.reads = patch.reads;135137 if (typeof patch.joinTerms === "string") clean.joinTerms = patch.joinTerms.slice(0, 20000);
@@ -314,7 +316,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
314316 case "storagestats": {315317 const kinds = relay.store.kindStats().map((k) => ({ ...k, days: s.retentionDays(k.kind), replaceable: isReplaceable(k.kind), protected: isProtected(k.kind) }));316318 const blobs = relay.sql.exec<{ n: number | null; bytes: number | null }>(`SELECT count(*) AS n, sum(size) AS bytes FROM blobs`).one();317- return reply({ result: { kinds, events: kinds.reduce((a, k) => a + k.n, 0), eventBytes: kinds.reduce((a, k) => a + k.bytes, 0), databaseBytes: relay.eventBytes(), blobs: blobs.n ?? 0, mediaBytes: blobs.bytes ?? 0, retention: s.listRetention() } });319+ return reply({ result: { kinds, events: kinds.reduce((a, k) => a + k.n, 0), eventBytes: kinds.reduce((a, k) => a + k.bytes, 0), databaseBytes: relay.eventBytes(), blobs: blobs.n ?? 0, mediaBytes: blobs.bytes ?? 0, dumps: listDumps(relay.sql).length, dumpBytes: dumpBytes(relay.sql), retention: s.listRetention() } });318320 }319321 case "setretention": {320322 // (kind | null, days): days 0 removes the rule.
@@ -403,6 +405,15 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
403405 await relay.publishMembership({ pubkey: pk }, { pubkey: caller });404406 return reply({ result: { owner: pk, previous: caller } });405407 }408+ case "listdumps":409+ return reply({ result: listDumps(relay.sql).map((d) => ({ ...d, url: "/dumps/" + d.name })) });410+ case "deletedump": {411+ const name = str(0);412+ if (!DUMP_NAME_RE.test(name)) return reply({ error: "invalid: not a dump name" }, 400);413+ return reply({ result: await deleteDump(relay, name) });414+ }415+ case "dumpnow":416+ return reply({ result: await writeDump(relay, t) });406417 case "changerelayname":407418 s.update({ name: str(0).slice(0, 200) });408419 await relay.publishMembership();
M src/relay.ts +13 -1
@@ -17,6 +17,7 @@ import { Bucket } from "./ratelimit.ts";
1717 import { bridge } from "./bridge.ts";1818 import { blossom, blobBytes, isBlobPath } from "./blossom.ts";1919 import { nip96 } from "./nip96.ts";20+import { dumpBytes, dumpDue, dumpDownload, writeDump } from "./dumps.ts";2021 import { claimFromProfile, nip05Document } from "./nip05.ts";2122 import { checkInvite, claimInvite, invitePage, termsPage } from "./invites.ts";2223 import { verifyNIP98 } from "./manage.ts";
@@ -500,8 +501,9 @@ export class Relay extends DurableObject<Env> {
500501 return this.store.databaseSize;501502 }502503 504+ // Files and dumps both live in R2 and both cost media storage.503505 mediaBytes(): number {504- return blobBytes(this.sql);506+ return blobBytes(this.sql) + dumpBytes(this.sql);505507 }506508 507509 fuelStatus() {
@@ -655,6 +657,7 @@ export class Relay extends DurableObject<Env> {
655657 return Response.json({ terms: this.settings.policy.joinTerms }, { headers: { "access-control-allow-origin": "*" } });656658 }657659 if (url.pathname === "/api/invites/claim" && req.method === "POST") return this.claimInviteRequest(req);660+ if (url.pathname.startsWith("/dumps/") && req.method === "GET") return dumpDownload(this, req);658661 if (url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname === "/report" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname)) return blossom(this, req);659662 if (url.pathname === "/.well-known/nostr/nip96.json" || url.pathname === "/nip96" || url.pathname.startsWith("/nip96/")) return nip96(this, req);660663 if (url.pathname === "/fuel" && req.method === "GET") {
@@ -1032,6 +1035,15 @@ export class Relay extends DurableObject<Env> {
10321035 } else if (!low && last) await this.ctx.storage.delete("fuel-low-at");10331036 }10341037 const next = this.store.sweepExpired(t);1038+ // ---- dumps: a scheduled JSONL of everything, into R2 (dumps.ts) ----1039+ if (dumpDue(this, t)) {1040+ try {1041+ await writeDump(this, t);1042+ } catch (err) {1043+ console.log("dump failed: " + (err instanceof Error ? err.message : String(err)));1044+ }1045+ }1046+ // ---- end dumps ----10351047 let at = t + 86400;10361048 if (next > 0 && next < at) at = next;10371049 const lease = this.settings.policy.lease;
M src/roles.ts +1 -1
M src/settings.ts +16 -1
@@ -36,6 +36,10 @@ export interface Policy {
3636 maxFuture: number; // seconds; 0 disables3737 maxLimit: number;3838 maxSubs: number;39+ // Nightly dumps: a JSONL of every event, written to R2 by the alarm and40+ // kept for `dumpsKeep` runs. Counted as media for fuel.41+ dumps: "off" | "daily" | "weekly";42+ dumpsKeep: number;3943 }4044 4145 export const DEFAULT_POLICY: Policy = {
@@ -63,6 +67,8 @@ export const DEFAULT_POLICY: Policy = {
6367 maxFuture: 900,6468 maxLimit: 500,6569 maxSubs: 20,70+ dumps: "off",71+ dumpsKeep: 7,6672 };6773 6874 export const SETTINGS_SCHEMA = `
@@ -79,6 +85,7 @@ CREATE TABLE IF NOT EXISTS nip05 (name TEXT PRIMARY KEY, pubkey TEXT NOT NULL, a
7985 CREATE TABLE IF NOT EXISTS reports (id TEXT PRIMARY KEY, reporter TEXT NOT NULL, target_pubkey TEXT NOT NULL, target_event TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', resolved_by TEXT NOT NULL DEFAULT '', resolved_at INTEGER NOT NULL DEFAULT 0, action TEXT NOT NULL DEFAULT '');8086 CREATE TABLE IF NOT EXISTS blobs (sha256 TEXT PRIMARY KEY, size INTEGER NOT NULL, type TEXT NOT NULL, uploader TEXT NOT NULL, uploaded INTEGER NOT NULL);8187 CREATE INDEX IF NOT EXISTS blobs_uploader ON blobs(uploader, uploaded DESC);88+CREATE TABLE IF NOT EXISTS dumps (name TEXT PRIMARY KEY, bytes INTEGER NOT NULL, events INTEGER NOT NULL, at INTEGER NOT NULL);8289 `;8390 8491 // A member is one person of this relay: the owner or someone let in. Their
@@ -92,6 +99,14 @@ export type Member = {
9299 via: string; // claimed | invite <code> | added | profile93100 };94101 102+// dumpFields validates the dump settings of a policy patch.103+export function dumpFields(patch: Record<string, unknown>): Partial<Policy> {104+ const out: Partial<Policy> = {};105+ if (patch.dumps === "off" || patch.dumps === "daily" || patch.dumps === "weekly") out.dumps = patch.dumps;106+ if (Number.isInteger(patch.dumpsKeep) && (patch.dumpsKeep as number) >= 1 && (patch.dumpsKeep as number) <= 60) out.dumpsKeep = patch.dumpsKeep as number;107+ return out;108+}109+95110 export const NAME_RE = /^[a-z0-9._-]{1,64}$/;96111 97112 const TAG_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
@@ -313,7 +328,7 @@ export class Settings {
313328 const policy = (c.policy && typeof c.policy === "object" ? c.policy : {}) as Record<string, unknown>;314329 const clean: Partial<Policy> = {};315330 for (const k of ["name", "description", "icon", "contact", "joinTerms"] as const) if (typeof policy[k] === "string") clean[k] = (policy[k] as string).slice(0, 20000);316- Object.assign(clean, publicFields(policy));331+ Object.assign(clean, publicFields(policy), dumpFields(policy));317332 if (policy.writes === "open" || policy.writes === "allowlist" || policy.writes === "owner") clean.writes = policy.writes;318333 if (policy.reads === "open" || policy.reads === "auth" || policy.reads === "members") clean.reads = policy.reads;319334 if (typeof policy.directoryPublic === "boolean") clean.directoryPublic = policy.directoryPublic;
M src/store.ts +5 -0
A test/data.test.ts +156 -0
@@ -0,0 +1,156 @@
1+// Dumps to R2.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 type { Relay } from "../src/relay.ts";7+import { writeDump } from "../src/dumps.ts";8+9+const now = () => Math.floor(Date.now() / 1000);10+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);11+const pk = (sk: Uint8Array) => getPublicKey(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+async function get(host: string, sk: Uint8Array | null, path: string) {22+ const url = `http://${host}${path}`;23+ const headers: Record<string, string> = {};24+ if (sk) headers.authorization = await getToken(url, "GET", (e) => finalizeEvent(e, sk), true);25+ return SELF.fetch(url, { headers });26+}27+28+async function post(host: string, sk: Uint8Array, path: string, body: unknown) {29+ const url = `http://${host}${path}`;30+ const headers: Record<string, string> = { "content-type": "application/json", authorization: await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, body as Record<string, unknown>) };31+ const resp = await SELF.fetch(url, { method: "POST", headers, body: JSON.stringify(body) });32+ return { status: resp.status, ...(await resp.json<any>()) };33+}34+35+class WS {36+ private queue: any[][] = [];37+ private waiters: ((m: any[]) => void)[] = [];38+ constructor(public ws: WebSocket) {39+ ws.accept();40+ ws.addEventListener("message", (e) => {41+ const m = JSON.parse(e.data as string);42+ const w = this.waiters.shift();43+ if (w) w(m);44+ else this.queue.push(m);45+ });46+ }47+ static async connect(host: string) {48+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });49+ const c = new WS(resp.webSocket!);50+ await c.expect("AUTH");51+ return c;52+ }53+ send(...m: unknown[]) {54+ this.ws.send(JSON.stringify(m));55+ }56+ recv(): Promise<any[]> {57+ const m = this.queue.shift();58+ if (m) return Promise.resolve(m);59+ return new Promise((res) => this.waiters.push(res));60+ }61+ async expect(type: string) {62+ const m = await this.recv();63+ expect(m[0], JSON.stringify(m)).toBe(type);64+ return m;65+ }66+ async ok(e: Event) {67+ this.send("EVENT", e);68+ const m = await this.expect("OK");69+ return { ok: m[2] as boolean, msg: m[3] as string };70+ }71+ private n = 0;72+ async req(filter: unknown) {73+ const id = "q" + ++this.n;74+ this.send("REQ", id, filter);75+ const events: Event[] = [];76+ for (;;) {77+ const m = await this.recv();78+ if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);79+ else if (m[0] === "EOSE" && m[1] === id) {80+ this.send("CLOSE", id);81+ return events;82+ } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);83+ }84+ }85+}86+87+describe("dumps", () => {88+ it("writes a JSONL of every event to R2, lists it, serves it to a signature, rotates and counts as media", async () => {89+ const host = "dumpy.bind.ws";90+ const owner = generateSecretKey();91+ const writer = generateSecretKey();92+ await rpc(host, owner, "claim");93+ const c = await WS.connect(host);94+ for (let i = 0; i < 3; i++) expect((await c.ok(ev(writer, 1, "note " + i))).ok).toBe(true);95+ expect((await rpc(host, owner, "setpolicy", { dumps: "daily", dumpsKeep: 2 })).result.dumps).toBe("daily");96+97+ const d = (await rpc(host, owner, "dumpnow")).result;98+ expect(d.name).toMatch(/^\d{4}-\d{2}-\d{2}\.jsonl$/);99+ const total = (await rpc(host, owner, "stats")).result.events;100+ expect(d.events).toBe(total);101+ expect(total).toBeGreaterThanOrEqual(3);102+ const obj = await env.MEDIA.get(`dumpy/dumps/${d.name}`);103+ expect(obj).not.toBeNull();104+ const text = await obj!.text();105+ const lines = text.split("\n").filter(Boolean);106+ expect(lines.length).toBe(d.events);107+ for (const l of lines) expect(JSON.parse(l).id).toMatch(/^[0-9a-f]{64}$/);108+ expect(d.bytes).toBe(text.length);109+110+ const list = (await rpc(host, owner, "listdumps")).result;111+ expect(list.map((x: any) => x.name)).toEqual([d.name]);112+ expect(list[0].url).toBe("/dumps/" + d.name);113+114+ // Download needs a signature from someone with the storage action.115+ const signed = await get(host, owner, "/dumps/" + d.name);116+ expect(signed.status).toBe(200);117+ expect(signed.headers.get("content-disposition")).toContain(d.name);118+ expect(await signed.text()).toBe(text);119+ expect((await get(host, null, "/dumps/" + d.name)).status).toBe(401);120+ expect((await get(host, writer, "/dumps/" + d.name)).status).toBe(403);121+ expect((await get(host, owner, "/dumps/nope.jsonl")).status).toBe(400);122+ expect((await get(host, owner, "/dumps/1999-01-01.jsonl")).status).toBe(404);123+124+ const stub = env.RELAY.getByName("dumpy");125+ await runInDurableObject(stub, async (r: Relay) => {126+ expect(r.mediaBytes()).toBe(d.bytes);127+ // Two older dumps and a keep of two: the oldest goes.128+ await writeDump(r, now() - 2 * 86400);129+ await writeDump(r, now() - 86400);130+ });131+ const after = (await rpc(host, owner, "listdumps")).result.map((x: any) => x.name);132+ expect(after.length).toBe(2);133+ expect(after[0]).toBe(d.name);134+ expect((await env.MEDIA.list({ prefix: "dumpy/dumps/" })).objects.length).toBe(2);135+ expect((await rpc(host, owner, "storagestats")).result.dumps).toBe(2);136+137+ expect((await rpc(host, owner, "deletedump", after[1])).result).toBe(true);138+ expect((await env.MEDIA.list({ prefix: "dumpy/dumps/" })).objects.length).toBe(1);139+ expect((await rpc(host, writer, "listdumps")).status).toBe(403);140+ });141+142+ it("the alarm writes a scheduled dump once a day, not twice", async () => {143+ const host = "dumpz.bind.ws";144+ const owner = generateSecretKey();145+ await rpc(host, owner, "claim");146+ const stub = env.RELAY.getByName("dumpz");147+ await runInDurableObject(stub, async (r: Relay) => r.alarm());148+ expect((await rpc(host, owner, "listdumps")).result.length).toBe(0);149+ await rpc(host, owner, "setpolicy", { dumps: "daily" });150+ await runInDurableObject(stub, async (r: Relay) => r.alarm());151+ expect((await rpc(host, owner, "listdumps")).result.length).toBe(1);152+ await runInDurableObject(stub, async (r: Relay) => r.alarm());153+ expect((await rpc(host, owner, "listdumps")).result.length).toBe(1);154+ });155+});156+