clone

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

relay: views, folds the relay signs as records

Dami committed 5bbfc73
A registry in views.ts: each view names a trigger, an audience and a fold.
Public views are stored as kind 30078 records signed by the identity key on
the group clock and taken down when switched off or no longer public;
members-only views are folded on request at GET /view/<name> for a member
who signs the request, never stored. Presence is an ephemeral kind 20078
from the socket list and recent writers, broadcast at most every 30 seconds.
Daily and hourly views run from the alarm, hourly ones only when a
fingerprint moved; articles republish ten seconds after a write. The rows a
run wrote are kept for the console and the digest. Retention and purge skip
everything the identity key signed.

Seven views: profiles, relays, calendar (NIP-52 with RSVPs), moderation
counts, articles, zaps and presence.
commit
5bbfc731a3b06e1d0c5b83598955b6a6b27e8599
parent
480b2b0635463cdfcd1558d5dd1b75337e478d94
author
Dami <dami.etoile@gmail.com>
9 files changed, +767 -13
Msrc/identity.ts10+
Msrc/manage.ts13+-
Msrc/notify.ts4+
Msrc/relay.ts120+++++++++++-
Msrc/roles.ts2+-
Msrc/settings.ts19++-
Msrc/store.ts11+-
Asrc/views.ts337++++++++++++++++++++++++++++++
Atest/views.test.ts264++++++++++++++++++++++++
M src/identity.ts +10 -0
M src/manage.ts +9 -4
@@ -7,9 +7,10 @@ import { bytesToHex } from "./negentropy.ts";
77 import type { Relay } from "./relay.ts";88 import { inviteCreator, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";99 import { descriptor, type Blob } from "./blossom.ts";10-import { blockedWords, gateFields, isWriteRule } from "./settings.ts";10+import { blockedWords, gateFields, isWriteRule, viewFields } from "./settings.ts";1111 import { isReplaceable, isProtected, publicFields, dumpFields } from "./settings.ts";1212 import { checkPullURL } from "./pull.ts";13+import { viewsSummary } from "./views.ts";1314 import { MAX_PINS } from "./groups.ts";1415 import { relaysFromList } from "./jobs.ts";1516 import { notify, notifySettings } from "./notify.ts";
@@ -63,7 +64,7 @@ const METHODS = [
6364 "storagestats", "setretention", "listretention", "purgekind",6465 "deleterelay", "exportconfig", "importconfig", "resetrules", "listpresets", "applypreset",6566 "pullfrom", "pullstatus", "listjobs", "addjob", "removejob", "runjob", "backfill", "transferowner", "notifytest",66- "listdumps", "deletedump", "dumpnow",67+ "listdumps", "deletedump", "dumpnow", "listviews",6768 "removesubtree",6869 "forkrelay",6970 "setsuccession", "clearsuccession", "successionstatus",
@@ -145,7 +146,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
145146 const patch = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};146147 const clean: Record<string, unknown> = {};147148 for (const k of ["name", "description", "icon", "contact"]) if (typeof patch[k] === "string") clean[k] = (patch[k] as string).slice(0, 2000);148- Object.assign(clean, publicFields(patch), dumpFields(patch), gateFields(patch));149+ Object.assign(clean, publicFields(patch), dumpFields(patch), gateFields(patch), viewFields(patch, p.views));149150 if (isWriteRule(patch.writes)) clean.writes = patch.writes;150151 if (patch.reads === "open" || patch.reads === "auth" || patch.reads === "members") clean.reads = patch.reads;151152 if (typeof patch.joinTerms === "string") clean.joinTerms = patch.joinTerms.slice(0, 20000);
@@ -160,8 +161,12 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
160161 if (typeof clean.maxSubs === "number") clean.maxSubs = Math.min(Math.max(clean.maxSubs as number, 1), 200);161162 s.update(clean);162163 await relay.publishMembership();164+ // A view switched off is taken down now; one switched on is published now.165+ if (patch.views && typeof patch.views === "object") for (const name of Object.keys(patch.views as object)) await relay.publishView(name);163166 return reply({ result: s.policy });164167 }168+ case "listviews":169+ return reply({ result: await viewsSummary(relay) });165170 case "banpubkey": {166171 const pk = str(0);167172 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);
@@ -409,7 +414,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
409414 if (!Number.isInteger(days) || days < 0) return reply({ error: "invalid: days out of range" }, 400);410415 if (kind !== null && isProtected(kind)) return reply({ error: `invalid: kind ${kind} is part of how the relay works and cannot be purged` }, 400);411416 const before = days > 0 ? t - days * 86400 : Number.MAX_SAFE_INTEGER;412- const gone = kind === null ? relay.store.purge(null, before, relay.store.kindStats().map((k) => k.kind).filter((k) => isReplaceable(k) || isProtected(k))) : relay.store.purge(kind, before);417+ const gone = kind === null ? relay.store.purge(null, before, relay.store.kindStats().map((k) => k.kind).filter((k) => isReplaceable(k) || isProtected(k)), relay.identity.pubkey) : relay.store.purge(kind, before, [], relay.identity.pubkey);413418 return reply({ result: { deleted: gone } });414419 }415420 case "listallowedkinds":
M src/notify.ts +4 -0
@@ -6,6 +6,7 @@
66 import { now, tagValues, type Event } from "./event.ts";77 import { dial } from "./pull.ts";88 import type { Relay } from "./relay.ts";9+import { viewRowsSince } from "./views.ts";910 import type { FuelStatus } from "./fuel.ts";1011 1112 export type NotifyKind = "reports" | "fuel" | "jobs" | "succession" | "digest" | "test";
@@ -137,6 +138,7 @@ export async function digestText(relay: Relay, since: number, t: number): Promis
137138 const spent = Math.floor(f.chargedMsats / 1000);138139 const balance = Math.floor(f.balanceMsats / 1000);139140 const sc = await relay.successionStatus();141+ const viewRows = await viewRowsSince(relay, since);140142 const lines: string[] = [];141143 if (joined || left) lines.push(`People: ${joined} joined${left ? `, ${left} left` : ""}.`);142144 if (events || files) lines.push(`Stored: ${events} events${files ? ` and ${files} files` : ""}.`);
@@ -148,5 +150,7 @@ export async function digestText(relay: Relay, since: number, t: number): Promis
148150 }149151 if (sc.succession && sc.handoverAt) lines.push(`Handover to ${sc.succession.heir.slice(0, 8)} in ${Math.max(0, Math.ceil((sc.handoverAt - t) / 86400))} days unless you sign in.`);150152 const days = Math.max(1, Math.round((t - since) / 86400));153+ // The views' housekeeping rides along with a week that had news; a quiet week stays quiet.154+ if (lines.length && viewRows) lines.push(`Views: ${viewRows} rows written.`);151155 return lines.length ? [`The last ${days} days on ${relay.slug}:`, ...lines].join("\n") : `Nothing changed on ${relay.slug} in the last ${days} days.`;152156 }
M src/relay.ts +118 -2
@@ -35,6 +35,7 @@ import { SIGNER_JS } from "./gen/signer.ts";
3535 import { isPagePath, pages } from "./pages.ts";3636 import { notify, fuelLow, fuelText } from "./notify.ts";3737 import { card } from "./card.ts";38+import { KIND_PRESENCE, PRESENCE_THROTTLE_S, VIEWS, latestStored, nip11Views, presenceTags, serveView, viewByName, viewD, viewEvent, viewOn, viewStored, type ViewRun } from "./views.ts";3839 import type { Blob } from "./blossom.ts";3940 4041 export interface Env {
@@ -140,6 +141,14 @@ export class Relay extends DurableObject<Env> {
140141 private ownerSeenWrite = 0; // ms; the presence write happens at most hourly141142 // A job round in flight, so overlapping alarms do not run two.142143 private working = false;144+ // Presence (views.ts): who wrote lately, in memory; the sockets are asked145+ // directly. A broadcast is at most one per PRESENCE_THROTTLE_S.146+ presenceActive = new Map<string, number>();147+ private presenceAt = 0;148+ private presenceTimer: ReturnType<typeof setTimeout> | null = null;149+ // Write-triggered views wait a moment so a burst republishes once.150+ private viewDirty = new Set<string>();151+ private viewTimer: ReturnType<typeof setTimeout> | null = null;143152 // The custom hostnames client, null when the host has not enabled them.144153 hostnames: Hostnames | null;145154
@@ -232,6 +241,106 @@ export class Relay extends DurableObject<Env> {
232241 events.push(this.identity.roster(this.settings.members(), t));233242 events.push(...this.identity.group(f, t));234243 this.emit(events, t);244+ this.markView("profiles");245+ }246+247+ // ---- views (views.ts) ----248+249+ // authedNow lists every pubkey with an authenticated socket open.250+ authedNow(): string[] {251+ const out = new Set<string>();252+ for (const ws of this.ctx.getWebSockets()) for (const pk of this.state(ws).authed) out.add(pk);253+ return [...out];254+ }255+256+ async viewRuns(name: string): Promise<ViewRun[]> {257+ return (await this.ctx.storage.get<ViewRun[]>("view-runs:" + name)) ?? [];258+ }259+260+ // publishView folds one stored view and writes its record, or takes the261+ // record down when the view is off, members-only or empty. The rows the262+ // fold wrote are recorded so the console and the digest can say what a263+ // view costs.264+ async publishView(name: string): Promise<void> {265+ const v = viewByName(name);266+ if (!v || v.trigger === "live" || this.settings.policy.owner === "") return;267+ await this.identity.ensure();268+ const self = this.identity.pubkey;269+ this.tally();270+ const before = this.meter.rowsWritten;271+ const takeDown = () => {272+ for (const r of this.sql.exec<{ id: string }>(`SELECT e.id FROM events e JOIN tags t ON t.event_id=e.id WHERE e.kind=30078 AND e.pubkey=? AND t.name='d' AND t.value=?`, self, viewD(name)).toArray()) this.store.deleteEvent(r.id);273+ };274+ if (!viewOn(this, name) || !viewStored(this, v)) takeDown();275+ else {276+ const fold = v.fold(this);277+ if (!fold) takeDown();278+ else this.emit([viewEvent(this, v, fold, true)], now());279+ }280+ this.tally();281+ const runs = (await this.viewRuns(name)).slice(-59);282+ runs.push({ at: now(), rows: Math.max(0, this.meter.rowsWritten - before) });283+ await this.ctx.storage.put("view-runs:" + name, runs);284+ if (v.fingerprint) await this.ctx.storage.put("view-fp:" + name, v.fingerprint(this));285+ }286+287+ // markView republishes a write-triggered view shortly, once for a burst.288+ markView(name: string) {289+ if (!viewOn(this, name) || this.settings.policy.owner === "") return;290+ this.viewDirty.add(name);291+ if (this.viewTimer) return;292+ this.viewTimer = setTimeout(() => {293+ this.viewTimer = null;294+ const names = [...this.viewDirty];295+ this.viewDirty.clear();296+ void (async () => {297+ for (const n of names) {298+ try {299+ await this.publishView(n);300+ } catch (err) {301+ console.log("view " + n + " failed: " + (err instanceof Error ? err.message : String(err)));302+ }303+ }304+ })();305+ }, 10_000);306+ }307+308+ // viewsTick runs the scheduled views: daily ones once a day, hourly ones309+ // once an hour and only when their fingerprint moved or nothing is stored.310+ private async viewsTick(t: number) {311+ if (this.settings.policy.owner === "") return;312+ for (const v of VIEWS) {313+ if (v.trigger === "live" || !viewOn(this, v.name)) continue;314+ const last = (await this.ctx.storage.get<number>("view-at:" + v.name)) ?? 0;315+ const period = v.trigger === "hourly" ? 3600 : 86400;316+ if (t - last < period - 300) continue;317+ await this.ctx.storage.put("view-at:" + v.name, t);318+ if (v.fingerprint && viewStored(this, v) && latestStored(this, v.name) !== null) {319+ const fp = v.fingerprint(this);320+ if (fp === (await this.ctx.storage.get<string>("view-fp:" + v.name))) continue;321+ }322+ await this.publishView(v.name);323+ }324+ }325+326+ // notePresence records a writer and broadcasts the presence view when the327+ // throttle allows, otherwise once it does.328+ notePresence(pubkey?: string) {329+ if (pubkey) this.presenceActive.set(pubkey, now());330+ if (!this.identity.pubkey || !viewOn(this, "presence") || this.settings.policy.owner === "") return;331+ const wait = this.presenceAt + PRESENCE_THROTTLE_S - Date.now() / 1000;332+ if (wait <= 0) this.broadcastPresence();333+ else if (!this.presenceTimer) {334+ this.presenceTimer = setTimeout(() => {335+ this.presenceTimer = null;336+ this.broadcastPresence();337+ }, wait * 1000);338+ }339+ }340+341+ private broadcastPresence() {342+ this.presenceAt = Date.now() / 1000;343+ this.broadcast(this.identity.sign(KIND_PRESENCE, [["d", viewD("presence")], ...presenceTags(this)], ""));235344 }236345 237346 // publishPins signs the group's pin list (39005); an empty list takes the
@@ -873,6 +982,7 @@ export class Relay extends DurableObject<Env> {
873982 }874983 if (url.pathname === "/api/invites/claim" && req.method === "POST") return this.claimInviteRequest(req);875984 if (url.pathname.startsWith("/dumps/") && req.method === "GET") return dumpDownload(this, req);985+ if (url.pathname.startsWith("/view/") && req.method === "GET") return serveView(this, req, verifyNIP98);876986 if (url.pathname === "/import" && req.method === "PUT") return importUpload(this, req);877987 if (url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname === "/report" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname)) return blossom(this, req);878988 if (url.pathname === "/.well-known/nostr/nip96.json" || url.pathname === "/nip96" || url.pathname.startsWith("/nip96/")) return nip96(this, req);
@@ -952,6 +1062,7 @@ export class Relay extends DurableObject<Env> {
9521062 if (this.identity.pubkey) {9531063 doc.self = this.identity.pubkey;9541064 doc.supported_nips = [...SUPPORTED_NIPS, 43];1065+ doc.views = nip11Views(this);9551066 }9561067 return doc;9571068 }
@@ -1084,6 +1195,7 @@ export class Relay extends DurableObject<Env> {
10841195 this.syncs.delete(ws);10851196 this.buckets.delete(ws);10861197 this.flushUsage();1198+ this.notePresence();10871199 }10881200 10891201 async webSocketError(ws: WebSocket) {
@@ -1257,6 +1369,8 @@ export class Relay extends DurableObject<Env> {
12571369 else this.ensureAlarm();12581370 if (e.kind === 0 && conn) claimFromProfile(this.settings, e.content, e.pubkey, conn.host, t);12591371 if (conn) void this.ownerSeen(e.pubkey);1372+ if (conn) this.notePresence(e.pubkey);1373+ if (e.kind === 30023) this.markView("articles");12601374 return { ok: true, msg: "", stored: true };12611375 }12621376
@@ -1282,6 +1396,7 @@ export class Relay extends DurableObject<Env> {
12821396 this.fuel.chargeStorage(t, this.eventBytes(), this.mediaBytes());12831397 this.store.drain();12841398 this.sweepRetention(t);1399+ await this.viewsTick(t);12851400 // Fuel notice: once when it turns low, then once a day while it stays low.12861401 if (this.settings.policy.notify.fuel) {12871402 const low = fuelLow(this.fuelStatus());
@@ -1329,8 +1444,8 @@ export class Relay extends DurableObject<Env> {
13291444 let gone = 0;13301445 const own = rules.filter((r) => r.kind !== null).map((r) => r.kind as number);13311446 for (const r of rules) {1332- if (r.kind !== null) gone += this.store.purge(r.kind, t - r.days * 86400);1333- 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)]);1447+ if (r.kind !== null) gone += this.store.purge(r.kind, t - r.days * 86400, [], this.identity.pubkey);1448+ 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)], this.identity.pubkey);13341449 }13351450 // Per-member keep-for rules, same pass, same exceptions as the catch-all.13361451 const limited = this.settings.limited();
@@ -1456,6 +1571,7 @@ export class Relay extends DurableObject<Env> {
14561571 if (hostOf(tagValues(e, "relay")[0] ?? "") !== hostOf("ws://" + s.host)) return fail("invalid: auth relay tag does not name this relay");14571572 if (!s.authed.includes(e.pubkey)) s.authed.push(e.pubkey);14581573 void this.ownerSeen(e.pubkey);1574+ this.notePresence();14591575 this.persist(ws, s);14601576 this.send(ws, "OK", e.id, true, "");14611577 }
M src/roles.ts +1 -1
M src/settings.ts +18 -1
@@ -68,6 +68,22 @@ export interface Policy {
6868 // invited_by is below `depth` may hold up to `quota` live invites. depth 06969 // turns it off, which leaves inviting to the owner and moderators.7070 memberInvites: { depth: number; quota: number };71+ // Views (views.ts): a name maps to false when the owner switched it off.72+ views: Record<string, boolean>;73+}74+75+export const VIEW_NAMES = ["profiles", "relays", "calendar", "moderation", "articles", "zaps", "presence"];76+77+// viewFields applies a `views` patch of name to boolean; unknown names are dropped.78+export function viewFields(patch: Record<string, unknown>, cur: Record<string, boolean>): Partial<Policy> {79+ if (!patch.views || typeof patch.views !== "object") return {};80+ const out: Record<string, boolean> = { ...cur };81+ for (const [k, v] of Object.entries(patch.views as Record<string, unknown>)) {82+ if (!VIEW_NAMES.includes(k) || typeof v !== "boolean") continue;83+ if (v) delete out[k];84+ else out[k] = false;85+ }86+ return { views: out };7187 }7288 7389 export const DEFAULT_POLICY: Policy = {
@@ -104,6 +120,7 @@ export const DEFAULT_POLICY: Policy = {
104120 dumps: "off",105121 dumpsKeep: 7,106122 memberInvites: { depth: 0, quota: 0 },123+ views: {},107124 };108125 109126 export const SETTINGS_SCHEMA = `
@@ -578,7 +595,7 @@ export class Settings {
578595 const policy = (c.policy && typeof c.policy === "object" ? c.policy : {}) as Record<string, unknown>;579596 const clean: Partial<Policy> = {};580597 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);581- Object.assign(clean, publicFields(policy), dumpFields(policy), gateFields(policy));598+ Object.assign(clean, publicFields(policy), dumpFields(policy), gateFields(policy), viewFields(policy, this.policy.views));582599 if (isWriteRule(policy.writes)) clean.writes = policy.writes;583600 if (policy.reads === "open" || policy.reads === "auth" || policy.reads === "members") clean.reads = policy.reads;584601 if (typeof policy.directoryPublic === "boolean") clean.directoryPublic = policy.directoryPublic;
M src/store.ts +6 -5
@@ -356,16 +356,17 @@ export class Store {
356356 357357 // purge deletes events of one kind, or of every kind not in `except` when358358 // kind is null, created before `before`. Returns how many went.359- purge(kind: number | null, before: number, except: number[] = []): number {359+ // exceptPubkey is the relay's own key: what it signed is never swept.360+ purge(kind: number | null, before: number, except: number[] = [], exceptPubkey = ""): number {360361 this.bytesByAuthor.clear();361362 if (kind !== null) {362- const n = this.x<{ n: number }>(`SELECT count(*) AS n FROM events WHERE kind=? AND created_at<?`, kind, before).one().n;363- if (n > 0) this.x(`DELETE FROM events WHERE kind=? AND created_at<?`, kind, before);363+ const n = this.x<{ n: number }>(`SELECT count(*) AS n FROM events WHERE kind=? AND created_at<? AND pubkey<>?`, kind, before, exceptPubkey).one().n;364+ if (n > 0) this.x(`DELETE FROM events WHERE kind=? AND created_at<? AND pubkey<>?`, kind, before, exceptPubkey);364365 return n;365366 }366367 const list = JSON.stringify(except);367- const n = this.x<{ n: number }>(`SELECT count(*) AS n FROM events WHERE created_at<? AND kind NOT IN (SELECT value FROM json_each(?))`, before, list).one().n;368- if (n > 0) this.x(`DELETE FROM events WHERE created_at<? AND kind NOT IN (SELECT value FROM json_each(?))`, before, list);368+ const n = this.x<{ n: number }>(`SELECT count(*) AS n FROM events WHERE created_at<? AND pubkey<>? AND kind NOT IN (SELECT value FROM json_each(?))`, before, exceptPubkey, list).one().n;369+ if (n > 0) this.x(`DELETE FROM events WHERE created_at<? AND pubkey<>? AND kind NOT IN (SELECT value FROM json_each(?))`, before, exceptPubkey, list);369370 return n;370371 }371372 }
A src/views.ts +337 -0
@@ -0,0 +1,337 @@
1+// Views: folds over what the relay holds, published as records the relay2+// signs (kind 30078, d = bind.ws/view/<name>) so a client reads one event3+// instead of recomputing from hundreds. Each view names its trigger, which4+// is its cost in rows written:5+//6+// daily once a day from the alarm7+// hourly once an hour from the alarm, only when a fingerprint changed8+// write when an accepted event touches it, coalesced, plus daily9+// live never stored: presence, an ephemeral event from memory10+//11+// A public view is stored and broadcast like the roster. A members-only12+// view is never stored, since a stored event is readable by anyone the read13+// rule lets in; it is folded and signed on request at GET /view/<name>.14+import { bolt11Msats } from "./fuel.ts";15+import { now, tag, tagValues, type Event } from "./event.ts";16+import type { Relay } from "./relay.ts";17+18+export const KIND_VIEW = 30078;19+// Presence is ephemeral: 20000 plus the view kind's 78, so a client that20+// knows one can guess the other.21+export const KIND_PRESENCE = 20078;22+export const PRESENCE_ACTIVE_S = 15 * 60;23+export const PRESENCE_THROTTLE_S = 30;24+export const viewD = (name: string) => "bind.ws/view/" + name;25+26+export type Trigger = "daily" | "hourly" | "write" | "live";27+export type Audience = "public" | "members";28+export interface Fold {29+ tags: string[][];30+ content: string;31+}32+export interface View {33+ name: string;34+ trigger: Trigger;35+ about: string;36+ audience: (relay: Relay) => Audience;37+ // fingerprint, for hourly views: republish only when it moved.38+ fingerprint?: (relay: Relay) => string;39+ fold: (relay: Relay) => Fold | null; // null takes the record down40+}41+export interface ViewRun {42+ at: number;43+ rows: number;44+}45+46+const addr = (kind: number, pubkey: string, d: string) => `${kind}:${pubkey}:${d}`;47+const parse = (raw: string) => JSON.parse(raw) as Event;48+const byReads = (relay: Relay): Audience => (relay.settings.policy.reads === "members" ? "members" : "public");49+const byDirectory = (relay: Relay): Audience => (relay.settings.policy.directoryPublic ? "public" : "members");50+51+// The pubkeys a view speaks for: the owner first, then members.52+function people(relay: Relay): string[] {53+ const m = relay.settings.members();54+ const owner = relay.settings.policy.owner;55+ return [owner, ...m.map((x) => x.pubkey).filter((p) => p !== owner)].filter(Boolean);56+}57+58+// newest returns the stored replaceable event of `kind` for each pubkey.59+function newest(relay: Relay, kind: number, pubkeys: string[]): Map<string, Event> {60+ const out = new Map<string, Event>();61+ if (pubkeys.length === 0) return out;62+ for (const r of relay.sql.exec<{ pubkey: string; raw: string }>(`SELECT pubkey, raw FROM events WHERE kind=? AND pubkey IN (SELECT value FROM json_each(?)) ORDER BY created_at DESC`, kind, JSON.stringify(pubkeys))) {63+ if (!out.has(r.pubkey)) out.set(r.pubkey, parse(r.raw));64+ }65+ return out;66+}67+68+const profiles: View = {69+ name: "profiles",70+ trigger: "daily",71+ about: "every member's newest profile in one record",72+ audience: byDirectory,73+ fold(relay) {74+ const who = people(relay);75+ const got = newest(relay, 0, who);76+ const host = relay.slug + "." + relay.domain;77+ const tags = who.map((pk) => {78+ const e = got.get(pk);79+ let meta: { name?: string; picture?: string; nip05?: string } = {};80+ try {81+ meta = e ? JSON.parse(e.content) : {};82+ } catch {83+ /* a profile that is not JSON */84+ }85+ const member = relay.settings.member(pk);86+ const nip05 = member?.name ? `${member.name}@${host}` : typeof meta.nip05 === "string" ? meta.nip05 : "";87+ return ["p", pk, String(meta.name ?? "").slice(0, 200), String(meta.picture ?? "").slice(0, 2000), nip05.slice(0, 200)];88+ });89+ return { tags, content: "" };90+ },91+};92+93+const relays: View = {94+ name: "relays",95+ trigger: "daily",96+ about: "where the members are: the union of their relay lists",97+ audience: byDirectory,98+ fold(relay) {99+ const counts = new Map<string, number>();100+ for (const e of newest(relay, 10002, people(relay)).values()) {101+ const seen = new Set<string>();102+ for (const t of e.tags) {103+ if (t[0] !== "r" || typeof t[1] !== "string") continue;104+ let u = "";105+ try {106+ const x = new URL(t[1].trim());107+ if (x.protocol !== "wss:" && x.protocol !== "ws:") continue;108+ u = (x.host + x.pathname).replace(/\/+$/, "").toLowerCase();109+ u = x.protocol + "//" + u;110+ } catch {111+ continue;112+ }113+ if (seen.has(u)) continue;114+ seen.add(u);115+ counts.set(u, (counts.get(u) ?? 0) + 1);116+ }117+ }118+ const tags = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 100).map(([u, n]) => ["r", u, String(n)]);119+ return { tags, content: "" };120+ },121+};122+123+// A NIP-52 start: a date for kind 31922, a unix time for 31923.124+function startOf(e: Event): number {125+ const s = tag(e, "start");126+ if (e.kind === 31922) {127+ const t = Date.parse(s + "T00:00:00Z");128+ return Number.isFinite(t) ? Math.floor(t / 1000) : 0;129+ }130+ const n = Number(s);131+ return Number.isInteger(n) && n > 0 ? n : 0;132+}133+134+const calendar: View = {135+ name: "calendar",136+ trigger: "hourly",137+ about: "what is on: calendar events starting in the next 30 days, with RSVPs counted",138+ audience: byReads,139+ fingerprint(relay) {140+ const r = relay.sql.exec<{ n: number; m: number | null }>(`SELECT count(*) AS n, max(created_at) AS m FROM events WHERE kind IN (31922,31923,31925)`).one();141+ return `${r.n}:${r.m ?? 0}:${Math.floor(now() / 86400)}`;142+ },143+ fold(relay) {144+ const t = now();145+ const upcoming: { a: string; start: number; title: string }[] = [];146+ for (const r of relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind IN (31922,31923) ORDER BY created_at DESC LIMIT 1000`)) {147+ const e = parse(r.raw);148+ const start = startOf(e);149+ if (start < t - 86400 || start > t + 30 * 86400) continue;150+ upcoming.push({ a: addr(e.kind, e.pubkey, tag(e, "d")), start, title: tag(e, "title").slice(0, 200) });151+ }152+ upcoming.sort((a, b) => a.start - b.start);153+ const top = upcoming.slice(0, 50);154+ const rsvps = new Map<string, Set<string>>();155+ if (top.length) {156+ for (const r of relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=31925 ORDER BY created_at DESC LIMIT 5000`)) {157+ const e = parse(r.raw);158+ if (tag(e, "status") !== "accepted") continue;159+ for (const a of tagValues(e, "a")) {160+ if (!rsvps.has(a)) rsvps.set(a, new Set());161+ rsvps.get(a)!.add(e.pubkey);162+ }163+ }164+ }165+ const tags = top.map((u) => {166+ const n = rsvps.get(u.a)?.size ?? 0;167+ return n ? ["a", u.a, String(u.start), u.title, String(n)] : ["a", u.a, String(u.start), u.title];168+ });169+ return { tags, content: "" };170+ },171+};172+173+const moderation: View = {174+ name: "moderation",175+ trigger: "daily",176+ about: "this month's moderation counts, no ids",177+ audience: () => "public",178+ fold(relay) {179+ const d = new Date(now() * 1000);180+ const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;181+ const one = (q: string, ...args: unknown[]) => relay.sql.exec<{ n: number | null }>(q, ...args).one().n ?? 0;182+ const counts = {183+ month: d.toISOString().slice(0, 7),184+ bans: one(`SELECT count(*) AS n FROM pubkey_rules WHERE rule='ban' AND at>=?`, monthStart),185+ reports: one(`SELECT count(*) AS n FROM reports WHERE at>=?`, monthStart),186+ resolved: one(`SELECT count(*) AS n FROM reports WHERE resolved_at>=?`, monthStart),187+ hidden: one(`SELECT count(*) AS n FROM event_rules WHERE rule='hide' AND at>=?`, monthStart),188+ deleted: one(`SELECT count(*) AS n FROM event_rules WHERE rule='ban' AND at>=?`, monthStart),189+ blocked_addresses: one(`SELECT count(*) AS n FROM ip_rules WHERE at>=?`, monthStart),190+ };191+ return { tags: [["month", counts.month]], content: JSON.stringify(counts) };192+ },193+};194+195+const articles: View = {196+ name: "articles",197+ trigger: "write",198+ about: "the newest hundred articles, by address",199+ audience: byReads,200+ fold(relay) {201+ const tags: string[][] = [];202+ for (const r of relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=30023 ORDER BY created_at DESC LIMIT 100`)) {203+ const e = parse(r.raw);204+ const published = tag(e, "published_at");205+ tags.push(["a", addr(e.kind, e.pubkey, tag(e, "d")), tag(e, "title").slice(0, 200), /^\d+$/.test(published) ? published : String(e.created_at)]);206+ }207+ return { tags, content: "" };208+ },209+};210+211+const zaps: View = {212+ name: "zaps",213+ trigger: "hourly",214+ about: "zap totals for the top notes and authors here",215+ audience: () => "public",216+ fingerprint(relay) {217+ 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();218+ return `${r.n}:${r.m ?? 0}`;219+ },220+ fold(relay) {221+ const byEvent = new Map<string, number>();222+ const byAuthor = new Map<string, number>();223+ const here = new Set(people(relay));224+ const self = relay.identity.pubkey;225+ const service = relay.fuel.cfg.servicePubkey;226+ for (const r of relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=9735 ORDER BY created_at DESC LIMIT 5000`)) {227+ const receipt = parse(r.raw);228+ let req: Event;229+ try {230+ req = JSON.parse(tag(receipt, "description"));231+ } catch {232+ continue;233+ }234+ if (!req || req.kind !== 9734 || !Array.isArray(req.tags)) continue;235+ const msats = bolt11Msats(tag(receipt, "bolt11"));236+ if (msats <= 0) continue;237+ let to = tag(req, "p");238+ if (service && to === service && self) to = self;239+ if (to && (here.has(to) || to === self)) byAuthor.set(to, (byAuthor.get(to) ?? 0) + msats);240+ const eid = tag(req, "e");241+ if (/^[0-9a-f]{64}$/.test(eid) && relay.sql.exec(`SELECT 1 FROM events WHERE id=?`, eid).toArray().length) byEvent.set(eid, (byEvent.get(eid) ?? 0) + msats);242+ }243+ const top = (m: Map<string, number>, key: string) => [...m].sort((a, b) => b[1] - a[1]).slice(0, 50).map(([k, v]) => [key, k, String(v)]);244+ return { tags: [...top(byEvent, "e"), ...top(byAuthor, "p")], content: "" };245+ },246+};247+248+const presence: View = {249+ name: "presence",250+ trigger: "live",251+ about: "who is connected now and who wrote in the last 15 minutes",252+ audience: byReads,253+ fold(relay) {254+ return { tags: presenceTags(relay), content: "" };255+ },256+};257+258+export const VIEWS: View[] = [profiles, relays, calendar, moderation, articles, zaps, presence];259+export const viewByName = (name: string): View | undefined => VIEWS.find((v) => v.name === name);260+export const viewOn = (relay: Relay, name: string): boolean => relay.settings.policy.views[name] !== false;261+export const viewStored = (relay: Relay, v: View): boolean => v.trigger !== "live" && v.audience(relay) === "public";262+263+// presenceTags folds the socket list and the recent writers: online beats active.264+export function presenceTags(relay: Relay): string[][] {265+ const t = now();266+ const online = new Set(relay.authedNow());267+ const tags: string[][] = [];268+ for (const pk of online) tags.push(["p", pk, "online"]);269+ for (const [pk, at] of relay.presenceActive) {270+ if (t - at > PRESENCE_ACTIVE_S) relay.presenceActive.delete(pk);271+ else if (!online.has(pk)) tags.push(["p", pk, "active"]);272+ }273+ return tags;274+}275+276+// viewEvent signs a fold as the view's record. Stored views use the group277+// clock so a newer record always replaces the older one.278+export function viewEvent(relay: Relay, v: View, fold: Fold, stored: boolean): Event {279+ const tags = [["d", viewD(v.name)], ["trigger", v.trigger], ...fold.tags];280+ if (stored) return relay.identity.view(tags, fold.content);281+ return relay.identity.sign(KIND_VIEW, tags, fold.content);282+}283+284+// latestStored reads the record the relay last published for a view.285+export function latestStored(relay: Relay, name: string): string | null {286+ const self = relay.identity.pubkey;287+ if (!self) return null;288+ const row = relay.sql.exec<{ raw: string }>(`SELECT e.raw FROM events e JOIN tags t ON t.event_id=e.id WHERE e.kind=? AND e.pubkey=? AND t.name='d' AND t.value=? ORDER BY e.created_at DESC LIMIT 1`, KIND_VIEW, self, viewD(name)).toArray()[0];289+ return row ? row.raw : null;290+}291+292+// nip11Views describes the views a relay keeps, for the information document.293+export function nip11Views(relay: Relay) {294+ return VIEWS.filter((v) => viewOn(relay, v.name)).map((v) => ({ name: v.name, kind: v.trigger === "live" ? KIND_PRESENCE : KIND_VIEW, d: viewD(v.name), trigger: v.trigger, audience: v.audience(relay) }));295+}296+297+// GET /view/<name>: the stored record, or a members-only fold signed on the298+// spot for a member who proves it with NIP-98, or presence from memory.299+// verify is the relay's NIP-98 check, passed in so this module does not import the management door.300+export async function serveView(relay: Relay, req: Request, verify: (header: string, url: string, method: string, body: string) => Event | string): Promise<Response> {301+ const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "no-store" } });302+ const name = new URL(req.url).pathname.slice("/view/".length);303+ const v = viewByName(name);304+ if (!v || !viewOn(relay, name)) return json({ error: "not found" }, 404);305+ if (relay.settings.policy.owner === "" || !relay.identity.pubkey) return json({ error: "not found" }, 404);306+ if (v.audience(relay) === "members") {307+ const header = req.headers.get("authorization");308+ const auth = header ? verify(header, req.url, req.method, "") : "auth-required: this view is for members; sign the request";309+ if (typeof auth === "string") return json({ error: auth }, 401);310+ if (!relay.settings.isAllowed(auth.pubkey)) return json({ error: "restricted: this view is for members" }, 403);311+ }312+ if (v.trigger === "live") return json(relay.identity.sign(KIND_PRESENCE, [["d", viewD(v.name)], ...presenceTags(relay)], ""));313+ if (viewStored(relay, v)) {314+ const raw = latestStored(relay, name);315+ return raw ? new Response(raw, { headers: { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "public, max-age=60" } }) : json({ error: "not yet" }, 404);316+ }317+ const fold = v.fold(relay);318+ return fold ? json(viewEvent(relay, v, fold, false)) : json({ error: "not yet" }, 404);319+}320+321+// viewsSummary lists every view with its state, for the console and NIP-86.322+export async function viewsSummary(relay: Relay) {323+ const out = [];324+ for (const v of VIEWS) {325+ const runs = await relay.viewRuns(v.name);326+ const last = runs[runs.length - 1] ?? null;327+ out.push({ name: v.name, about: v.about, trigger: v.trigger, audience: v.audience(relay), on: viewOn(relay, v.name), stored: viewStored(relay, v), last, path: "/view/" + v.name });328+ }329+ return out;330+}331+332+// viewRowsSince sums the rows the views wrote in a window, for the digest.333+export async function viewRowsSince(relay: Relay, since: number): Promise<number> {334+ let rows = 0;335+ for (const v of VIEWS) for (const r of await relay.viewRuns(v.name)) if (r.at >= since) rows += r.rows;336+ return rows;337+}
A test/views.test.ts +264 -0
@@ -0,0 +1,264 @@
1+// Views: folds the relay signs as kind 30078 records, and presence as an2+// ephemeral kind 20078 from memory.3+import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { describe, it, expect } from "vitest";5+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";6+import { getToken } from "nostr-tools/nip98";7+import type { Relay } from "../src/relay.ts";8+import { KIND_PRESENCE, KIND_VIEW, viewD } from "../src/views.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+async function view(host: string, name: string, sk: Uint8Array | null = null) {22+ const url = `http://${host}/view/${name}`;23+ const headers: Record<string, string> = {};24+ if (sk) headers.authorization = await getToken(url, "GET", (e) => finalizeEvent(e, sk), true);25+ const resp = await SELF.fetch(url, { headers });26+ return { status: resp.status, body: (await resp.json()) as any };27+}28+29+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();30+31+class WS {32+ private queue: any[][] = [];33+ private waiters: ((m: any[]) => void)[] = [];34+ constructor(public ws: WebSocket) {35+ ws.accept();36+ ws.addEventListener("message", (e) => {37+ const m = JSON.parse(e.data as string);38+ const w = this.waiters.shift();39+ if (w) w(m);40+ else this.queue.push(m);41+ });42+ }43+ challenge = "";44+ static async connect(host: string) {45+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });46+ const c = new WS(resp.webSocket!);47+ const m = await c.expect("AUTH");48+ c.challenge = m[1];49+ return c;50+ }51+ send(...m: unknown[]) {52+ this.ws.send(JSON.stringify(m));53+ }54+ recv(ms = 0): Promise<any[] | null> {55+ const m = this.queue.shift();56+ if (m) return Promise.resolve(m);57+ return new Promise((res) => {58+ this.waiters.push(res);59+ if (ms) setTimeout(() => {60+ const i = this.waiters.indexOf(res);61+ if (i >= 0) { this.waiters.splice(i, 1); res(null); }62+ }, ms);63+ });64+ }65+ async expect(type: string) {66+ const m = await this.recv();67+ expect(m![0], JSON.stringify(m)).toBe(type);68+ return m!;69+ }70+ async ok(e: Event) {71+ this.send("EVENT", e);72+ const m = await this.expect("OK");73+ return { ok: m[2] as boolean, msg: m[3] as string };74+ }75+ async auth(sk: Uint8Array, host: string) {76+ this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));77+ const m = await this.expect("OK");78+ expect(m[2], m[3]).toBe(true);79+ }80+ private n = 0;81+ async req(filter: unknown) {82+ const id = "q" + ++this.n;83+ this.send("REQ", id, filter);84+ const events: Event[] = [];85+ for (;;) {86+ const m = await this.recv();87+ if (!m) throw new Error("no answer");88+ if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);89+ else if (m[0] === "EOSE" && m[1] === id) {90+ this.send("CLOSE", id);91+ return events;92+ } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);93+ }94+ }95+}96+97+const alarm = (name: string) => runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => r.alarm());98+const tagsOf = (e: Event, name: string) => e.tags.filter((t) => t[0] === name);99+100+// A relay with an owner, a member with a profile and a relay list, a101+// calendar event with an RSVP, two articles and a zap receipt.102+async function seed(name: string) {103+ const host = `${name}.bind.ws`;104+ const owner = generateSecretKey();105+ const alice = generateSecretKey();106+ await rpc(host, owner, "claim");107+ await rpc(host, owner, "setmember", getPublicKey(alice), { name: "alice" });108+ const c = await WS.connect(host);109+ await c.auth(alice, host);110+ const put = async (e: Event) => expect((await c.ok(e)).msg).toBe("");111+ await put(ev(alice, 0, JSON.stringify({ name: "Alice", picture: "https://x/a.png" })));112+ await put(ev(alice, 10002, "", [["r", "wss://relay.damus.io"], ["r", "wss://nos.lol/"]]));113+ await put(ev(owner, 10002, "", [["r", "wss://nos.lol"]]));114+ const cal = ev(alice, 31923, "", [["d", "picnic"], ["title", "Picnic"], ["start", String(now() + 3 * 86400)]]);115+ await put(cal);116+ await put(ev(owner, 31925, "", [["d", "r1"], ["a", `31923:${getPublicKey(alice)}:picnic`], ["status", "accepted"], ["p", getPublicKey(alice)]]));117+ const a1 = ev(alice, 30023, "words", [["d", "one"], ["title", "One"], ["published_at", "1700000000"]], now() - 100);118+ const a2 = ev(alice, 30023, "more words", [["d", "two"], ["title", "Two"]]);119+ await put(a1);120+ await put(a2);121+ const payer = generateSecretKey();122+ const zapReq = ev(payer, 9734, "", [["p", getPublicKey(alice)], ["e", a1.id], ["relays", "wss://" + host], ["amount", "1000000"]]);123+ await put(ev(generateSecretKey(), 9735, "", [["p", getPublicKey(alice)], ["e", a1.id], ["bolt11", "lnbc10u1abc"], ["description", JSON.stringify(zapReq)]]));124+ return { host, owner, alice, c, a1, a2, cal };125+}126+127+describe("views", () => {128+ it("publishes the six stored views from the alarm, signed by the relay", async () => {129+ const s = await seed("views");130+ await rpc(s.host, s.owner, "banpubkey", getPublicKey(generateSecretKey()), "spam");131+ await alarm("views");132+ const doc = await info(s.host);133+ expect(doc.views.map((v: any) => v.name)).toEqual(["profiles", "relays", "calendar", "moderation", "articles", "zaps", "presence"]);134+ expect(doc.views.find((v: any) => v.name === "presence").kind).toBe(KIND_PRESENCE);135+136+ const profiles = (await view(s.host, "profiles")).body as Event;137+ expect(profiles.kind).toBe(KIND_VIEW);138+ expect(profiles.pubkey).toBe(doc.self);139+ expect(tagsOf(profiles, "d")[0][1]).toBe(viewD("profiles"));140+ const ps = tagsOf(profiles, "p");141+ expect(ps[0][1]).toBe(getPublicKey(s.owner));142+ expect(ps[1]).toEqual(["p", getPublicKey(s.alice), "Alice", "https://x/a.png", "alice@views.bind.ws"]);143+144+ const relays = (await view(s.host, "relays")).body as Event;145+ expect(tagsOf(relays, "r")).toEqual([["r", "wss://nos.lol", "2"], ["r", "wss://relay.damus.io", "1"]]);146+147+ const calendar = (await view(s.host, "calendar")).body as Event;148+ const a = tagsOf(calendar, "a");149+ expect(a.length).toBe(1);150+ expect(a[0][1]).toBe(`31923:${getPublicKey(s.alice)}:picnic`);151+ expect(a[0][3]).toBe("Picnic");152+ expect(a[0][4]).toBe("1");153+154+ const moderation = (await view(s.host, "moderation")).body as Event;155+ const counts = JSON.parse(moderation.content);156+ expect(counts.bans).toBe(1);157+ expect(counts.month).toBe(new Date().toISOString().slice(0, 7));158+159+ const articles = (await view(s.host, "articles")).body as Event;160+ expect(tagsOf(articles, "a").map((t) => t.slice(1))).toEqual([161+ [`30023:${getPublicKey(s.alice)}:two`, "Two", String(s.a2.created_at)],162+ [`30023:${getPublicKey(s.alice)}:one`, "One", "1700000000"],163+ ]);164+165+ const zaps = (await view(s.host, "zaps")).body as Event;166+ expect(tagsOf(zaps, "e")).toEqual([["e", s.a1.id, "1000000"]]);167+ expect(tagsOf(zaps, "p")).toEqual([["p", getPublicKey(s.alice), "1000000"]]);168+169+ // The records are ordinary events a client can subscribe to.170+ const got = await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self] });171+ expect(got.map((e) => tagsOf(e, "d")[0][1]).sort()).toEqual(["articles", "calendar", "moderation", "profiles", "relays", "zaps"].map(viewD).sort());172+ const runs = (await rpc(s.host, s.owner, "listviews")).result;173+ expect(runs.find((v: any) => v.name === "zaps").last.rows).toBeGreaterThan(0);174+ });175+176+ it("hourly views republish only when their inputs moved", async () => {177+ const s = await seed("views-hourly");178+ await alarm("views-hourly");179+ const first = (await view(s.host, "zaps")).body as Event;180+ const backdate = () => runInDurableObject(env.RELAY.getByName("views-hourly"), async (_r: Relay, state) => state.storage.put("view-at:zaps", now() - 4000));181+ await backdate();182+ await alarm("views-hourly");183+ expect(((await view(s.host, "zaps")).body as Event).id).toBe(first.id);184+ const payer = generateSecretKey();185+ const zapReq = ev(payer, 9734, "", [["p", getPublicKey(s.alice)], ["e", s.a2.id], ["relays", "wss://" + s.host], ["amount", "2000000"]]);186+ expect((await s.c.ok(ev(generateSecretKey(), 9735, "", [["p", getPublicKey(s.alice)], ["bolt11", "lnbc20u1abc"], ["description", JSON.stringify(zapReq)]]))).ok).toBe(true);187+ await backdate();188+ await alarm("views-hourly");189+ const second = (await view(s.host, "zaps")).body as Event;190+ expect(second.id).not.toBe(first.id);191+ expect(tagsOf(second, "p")).toEqual([["p", getPublicKey(s.alice), "3000000"]]);192+ });193+194+ it("a members-only view is folded on request and never stored", async () => {195+ const s = await seed("views-members");196+ await rpc(s.host, s.owner, "setpolicy", { directoryPublic: false });197+ await alarm("views-members");198+ expect((await view(s.host, "profiles")).status).toBe(401);199+ expect((await view(s.host, "profiles", generateSecretKey())).status).toBe(403);200+ const mine = await view(s.host, "profiles", s.alice);201+ expect(mine.status).toBe(200);202+ expect(tagsOf(mine.body, "p").length).toBe(2);203+ const doc = await info(s.host);204+ expect(doc.views.find((v: any) => v.name === "profiles").audience).toBe("members");205+ const stored = await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self], "#d": [viewD("profiles")] });206+ expect(stored).toEqual([]);207+ // Public again: the daily tick stores it.208+ await rpc(s.host, s.owner, "setpolicy", { directoryPublic: true });209+ await runInDurableObject(env.RELAY.getByName("views-members"), async (_r: Relay, state) => state.storage.put("view-at:profiles", 0));210+ await alarm("views-members");211+ expect((await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self], "#d": [viewD("profiles")] })).length).toBe(1);212+ });213+214+ it("presence is broadcast on auth and on a write, throttled, and read from memory", async () => {215+ const s = await seed("views-presence");216+ const watcher = await WS.connect(s.host);217+ watcher.send("REQ", "p", { kinds: [KIND_PRESENCE] });218+ await watcher.expect("EOSE");219+ const bob = generateSecretKey();220+ const b = await WS.connect(s.host);221+ // Seeding signed alice in, which broadcast once; start the throttle over.222+ await runInDurableObject(env.RELAY.getByName("views-presence"), async (r: Relay) => { (r as unknown as { presenceAt: number }).presenceAt = 0; });223+ await b.auth(bob, s.host);224+ const first = await watcher.recv(2000);225+ expect(first && first[0]).toBe("EVENT");226+ const pres = first![2] as Event;227+ expect(pres.kind).toBe(KIND_PRESENCE);228+ expect(tagsOf(pres, "p").some((t) => t[1] === getPublicKey(bob) && t[2] === "online")).toBe(true);229+ // A write inside the throttle window waits.230+ expect((await b.ok(ev(bob, 1, "hi", [], now()))).ok).toBe(true);231+ expect(await watcher.recv(400)).toBeNull();232+ const live = await view(s.host, "presence");233+ expect(live.status).toBe(200);234+ expect(tagsOf(live.body, "p").some((t) => t[1] === getPublicKey(bob) && t[2] === "online")).toBe(true);235+ // Members-only reads keep it to members.236+ await rpc(s.host, s.owner, "setpolicy", { reads: "members" });237+ expect((await view(s.host, "presence")).status).toBe(401);238+ expect((await view(s.host, "presence", s.alice)).status).toBe(200);239+ });240+241+ it("retention leaves what the relay signed alone, and a switch takes a view down", async () => {242+ const s = await seed("views-keep");243+ await alarm("views-keep");244+ const doc = await info(s.host);245+ expect((await s.c.ok(ev(s.alice, KIND_VIEW, "", [["d", "mine"]], now() - 10 * 86400))).ok).toBe(true);246+ await rpc(s.host, s.owner, "setretention", KIND_VIEW, 1);247+ await runInDurableObject(env.RELAY.getByName("views-keep"), async (r: Relay) => {248+ r.sql.exec(`UPDATE events SET created_at=? WHERE kind=?`, now() - 10 * 86400, KIND_VIEW);249+ expect(r.sweepRetention(now())).toBe(1);250+ });251+ expect((await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self] })).length).toBe(6);252+ expect((await s.c.req({ kinds: [KIND_VIEW], authors: [getPublicKey(s.alice)] })).length).toBe(0);253+ expect((await rpc(s.host, s.owner, "purgekind", KIND_VIEW, 0)).result.deleted).toBe(0);254+255+ await rpc(s.host, s.owner, "setpolicy", { views: { articles: false } });256+ expect((await view(s.host, "articles")).status).toBe(404);257+ expect((await s.c.req({ kinds: [KIND_VIEW], authors: [doc.self], "#d": [viewD("articles")] })).length).toBe(0);258+ expect((await info(s.host)).views.map((v: any) => v.name)).not.toContain("articles");259+ const exported = (await rpc(s.host, s.owner, "exportconfig")).result;260+ expect(exported.policy.views).toEqual({ articles: false });261+ await rpc(s.host, s.owner, "setpolicy", { views: { articles: true } });262+ expect((await view(s.host, "articles")).status).toBe(200);263+ });264+});