* pull: history imports resume ordinary queries when sync is unavailable and report coverage per source Bounds query windows and response buffers, retains partial and refused source results, and continues later sources after failed retries. Tests cover fallback, persistence, timestamp saturation and invalid responses. * lists: retain bounded private versions so owners can restore follows relay lists and bookmarks * lists: show restore diffs, supersede future versions, and bound history storage * console: list recovery identifies named bookmarks and shares the data guide Keeps recovery guidance in the existing user and management references, including signer-only access and retained version limits. * feat: add opt-in NIP-65 delivery * fix: make relay delivery progress durable per target * fix: retain failed relay delivery progress * lists: expire saved versions and support colon address deletion * test: cover owner-wide list history privacy and deletion * delivery: automatic sends respect current visibility and expose target results Runs delivery under relay admission, bounds socket waits and errors, accounts for query usage, and excludes expired or banned events and members-only content. * test: cover rebroadcast refusal cursor safety * backups: portable state, site and Git archives restore only onto a fresh relay * recovery: portable backups preserve private state and restore through authenticated fresh-target previews Includes bounded archives, atomic restore metadata, source-owner authorization, retained list and visibility state, billed archive storage, and usable backup controls. Integration tests cover HTTP restore, upload caps, import fallback, owner isolation and independent target retries. * test: checkpoint migration allows hosted CI time for 260 sequential commits Scopes a 90-second timeout to the migration fixtures after the growing-ref case completed in about 41 seconds on CI against a 30-second deadline. Keeps the workload and assertions intact.
- commit
- 879847d2aa07585eba840f5ebe645064cd54fc31
- parent
- 622e04b8f34e4f7326d9678904142dbd045e17e2
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | README.md | 2 | +- |
| M | docs/ | 30 | +++- |
| M | docs/ | 6 | +- |
| A | src/ | 280 | ++++++++++++++++++++++++++++++ |
| M | src/ | 31 | +++- |
| M | src/ | 67 | +++++++- |
| A | src/ | 113 | ++++++++++++ |
| M | src/ | 4 | +- |
| M | src/ | 2 | +- |
| M | src/ | 94 | +++++++--- |
| A | src/ | 82 | +++++++++ |
| M | src/ | 29 | +++- |
| M | src/ | 208 | +++++++++++++++++++--- |
| M | src/ | 16 | +- |
| M | src/ | 3 | + |
| M | src/ | 7 | + |
| M | src/ | 34 | +++- |
| A | test/ | 152 | ++++++++++++++++ |
| A | test/ | 63 | +++++++ |
| M | test/ | 3 | + |
| M | test/ | 4 | +- |
| M | test/ | 37 | ++++ |
| M | test/ | 8 | +- |
| A | test/ | 102 | +++++++++++ |
| A | test/ | 225 | ++++++++++++++++++++++++ |
@@ -41,7 +41,7 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena
4141 - [Relay configuration](docs/01-relay-configuration.md): the console, tab by tab.4242 - [Understanding fuel](docs/02-understanding-fuel.md): what is measured, what is free, what a zap buys.4343 - [People and groups](docs/03-people-and-groups.md): members, invites, moderators, groups, handing over.44-- [Data and names](docs/04-data-and-names.md): jobs, dumps, presets, forks, leaving.44+- [Data and names](docs/04-data-and-names.md): list recovery, delivery, imports, backups, presets and forks.4545 - [Your relay on the web](docs/05-your-relay-on-the-web.md): pages, static sites, Git hosting, feed, card, custom domains and media.4646 4747 **For scripts and agents**@@ -11,17 +11,31 @@ What your relay holds, how it moves and how a name can do one job.
1111 1212 The Data tab shows bytes by kind, the files people uploaded and a keep-for rule per kind. Kinds the relay depends on, such as profiles, contact lists, relay lists, zap receipts and the roster, are never expired or purged. The controls are in [Relay configuration](01-relay-configuration.md#data).1313 14+## Recover a list15+16+The Data tab keeps older versions of your follows, relay lists and bookmarks (kinds 3, 10002, 10003 and 30003). Versions stay private to the publishing key and stay outside event queries, search and ordinary dumps. History starts when a newer version replaces a stored list.17+18+Choose **Restore** to preview added and removed tags and whether the content changed. Your extension or remote signer signs the old contents with a newer timestamp, then the console publishes it through the normal event door. The relay never receives your private key. History keeps at most twelve versions per list, 96 per publishing key and 4,096 per relay. Author deletions and vanish remove matching saved history.19+1420 ## Jobs1521 16-A job is work the relay does on its own, one small round at a time, so it keeps going while the relay sleeps between rounds. The **Jobs** table on the Sync tab lists each job with its relays, filter, schedule and last result, with run-now and remove. A job that fails three rounds in a row stops and says why.22+A job is work the relay does on its own, one small round at a time, so it keeps going while the relay sleeps between rounds. The **Jobs** table on the Sync tab lists each job with its relays, filter, schedule and last result, with run-now and remove. A job that fails three rounds in a row stops and says why. A pull retries a failing source three times, then continues with the next source. An explicit refusal skips that source immediately.1723 1824 A job runs once, or every hour, six hours or day. Up to five standing jobs and 20 jobs in all per relay. Your bans and kind rules apply to what arrives. Your write rule does not: you asked for these events.1925 20-**Pull** copies what another relay has and yours lacks, by sync (NIP-77). Run it again and only new events come over. Files come along when the other relay is on bind.ws and the pull has no filter. With an interval, a pull is a standing mirror that keeps your name in step with the other relay. The other relay has to let anyone read.26+**Pull** copies what another relay has and yours lacks. It tries sync (NIP-77) first, then ordinary NIP-01 queries if sync is unavailable. Query progress survives between rounds and relay restarts. Run it again and events already stored are deduplicated. Files come along when the other relay is on bind.ws and the pull has no filter. With an interval, a pull is a standing mirror that keeps your name in step with the other relay. The other relay has to let anyone read.27+28+The Jobs table exposes **Source results** with mode, status, counts and any refusal or coverage warning for each relay. A completed NIP-77 sync is marked complete. Query scans are best effort: relays may silently cap or omit history. Full query windows split by time; a full one-second window is explicitly partial because more events may share its timestamp. Imports stop at 2,048 pages per source and 500 events per query. Narrow the author or time filter and run again if a source reaches a limit. Imports never send your signing key or authenticate on your behalf.2129 2230 **Fetch my history** pulls your own events from every relay in your relay list, if a client has published that list here. Or give it relays to fetch from, separated by commas.2331 24-**Rebroadcast** sends what your relay holds to other relays. Choose kinds and a window in days, or leave both blank for everything. As a standing job it forwards only what arrived since the last run. A target that refuses five events in a row is left alone for that round. Events that only their author may publish are never sent, and a members-only relay never sends private messages.32+**Rebroadcast** sends what your relay holds to other relays. Choose kinds and a window in days, or leave both blank for everything. As a standing job it forwards only what arrived since the last run. Each target has its own saved cursor and status, so a failing destination cannot skip history because another succeeded. A target that refuses five events in a row is left alone for that round; unacknowledged history remains available for a later retry. Events that only their author may publish are never sent, and a members-only relay never sends private messages.33+34+## Automatic delivery35+36+Enable **Automatic NIP-65 delivery** on the Rules tab to send newly accepted public events by you and your members to the author's write relays and tagged people's read relays. Routing uses the kind 10002 relay lists already stored here. It is off by default, with eight targets per event and a configurable maximum of one through sixteen.37+38+The Sync tab shows recent event/target results: pending, accepted or rejected, attempts and the last error. Each target gets up to four attempts. The queue holds at most 512 pending deliveries and 1,024 terminal results, kept for at most seven days. A full queue can omit new deliveries. This is best effort and costs fuel. Private kinds, protected events, imports and relay-generated events are excluded. Members-only relays never route automatically. Pending deliveries recheck the current event, membership and routing before sending.2539 2640 ## Import a file2741 @@ -31,6 +45,14 @@ The reverse of a dump. Under **Import a file** on the Data tab, pick a JSONL of
3145 3246 Under **Dumps**, choose daily or weekly and how many to keep, seven by default. The relay writes every event as one JSONL file and keeps the newest few. **Dump now** writes one on the spot. Each file lists its event count and size, with download and delete. Downloading takes your signature; the files are never a public link.3347 48+## Portable backups49+50+On the Data tab, **Create and download** makes a private archive with configuration, signed events, saved list history, hidden and pending state, site/media blobs and Git objects. Existing archives can be downloaded or deleted. Each binary object and the archive have SHA-256 integrity checks; event signatures are checked on restore. Stored archives count toward fuel.51+52+Open a fresh, unclaimed relay and choose the archive in **Restore a backup**. Sign with the original owner's key, preview its source identity, counts and configuration, then restore. The target must be empty and unleased. Restore stages files before applying the database in one transaction. The source identity is recorded; the target gets a new relay key. Signed site and Git references still name their original URLs, so publish updated service references from your client after moving.53+54+This portable format is for small relays: at most 8 MiB per archive and 12,000 entries, including state records. Memory budgeting can refuse an archive below the wire limit. Larger relays need the separate event dump, configuration export, Blossom download and Git clone paths. Credentials, fuel credits, custom domains, leases, succession, callback registrations, existing dumps/backups and transient jobs are excluded. Configure those again on the target. Owner archives contain private relay data; keep the downloaded file private.55+3456 ## Presets and one name per job3557 3658 Names are cheap, so a relay does not have to do everything. On the Rules tab, **Presets** sets writes, reads, the directory, the kind rules and the keep-for rules in one click. Limits, identity, people and bans stay. Your own profile and lists always land, whatever the kind rules say.@@ -75,7 +97,7 @@ Your data is yours. A relay that speaks sync can pull the events your read rule
7597 nak sync -a <your-pubkey> wss://<name>.bind.ws wss://<other-relay>7698 ```7799 78-Or download a dump.100+Or download a dump or a portable backup.79101 80102 Event sync and JSONL dumps do not contain site files or Git packs. An unfiltered81103 bind.ws pull can copy Blossom files, but it does not copy GRASP repositories.@@ -144,6 +144,9 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted,
144144 - `gitstorage owner identifier`: an owner-only inventory of one accepted GRASP repository. The result compares bounded physical R2 listing with live Git dependencies and reports physical, live, unreferenced and unknown objects by class, SQL reservations and the byte difference. It never deletes data.145145 - `deleteblob sha256`.146146 - `listdumps`, `dumpnow`, `deletedump name`.147+- `backupnow [id]`, `listbackups`, `deletebackup id`: portable archives; owner NIP-98 downloads use `/backups/id`. POST the exact archive with a payload-bound NIP-98 signature to `/backups/preview`, then separately sign `/backups/restore` on a fresh target. Both require the archived owner key. Archives are capped at 8 MiB and 12,000 entries.148+- `listlisthistory`: private older list versions belonging to the authenticated owner, moderator or member.149+- `restorelist eventId`: an unsigned `draft` and `diff` (added tags, removed tags, content changed) for one saved version belonging to that signer. Sign the draft in the client and publish it normally.147150 148151 **Config**149152 @@ -153,7 +156,8 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted,
153156 154157 - `pullfrom url`, `pullstatus`: copy one relay and follow it.155158 - `addjob {kind, relays, filter?, every?, label?}`: a `pull` or `push`, once or every 1, 6 or 24 hours. Up to 10 relays; filters take up to 50 authors and 50 kinds and a `since`.156-- `removejob id`, `runjob id`, `listjobs`.159+- `removejob id`, `runjob id`, `listjobs`. Pull jobs expose `pullSources` while running and `last.sources` after finishing: each source carries its URL, mode, status, stored/skipped/blob counts, retry count, error and coverage warning. Query window progress is persisted with the job.160+- `deliverystatus`: recent automatic NIP-65 per-event target status, attempts and last error. Enable with `setpolicy {delivery: {enabled: true, maxTargets: 8}}`.157161 - `backfill [relays?]`: your own events from your kind 10002 here, or from the list.158162 159163 **Transfer**@@ -0,0 +1,280 @@
1+// Portable relay backups: a bounded JSON archive containing the owner's2+// configuration, signed events, Blossom blobs and hosted Git objects. The3+// archive is sealed by a manifest hash before it is stored or restored.4+import { sha256 } from "@noble/hashes/sha2.js";5+import { applyConfig, exportConfig, parseConfig } from "./config.ts";6+import { bytesToHex } from "./negentropy.ts";7+import { verifyNIP98 } from "./auth.ts";8+import { can } from "./roles.ts";9+import { expiration, now, validate, type Event } from "./event.ts";10+import { type Relay } from "./relay.ts";11+import { KIND_PUSH_REGISTRATION } from "./kinds.ts";12+import { archiveCurrent, isListKind } from "./list-history.ts";13+import { Settings } from "./settings.ts";14+15+export const BACKUP_FORMAT = "bind.ws/relay-backup/1";16+// JSON parsing, base64 expansion and integrity copies coexist in the Worker17+// heap, so the portable form stays well below the platform heap ceiling.18+export const BACKUP_MAX_BYTES = 8 * 1024 * 1024;19+export const BACKUP_MAX_OBJECTS = 12_000;20+export const BACKUP_SCHEMA = `CREATE TABLE IF NOT EXISTS backups (id TEXT PRIMARY KEY, bytes INTEGER NOT NULL);`;21+export const backupBytes = (relay: Relay): number => relay.sql.exec<{ n: number }>(`SELECT coalesce(sum(bytes),0) n FROM backups`).one().n;22+export const BACKUP_ID_RE = /^[a-z0-9][a-z0-9_-]{2,63}$/;23+24+type Payload = { sha256: string; size: number; data: string };25+export type BackupArchive = {26+ format: typeof BACKUP_FORMAT;27+ manifest: { id: string; slug: string; owner: string; relayIdentity: string; createdAt: number; bytes: number; events: number; blobs: number; git: number; archiveSha256: string };28+ config: unknown;29+ events: string[];30+ blobs: (Payload & { sha256: string; type: string; uploader: string; uploaded: number })[];31+ git: (Payload & { key: string })[];32+ state?: { listHistory: string[]; hidden: string[]; hosted: string[]; pending: { id: string; until: number }[] };33+};34+35+const enc = new TextEncoder();36+const dec = new TextDecoder();37+const toB64 = (bytes: Uint8Array) => {38+ let out = "";39+ for (let i = 0; i < bytes.length; i += 0x8000) out += String.fromCharCode(...bytes.subarray(i, i + 0x8000));40+ return btoa(out);41+};42+const fromB64 = (value: string) => {43+ const raw = atob(value);44+ const out = new Uint8Array(raw.length);45+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);46+ return out;47+};48+const digest = (bytes: Uint8Array) => bytesToHex(sha256(bytes));49+const archiveKey = (relay: Relay, id: string) => `${relay.slug}/backups/${id}.json`;50+51+const bytesOf = (archive: BackupArchive) => enc.encode(JSON.stringify(archive));52+const unsignedBytes = (archive: BackupArchive) => {53+ const copy = structuredClone(archive);54+ copy.manifest.archiveSha256 = "";55+ return enc.encode(JSON.stringify(copy));56+};57+58+// createBackup snapshots the visible database and every tenant-owned media59+// object. The cap makes the bounded in-memory archive explicit.60+export async function createBackup(relay: Relay, id: string): Promise<{ manifest: BackupArchive["manifest"]; key: string } | string> {61+ if (!BACKUP_ID_RE.test(id)) return "invalid: backup id must be 3 to 64 lowercase letters, digits, dash or underscore";62+ if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return "restricted: relay is not active";63+ const owner = relay.settings.policy.owner;64+ if (!owner) return "restricted: relay has no owner";65+ const events: string[] = [];66+ let estimate = 1024;67+ let entries = 0;68+ const reserve = (bytes: number, count = 1) => {69+ // JSON strings and base64 coexist with decoded buffers during sealing.70+ const next = estimate + Math.ceil(bytes * 2) + 256;71+ if (next > BACKUP_MAX_BYTES || entries + count > BACKUP_MAX_OBJECTS) return false;72+ estimate = next;73+ entries += count;74+ return true;75+ };76+ let seq = 0;77+ for (;;) {78+ const page = relay.store.dumpPage(seq, 1);79+ if (!page.length) break;80+ for (const x of page) {81+ try {82+ if ((JSON.parse(x.raw) as Event).kind === KIND_PUSH_REGISTRATION) continue;83+ if (!reserve(enc.encode(x.raw).length)) return "restricted: backup exceeds its bounded size or object limit";84+ events.push(x.raw);85+ } catch { return "error: event serialization failed during backup"; }86+ }87+ seq = page[page.length - 1].seq;88+ }89+ const blobs: BackupArchive["blobs"] = [];90+ const blobRows = relay.sql.exec<{ sha256: string; size: number; type: string; uploader: string; uploaded: number }>(`SELECT * FROM blobs ORDER BY uploaded LIMIT 12001`).toArray();91+ for (const b of blobRows) {92+ if (!reserve(b.size)) return "restricted: backup exceeds its bounded size or object limit";93+ const obj = await relay.media.get(`${relay.slug}/${b.sha256}`);94+ if (!obj) return `error: blob ${b.sha256} disappeared during backup`;95+ if (obj.size !== b.size) return "error: blob size changed during backup";96+ const data = new Uint8Array(await obj.arrayBuffer());97+ if (data.length !== b.size) return `error: blob ${b.sha256} changed during backup`;98+ if (data.length !== b.size || digest(data) !== b.sha256) return `error: blob ${b.sha256} failed integrity check`;99+ blobs.push({ ...b, data: toB64(data), sha256: b.sha256, size: data.length });100+ }101+ const git: BackupArchive["git"] = [];102+ let cursor: string | undefined;103+ for (;;) {104+ const listed = await relay.media.list({ prefix: `${relay.slug}/git/`, cursor, limit: 1000 });105+ for (const item of listed.objects) {106+ if (git.length + blobs.length >= BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached; no complete archive";107+ if (item.size > 4 * 1024 * 1024) return "restricted: Git object exceeds the backup object limit";108+ if (!reserve(item.size)) return "restricted: backup exceeds its bounded size or object limit";109+ const obj = await relay.media.get(item.key);110+ if (!obj) return `error: Git object ${item.key} disappeared during backup`;111+ const data = new Uint8Array(await obj.arrayBuffer());112+ git.push({ key: item.key.slice(relay.slug.length + 1), sha256: digest(data), size: data.length, data: toB64(data) });113+ }114+ if (!listed.truncated) break;115+ cursor = listed.cursor;116+ }117+ const config = exportConfig(relay.settings, relay.slug);118+ if (!reserve(enc.encode(JSON.stringify(config)).length)) return "restricted: backup exceeds its bounded size or object limit";119+ const state = { listHistory: [] as string[], hidden: [...relay.settings.hiddenEvents], hosted: relay.sql.exec<{ id: string }>(`SELECT id FROM grasp_hosted`).toArray().map((r) => r.id), pending: relay.sql.exec<{ id: string; until: number }>(`SELECT id,until FROM grasp_pending`).toArray() };120+ for (const row of relay.sql.exec<{ raw: string }>(`SELECT raw FROM list_history WHERE expires=0 OR expires>? ORDER BY saved_at`, now())) {121+ if (!reserve(enc.encode(row.raw).length)) return "restricted: backup history exceeds its bounded size";122+ state.listHistory.push(row.raw);123+ }124+ if (!reserve(enc.encode(JSON.stringify({ ...state, listHistory: [] })).length, state.hidden.length + state.hosted.length + state.pending.length)) return "restricted: backup state exceeds its bounded size";125+ const archive = { format: BACKUP_FORMAT, manifest: { id, slug: relay.slug, owner, relayIdentity: relay.identity.pubkey, createdAt: now(), bytes: 0, events: events.length, blobs: blobs.length, git: git.length, archiveSha256: "" }, config, events, blobs, git, state } as BackupArchive;126+ archive.manifest.archiveSha256 = "0".repeat(64);127+ for (let i = 0; i < 4; i++) archive.manifest.bytes = bytesOf(archive).length;128+ archive.manifest.archiveSha256 = digest(unsignedBytes(archive));129+ const finalBytes = bytesOf(archive);130+ if (finalBytes.length > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB; use smaller retention or separate Git repositories";131+ relay.sql.exec(`INSERT OR REPLACE INTO backups(id,bytes) VALUES(?,?)`, id, finalBytes.length);132+ await relay.media.put(archiveKey(relay, id), finalBytes, { httpMetadata: { contentType: "application/json" } });133+ relay.meterBytes(0, finalBytes.length);134+ return { manifest: archive.manifest, key: archiveKey(relay, id) };135+}136+137+export async function listBackups(relay: Relay) {138+ const listed = await relay.media.list({ prefix: `${relay.slug}/backups/` });139+ return listed.objects.filter((x) => x.key.endsWith(".json")).map((x) => ({ id: x.key.slice(`${relay.slug}/backups/`.length, -5), bytes: x.size }));140+}141+142+export async function deleteBackup(relay: Relay, id: string) {143+ if (!BACKUP_ID_RE.test(id)) return false;144+ await relay.media.delete(archiveKey(relay, id));145+ relay.sql.exec(`DELETE FROM backups WHERE id=?`, id);146+ return true;147+}148+149+const checkedArchive = (bytes: Uint8Array): BackupArchive | string => {150+ if (bytes.length > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB";151+ let archive: BackupArchive;152+ try { archive = JSON.parse(dec.decode(bytes)) as BackupArchive; } catch { return "invalid: backup is not JSON"; }153+ if (!archive || archive.format !== BACKUP_FORMAT || !archive.manifest || !Array.isArray(archive.events) || !Array.isArray(archive.blobs) || !Array.isArray(archive.git)) return "invalid: unsupported backup format";154+ if (archive.manifest.archiveSha256 !== digest(unsignedBytes(archive))) return "invalid: backup integrity check failed";155+ if (archive.events.length + archive.blobs.length + archive.git.length > BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached";156+ for (const raw of archive.events) {157+ try { if (typeof raw !== "string" || (validate(JSON.parse(raw) as Event) || JSON.parse(raw).kind === KIND_PUSH_REGISTRATION)) return "invalid: backup contains an invalid event"; }158+ catch { return "invalid: backup contains malformed event JSON"; }159+ }160+ for (const b of archive.blobs) { let data: Uint8Array; try { data = fromB64(b.data); } catch { return "invalid: malformed blob data"; } if (data.length !== b.size || digest(data) !== b.sha256) return "invalid: blob integrity check failed"; }161+ for (const g of archive.git) { let data: Uint8Array; try { data = fromB64(g.data); } catch { return "invalid: malformed Git data"; } if (data.length !== g.size || digest(data) !== g.sha256 || typeof g.key !== "string" || !g.key.startsWith("git/")) return "invalid: Git integrity check failed"; }162+ const state = archive.state;163+ if (state) {164+ if (!Array.isArray(state.listHistory) || !Array.isArray(state.hidden) || !Array.isArray(state.hosted) || !Array.isArray(state.pending)) return "invalid: backup state malformed";165+ if (archive.events.length + archive.blobs.length + archive.git.length + state.listHistory.length + state.hidden.length + state.hosted.length + state.pending.length > BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached";166+ const hex = (v: unknown) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);167+ if (state.hidden.some((v) => !hex(v)) || state.hosted.some((v) => !hex(v)) || state.pending.some((v) => !v || !hex(v.id) || !Number.isSafeInteger(v.until))) return "invalid: backup state malformed";168+ for (const raw of state.listHistory) {169+ try { const event = JSON.parse(raw); if (typeof raw !== "string" || validate(event) || !isListKind(event.kind)) return "invalid: backup list history malformed"; }170+ catch { return "invalid: backup list history malformed"; }171+ }172+ }173+ return archive;174+};175+176+// restoreBackup only accepts an unclaimed, empty target. Events are already177+// signed and validated in the archive; direct storage preserves private data178+// and avoids applying the target's ordinary write policy to old history.179+export async function restoreBackup(relay: Relay, bytes: Uint8Array, caller: string): Promise<unknown | string> {180+ const archive = checkedArchive(bytes);181+ if (typeof archive === "string") return archive;182+ if (relay.settings.policy.owner !== "" || relay.settings.isLeased() || relay.sql.exec(`SELECT 1 FROM events LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM blobs LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM grasp_objects LIMIT 1`).toArray().length || (relay.slug !== "" && (await relay.media.list({ prefix: `${relay.slug}/`, limit: 1 })).objects.length)) return "restricted: restore requires a fresh, unclaimed relay";183+ if (archive.manifest.owner !== caller) return "restricted: target signer is not the backup owner";184+ const config = archive.config as Record<string, unknown>;185+ const parsed = parseConfig(config, relay.settings.policy);186+ if (typeof parsed === "string") return parsed;187+ const staged: string[] = [];188+ try {189+ for (const b of archive.blobs) {190+ const data = fromB64(b.data);191+ const key = `${relay.slug}/${b.sha256}`;192+ await relay.media.put(key, data, { httpMetadata: { contentType: b.type } }); staged.push(key);193+ }194+ for (const g of archive.git) { const key = `${relay.slug}/${g.key}`; await relay.media.put(key, fromB64(g.data)); staged.push(key); }195+ } catch (error) {196+ await relay.media.delete(staged).catch(() => {});197+ return "error: restore storage staging failed";198+ }199+ try {200+ relay.storage.transactionSync(() => {201+ relay.settings.update({ owner: caller });202+ applyConfig(relay.settings, parsed, now());203+ for (const raw of archive.events) {204+ const e = JSON.parse(raw) as Event;205+ const error = relay.store.save(e, now());206+ if (error && !error.startsWith("duplicate:")) throw new Error(`error: event restore stopped: ${error}`);207+208+ }209+ for (const raw of archive.state?.listHistory ?? []) {210+ const event = JSON.parse(raw) as Event;211+ if (!expiration(event) || expiration(event) > now()) archiveCurrent((q, ...args) => relay.sql.exec(q, ...args as SqlStorageValue[]), event, now());212+ }213+ for (const id of archive.state?.hidden ?? []) relay.settings.setEvent(id, "hide");214+ for (const id of archive.state?.hosted ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_hosted(id) SELECT id FROM events WHERE id=?`, id);215+ for (const row of archive.state?.pending ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_pending(id,until) SELECT id,? FROM events WHERE id=?`, row.until, row.id);216+ for (const b of archive.blobs) relay.sql.exec(`INSERT OR REPLACE INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)`, b.sha256, b.size, b.type, b.uploader, b.uploaded);217+ for (const g of archive.git) relay.sql.exec(`INSERT OR REPLACE INTO grasp_objects(key,owner,size) VALUES(?,?,?)`, `${relay.slug}/${g.key}`, caller, g.size);218+ });219+ } catch (error) {220+ relay.settings = new Settings(relay.sql);221+ relay.settings.load();222+ relay.store.hidden = relay.settings.hiddenEvents;223+ await relay.media.delete(staged).catch(() => {});224+ return error instanceof Error && error.message.startsWith("error:") ? error.message : "error: restore transaction failed";225+ }226+ await relay.syncSites();227+ await relay.publishMembership();228+ await relay.publishDiscovery();229+ return { restored: true, owner: caller, sourceRelayIdentity: archive.manifest.relayIdentity, targetRelayIdentity: relay.identity.pubkey, events: archive.events.length, blobs: archive.blobs.length, git: archive.git.length, bytes: bytes.length };230+}231+232+export async function backupDownload(relay: Relay, req: Request, id: string): Promise<Response> {233+ const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, "");234+ if (typeof auth === "string") return Response.json({ error: auth }, { status: 401 });235+ if (!can(relay.settings.roleOf(auth.pubkey), "storage")) return Response.json({ error: "restricted: not the relay owner" }, { status: 403 });236+ if (!BACKUP_ID_RE.test(id)) return Response.json({ error: "invalid: backup id" }, { status: 400 });237+ const obj = await relay.media.get(archiveKey(relay, id));238+ if (!obj) return Response.json({ error: "not found" }, { status: 404 });239+ return new Response(obj.body, { headers: { "content-type": "application/json", "content-length": String(obj.size), "cache-control": "private, no-store", "content-disposition": `attachment; filename="${relay.slug}-${id}.json"` } });240+}241+242+async function readCapped(req: Request): Promise<Uint8Array | string> {243+ const len = Number(req.headers.get("content-length") ?? 0);244+ if (len > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB";245+ if (!req.body) return new Uint8Array();246+ const reader = req.body.getReader(); const chunks: Uint8Array[] = []; let total = 0;247+ for (;;) {248+ const part = await reader.read();249+ if (part.done) break;250+ total += part.value.byteLength;251+ if (total > BACKUP_MAX_BYTES) { await reader.cancel(); return "restricted: backup exceeds 8 MiB"; }252+ chunks.push(part.value);253+ }254+ const out = new Uint8Array(total); let at = 0;255+ for (const chunk of chunks) { out.set(chunk, at); at += chunk.length; }256+ return out;257+}258+259+export async function restoreBackupRequest(relay: Relay, req: Request): Promise<Response> {260+ const preauth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, "");261+ if (typeof preauth === "string") return Response.json({ error: preauth }, { status: 401 });262+ if (relay.settings.policy.owner !== "" || relay.settings.isLeased()) return Response.json({ error: "restricted: restore requires a fresh, unclaimed relay" }, { status: 403 });263+ const input = await readCapped(req);264+ if (typeof input === "string") return Response.json({ error: input }, { status: 413 });265+ const bytes = input;266+ const body = new TextDecoder().decode(bytes);267+ const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, body);268+ if (typeof auth === "string") return Response.json({ error: auth }, { status: 401 });269+ if (new URL(req.url).pathname === "/backups/preview") {270+ const parsed = checkedArchive(bytes);271+ if (typeof parsed === "string") return Response.json({ error: parsed }, { status: 400 });272+ const cfg = parseConfig(parsed.config, relay.settings.policy);273+ if (typeof cfg === "string") return Response.json({ error: cfg }, { status: 400 });274+ if (relay.settings.policy.owner !== "" || relay.settings.isLeased() || relay.sql.exec(`SELECT 1 FROM events LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM blobs LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM grasp_objects LIMIT 1`).toArray().length || (relay.slug !== "" && (await relay.media.list({ prefix: `${relay.slug}/`, limit: 1 })).objects.length)) return Response.json({ error: "restricted: restore requires a fresh, unclaimed relay" }, { status: 403 });275+ if (parsed.manifest.owner !== auth.pubkey) return Response.json({ error: "restricted: target signer is not the backup owner" }, { status: 403 });276+ return Response.json({ result: { preview: true, source: parsed.manifest, targetIsFresh: true, config: parsed.config, events: parsed.events.length, blobs: parsed.blobs.length, git: parsed.git.length, bytes: bytes.length } });277+ }278+ const result = await restoreBackup(relay, bytes, auth.pubkey);279+ return typeof result === "string" ? Response.json({ error: result }, { status: result.startsWith("invalid:") ? 400 : 403 }) : Response.json({ result });280+}@@ -17,6 +17,15 @@
1717 <h2>Nobody owns this relay yet.</h2>1818 <p class="lead">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>1919 <div class="row"><button id="claim" class="btn pri">Claim this relay</button><button class="btn remote" data-note="claimnote">Use a remote signer</button><span class="note" id="claimnote"></span></div>20+ <div class="block">21+ <h3>Restore a backup</h3>22+ <p class="note">Choose a private bind.ws archive to inspect it before restoring. Preview does not claim this relay. Restore accepts only an empty relay and the archive owner's signature.</p>23+ <div class="form">24+ <label class="wide"><span>Archive <small>up to 8 MB</small></span><input class="txt" type="file" id="backupfile" accept="application/json"></label>25+ </div>26+ <div class="actions"><button type="button" id="backupremote" class="btn">Use a remote signer</button><button type="button" id="backuppreview" class="btn">Preview restore</button><button type="button" id="backuprestore" class="btn pri" disabled>Restore</button></div>27+ <div id="backup-preview" class="note"></div>28+ </div>2029 </section>2130 2231 <section id="leased" class="hidden">@@ -235,7 +244,9 @@
235244 <form id="push-policy-form" class="limits">236245 <label><span>Push callback origins <small>One HTTPS origin per line, also approved by your host operator. Relay push costs fuel.</small></span><textarea class="txt" name="origins" rows="3" placeholder="https://push.example.com"></textarea></label>237246 <label><span>Lettered NIP advertisements <small>Some older clients reject mixed NIP identifiers. Relay push requires them.</small></span><input type="checkbox" name="lettered"></label>238- <div class="actions"><button class="btn">Save callback policy</button></div>247+ <label><span>Automatic NIP-65 delivery <small>Send your public events to your write relays and tagged people's read relays. Each event is bounded to a few targets.</small></span><input type="checkbox" name="delivery"></label>248+ <label><span>Maximum delivery targets <small>Per event</small></span><input class="txt num" type="number" name="deliveryMax" min="1" max="16" value="8" style="width:5rem"></label>249+ <div class="actions"><button class="btn">Save delivery policy</button></div>239250 </form>240251 </div>241252 <div class="block">@@ -295,6 +306,11 @@
295306 <section class="panel" data-panel="data">296307 <h2>Data</h2>297308 <p class="lead">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>309+ <div class="block" id="listhistoryblock">310+ <h3>Recover your lists</h3>311+ <p class="note">Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.</p>312+ <div class="scroll"><table id="listhistory" class="events"><thead><tr><th>List</th><th>Created</th><th>Saved</th><th></th></tr></thead><tbody></tbody></table></div>313+ </div>298314 <div class="kbar" id="s-bar"></div><span class="legend slegend" id="s-legend"></span>299315 <div class="usage" id="s-totals"></div>300316 <div class="block">@@ -316,6 +332,13 @@
316332 <form id="dumpform" class="addrow"><select class="txt" name="dumps" style="max-width:8rem"><option value="off">off</option><option value="daily">daily</option><option value="weekly">weekly</option></select><input class="txt num" type="number" name="keep" min="1" max="60" value="7" title="how many to keep" style="width:5rem;flex:0 0 auto"><button class="btn">Save</button><button type="button" id="dumpnow" class="btn">Dump now</button></form>317333 <ul id="dumps" class="plain"></ul>318334 </div>335+ <div class="block">336+ <h3>Backups <span class="count" id="backups-count"></span></h3>337+ <p class="note">Portable copies include configuration, events, site files and Git objects. They are private and bounded at 8 MB.</p>338+ <div class="form"><label><span>Archive name</span><input class="txt mono" id="backupid" placeholder="backup" value="backup"></label></div>339+ <div class="actions"><button type="button" id="backupnow" class="btn">Create and download</button></div>340+ <ul id="backups" class="plain"></ul>341+ </div>319342 <div class="block">320343 <h3>Import a file</h3>321344 <p class="note">A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.</p>@@ -330,6 +353,7 @@
330353 <h3>Jobs</h3>331354 <p class="note">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>332355 <div class="scroll"><table id="jobs" class="events"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>356+333357 <form id="pullform" class="addrow"><input class="txt mono" name="url" placeholder="wss://other.relay" required><select class="txt" name="every" style="max-width:9rem"><option value="0">once</option><option value="1">every hour</option><option value="6">every 6 hours</option><option value="24">every day</option></select><button class="btn">Pull</button></form>334358 <form id="backfillform" class="addrow"><input class="txt mono" name="relays" placeholder="relays, comma separated; blank uses your relay list"><button class="btn">Fetch my history</button></form>335359 <form id="pushform">@@ -342,6 +366,11 @@
342366 <div class="actions"><button class="btn">Rebroadcast</button></div>343367 </form>344368 </div>369+ <div class="block">370+ <h3>Automatic delivery</h3>371+ <p class="note">Recent per-target results for automatic NIP-65 delivery.</p>372+ <div class="scroll"><table id="deliveries"><thead><tr><th>Event</th><th>Target</th><th>Status</th><th>Attempts</th><th>Last error</th></tr></thead><tbody></tbody></table></div>373+ </div>345374 <div class="block">346375 <h3>Fork this relay</h3>347376 <p class="note">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>@@ -335,6 +335,8 @@
335335 renderFeatures(p.features || {});336336 $("#push-policy-form").elements.origins.value = (p.pushCallbacks || []).join("\n");337337 $("#push-policy-form").elements.lettered.checked = !!p.letteredNips;338+ $("#push-policy-form").elements.delivery.checked = !!p.delivery?.enabled;339+ $("#push-policy-form").elements.deliveryMax.value = p.delivery?.maxTargets || 8;338340 fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;339341 const fj = $("#joinform"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;340342 loadCard();@@ -437,10 +439,10 @@
437439 ev.preventDefault();438440 const form = ev.target;439441 const origins = form.elements.origins.value.split(/\s+/).filter(Boolean);440- const updated = await rpc("setpolicy", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });442+ const updated = await rpc("setpolicy", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });441443 if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\/$/, "")))])) throw new Error("Use up to sixteen exact HTTPS origins, with no path or credentials.");442444 policy = updated;443- toast("Saved callback policy"); await loadInfo();445+ toast("Saved delivery policy"); await loadInfo();444446 }));445447 446448 async function loadViews() {@@ -471,6 +473,11 @@
471473 $("#dumps-count").textContent = list.length || "";472474 $("#dumps").innerHTML = list.length ? list.map((d) => '<li><i class="ev">jsonl</i><span><b class="mono">' + esc(d.name) + '</b> <span class="dim">' + d.events.toLocaleString() + " events, " + fmtBytes(d.bytes) + "</span></span><span>" + ib("copy", "Download", "downloaddump", d.name) + ib("trash", "Delete", "deletedump", d.name, "danger") + "</span></li>").join("") : '<li class="empty">no dumps yet</li>';473475 }476+ async function loadBackups() {477+ const list = await rpc("listbackups");478+ $("#backups-count").textContent = list.length || "";479+ $("#backups").innerHTML = list.length ? list.map((b) => '<li><i class="ev">backup</i><span><b class="mono">' + esc(b.id) + '</b> <span class="dim">' + fmtBytes(b.bytes) + '</span></span><span>' + ib("copy", "Download", "downloadbackup", b.id) + ib("trash", "Delete", "deletebackup", b.id, "danger") + '</span></li>').join("") : '<li class="empty">no backups yet</li>';480+ }474481 // A dump is fetched with a signed request and handed to the browser as a file.475482 async function downloadDump(name) {476483 if (!signer.ready()) throw new Error(NO_SIGNER);@@ -481,6 +488,11 @@
481488 const a = document.createElement("a");482489 a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + name; a.click(); URL.revokeObjectURL(a.href);483490 }491+ async function downloadBackup(id) {492+ const resp = await backupRequest("/backups/" + encodeURIComponent(id), "GET");493+ if (!resp.ok) throw new Error("backup download failed");494+ const a = document.createElement("a"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + id + ".json"; a.click(); URL.revokeObjectURL(a.href);495+ }484496 // A plain member's own invites, when the owner lets members invite.485497 async function loadMine() {486498 const sec = $("#myinvites");@@ -496,6 +508,7 @@
496508 pollJobs();497509 loadViews();498510 loadDumps().catch(() => {});511+ loadBackups().catch(() => {});499512 const st = storage;500513 const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);501514 const parts = [...top.map((k, i) => [k.kind + " " + kindName(k.kind), "k" + (i + 1), k.bytes]), ...(rest ? [["other", "k5", rest]] : [])];@@ -517,6 +530,13 @@
517530 return '<tr class="' + (k.kind === null ? "any" : "") + '" data-kind="' + key + '"><td>' + pill + '</td><td class="r mono">' + (k.n === undefined ? "" : k.n.toLocaleString()) + '</td><td class="r mono">' + (k.bytes === undefined ? "" : fmtBytes(k.bytes)) + '</td><td class="dim">' + (k.oldest ? esc(fmtDay(k.oldest)) : "") + "</td>" + cells + "</tr>";518531 };519532 $("#kinds tbody").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join("");533+ loadListHistory().catch(() => {});534+ }535+536+ async function loadListHistory() {537+ const rows = await rpc("listlisthistory");538+ const labels = { 3: "follows", 10002: "relay list", 10003: "bookmarks", 30003: "bookmark list" };539+ $("#listhistory tbody").innerHTML = rows.length ? rows.map((r) => '<tr><td>' + esc((labels[r.kind] || ("kind " + r.kind)) + (r.d ? " / " + r.d : "")) + '</td><td class="dim">' + esc(fmtTime(r.created_at)) + '</td><td class="dim">' + esc(fmtTime(r.saved_at)) + '</td><td class="r">' + ib("undo", "Restore this version", "restorelist", r.event_id) + '</td></tr>').join("") : '<tr><td colspan="4" class="muted">no older list versions yet</td></tr>';520540 }521541 522542 let searchQuery = "";@@ -593,13 +613,22 @@
593613 const l = j.last;594614 const count = (stored, blobs, sent, refused) => (j.kind === "mirror" ? blobs.toLocaleString() + " files mirrored" : j.kind === "import" ? stored.toLocaleString() + " events" + ((j.last ? j.last.duplicates : j.duplicates) ? ", " + (j.last ? j.last.duplicates : j.duplicates) + " already here" : "") : j.kind === "pull" ? stored.toLocaleString() + " events" + (blobs ? ", " + blobs + " files" : "") : sent.toLocaleString() + " sent" + (refused ? ", " + refused + " refused" : ""));595615 const res = j.running ? "running: " + count(j.stored, j.blobs, j.sent, j.refused) + "..." : !l ? "waiting" : l.error ? "failed: " + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? ", " + l.skipped + " skipped" : "") + ", " + fmtTime(l.finishedAt);596- return "<tr><td>" + what + "</td><td class=\"mono\">" + j.relays.map(esc).join("<br>") + "</td><td>" + (f.join(", ") || "everything") + "</td><td>" + when + "</td><td>" + esc(res) + "</td><td>" + (j.running ? "" : ib("undo", "Run now", "runjob", j.id)) + ib("x", "Remove", "removejob", j.id) + "</td></tr>";616+ const sources = j.running ? j.pullSources : l?.sources;617+ const details = sources?.length ? '<details><summary>Source results</summary>' + sources.map((s) => '<p><code>' + esc(s.url) + '</code>: ' + esc(s.status) + ', ' + s.stored + ' stored, ' + s.skipped + ' skipped' + (s.error || s.warning ? '<br>' + esc(s.error || s.warning) : '') + '</p>').join('') + '</details>' : '';618+ const targets = j.kind === "push" && j.targetStatus ? "<br><small>" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + ": " + esc(s.status)).join("<br>") + "</small>" : "";619+ return "<tr><td>" + what + "</td><td class=\"mono\">" + j.relays.map(esc).join("<br>") + targets + "</td><td>" + (f.join(", ") || "everything") + "</td><td>" + when + "</td><td>" + esc(res) + details + "</td><td>" + (j.running ? "" : ib("undo", "Run now", "runjob", j.id)) + ib("x", "Remove", "removejob", j.id) + "</td></tr>";597620 };598621 async function pollJobs() {599622 clearTimeout(jobsTimer);600623 let jobs;601624 try { jobs = await rpc("listjobs"); } catch { return; }602625 $("#jobs tbody").innerHTML = jobs.length ? jobs.map(fmtJob).join("") : '<tr><td colspan="6" class="muted">no jobs yet</td></tr>';626+ if (myRole === "owner") {627+ try {628+ const deliveries = await rpc("deliverystatus");629+ $("#deliveries tbody").innerHTML = deliveries.length ? deliveries.map((d) => '<tr><td class="mono" title="' + esc(d.event_id) + '">' + esc(d.event_id.slice(0, 12)) + '</td><td class="mono">' + esc(d.target) + '</td><td>' + esc(d.status) + '</td><td>' + d.attempts + '</td><td>' + esc(d.error || "") + '</td></tr>').join("") : '<tr><td colspan="5" class="muted">no automatic deliveries yet</td></tr>';630+ } catch { /* unavailable to non-owners */ }631+ }603632 if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);604633 }605634 const urls = (s) => s.split(/[\s,]+/).map((u) => u.trim()).filter(Boolean);@@ -856,6 +885,19 @@
856885 ev.target.reset(); toast("Importing " + fmtBytes(r.bytes)); await pollJobs();857886 });858887 $("#dumpnow").onclick = guard(async () => { const d = await rpc("dumpnow"); toast("Dumped " + d.events.toLocaleString() + " events"); await loadStorage(); });888+ async function backupRequest(path, method, body) {889+ if (!signer.ready()) throw new Error(NO_SIGNER);890+ const hash = await sha256hex(body || "");891+ const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", location.origin + path], ["method", method], ["payload", hash]] });892+ return fetch(path, { method, headers: { authorization: "Nostr " + btoa(JSON.stringify(token)), ...(body ? { "content-type": "application/json" } : {}) }, body });893+ }894+ $("#backupnow").onclick = guard(async () => { const id = $("#backupid").value.trim() || "backup"; const r = await rpc("backupnow", id); const resp = await backupRequest("/backups/" + id, "GET"); if (!resp.ok) throw new Error("backup download failed"); const a = document.createElement("a"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + id + ".json"; a.click(); URL.revokeObjectURL(a.href); toast("Backup downloaded"); await loadStorage(); void r; });895+ async function selectedBackup() { const f = $("#backupfile").files[0]; if (!f) throw new Error("Choose a backup archive first."); if (f.size > 8 * 1024 * 1024) throw new Error("Backups are limited to 8 MB."); return { file: f, body: await f.text() }; }896+ let backupPreview = null;897+ $("#backupfile").onchange = () => { backupPreview = null; $("#backup-preview").textContent = ""; $("#backuprestore").disabled = true; };898+ $("#backupremote").onclick = () => showRemote(null);899+ $("#backuppreview").onclick = guard(async () => { const selected = await selectedBackup(); const r = await (await backupRequest("/backups/preview", "POST", selected.body)).json(); if (r.error) throw new Error(r.error); backupPreview = { file: selected.file, body: selected.body }; $("#backuprestore").disabled = false; const s = r.result.source; $("#backup-preview").innerHTML = '<p><b>Ready to restore.</b> ' + esc(String(r.result.events)) + ' events, ' + esc(String(r.result.blobs)) + ' files, ' + esc(String(r.result.git)) + ' Git objects, ' + esc(fmtBytes(r.result.bytes)) + '.</p><p>Source relay identity: <code>' + esc(s.relayIdentity || "unknown") + '</code></p><pre>' + esc(JSON.stringify(r.result.config, null, 2)) + '</pre>'; });900+ $("#backuprestore").onclick = guard(async () => { const selected = await selectedBackup(); if (!backupPreview || backupPreview.file !== selected.file || backupPreview.body !== selected.body) throw new Error("Preview this exact archive before restoring."); if (!confirm("Restore this archive onto this fresh relay? This cannot be undone.")) return; const r = await (await backupRequest("/backups/restore", "POST", selected.body)).json(); if (r.error) throw new Error(r.error); me = await signer.getPublicKey(); localStorage.setItem("me", me); toast("Backup restored"); await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople(); });859901 $("#treeform").onsubmit = guard(async (ev) => {860902 const f = ev.target;861903 policy = await rpc("setpolicy", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });@@ -962,6 +1004,25 @@
9621004 if (act === "deleteblob" && !confirm("Delete this file for good?")) return;9631005 if (act === "deletedump" && !confirm("Delete this dump?")) return;9641006 if (act === "downloaddump") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }1007+ if (act === "deletebackup" && !confirm("Delete this backup?")) return;1008+ if (act === "downloadbackup") { b.disabled = true; try { await downloadBackup(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }1009+ if (act === "deletebackup") { b.disabled = true; try { await rpc("deletebackup", id); toast("Backup deleted"); await loadBackups(); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }1010+ if (act === "restorelist") {1011+ if (!signer.ready()) { toast(NO_SIGNER); return; }1012+ try {1013+ const preview = await rpc("restorelist", id);1014+ const d = preview.diff || {};1015+ const added = (d.addedTags || []).map((t) => "+ " + JSON.stringify(t)).join("\n");1016+ const removed = (d.removedTags || []).map((t) => "- " + JSON.stringify(t)).join("\n");1017+ const changes = [added, removed, d.contentChanged ? "content changed" : "content unchanged"].filter(Boolean).join("\n");1018+ if (!confirm("Restore this list version?\n\n" + (changes || "No tag or content changes") + "\n\nIt will be signed and published as the newest version.")) return;1019+ const signed = await signer.signEvent(preview.draft);1020+ const result = await bridge("/events", signed);1021+ if (!result.accepted) throw new Error(result.message || "The relay refused the restored list.");1022+ toast("List restored"); await loadListHistory(); await loadStorage();1023+ } catch (e) { toast(e.message); } finally { b.disabled = false; }1024+ return;1025+ }9651026 if ((act === "removemember" || act === "banpubkey") && (window.__members || []).some((m) => m.invited_by === id) && confirm("Also remove everyone this member invited, and everyone they invited in turn?")) {9661027 b.disabled = true;9671028 try { const r = await rpc("removesubtree", id); if (act === "banpubkey") await rpc("banpubkey", id, "", erase); toast("Removed " + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }@@ -0,0 +1,113 @@
1+// NIP-65 delivery: a small, durable, opt-in fanout queue. This is deliberately2+// separate from NIP-9a callbacks: targets are Nostr relays and receive EVENT.3+import { expiration, isPrivate, now, tagValues, type Event } from "./event.ts";4+import { dial, Socket, checkPullURL } from "./pull.ts";5+import type { Relay } from "./relay.ts";6+7+export const DELIVERY_SCHEMA = `8+CREATE TABLE IF NOT EXISTS delivery_queue (9+ event_id TEXT NOT NULL, target TEXT NOT NULL, author TEXT NOT NULL,10+ due INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,11+ status TEXT NOT NULL DEFAULT 'pending', error TEXT NOT NULL DEFAULT '',12+ updated_at INTEGER NOT NULL, PRIMARY KEY(event_id,target)13+);14+CREATE INDEX IF NOT EXISTS delivery_due ON delivery_queue(status,due);15+`;16+const MAX_QUEUE = 512, MAX_ATTEMPTS = 4, TIMEOUT = 5000, BATCH = 4;17+const rows = <T extends Record<string, any>>(r: Relay, q: string, ...a: any[]): T[] => { const c = r.sql.exec<T>(q, ...a); const out = c.toArray(); r.meterPush(c.rowsRead, c.rowsWritten); return out; };18+const exec = (r: Relay, q: string, ...a: any[]) => { const c = r.sql.exec(q, ...a); r.meterPush(c.rowsRead, c.rowsWritten); return c; };19+20+function list(r: Relay, pk: string): { read: string[]; write: string[] } {21+ const row = r.store.query({ kinds: [10002], authors: [pk], tags: {} }, { pubkeys: [], all: true }, 1, now()).rows[0];22+ const out = { read: [] as string[], write: [] as string[] };23+ if (!row) return out;24+ try {25+ for (const t of (JSON.parse(row) as Event).tags) {26+ if (t[0] !== "r" || !t[1]) continue;27+ let u: URL; try { u = new URL(t[1]); } catch { continue; }28+ if ((u.protocol !== "wss:" && u.protocol !== "ws:") || u.username || u.password || u.hash) continue;29+ // NIP-65 is user supplied egress. Refuse obvious loopback/private30+ // destinations; public DNS names remain the interoperable path.31+ const h = u.hostname.toLowerCase();32+ if (!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*$/.test(h) || /\.(?:localhost|local|internal|lan|home|test|invalid|onion)$/.test(h)) continue;33+ const url = u.toString().replace(/\/$/, "");34+ const marker = !t[2] ? "both" : t[2] === "read" ? "read" : t[2] === "write" ? "write" : "";35+ if (!marker) continue;36+ if ((marker === "read" || marker === "both") && !out.read.includes(url)) out.read.push(url);37+ if ((marker === "write" || marker === "both") && !out.write.includes(url)) out.write.push(url);38+ }39+ } catch { /* malformed historical list */ }40+ return out;41+}42+43+function targets(r: Relay, e: Event): string[] {44+ const max = r.settings.policy.delivery?.maxTargets ?? 8;45+ const out: string[] = [];46+ const add = (u: string) => {47+ if (out.length >= max || out.includes(u)) return;48+ if (checkPullURL(u, r.slug, r.domain)) return;49+ out.push(u);50+ };51+ for (const u of list(r, e.pubkey).write) add(u);52+ for (const pk of tagValues(e, "p").slice(0, 32)) for (const u of list(r, pk).read) add(u);53+ return out;54+}55+56+export function queueDelivery(r: Relay, e: Event): boolean {57+ const p = r.settings.policy.delivery;58+ if (!p?.enabled || r.settings.policy.reads === "members" || r.settings.leaseExpired(now()) || e.pubkey === r.identity.pubkey || r.settings.isBanned(e.pubkey) || !r.settings.isAllowed(e.pubkey) || isPrivate(e.kind) || e.tags.some((t: string[]) => t[0] === "-")) return false;59+ const ts = targets(r, e);60+ if (!ts.length) return false;61+ let n = rows<{ n: number }>(r, `SELECT count(*) n FROM delivery_queue WHERE status='pending'`)[0]?.n ?? 0;62+ let added = false;63+ for (const target of ts) {64+ if (n >= MAX_QUEUE) break;65+ exec(r, `INSERT OR IGNORE INTO delivery_queue(event_id,target,author,due,attempts,status,error,updated_at) VALUES(?,?,?,?,0,'pending','',?)`, e.id, target, e.pubkey, now(), now());66+ if (rows<{ n: number }>(r, `SELECT changes() AS n`)[0]?.n) { n++; added = true; }67+ }68+ return added;69+}70+71+async function send(r: Relay, target: string, e: Event): Promise<{ ok: boolean; error: string }> {72+ let s: Socket | null = null;73+ try {74+ s = new Socket(await dial(r, target));75+ s.send("EVENT", e);76+ const end = Date.now() + TIMEOUT;77+ while (Date.now() < end) {78+ const m = await s.recv(end - Date.now());79+ if (m[0] !== "OK" || m[1] !== e.id) continue;80+ if (m[2] === true || String(m[3] ?? "").startsWith("duplicate:")) return { ok: true, error: "" };81+ return { ok: false, error: String(m[3] ?? "rejected").slice(0, 300) };82+ }83+ return { ok: false, error: "timeout" };84+ } catch (e) { return { ok: false, error: (e instanceof Error ? e.message : String(e)).slice(0, 300) }; }85+ finally { s?.close(); }86+}87+88+export async function deliveryTick(r: Relay): Promise<number> {89+ if (!r.settings.policy.delivery?.enabled) { exec(r, `DELETE FROM delivery_queue`); return 0; }90+ const t = now();91+ exec(r, `DELETE FROM delivery_queue WHERE status<>'pending' AND updated_at<?`, t - 7 * 86400);92+ exec(r, `DELETE FROM delivery_queue WHERE rowid IN (SELECT rowid FROM delivery_queue WHERE status<>'pending' ORDER BY updated_at ASC LIMIT max(0,(SELECT count(*) FROM delivery_queue WHERE status<>'pending')-1024))`);93+ const jobs = rows<{ event_id: string; target: string; attempts: number }>(r, `SELECT event_id,target,attempts FROM delivery_queue WHERE status='pending' AND due<=? ORDER BY due LIMIT ?`, t, BATCH);94+ for (const j of jobs) {95+ const author = rows<{ author: string }>(r, `SELECT author FROM delivery_queue WHERE event_id=? AND target=?`, j.event_id, j.target)[0]?.author ?? "";96+ const ev = rows<{ raw: string }>(r, `SELECT raw FROM events WHERE id=? AND pubkey=? AND kind NOT IN (4,1059,21059,24133)`, j.event_id, author)[0];97+ if (!ev) { exec(r, `UPDATE delivery_queue SET status='rejected',error='event unavailable',updated_at=? WHERE event_id=? AND target=?`, t, j.event_id, j.target); continue; }98+ const event = JSON.parse(ev.raw) as Event;99+ if (r.settings.policy.reads === "members" || r.settings.isBanned(author) || !r.settings.isAllowed(author) || isPrivate(event.kind) || event.tags.some((t) => t[0] === "-") || (expiration(event) > 0 && expiration(event) <= now()) || !targets(r, event).includes(j.target)) { exec(r, `UPDATE delivery_queue SET status='rejected',error='routing no longer permitted',updated_at=? WHERE event_id=? AND target=?`, t, j.event_id, j.target); continue; }100+ exec(r, `UPDATE delivery_queue SET attempts=attempts+1,due=?,updated_at=? WHERE event_id=? AND target=?`, t + 60, t, j.event_id, j.target);101+ const result = await send(r, j.target, event);102+ const attempts = j.attempts + 1;103+ if (result.ok || attempts >= MAX_ATTEMPTS) {104+ exec(r, `UPDATE delivery_queue SET status=?,error=?,updated_at=? WHERE event_id=? AND target=?`, result.ok ? "accepted" : "rejected", result.error, now(), j.event_id, j.target);105+ } else exec(r, `UPDATE delivery_queue SET due=?,error=?,updated_at=? WHERE event_id=? AND target=?`, now() + [30, 120, 600][Math.min(attempts - 1, 2)], result.error, now(), j.event_id, j.target);106+ }107+ const next = rows<{ next: number | null }>(r, `SELECT min(due) next FROM delivery_queue WHERE status='pending'`)[0]?.next ?? 0;108+ return next;109+}110+111+export function deliveryStatus(r: Relay) {112+ return rows<{ event_id: string; target: string; status: string; attempts: number; error: string; updated_at: number }>(r, `SELECT event_id,target,status,attempts,error,updated_at FROM delivery_queue ORDER BY updated_at DESC LIMIT 100`);113+}@@ -1,4 +1,4 @@
11 // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console.2-export const CONSOLE_HTML = "<main>\n <header class=\"mast\">\n <div class=\"banner hidden\" id=\"banner\"><img id=\"bannerimg\" alt=\"\"></div>\n <div class=\"wordmark\"><img id=\"iconimg\" class=\"icon hidden\" alt=\"\"><h1 id=\"title\"></h1></div>\n <div class=\"left\"><div class=\"care\" id=\"care\"></div></div>\n <div class=\"right\">\n <span class=\"urlrow\"><code class=\"pill\" id=\"url\"></code><button class=\"ib\" id=\"copy\" title=\"Copy relay URL\" aria-label=\"Copy relay URL\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></span>\n <span class=\"owner\"><i class=\"av hidden\" id=\"owner-av\"></i><span id=\"ownerline\"></span><em id=\"who\"></em>\n <button id=\"signin\" class=\"btn hidden\">Sign in with extension</button>\n <button id=\"signin46\" class=\"btn hidden\">Remote signer</button>\n <button id=\"signout\" class=\"ib hidden\" title=\"Sign out\" aria-label=\"Sign out\"><svg viewBox=\"0 0 24 24\"><path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4M15 8l5 4-5 4M20 12H9\"/></svg></button>\n </span>\n </div>\n </header>\n\n <section id=\"unclaimed\" class=\"hidden\">\n <h2>Nobody owns this relay yet.</h2>\n <p class=\"lead\">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>\n <div class=\"row\"><button id=\"claim\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"claimnote\">Use a remote signer</button><span class=\"note\" id=\"claimnote\"></span></div>\n </section>\n\n <section id=\"leased\" class=\"hidden\">\n <h2>A temporary relay, for now.</h2>\n <p class=\"lead\">Anyone can read and write here until <b id=\"lease-until\"></b>. Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.</p>\n <div class=\"row\"><button id=\"claimlease\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"leasenote\">Use a remote signer</button><span class=\"note\" id=\"leasenote\"></span></div>\n </section>\n\n <section id=\"remotesec\" class=\"hidden\">\n <h2>Connect a remote signer.</h2>\n <p class=\"lead\">Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the <span class=\"mono\">bunker://</span> URL the app gives you.</p>\n <div class=\"row\"><a id=\"nclink\" class=\"btn pri\" href=\"#\">Open in your signer app</a><button id=\"nccopy\" class=\"btn\">Copy link</button><span class=\"note\" id=\"ncnote\"></span></div>\n <form id=\"bunkerform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"bunker://…\" required><button class=\"btn\">Connect</button></form>\n <img id=\"ncqr\" class=\"hidden\" alt=\"QR code of the signer link\" width=\"180\" height=\"180\">\n <p class=\"note\" id=\"remotenote\"></p>\n </section>\n\n <section id=\"foldsec\" class=\"folds\">\n <div id=\"frontsec\" class=\"hidden\">\n <p class=\"lead hidden\" id=\"desc\"></p>\n <p class=\"metarow hidden\" id=\"metarow\"></p>\n </div>\n <details class=\"disclosure\" id=\"aboutsec\" name=\"page\"><summary>About, for clients</summary><div id=\"about\" class=\"about\"></div></details>\n <details class=\"disclosure\" id=\"connectsec\" name=\"page\"><summary>Connect</summary>\n <div class=\"doors\">\n <div class=\"door\"><small>nostr relay</small><div class=\"v\"><span id=\"d-ws\"></span><button class=\"ib\" data-copy=\"ws\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>Blossom media</small><div class=\"v\"><span id=\"d-blossom\"></span><button class=\"ib\" data-copy=\"blossom\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>names</small><div class=\"v\"><span id=\"d-nip05\"></span></div></div>\n <div class=\"door\"><small>HTTP bridge, NIP-98</small><div class=\"v\"><span>POST /events, /query, /count</span></div></div>\n </div>\n <div class=\"block hidden\" id=\"git-connect\">\n <h3>Git repositories, ntig</h3>\n <div>\n <p class=\"note\">Use ordinary Git to clone a repository hosted here.</p>\n <div class=\"door\"><small>Clone a repository</small><div class=\"v\"><span id=\"git-clone\"></span><button class=\"ib\" id=\"git-copy\" title=\"Copy clone example\" aria-label=\"Copy clone example\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <p class=\"note\">Replace <span class=\"mono\"><npub></span> with the repository owner's npub, yours for your own repository, and <span class=\"mono\"><repo></span> with its repository name, such as <span class=\"mono\">my-project</span>.</p>\n <p class=\"note\">To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. <a href=\"https://github.com/FelineStateMachine/bindws/blob/main/docs/22-grasp-01-git-hosting.md\">Git hosting guide</a>.</p>\n </div>\n </div>\n <div class=\"block\">\n <h3>Open it in an app</h3>\n <p class=\"note\">Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add <span class=\"mono\" id=\"apps-ws\"></span> under the app's relay settings to post here.</p>\n <div class=\"apps\" id=\"apps\"></div>\n <div class=\"phones\" id=\"phones\"></div>\n </div>\n </details>\n </section>\n\n <section id=\"peoplesec\" class=\"hidden\">\n <h2>People <span class=\"count\" id=\"people-count\"></span></h2>\n <p class=\"note hidden\" id=\"people-note\">Hidden from visitors. Only you see this list.</p>\n <div class=\"dir\" id=\"dir\"></div>\n </section>\n\n <section id=\"fuelsec\" class=\"hidden\">\n <h2>Fuel</h2>\n <p class=\"lead\">Free allowance first; past it, usage burns sats. Anyone can zap a top-up.</p>\n <div class=\"gauges\">\n <div class=\"gauge\"><small>Events stored</small><div class=\"bar\"><i id=\"g-events\"></i><b id=\"p-events\"></b></div><span id=\"t-events\"></span></div>\n <div class=\"gauge\"><small>Files stored</small><div class=\"bar\"><i id=\"g-media\"></i><b id=\"p-media\"></b></div><span id=\"t-media\"></span></div>\n <div class=\"gauge\"><small>Awake this month</small><div class=\"bar\"><i id=\"g-active\"></i><b id=\"p-active\"></b></div><span id=\"t-active\"></span></div>\n <div class=\"gauge\"><small>Rows written this month</small><div class=\"bar\"><i id=\"g-rows\"></i><b id=\"p-rows\"></b></div><span id=\"t-rows\"></span></div>\n </div>\n <p class=\"balance\" id=\"fuel-balance\"></p>\n <form id=\"topup\" class=\"topup hidden\"><span class=\"sats\"><input type=\"number\" name=\"sats\" min=\"1\" value=\"1000\"><span>sats</span></span><button class=\"btn pri\">⚡ Zap to top up</button><span class=\"note\" id=\"topup-note\"></span></form>\n <div id=\"invoice\" class=\"invoice hidden\">\n <p>Pay from any lightning wallet. The receipt lands on this relay and credits it automatically.</p>\n <div class=\"row\"><a id=\"inv-link\" class=\"btn\" href=\"#\">Open in wallet</a><button id=\"inv-copy\" class=\"ib\" title=\"Copy invoice\" aria-label=\"Copy invoice\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button><span class=\"state\" id=\"inv-state\"></span></div>\n <textarea id=\"inv-text\" readonly></textarea>\n </div>\n </section>\n\n <section id=\"myinvites\" class=\"hidden\">\n <h2>Your invites</h2>\n <p class=\"lead\">The owner lets members bring people in. Each link admits one person and lasts three days.</p>\n <div class=\"row\"><button id=\"mintmine\" class=\"btn pri\">New invite link</button><span class=\"note\" id=\"minenote\"></span></div>\n <ul id=\"mine\" class=\"plain\"></ul>\n </section>\n\n\n <div class=\"console hidden\" id=\"console\">\n <nav class=\"tabs\" id=\"tabs\">\n <a href=\"#people\" data-tab=\"people\">People<span class=\"count\" id=\"tc-people\"></span></a>\n <a href=\"#moderation\" data-tab=\"moderation\">Moderation<span class=\"count\" id=\"tc-reports\"></span></a>\n <a href=\"#rules\" data-tab=\"rules\">Rules</a>\n <a href=\"#identity\" data-tab=\"identity\">Identity</a>\n <a href=\"#data\" data-tab=\"data\">Data</a>\n <a href=\"#sync\" data-tab=\"sync\">Sync</a>\n <a href=\"#views\" data-tab=\"views\">Views</a>\n <a href=\"#health\" data-tab=\"health\">Health</a>\n <a href=\"#owner\" data-tab=\"owner\">Owner</a>\n </nav>\n\n <section class=\"panel\" data-panel=\"people\">\n <h2>People</h2>\n <p class=\"lead\">The member list is published as a signed roster; a name makes someone <span class=\"mono\" id=\"nip05-example\"></span>.</p>\n <div class=\"block\">\n <form id=\"addmember\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"name\" placeholder=\"name (optional)\" pattern=\"[a-z0-9._-]{1,64}\" style=\"max-width:9rem\"><input class=\"txt\" name=\"note\" placeholder=\"note\" style=\"max-width:9rem\"><button class=\"ib pri\" title=\"Add member\" aria-label=\"Add member\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <div class=\"scroll\"><table id=\"members\" class=\"people-table\"><thead><tr><th>Who</th><th>Name</th><th>Note</th><th>Limits</th><th>Joined</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <div class=\"two\">\n <div>\n <h3>Invites</h3>\n <form id=\"mintinvite\" class=\"addrow\"><select class=\"txt\" name=\"ttl\" style=\"max-width:8rem\"><option value=\"3600\">1 hour</option><option value=\"86400\">1 day</option><option value=\"259200\" selected>3 days</option><option value=\"2592000\">30 days</option></select><input class=\"txt num\" type=\"number\" name=\"max\" min=\"0\" value=\"0\" title=\"uses, 0 = unlimited\" style=\"width:5rem;flex:0 0 auto\"><input class=\"txt\" name=\"note\" placeholder=\"note\"><button class=\"ib pri\" title=\"New invite\" aria-label=\"New invite\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <ul id=\"invites\" class=\"plain\"></ul>\n <form id=\"treeform\" class=\"addrow hidden\" title=\"Members invite members: how many hops from you the tree reaches, and how many live invites each member may hold\"><span class=\"dim\">members invite</span><input class=\"txt num\" type=\"number\" name=\"depth\" min=\"0\" max=\"10\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">hops deep,</span><input class=\"txt num\" type=\"number\" name=\"quota\" min=\"0\" max=\"100\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">each</span><button class=\"btn\">Save</button></form>\n </div>\n <div>\n <h3>Joining</h3>\n <form id=\"joinform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Shown before someone accepts an invite</span><textarea name=\"joinTerms\" placeholder=\"House rules, expectations, anything a newcomer should agree to.\" maxlength=\"20000\"></textarea></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"directoryPublic\"> Show the people directory to visitors</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save</button></div>\n </form>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"moderation\">\n <h2>Moderation</h2>\n <p class=\"lead\">Reports never show in the feed. Banning also deletes the reported thing.</p>\n <div class=\"block\">\n <h3>Reports <span class=\"count\" id=\"reports-count\"></span></h3>\n <form id=\"thresholdform\" class=\"addrow\"><span class=\"dim\">hide an event once</span><input class=\"txt num\" type=\"number\" name=\"reportThreshold\" min=\"0\" max=\"100\" style=\"width:4.5rem;flex:0 0 auto\"><span class=\"dim\">different people report it; 0 never</span><button class=\"btn\">Save</button></form>\n <div class=\"scroll\"><table id=\"reports\" class=\"events\"><thead><tr><th>Time</th><th>Type</th><th>About</th><th>Reason</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Log</h3>\n <p class=\"dim\">Every change made here or by a moderation event, newest first, the last 5,000.</p>\n <div class=\"scroll\"><table id=\"audit\" class=\"events\"><thead><tr><th>Time</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead><tbody></tbody></table></div>\n <button class=\"btn\" id=\"audit-more\" type=\"button\">Older</button>\n </div>\n <div class=\"block two\">\n <div>\n <h3>Bans</h3>\n <form id=\"addban\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><label class=\"note\" title=\"Also delete everything they wrote and uploaded\"><input type=\"checkbox\" name=\"erase\"> erase</label><button class=\"ib danger\" title=\"Ban\" aria-label=\"Ban\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"bans\" class=\"plain\"></ul>\n <ul id=\"banned-events\" class=\"plain\"></ul>\n </div>\n <div>\n <h3>Blocked addresses</h3>\n <form id=\"addblock\" class=\"addrow\"><input class=\"txt mono\" name=\"ip\" placeholder=\"IP address\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><button class=\"ib danger\" title=\"Block\" aria-label=\"Block\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"blocks\" class=\"plain\"></ul>\n </div>\n </div>\n <div class=\"block\">\n <h3>Recent events</h3>\n <p class=\"note\">Delete removes one thing. Ban also refuses it forever. Search covers notes, articles and profiles.</p>\n <form id=\"searchform\" class=\"addrow\"><input class=\"txt\" name=\"q\" placeholder=\"search\" maxlength=\"200\"><button class=\"btn\">Search</button><button type=\"button\" id=\"searchclear\" class=\"btn hidden\">Clear</button></form>\n <div class=\"scroll\"><table id=\"events\" class=\"events\"><thead><tr><th>Time</th><th>Kind</th><th>Author</th><th>Content</th><th></th></tr></thead><tbody></tbody></table></div>\n <div class=\"actions left\"><button id=\"more\" class=\"btn\">Load more</button></div>\n </div>\n <div class=\"block\">\n <h3>Pinned <span class=\"count\" id=\"pins-count\"></span></h3>\n <p class=\"note\">Group clients show these at the top. Up to 20, in this order.</p>\n <form id=\"pinform\" class=\"addrow\"><input class=\"txt mono\" name=\"id\" placeholder=\"event id or address kind:pubkey:d\" required><button class=\"btn\">Pin</button></form>\n <ul id=\"pins\" class=\"plain\"></ul>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"rules\">\n <h2>Rules</h2>\n <p class=\"lead\">Bans apply regardless of these.</p>\n <div class=\"block\">\n <h3>Presets</h3>\n <p class=\"note\">One click sets writes, reads, kinds and keep-for together. Limits, identity and people stay.</p>\n <div id=\"presets\"></div>\n <div class=\"row hidden\" id=\"presetsourcerow\"><input class=\"txt mono\" id=\"presetsource\" placeholder=\"wss://source.relay to mirror\" style=\"max-width:24rem\"><span class=\"note\">Replica presets keep a standing pull of their kinds from this relay.</span></div>\n <p class=\"note\" id=\"presetnote\"></p>\n </div>\n <form id=\"access\">\n <div class=\"two\">\n <div><h3>Writes</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"writes\" value=\"open\"><b>Anyone</b><small>Every valid event is accepted, unless banned.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"allowlist\"><b>Members</b><small>Only people on the member list, and you.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"wot\"><b>Members and their follows</b><small>Members, plus everyone in a member's contact list.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"owner\"><b>Only me</b><small>Nobody else can post.</small></label>\n </div></div>\n <div><h3>Reads</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"reads\" value=\"open\"><b>Anyone</b><small>DMs and gift wraps still go only to their parties.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"auth\"><b>Authenticated only</b><small>Every query needs a NIP-42 sign-in first.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"members\"><b>Members only</b><small>Removing a member ends their subscriptions.</small></label>\n </div></div>\n </div>\n <div class=\"form\" style=\"margin-top:1rem\">\n <label><span>Open to anyone <small>kinds anyone may write, whatever the rule above says</small></span><input class=\"txt mono\" name=\"openKinds\" placeholder=\"7, 9735\"></label>\n <label class=\"switch\"><input type=\"checkbox\" name=\"guestReplies\"> Anyone may reply to a member's note</label>\n </div>\n <div class=\"limits\">\n <label><span>Minimum proof of work <small>bits</small></span><input class=\"txt num\" type=\"number\" name=\"minPow\" min=\"0\" max=\"64\"></label>\n <label><span>Reject events dated in the future by more than <small>seconds</small></span><input class=\"txt num\" type=\"number\" name=\"maxFuture\" min=\"0\"></label>\n <label><span>Max events per query</span><input class=\"txt num\" type=\"number\" name=\"maxLimit\" min=\"1\" max=\"5000\"></label>\n <label><span>Max subscriptions per connection</span><input class=\"txt num\" type=\"number\" name=\"maxSubs\" min=\"1\" max=\"200\"></label>\n <label><span>Max message size <small>KB</small></span><input class=\"txt num\" type=\"number\" name=\"maxMessageKB\" min=\"16\" max=\"1024\"></label>\n <label><span>Events per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"eventsPerMinute\" min=\"1\"></label>\n <label><span>Queries per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"reqsPerMinute\" min=\"1\"></label>\n <label><span>Max upload size <small>MB</small></span><input class=\"txt num\" type=\"number\" name=\"maxBlobMB\" min=\"1\" max=\"95\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save rules</button></div>\n </form>\n <div class=\"block\">\n <h3>Kinds</h3>\n <p class=\"note\">An empty allow list means every kind. Blocks always win.</p>\n <form id=\"kindform\" class=\"addrow\"><input class=\"txt num\" type=\"number\" name=\"kind\" min=\"0\" max=\"65535\" placeholder=\"kind\" required style=\"width:7rem;flex:0 0 auto\"><button class=\"ib\" name=\"rule\" value=\"allow\" title=\"Allow kind\" aria-label=\"Allow kind\"><svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg></button><button class=\"ib danger\" name=\"rule\" value=\"block\" title=\"Block kind\" aria-label=\"Block kind\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <p class=\"kindline\">Allowed: <span id=\"kinds-allow\"></span></p>\n <p class=\"kindline\">Blocked: <span id=\"kinds-block\"></span></p>\n </div>\n <div class=\"block\">\n <h3>Features</h3>\n <p class=\"note\">Each is a door or a cost. Off leaves the NIP-11 list, answers 404 at its door and is refused at the socket.</p>\n <div id=\"features\" class=\"limits\"></div>\n <form id=\"push-policy-form\" class=\"limits\">\n <label><span>Push callback origins <small>One HTTPS origin per line, also approved by your host operator. Relay push costs fuel.</small></span><textarea class=\"txt\" name=\"origins\" rows=\"3\" placeholder=\"https://push.example.com\"></textarea></label>\n <label><span>Lettered NIP advertisements <small>Some older clients reject mixed NIP identifiers. Relay push requires them.</small></span><input type=\"checkbox\" name=\"lettered\"></label>\n <div class=\"actions\"><button class=\"btn\">Save callback policy</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Blocked words</h3>\n <p class=\"note\">Content containing one is refused. You and your moderators are exempt. An entry written /like this/ is a regular expression.</p>\n <form id=\"wordsform\"><textarea class=\"txt\" name=\"words\" rows=\"4\" placeholder=\"one word, phrase or /pattern/ per line\"></textarea><label class=\"switch\"><input type=\"checkbox\" name=\"inTags\"> Also search tags</label><div class=\"actions\"><button class=\"btn\">Save words</button></div></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"identity\">\n <h2>Identity</h2>\n <form id=\"identity\">\n <h3>Profile</h3>\n <div class=\"form\">\n <label><span>Name</span><input class=\"txt\" name=\"name\" maxlength=\"200\"></label>\n <label><span>Contact</span><input class=\"txt\" name=\"contact\" placeholder=\"mailto:you@example.com\" maxlength=\"500\"></label>\n <label class=\"wide\"><span>Description</span><textarea name=\"description\" maxlength=\"2000\"></textarea></label>\n <label class=\"wide\"><span>Icon URL</span><input class=\"txt\" name=\"icon\" placeholder=\"https://…/icon.png\" maxlength=\"2000\"></label>\n <label class=\"wide\"><span>Banner URL</span><input class=\"txt\" name=\"banner\" placeholder=\"https://…/banner.png\" maxlength=\"2000\"></label>\n </div>\n <h3 class=\"gap\">For directories</h3>\n <div class=\"form\">\n <label><span>Posting policy URL</span><input class=\"txt\" name=\"postingPolicy\" placeholder=\"https://…/posting\" maxlength=\"2000\"></label>\n <label><span>Privacy policy URL</span><input class=\"txt\" name=\"privacyPolicy\" placeholder=\"https://…/privacy\" maxlength=\"2000\"></label>\n <label><span>Tags</span><input class=\"txt\" name=\"tags\" placeholder=\"bitcoin, cats\"></label>\n <label><span>Languages</span><input class=\"txt\" name=\"languageTags\" placeholder=\"en, pt-BR\"></label>\n <label><span>Countries</span><input class=\"txt\" name=\"relayCountries\" placeholder=\"US, DE\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Your own domain</h3>\n <p class=\"note\">Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.</p>\n <form id=\"adddomain\"><div class=\"form\"><label><span>Hostname</span><input class=\"txt mono\" name=\"host\" placeholder=\"relay.example.com\" required></label><label><span>Destination</span><select class=\"txt\" name=\"site\"><option value=\"\">This relay</option></select></label></div><div class=\"actions\"><button class=\"btn\">Add domain</button></div></form>\n <div id=\"domains\"></div>\n <p class=\"note\" id=\"domainnote\"></p>\n </div>\n <div class=\"block\">\n <h3>Your relay lists</h3>\n <p class=\"note\">Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>\n <div id=\"wire\"></div>\n </div>\n <div class=\"block\">\n <h3>Share</h3>\n <p class=\"note\">A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.</p>\n <div class=\"share\">\n <a href=\"/card.svg\" target=\"_blank\" rel=\"noopener\"><img id=\"cardimg\" src=\"/card.svg\" alt=\"relay card\" width=\"600\" height=\"315\"></a>\n <div class=\"share-side\">\n <img id=\"naddrqr\" class=\"hidden\" alt=\"QR code of the group address\" width=\"150\" height=\"150\">\n <code class=\"mono naddr\" id=\"naddr\"></code>\n <div class=\"row\"><button class=\"btn\" id=\"copynaddr\">Copy naddr</button><button class=\"btn\" id=\"copyembed\">Copy embed</button></div>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"data\">\n <h2>Data</h2>\n <p class=\"lead\">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>\n <div class=\"kbar\" id=\"s-bar\"></div><span class=\"legend slegend\" id=\"s-legend\"></span>\n <div class=\"usage\" id=\"s-totals\"></div>\n <div class=\"block\">\n <h3>By kind</h3>\n <div class=\"scroll\"><table id=\"kinds\" class=\"events\"><thead><tr><th>Kind</th><th class=\"r\">Count</th><th class=\"r\">Size</th><th>Oldest</th><th>Keep for</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Files <span class=\"count\" id=\"blobs-count\"></span></h3>\n <div class=\"scroll\"><table id=\"blobs\" class=\"events\"><thead><tr><th>Time</th><th>File</th><th>Size</th><th>Uploader</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Sites <span class=\"count\" id=\"sites-count\"></span></h3>\n <p class=\"note\">Published NIP-5A manifests and the hostnames where they are served.</p>\n <div class=\"scroll\"><table id=\"sites\" class=\"events\"><thead><tr><th>Author</th><th>Name</th><th>URL</th><th class=\"r\">Files</th><th class=\"r\">Size</th><th>Expiry</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Dumps <span class=\"count\" id=\"dumps-count\"></span></h3>\n <p class=\"note\">Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.</p>\n <form id=\"dumpform\" class=\"addrow\"><select class=\"txt\" name=\"dumps\" style=\"max-width:8rem\"><option value=\"off\">off</option><option value=\"daily\">daily</option><option value=\"weekly\">weekly</option></select><input class=\"txt num\" type=\"number\" name=\"keep\" min=\"1\" max=\"60\" value=\"7\" title=\"how many to keep\" style=\"width:5rem;flex:0 0 auto\"><button class=\"btn\">Save</button><button type=\"button\" id=\"dumpnow\" class=\"btn\">Dump now</button></form>\n <ul id=\"dumps\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Import a file</h3>\n <p class=\"note\">A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.</p>\n <form id=\"importform\" class=\"addrow\"><input class=\"txt\" type=\"file\" name=\"file\" accept=\".jsonl,.json,.ndjson,application/json\" required><button class=\"btn\">Import</button></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"sync\">\n <h2>Sync</h2>\n <p class=\"lead\">Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.</p>\n <div class=\"block\">\n <h3>Jobs</h3>\n <p class=\"note\">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>\n <div class=\"scroll\"><table id=\"jobs\" class=\"events\"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><select class=\"txt\" name=\"every\" style=\"max-width:9rem\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select><button class=\"btn\">Pull</button></form>\n <form id=\"backfillform\" class=\"addrow\"><input class=\"txt mono\" name=\"relays\" placeholder=\"relays, comma separated; blank uses your relay list\"><button class=\"btn\">Fetch my history</button></form>\n <form id=\"pushform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Rebroadcast to</span><input class=\"txt mono\" name=\"targets\" placeholder=\"wss://a.relay, wss://b.relay\" required></label>\n <label><span>Kinds <small>blank for all</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label><span>Newer than <small>days, blank for all</small></span><input class=\"txt num\" type=\"number\" name=\"days\" min=\"0\"></label>\n <label><span>Schedule</span><select class=\"txt\" name=\"every\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Rebroadcast</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Fork this relay</h3>\n <p class=\"note\">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>\n <form id=\"forkform\">\n <div class=\"form\">\n <label><span>Name <small>optional</small></span><input class=\"txt mono\" name=\"name\" placeholder=\"a memorable one if blank\" pattern=\"[a-z0-9-]{3,32}\"></label>\n <label><span>Who claims it <small>npub or hex, you if blank</small></span><input class=\"txt mono\" name=\"holder\"></label>\n <label><span>What to copy</span><select class=\"txt\" name=\"scope\"><option value=\"all\">everything</option><option value=\"mine\">only my events</option><option value=\"kinds\">these kinds</option></select></label>\n <label id=\"forkkinds\" class=\"hidden\"><span>Kinds <small>comma separated</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"people\" checked> Copy the people</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Fork</button></div>\n </form>\n <p class=\"note\" id=\"forknote\"></p>\n <div class=\"row hidden\" id=\"forkresult\"><span class=\"mono\" id=\"forkurl\"></span><button class=\"ib\" id=\"forkcopy\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"views\">\n <h2>Views</h2>\n <p class=\"lead\">Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.</p>\n <div class=\"block\">\n <div id=\"views\"></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"health\">\n <h2>Health</h2>\n <div class=\"counters\">\n <div class=\"counter\"><b class=\"big\" id=\"h-last\">–</b><small>since last event</small><span class=\"sub\" id=\"h-last-sub\"></span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-conns\">–</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">–</b><small id=\"h-fuel-label\">fuel</small><span class=\"sub\" id=\"h-fuel-sub\"></span></div>\n <div class=\"counter\"><div class=\"kbar\" id=\"h-kinds\"></div><small>used for, last 30 days</small><span class=\"sub legend\" id=\"h-kinds-legend\"></span></div>\n </div>\n <div class=\"usage\" id=\"usage\"></div>\n <div class=\"block\">\n <h3>Zaps received</h3>\n <div class=\"scroll\"><table id=\"credits\"><thead><tr><th>When</th><th>From</th><th class=\"r\">Sats</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Notifications</h3>\n <p class=\"note\">The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.</p>\n <form id=\"notify\">\n <div class=\"form\">\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"reports\"> A report arrives</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"fuel\"> Fuel runs low</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"jobs\"> A pull finishes</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"succession\"> The handover clock is running</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"digest\"> A note every week on how the relay is doing</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save notifications</button><button type=\"button\" id=\"notifytest\" class=\"btn\">Send a test message</button></div>\n </form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"owner\">\n <h2>Owner</h2>\n <p class=\"lead\">The relay's key, events, files and fuel stay put through everything here except delete.</p>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.</p>\n <div class=\"row\"><button id=\"exportcfg\" class=\"btn\">Export configuration</button><button id=\"importcfg\" class=\"btn\">Import configuration</button><input type=\"file\" id=\"cfgfile\" accept=\"application/json,.json\" class=\"hidden\"></div>\n </div>\n <div class=\"block danger-zone\">\n <h3>Transfer ownership</h3>\n <p class=\"note\">Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.</p>\n <form id=\"transfer\" class=\"addrow\"><select class=\"txt mono\" name=\"pubkey\" required></select><button class=\"btn danger\">Transfer</button></form>\n </div>\n <div class=\"block danger-zone\">\n <h3>If I lose my key</h3>\n <p class=\"note\">Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.</p>\n <form id=\"succession\">\n <div class=\"form\">\n <label><span>Heir <small>a member</small></span><select class=\"txt mono\" name=\"heir\" required></select></label>\n <label><span>Hand over</span><select class=\"txt\" name=\"afterDays\"><option value=\"90\">after 3 months away</option><option value=\"180\" selected>after 6 months away</option><option value=\"365\">after a year away</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Set heir</button><button type=\"button\" id=\"clearsuccession\" class=\"btn hidden\">Clear</button></div>\n </form>\n <p class=\"note\" id=\"successionnote\"></p>\n </div>\n <div class=\"block danger-zone\">\n <h3>Delete this relay</h3>\n <p class=\"note\">Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.</p>\n <button id=\"deleterelay\" class=\"btn danger\">Delete this relay</button>\n </div>\n </section>\n </div>\n\n <footer class=\"pg\"><p>a bind.ws relay</p><a href=\"https://github.com/FelineStateMachine/bindws\">source</a></footer>\n</main>\n";2+export const CONSOLE_HTML = "<main>\n <header class=\"mast\">\n <div class=\"banner hidden\" id=\"banner\"><img id=\"bannerimg\" alt=\"\"></div>\n <div class=\"wordmark\"><img id=\"iconimg\" class=\"icon hidden\" alt=\"\"><h1 id=\"title\"></h1></div>\n <div class=\"left\"><div class=\"care\" id=\"care\"></div></div>\n <div class=\"right\">\n <span class=\"urlrow\"><code class=\"pill\" id=\"url\"></code><button class=\"ib\" id=\"copy\" title=\"Copy relay URL\" aria-label=\"Copy relay URL\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></span>\n <span class=\"owner\"><i class=\"av hidden\" id=\"owner-av\"></i><span id=\"ownerline\"></span><em id=\"who\"></em>\n <button id=\"signin\" class=\"btn hidden\">Sign in with extension</button>\n <button id=\"signin46\" class=\"btn hidden\">Remote signer</button>\n <button id=\"signout\" class=\"ib hidden\" title=\"Sign out\" aria-label=\"Sign out\"><svg viewBox=\"0 0 24 24\"><path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4M15 8l5 4-5 4M20 12H9\"/></svg></button>\n </span>\n </div>\n </header>\n\n <section id=\"unclaimed\" class=\"hidden\">\n <h2>Nobody owns this relay yet.</h2>\n <p class=\"lead\">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>\n <div class=\"row\"><button id=\"claim\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"claimnote\">Use a remote signer</button><span class=\"note\" id=\"claimnote\"></span></div>\n <div class=\"block\">\n <h3>Restore a backup</h3>\n <p class=\"note\">Choose a private bind.ws archive to inspect it before restoring. Preview does not claim this relay. Restore accepts only an empty relay and the archive owner's signature.</p>\n <div class=\"form\">\n <label class=\"wide\"><span>Archive <small>up to 8 MB</small></span><input class=\"txt\" type=\"file\" id=\"backupfile\" accept=\"application/json\"></label>\n </div>\n <div class=\"actions\"><button type=\"button\" id=\"backupremote\" class=\"btn\">Use a remote signer</button><button type=\"button\" id=\"backuppreview\" class=\"btn\">Preview restore</button><button type=\"button\" id=\"backuprestore\" class=\"btn pri\" disabled>Restore</button></div>\n <div id=\"backup-preview\" class=\"note\"></div>\n </div>\n </section>\n\n <section id=\"leased\" class=\"hidden\">\n <h2>A temporary relay, for now.</h2>\n <p class=\"lead\">Anyone can read and write here until <b id=\"lease-until\"></b>. Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.</p>\n <div class=\"row\"><button id=\"claimlease\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"leasenote\">Use a remote signer</button><span class=\"note\" id=\"leasenote\"></span></div>\n </section>\n\n <section id=\"remotesec\" class=\"hidden\">\n <h2>Connect a remote signer.</h2>\n <p class=\"lead\">Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the <span class=\"mono\">bunker://</span> URL the app gives you.</p>\n <div class=\"row\"><a id=\"nclink\" class=\"btn pri\" href=\"#\">Open in your signer app</a><button id=\"nccopy\" class=\"btn\">Copy link</button><span class=\"note\" id=\"ncnote\"></span></div>\n <form id=\"bunkerform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"bunker://…\" required><button class=\"btn\">Connect</button></form>\n <img id=\"ncqr\" class=\"hidden\" alt=\"QR code of the signer link\" width=\"180\" height=\"180\">\n <p class=\"note\" id=\"remotenote\"></p>\n </section>\n\n <section id=\"foldsec\" class=\"folds\">\n <div id=\"frontsec\" class=\"hidden\">\n <p class=\"lead hidden\" id=\"desc\"></p>\n <p class=\"metarow hidden\" id=\"metarow\"></p>\n </div>\n <details class=\"disclosure\" id=\"aboutsec\" name=\"page\"><summary>About, for clients</summary><div id=\"about\" class=\"about\"></div></details>\n <details class=\"disclosure\" id=\"connectsec\" name=\"page\"><summary>Connect</summary>\n <div class=\"doors\">\n <div class=\"door\"><small>nostr relay</small><div class=\"v\"><span id=\"d-ws\"></span><button class=\"ib\" data-copy=\"ws\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>Blossom media</small><div class=\"v\"><span id=\"d-blossom\"></span><button class=\"ib\" data-copy=\"blossom\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>names</small><div class=\"v\"><span id=\"d-nip05\"></span></div></div>\n <div class=\"door\"><small>HTTP bridge, NIP-98</small><div class=\"v\"><span>POST /events, /query, /count</span></div></div>\n </div>\n <div class=\"block hidden\" id=\"git-connect\">\n <h3>Git repositories, ntig</h3>\n <div>\n <p class=\"note\">Use ordinary Git to clone a repository hosted here.</p>\n <div class=\"door\"><small>Clone a repository</small><div class=\"v\"><span id=\"git-clone\"></span><button class=\"ib\" id=\"git-copy\" title=\"Copy clone example\" aria-label=\"Copy clone example\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <p class=\"note\">Replace <span class=\"mono\"><npub></span> with the repository owner's npub, yours for your own repository, and <span class=\"mono\"><repo></span> with its repository name, such as <span class=\"mono\">my-project</span>.</p>\n <p class=\"note\">To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. <a href=\"https://github.com/FelineStateMachine/bindws/blob/main/docs/22-grasp-01-git-hosting.md\">Git hosting guide</a>.</p>\n </div>\n </div>\n <div class=\"block\">\n <h3>Open it in an app</h3>\n <p class=\"note\">Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add <span class=\"mono\" id=\"apps-ws\"></span> under the app's relay settings to post here.</p>\n <div class=\"apps\" id=\"apps\"></div>\n <div class=\"phones\" id=\"phones\"></div>\n </div>\n </details>\n </section>\n\n <section id=\"peoplesec\" class=\"hidden\">\n <h2>People <span class=\"count\" id=\"people-count\"></span></h2>\n <p class=\"note hidden\" id=\"people-note\">Hidden from visitors. Only you see this list.</p>\n <div class=\"dir\" id=\"dir\"></div>\n </section>\n\n <section id=\"fuelsec\" class=\"hidden\">\n <h2>Fuel</h2>\n <p class=\"lead\">Free allowance first; past it, usage burns sats. Anyone can zap a top-up.</p>\n <div class=\"gauges\">\n <div class=\"gauge\"><small>Events stored</small><div class=\"bar\"><i id=\"g-events\"></i><b id=\"p-events\"></b></div><span id=\"t-events\"></span></div>\n <div class=\"gauge\"><small>Files stored</small><div class=\"bar\"><i id=\"g-media\"></i><b id=\"p-media\"></b></div><span id=\"t-media\"></span></div>\n <div class=\"gauge\"><small>Awake this month</small><div class=\"bar\"><i id=\"g-active\"></i><b id=\"p-active\"></b></div><span id=\"t-active\"></span></div>\n <div class=\"gauge\"><small>Rows written this month</small><div class=\"bar\"><i id=\"g-rows\"></i><b id=\"p-rows\"></b></div><span id=\"t-rows\"></span></div>\n </div>\n <p class=\"balance\" id=\"fuel-balance\"></p>\n <form id=\"topup\" class=\"topup hidden\"><span class=\"sats\"><input type=\"number\" name=\"sats\" min=\"1\" value=\"1000\"><span>sats</span></span><button class=\"btn pri\">⚡ Zap to top up</button><span class=\"note\" id=\"topup-note\"></span></form>\n <div id=\"invoice\" class=\"invoice hidden\">\n <p>Pay from any lightning wallet. The receipt lands on this relay and credits it automatically.</p>\n <div class=\"row\"><a id=\"inv-link\" class=\"btn\" href=\"#\">Open in wallet</a><button id=\"inv-copy\" class=\"ib\" title=\"Copy invoice\" aria-label=\"Copy invoice\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button><span class=\"state\" id=\"inv-state\"></span></div>\n <textarea id=\"inv-text\" readonly></textarea>\n </div>\n </section>\n\n <section id=\"myinvites\" class=\"hidden\">\n <h2>Your invites</h2>\n <p class=\"lead\">The owner lets members bring people in. Each link admits one person and lasts three days.</p>\n <div class=\"row\"><button id=\"mintmine\" class=\"btn pri\">New invite link</button><span class=\"note\" id=\"minenote\"></span></div>\n <ul id=\"mine\" class=\"plain\"></ul>\n </section>\n\n\n <div class=\"console hidden\" id=\"console\">\n <nav class=\"tabs\" id=\"tabs\">\n <a href=\"#people\" data-tab=\"people\">People<span class=\"count\" id=\"tc-people\"></span></a>\n <a href=\"#moderation\" data-tab=\"moderation\">Moderation<span class=\"count\" id=\"tc-reports\"></span></a>\n <a href=\"#rules\" data-tab=\"rules\">Rules</a>\n <a href=\"#identity\" data-tab=\"identity\">Identity</a>\n <a href=\"#data\" data-tab=\"data\">Data</a>\n <a href=\"#sync\" data-tab=\"sync\">Sync</a>\n <a href=\"#views\" data-tab=\"views\">Views</a>\n <a href=\"#health\" data-tab=\"health\">Health</a>\n <a href=\"#owner\" data-tab=\"owner\">Owner</a>\n </nav>\n\n <section class=\"panel\" data-panel=\"people\">\n <h2>People</h2>\n <p class=\"lead\">The member list is published as a signed roster; a name makes someone <span class=\"mono\" id=\"nip05-example\"></span>.</p>\n <div class=\"block\">\n <form id=\"addmember\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"name\" placeholder=\"name (optional)\" pattern=\"[a-z0-9._-]{1,64}\" style=\"max-width:9rem\"><input class=\"txt\" name=\"note\" placeholder=\"note\" style=\"max-width:9rem\"><button class=\"ib pri\" title=\"Add member\" aria-label=\"Add member\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <div class=\"scroll\"><table id=\"members\" class=\"people-table\"><thead><tr><th>Who</th><th>Name</th><th>Note</th><th>Limits</th><th>Joined</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <div class=\"two\">\n <div>\n <h3>Invites</h3>\n <form id=\"mintinvite\" class=\"addrow\"><select class=\"txt\" name=\"ttl\" style=\"max-width:8rem\"><option value=\"3600\">1 hour</option><option value=\"86400\">1 day</option><option value=\"259200\" selected>3 days</option><option value=\"2592000\">30 days</option></select><input class=\"txt num\" type=\"number\" name=\"max\" min=\"0\" value=\"0\" title=\"uses, 0 = unlimited\" style=\"width:5rem;flex:0 0 auto\"><input class=\"txt\" name=\"note\" placeholder=\"note\"><button class=\"ib pri\" title=\"New invite\" aria-label=\"New invite\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <ul id=\"invites\" class=\"plain\"></ul>\n <form id=\"treeform\" class=\"addrow hidden\" title=\"Members invite members: how many hops from you the tree reaches, and how many live invites each member may hold\"><span class=\"dim\">members invite</span><input class=\"txt num\" type=\"number\" name=\"depth\" min=\"0\" max=\"10\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">hops deep,</span><input class=\"txt num\" type=\"number\" name=\"quota\" min=\"0\" max=\"100\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">each</span><button class=\"btn\">Save</button></form>\n </div>\n <div>\n <h3>Joining</h3>\n <form id=\"joinform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Shown before someone accepts an invite</span><textarea name=\"joinTerms\" placeholder=\"House rules, expectations, anything a newcomer should agree to.\" maxlength=\"20000\"></textarea></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"directoryPublic\"> Show the people directory to visitors</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save</button></div>\n </form>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"moderation\">\n <h2>Moderation</h2>\n <p class=\"lead\">Reports never show in the feed. Banning also deletes the reported thing.</p>\n <div class=\"block\">\n <h3>Reports <span class=\"count\" id=\"reports-count\"></span></h3>\n <form id=\"thresholdform\" class=\"addrow\"><span class=\"dim\">hide an event once</span><input class=\"txt num\" type=\"number\" name=\"reportThreshold\" min=\"0\" max=\"100\" style=\"width:4.5rem;flex:0 0 auto\"><span class=\"dim\">different people report it; 0 never</span><button class=\"btn\">Save</button></form>\n <div class=\"scroll\"><table id=\"reports\" class=\"events\"><thead><tr><th>Time</th><th>Type</th><th>About</th><th>Reason</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Log</h3>\n <p class=\"dim\">Every change made here or by a moderation event, newest first, the last 5,000.</p>\n <div class=\"scroll\"><table id=\"audit\" class=\"events\"><thead><tr><th>Time</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead><tbody></tbody></table></div>\n <button class=\"btn\" id=\"audit-more\" type=\"button\">Older</button>\n </div>\n <div class=\"block two\">\n <div>\n <h3>Bans</h3>\n <form id=\"addban\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><label class=\"note\" title=\"Also delete everything they wrote and uploaded\"><input type=\"checkbox\" name=\"erase\"> erase</label><button class=\"ib danger\" title=\"Ban\" aria-label=\"Ban\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"bans\" class=\"plain\"></ul>\n <ul id=\"banned-events\" class=\"plain\"></ul>\n </div>\n <div>\n <h3>Blocked addresses</h3>\n <form id=\"addblock\" class=\"addrow\"><input class=\"txt mono\" name=\"ip\" placeholder=\"IP address\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><button class=\"ib danger\" title=\"Block\" aria-label=\"Block\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"blocks\" class=\"plain\"></ul>\n </div>\n </div>\n <div class=\"block\">\n <h3>Recent events</h3>\n <p class=\"note\">Delete removes one thing. Ban also refuses it forever. Search covers notes, articles and profiles.</p>\n <form id=\"searchform\" class=\"addrow\"><input class=\"txt\" name=\"q\" placeholder=\"search\" maxlength=\"200\"><button class=\"btn\">Search</button><button type=\"button\" id=\"searchclear\" class=\"btn hidden\">Clear</button></form>\n <div class=\"scroll\"><table id=\"events\" class=\"events\"><thead><tr><th>Time</th><th>Kind</th><th>Author</th><th>Content</th><th></th></tr></thead><tbody></tbody></table></div>\n <div class=\"actions left\"><button id=\"more\" class=\"btn\">Load more</button></div>\n </div>\n <div class=\"block\">\n <h3>Pinned <span class=\"count\" id=\"pins-count\"></span></h3>\n <p class=\"note\">Group clients show these at the top. Up to 20, in this order.</p>\n <form id=\"pinform\" class=\"addrow\"><input class=\"txt mono\" name=\"id\" placeholder=\"event id or address kind:pubkey:d\" required><button class=\"btn\">Pin</button></form>\n <ul id=\"pins\" class=\"plain\"></ul>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"rules\">\n <h2>Rules</h2>\n <p class=\"lead\">Bans apply regardless of these.</p>\n <div class=\"block\">\n <h3>Presets</h3>\n <p class=\"note\">One click sets writes, reads, kinds and keep-for together. Limits, identity and people stay.</p>\n <div id=\"presets\"></div>\n <div class=\"row hidden\" id=\"presetsourcerow\"><input class=\"txt mono\" id=\"presetsource\" placeholder=\"wss://source.relay to mirror\" style=\"max-width:24rem\"><span class=\"note\">Replica presets keep a standing pull of their kinds from this relay.</span></div>\n <p class=\"note\" id=\"presetnote\"></p>\n </div>\n <form id=\"access\">\n <div class=\"two\">\n <div><h3>Writes</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"writes\" value=\"open\"><b>Anyone</b><small>Every valid event is accepted, unless banned.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"allowlist\"><b>Members</b><small>Only people on the member list, and you.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"wot\"><b>Members and their follows</b><small>Members, plus everyone in a member's contact list.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"owner\"><b>Only me</b><small>Nobody else can post.</small></label>\n </div></div>\n <div><h3>Reads</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"reads\" value=\"open\"><b>Anyone</b><small>DMs and gift wraps still go only to their parties.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"auth\"><b>Authenticated only</b><small>Every query needs a NIP-42 sign-in first.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"members\"><b>Members only</b><small>Removing a member ends their subscriptions.</small></label>\n </div></div>\n </div>\n <div class=\"form\" style=\"margin-top:1rem\">\n <label><span>Open to anyone <small>kinds anyone may write, whatever the rule above says</small></span><input class=\"txt mono\" name=\"openKinds\" placeholder=\"7, 9735\"></label>\n <label class=\"switch\"><input type=\"checkbox\" name=\"guestReplies\"> Anyone may reply to a member's note</label>\n </div>\n <div class=\"limits\">\n <label><span>Minimum proof of work <small>bits</small></span><input class=\"txt num\" type=\"number\" name=\"minPow\" min=\"0\" max=\"64\"></label>\n <label><span>Reject events dated in the future by more than <small>seconds</small></span><input class=\"txt num\" type=\"number\" name=\"maxFuture\" min=\"0\"></label>\n <label><span>Max events per query</span><input class=\"txt num\" type=\"number\" name=\"maxLimit\" min=\"1\" max=\"5000\"></label>\n <label><span>Max subscriptions per connection</span><input class=\"txt num\" type=\"number\" name=\"maxSubs\" min=\"1\" max=\"200\"></label>\n <label><span>Max message size <small>KB</small></span><input class=\"txt num\" type=\"number\" name=\"maxMessageKB\" min=\"16\" max=\"1024\"></label>\n <label><span>Events per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"eventsPerMinute\" min=\"1\"></label>\n <label><span>Queries per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"reqsPerMinute\" min=\"1\"></label>\n <label><span>Max upload size <small>MB</small></span><input class=\"txt num\" type=\"number\" name=\"maxBlobMB\" min=\"1\" max=\"95\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save rules</button></div>\n </form>\n <div class=\"block\">\n <h3>Kinds</h3>\n <p class=\"note\">An empty allow list means every kind. Blocks always win.</p>\n <form id=\"kindform\" class=\"addrow\"><input class=\"txt num\" type=\"number\" name=\"kind\" min=\"0\" max=\"65535\" placeholder=\"kind\" required style=\"width:7rem;flex:0 0 auto\"><button class=\"ib\" name=\"rule\" value=\"allow\" title=\"Allow kind\" aria-label=\"Allow kind\"><svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg></button><button class=\"ib danger\" name=\"rule\" value=\"block\" title=\"Block kind\" aria-label=\"Block kind\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <p class=\"kindline\">Allowed: <span id=\"kinds-allow\"></span></p>\n <p class=\"kindline\">Blocked: <span id=\"kinds-block\"></span></p>\n </div>\n <div class=\"block\">\n <h3>Features</h3>\n <p class=\"note\">Each is a door or a cost. Off leaves the NIP-11 list, answers 404 at its door and is refused at the socket.</p>\n <div id=\"features\" class=\"limits\"></div>\n <form id=\"push-policy-form\" class=\"limits\">\n <label><span>Push callback origins <small>One HTTPS origin per line, also approved by your host operator. Relay push costs fuel.</small></span><textarea class=\"txt\" name=\"origins\" rows=\"3\" placeholder=\"https://push.example.com\"></textarea></label>\n <label><span>Lettered NIP advertisements <small>Some older clients reject mixed NIP identifiers. Relay push requires them.</small></span><input type=\"checkbox\" name=\"lettered\"></label>\n <label><span>Automatic NIP-65 delivery <small>Send your public events to your write relays and tagged people's read relays. Each event is bounded to a few targets.</small></span><input type=\"checkbox\" name=\"delivery\"></label>\n <label><span>Maximum delivery targets <small>Per event</small></span><input class=\"txt num\" type=\"number\" name=\"deliveryMax\" min=\"1\" max=\"16\" value=\"8\" style=\"width:5rem\"></label>\n <div class=\"actions\"><button class=\"btn\">Save delivery policy</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Blocked words</h3>\n <p class=\"note\">Content containing one is refused. You and your moderators are exempt. An entry written /like this/ is a regular expression.</p>\n <form id=\"wordsform\"><textarea class=\"txt\" name=\"words\" rows=\"4\" placeholder=\"one word, phrase or /pattern/ per line\"></textarea><label class=\"switch\"><input type=\"checkbox\" name=\"inTags\"> Also search tags</label><div class=\"actions\"><button class=\"btn\">Save words</button></div></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"identity\">\n <h2>Identity</h2>\n <form id=\"identity\">\n <h3>Profile</h3>\n <div class=\"form\">\n <label><span>Name</span><input class=\"txt\" name=\"name\" maxlength=\"200\"></label>\n <label><span>Contact</span><input class=\"txt\" name=\"contact\" placeholder=\"mailto:you@example.com\" maxlength=\"500\"></label>\n <label class=\"wide\"><span>Description</span><textarea name=\"description\" maxlength=\"2000\"></textarea></label>\n <label class=\"wide\"><span>Icon URL</span><input class=\"txt\" name=\"icon\" placeholder=\"https://…/icon.png\" maxlength=\"2000\"></label>\n <label class=\"wide\"><span>Banner URL</span><input class=\"txt\" name=\"banner\" placeholder=\"https://…/banner.png\" maxlength=\"2000\"></label>\n </div>\n <h3 class=\"gap\">For directories</h3>\n <div class=\"form\">\n <label><span>Posting policy URL</span><input class=\"txt\" name=\"postingPolicy\" placeholder=\"https://…/posting\" maxlength=\"2000\"></label>\n <label><span>Privacy policy URL</span><input class=\"txt\" name=\"privacyPolicy\" placeholder=\"https://…/privacy\" maxlength=\"2000\"></label>\n <label><span>Tags</span><input class=\"txt\" name=\"tags\" placeholder=\"bitcoin, cats\"></label>\n <label><span>Languages</span><input class=\"txt\" name=\"languageTags\" placeholder=\"en, pt-BR\"></label>\n <label><span>Countries</span><input class=\"txt\" name=\"relayCountries\" placeholder=\"US, DE\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Your own domain</h3>\n <p class=\"note\">Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.</p>\n <form id=\"adddomain\"><div class=\"form\"><label><span>Hostname</span><input class=\"txt mono\" name=\"host\" placeholder=\"relay.example.com\" required></label><label><span>Destination</span><select class=\"txt\" name=\"site\"><option value=\"\">This relay</option></select></label></div><div class=\"actions\"><button class=\"btn\">Add domain</button></div></form>\n <div id=\"domains\"></div>\n <p class=\"note\" id=\"domainnote\"></p>\n </div>\n <div class=\"block\">\n <h3>Your relay lists</h3>\n <p class=\"note\">Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>\n <div id=\"wire\"></div>\n </div>\n <div class=\"block\">\n <h3>Share</h3>\n <p class=\"note\">A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.</p>\n <div class=\"share\">\n <a href=\"/card.svg\" target=\"_blank\" rel=\"noopener\"><img id=\"cardimg\" src=\"/card.svg\" alt=\"relay card\" width=\"600\" height=\"315\"></a>\n <div class=\"share-side\">\n <img id=\"naddrqr\" class=\"hidden\" alt=\"QR code of the group address\" width=\"150\" height=\"150\">\n <code class=\"mono naddr\" id=\"naddr\"></code>\n <div class=\"row\"><button class=\"btn\" id=\"copynaddr\">Copy naddr</button><button class=\"btn\" id=\"copyembed\">Copy embed</button></div>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"data\">\n <h2>Data</h2>\n <p class=\"lead\">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>\n <div class=\"block\" id=\"listhistoryblock\">\n <h3>Recover your lists</h3>\n <p class=\"note\">Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.</p>\n <div class=\"scroll\"><table id=\"listhistory\" class=\"events\"><thead><tr><th>List</th><th>Created</th><th>Saved</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"kbar\" id=\"s-bar\"></div><span class=\"legend slegend\" id=\"s-legend\"></span>\n <div class=\"usage\" id=\"s-totals\"></div>\n <div class=\"block\">\n <h3>By kind</h3>\n <div class=\"scroll\"><table id=\"kinds\" class=\"events\"><thead><tr><th>Kind</th><th class=\"r\">Count</th><th class=\"r\">Size</th><th>Oldest</th><th>Keep for</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Files <span class=\"count\" id=\"blobs-count\"></span></h3>\n <div class=\"scroll\"><table id=\"blobs\" class=\"events\"><thead><tr><th>Time</th><th>File</th><th>Size</th><th>Uploader</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Sites <span class=\"count\" id=\"sites-count\"></span></h3>\n <p class=\"note\">Published NIP-5A manifests and the hostnames where they are served.</p>\n <div class=\"scroll\"><table id=\"sites\" class=\"events\"><thead><tr><th>Author</th><th>Name</th><th>URL</th><th class=\"r\">Files</th><th class=\"r\">Size</th><th>Expiry</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Dumps <span class=\"count\" id=\"dumps-count\"></span></h3>\n <p class=\"note\">Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.</p>\n <form id=\"dumpform\" class=\"addrow\"><select class=\"txt\" name=\"dumps\" style=\"max-width:8rem\"><option value=\"off\">off</option><option value=\"daily\">daily</option><option value=\"weekly\">weekly</option></select><input class=\"txt num\" type=\"number\" name=\"keep\" min=\"1\" max=\"60\" value=\"7\" title=\"how many to keep\" style=\"width:5rem;flex:0 0 auto\"><button class=\"btn\">Save</button><button type=\"button\" id=\"dumpnow\" class=\"btn\">Dump now</button></form>\n <ul id=\"dumps\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Backups <span class=\"count\" id=\"backups-count\"></span></h3>\n <p class=\"note\">Portable copies include configuration, events, site files and Git objects. They are private and bounded at 8 MB.</p>\n <div class=\"form\"><label><span>Archive name</span><input class=\"txt mono\" id=\"backupid\" placeholder=\"backup\" value=\"backup\"></label></div>\n <div class=\"actions\"><button type=\"button\" id=\"backupnow\" class=\"btn\">Create and download</button></div>\n <ul id=\"backups\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Import a file</h3>\n <p class=\"note\">A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.</p>\n <form id=\"importform\" class=\"addrow\"><input class=\"txt\" type=\"file\" name=\"file\" accept=\".jsonl,.json,.ndjson,application/json\" required><button class=\"btn\">Import</button></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"sync\">\n <h2>Sync</h2>\n <p class=\"lead\">Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.</p>\n <div class=\"block\">\n <h3>Jobs</h3>\n <p class=\"note\">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>\n <div class=\"scroll\"><table id=\"jobs\" class=\"events\"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>\n\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><select class=\"txt\" name=\"every\" style=\"max-width:9rem\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select><button class=\"btn\">Pull</button></form>\n <form id=\"backfillform\" class=\"addrow\"><input class=\"txt mono\" name=\"relays\" placeholder=\"relays, comma separated; blank uses your relay list\"><button class=\"btn\">Fetch my history</button></form>\n <form id=\"pushform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Rebroadcast to</span><input class=\"txt mono\" name=\"targets\" placeholder=\"wss://a.relay, wss://b.relay\" required></label>\n <label><span>Kinds <small>blank for all</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label><span>Newer than <small>days, blank for all</small></span><input class=\"txt num\" type=\"number\" name=\"days\" min=\"0\"></label>\n <label><span>Schedule</span><select class=\"txt\" name=\"every\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Rebroadcast</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Automatic delivery</h3>\n <p class=\"note\">Recent per-target results for automatic NIP-65 delivery.</p>\n <div class=\"scroll\"><table id=\"deliveries\"><thead><tr><th>Event</th><th>Target</th><th>Status</th><th>Attempts</th><th>Last error</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Fork this relay</h3>\n <p class=\"note\">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>\n <form id=\"forkform\">\n <div class=\"form\">\n <label><span>Name <small>optional</small></span><input class=\"txt mono\" name=\"name\" placeholder=\"a memorable one if blank\" pattern=\"[a-z0-9-]{3,32}\"></label>\n <label><span>Who claims it <small>npub or hex, you if blank</small></span><input class=\"txt mono\" name=\"holder\"></label>\n <label><span>What to copy</span><select class=\"txt\" name=\"scope\"><option value=\"all\">everything</option><option value=\"mine\">only my events</option><option value=\"kinds\">these kinds</option></select></label>\n <label id=\"forkkinds\" class=\"hidden\"><span>Kinds <small>comma separated</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"people\" checked> Copy the people</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Fork</button></div>\n </form>\n <p class=\"note\" id=\"forknote\"></p>\n <div class=\"row hidden\" id=\"forkresult\"><span class=\"mono\" id=\"forkurl\"></span><button class=\"ib\" id=\"forkcopy\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"views\">\n <h2>Views</h2>\n <p class=\"lead\">Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.</p>\n <div class=\"block\">\n <div id=\"views\"></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"health\">\n <h2>Health</h2>\n <div class=\"counters\">\n <div class=\"counter\"><b class=\"big\" id=\"h-last\">–</b><small>since last event</small><span class=\"sub\" id=\"h-last-sub\"></span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-conns\">–</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">–</b><small id=\"h-fuel-label\">fuel</small><span class=\"sub\" id=\"h-fuel-sub\"></span></div>\n <div class=\"counter\"><div class=\"kbar\" id=\"h-kinds\"></div><small>used for, last 30 days</small><span class=\"sub legend\" id=\"h-kinds-legend\"></span></div>\n </div>\n <div class=\"usage\" id=\"usage\"></div>\n <div class=\"block\">\n <h3>Zaps received</h3>\n <div class=\"scroll\"><table id=\"credits\"><thead><tr><th>When</th><th>From</th><th class=\"r\">Sats</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Notifications</h3>\n <p class=\"note\">The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.</p>\n <form id=\"notify\">\n <div class=\"form\">\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"reports\"> A report arrives</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"fuel\"> Fuel runs low</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"jobs\"> A pull finishes</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"succession\"> The handover clock is running</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"digest\"> A note every week on how the relay is doing</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save notifications</button><button type=\"button\" id=\"notifytest\" class=\"btn\">Send a test message</button></div>\n </form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"owner\">\n <h2>Owner</h2>\n <p class=\"lead\">The relay's key, events, files and fuel stay put through everything here except delete.</p>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.</p>\n <div class=\"row\"><button id=\"exportcfg\" class=\"btn\">Export configuration</button><button id=\"importcfg\" class=\"btn\">Import configuration</button><input type=\"file\" id=\"cfgfile\" accept=\"application/json,.json\" class=\"hidden\"></div>\n </div>\n <div class=\"block danger-zone\">\n <h3>Transfer ownership</h3>\n <p class=\"note\">Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.</p>\n <form id=\"transfer\" class=\"addrow\"><select class=\"txt mono\" name=\"pubkey\" required></select><button class=\"btn danger\">Transfer</button></form>\n </div>\n <div class=\"block danger-zone\">\n <h3>If I lose my key</h3>\n <p class=\"note\">Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.</p>\n <form id=\"succession\">\n <div class=\"form\">\n <label><span>Heir <small>a member</small></span><select class=\"txt mono\" name=\"heir\" required></select></label>\n <label><span>Hand over</span><select class=\"txt\" name=\"afterDays\"><option value=\"90\">after 3 months away</option><option value=\"180\" selected>after 6 months away</option><option value=\"365\">after a year away</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Set heir</button><button type=\"button\" id=\"clearsuccession\" class=\"btn hidden\">Clear</button></div>\n </form>\n <p class=\"note\" id=\"successionnote\"></p>\n </div>\n <div class=\"block danger-zone\">\n <h3>Delete this relay</h3>\n <p class=\"note\">Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.</p>\n <button id=\"deleterelay\" class=\"btn danger\">Delete this relay</button>\n </div>\n </section>\n </div>\n\n <footer class=\"pg\"><p>a bind.ws relay</p><a href=\"https://github.com/FelineStateMachine/bindws\">source</a></footer>\n</main>\n";33 export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n";4-export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '<span class=\"key\" data-short=\"' + short(hex) + '\" title=\"' + hex + '\"><span class=\"full\">' + hex + '</span></span>' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '<i class=\"av\" style=\"--h:' + hue(hex) + '\"></i>';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '<svg viewBox=\"0 0 24 24\"><path d=\"M6 6l12 12M18 6L6 18\"/></svg>',\n undo: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 10h10a5 5 0 0 1 0 10h-3M4 10l4-4M4 10l4 4\"/></svg>',\n check: '<svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg>',\n trash: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 7h16M6 7l1.2 13h9.6L18 7\"/></svg>',\n ban: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg>',\n banuser: '<svg viewBox=\"0 0 24 24\"><circle cx=\"10\" cy=\"8\" r=\"4\"/><path d=\"M3 21a7 7 0 0 1 11-5.7M15 15l6 6M21 15l-6 6\"/></svg>',\n eye: '<svg viewBox=\"0 0 24 24\"><path d=\"M2 12s4-6 10-6 10 6 10 6-4 6-10 6S2 12 2 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>',\n lock: '<svg viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10\" width=\"14\" height=\"10\" rx=\"2\"/><path d=\"M8 10V7a4 4 0 0 1 8 0v3\"/></svg>',\n pen: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 20l4-1 11-11-3-3L5 16l-1 4zM13 8l3 3\"/></svg>',\n people: '<svg viewBox=\"0 0 24 24\"><circle cx=\"9\" cy=\"8\" r=\"3.5\"/><circle cx=\"17\" cy=\"9\" r=\"2.5\"/><path d=\"M3 20a6 6 0 0 1 12 0M15 20a4.5 4.5 0 0 1 7-3.5\"/></svg>',\n person: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/></svg>',\n gauge: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 16a8 8 0 0 1 16 0M12 16l4-5\"/><circle cx=\"12\" cy=\"16\" r=\"1.2\"/></svg>',\n bolt: '<svg viewBox=\"0 0 24 24\"><path d=\"M13 3L5 14h6l-1 7 8-11h-6z\"/></svg>',\n copy: '<svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg>',\n pin: '<svg viewBox=\"0 0 24 24\"><path d=\"M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6zM12 15v5\"/></svg>',\n };\n const ib = (icon, label, act, id, extra) => '<button class=\"ib' + (extra ? \" \" + extra : \"\") + '\" title=\"' + label + '\" aria-label=\"' + label + '\" data-act=\"' + act + '\" data-id=\"' + esc(id) + '\">' + IC[icon] + \"</button>\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('<a href=\"' + esc(u) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(u) + '\">' + esc(label) + \"</a>\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('<span class=\"tag\">' + esc(t) + \"</span>\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"<span>\" + esc(where.join(\", \")) + \"</span>\");\n const link = (label, href) => href ? '<a href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"<span>\" + esc(info.contact) + \"</span>\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('<span class=\"sep\">|</span>'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by <b>' + key(owner) + \"</b>\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"<div><small>\" + k + \"</small><div\" + ([\"Owner\", \"NIPs\", \"Relay key\"].includes(k) ? ' class=\"mono\"' : \"\") + \">\" + (v === undefined || v === \"\" ? '<span class=\"muted\">–</span>' : v instanceof Html ? v.s : esc(v)) + \"</div></div>\").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '<span class=\"g' + (off ? \" off\" : \"\") + '\" title=\"' + label + '\"><i>' + IC[icon] + \"</i><small>\" + label + \"</small></span>\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '<span class=\"who' + (m.pubkey === me ? \" me\" : \"\") + '\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + '<span class=\"mono\">' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"</span>\" + (m.role === \"owner\" ? '<span class=\"role\">owner</span>' : \"\") + \"</span>\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"/<npub>/<repo>.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? '<span class=\"chip\">out of fuel</span> Writes are paused until someone tops up.'\n : \"Balance <b>\" + sats.toLocaleString() + \" sats</b>. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '<tr><td class=\"dim\">' + esc(fmtTime(c.at)) + '</td><td class=\"mono\" title=\"' + esc(c.payer) + '\">' + av(c.payer) + key(c.payer) + '</td><td class=\"mono r\">' + Math.floor(c.msats / 1000).toLocaleString() + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"3\" class=\"muted\">no zaps yet</td></tr>';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '<i class=\"' + cls + '\" style=\"flex:' + n + '\" title=\"' + name + \" \" + Math.round(n * 100 / total) + '%\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '<span><i class=\"' + cls + '\"></i>' + name + \" \" + Math.round(n * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '<tr data-seq=\"' + r.seq + '\"><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td class=\"mono\" title=\"' + esc(r.actor) + '\">' + av(r.actor) + key(r.actor) + '</td><td><span class=\"kind\">' + esc(r.action) + '</span></td><td class=\"mono\">' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '</td><td class=\"c\">' + esc(r.detail) + '</td></tr>').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || '<tr><td class=\"dim\" colspan=\"5\">Nothing yet</td></tr>';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' <span class=\"chip\">owner</span>' : myRole === \"owner\" ? ' <select class=\"txt role\" title=\"Role\"><option value=\"member\"' + (m.role === \"member\" ? \" selected\" : \"\") + '>member</option><option value=\"moderator\"' + (m.role === \"moderator\" ? \" selected\" : \"\") + '>moderator</option></select>' : m.role === \"moderator\" ? ' <span class=\"chip\">moderator</span>' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? '<input class=\"txt num keep\" type=\"number\" min=\"0\" placeholder=\"days\" title=\"Keep this member\\'s events for this many days; blank follows the relay rules\" value=\"' + (m.keep_days || \"\") + '\"><input class=\"txt num cap\" type=\"number\" min=\"0\" placeholder=\"KB\" title=\"Cap on stored bytes, in KB; blank is unlimited\" value=\"' + (m.max_bytes ? Math.round(m.max_bytes / 1024) : \"\") + '\">'\n : '<span class=\"dim\">' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"</span>\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '<tr data-pk=\"' + m.pubkey + '\"><td class=\"mono\" title=\"' + esc(m.pubkey) + '\" style=\"padding-left:' + (0.5 + m.depth * 1.1) + 'rem\">' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' <span class=\"dim\">via ' + esc(nameOf(m.invited_by)) + \"</span>\" : \"\") + (m.invites ? ' <span class=\"chip\" title=\"live invites\">' + m.invites + \" inv</span>\" : \"\") + '</td><td class=\"name\"><input class=\"txt name\" value=\"' + esc(m.name || \"\") + '\" placeholder=\"name\" pattern=\"[a-z0-9._-]{1,64}\"></td><td><input class=\"txt note\" value=\"' + esc(m.note || \"\") + '\" placeholder=\"note\"></td><td class=\"lim\">' + limits(m) + '</td><td class=\"dim\">' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '</td><td class=\"r\">' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"</td></tr>\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '<option value=\"' + m.pubkey + '\">' + esc(m.name ? m.name + \"@\" + host : key(m.pubkey)) + \"</option>\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + \"</a> \" + esc(i.note || \"\") + ' <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites</li>';\n const person = (r, icon, label, act) => \"<li>\" + av(r.pubkey) + '<span><b class=\"mono\" title=\"' + esc(r.pubkey) + '\">' + short(r.pubkey) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(icon, label, act, r.pubkey) + \"</li>\";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '<li class=\"empty\">nobody banned</li>';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '<li><i class=\"ev\">ip</i><span><b class=\"mono\">' + esc(r.ip) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"</li>\").join(\"\") : '<li class=\"empty\">no addresses blocked</li>';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '<li><i class=\"ev\">ev</i><span><b class=\"mono\" title=\"' + esc(r.id) + '\">' + key(r.id) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"</li>\").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '<tr><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td><span class=\"kind\">' + esc(r.type || \"report\") + '</span></td><td class=\"mono\" title=\"' + esc(r.target_pubkey) + '\">' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' <a class=\"dim\" href=\"/' + esc(r.target_event) + '\" target=\"_blank\" rel=\"noopener\">file ' + key(r.target_event) + \"</a>\" : ' <span class=\"dim\">ev ' + key(r.target_event) + (r.hidden ? ' <span class=\"tag\">hidden</span>' : \"\") + \"</span>\") : \"\") + '</td><td class=\"c\">' + esc(r.content) + '</td><td class=\"r\">' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">nothing reported</td></tr>';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '<tr><td class=\"dim\">' + esc(fmtTime(b.uploaded)) + '</td><td class=\"mono\"><a href=\"' + esc(b.url) + '\" target=\"_blank\" rel=\"noopener\">' + key(b.sha256) + '</a> <span class=\"dim\">' + esc(b.type) + '</span></td><td class=\"mono\">' + fmtBytes(b.size) + '</td><td class=\"mono\" title=\"' + esc(b.uploader) + '\">' + av(b.uploader) + key(b.uploader) + '</td><td class=\"r\">' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no uploads</td></tr>';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '<tr><td class=\"mono\" title=\"' + esc(s.author) + '\">' + av(s.author) + key(s.author) + '</td><td class=\"mono\">' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '</td><td><a href=\"' + esc(s.url) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(s.url) + '\">' + esc(s.url) + '</a></td><td class=\"r mono\">' + esc(s.paths) + (s.missing ? ' <span class=\"chip bad\" title=\"files still being mirrored\">' + esc(s.missing) + ' missing</span>' : '') + '</td><td class=\"r mono\">' + fmtBytes(s.size) + '</td><td class=\"dim\">' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '</td><td class=\"r\">' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '</td></tr>').join(\"\") : '<tr><td colspan=\"7\" class=\"muted\">no sites yet</td></tr>';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '<span class=\"tag ' + cls + '\">' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"</span>\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : '<span class=\"tag plain\">all</span>';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : '<span class=\"tag plain\">none</span>';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '<option value=\"' + v + '\"' + (cur === v ? \" selected\" : \"\") + \">\" + l + \"</option>\"; }).join(\"\");\n return \"<label><span>\" + esc(title) + \" <small>\" + esc(about) + \"</small></span><select class=\\\"txt\\\" data-feature=\\\"\" + k + \"\\\">\" + opts + \"</select></label>\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved callback policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '<select class=\"txt\" data-view=\"' + esc(v.name) + '\" style=\"width:auto\">' + choices.map((c) => '<option value=\"' + c + '\"' + (c === v.trigger ? \" selected\" : \"\") + \">\" + label(c) + \"</option>\").join(\"\") + \"</select>\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"<span>\" + esc(x) + \"</span>\").join('<span class=\"sep\">|</span>');\n return '<div class=\"wire-row' + (v.on ? \"\" : \" dim\") + '\"><div class=\"wire-main\"><b>' + esc(v.name) + '</b><br><small>' + esc(v.about) + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + pick + (v.on ? '<a class=\"btn\" href=\"' + esc(v.path) + '\" target=\"_blank\" rel=\"noopener\">Open</a>' : \"\") + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '<li><i class=\"ev\">jsonl</i><span><b class=\"mono\">' + esc(d.name) + '</b> <span class=\"dim\">' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"</span></span><span>\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"</span></li>\").join(\"\") : '<li class=\"empty\">no dumps yet</li>';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + '</a> <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites yet</li>';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<i class=\"' + c + '\" style=\"flex:' + b + '\" title=\"' + esc(n) + \" \" + fmtBytes(b) + '\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<span><i class=\"' + c + '\"></i>' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" <small>\" + fmtBytes(st.eventBytes) + \"</small>\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" <small>\" + fmtBytes(st.mediaBytes) + \"</small>\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '<span class=\"kind' + (k.protected ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + k.kind + \"</span> \" + esc(kindName(k.kind));\n const cells = k.protected\n ? '<td class=\"keep sys\" colspan=\"2\" title=\"The relay depends on this kind, so it is never expired or purged.\">required</td>'\n : '<td class=\"keep\"><input class=\"txt num days\" type=\"number\" min=\"0\" max=\"36500\" placeholder=\"' + placeholder + '\" value=\"' + own(k.kind) + '\"> days</td><td class=\"r\">' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"</td>\";\n return '<tr class=\"' + (k.kind === null ? \"any\" : \"\") + '\" data-kind=\"' + key + '\"><td>' + pill + '</td><td class=\"r mono\">' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '</td><td class=\"r mono\">' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '</td><td class=\"dim\">' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"</td>\" + cells + \"</tr>\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '<tr><td class=\"dim\">' + esc(fmtTime(e.created_at)) + '</td><td><span class=\"kind' + (SYS_KINDS.has(e.kind) ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + e.kind + '</span></td><td class=\"mono\" title=\"' + e.pubkey + '\">' + av(e.pubkey) + key(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no events yet</td></tr>';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '<li><i class=\"ev\">' + (t[0] === \"e\" ? \"event\" : \"address\") + '</i><span class=\"mono\">' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"</span><span>\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"</span></li>\").join(\"\") : '<li class=\"muted\">nothing pinned</li>';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n return \"<tr><td>\" + what + \"</td><td class=\\\"mono\\\">\" + j.relays.map(esc).join(\"<br>\") + \"</td><td>\" + (f.join(\", \") || \"everything\") + \"</td><td>\" + when + \"</td><td>\" + esc(res) + \"</td><td>\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"</td></tr>\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : '<tr><td colspan=\"6\" class=\"muted\">no jobs yet</td></tr>';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '<button class=\"btn\" data-preset=\"' + esc(p.name) + '\" title=\"' + esc(p.about) + '\">' + esc(p.title) + \"</button>\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '<span class=\"pill ' + cls + '\">' + esc(label) + \"</span>\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-wire=\"' + l.kind + '\" data-do=\"' + act + '\"' + (busy ? \" disabled\" : \"\") + \">\" + label + \"</button>\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"<span>\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"</span>\" : w.state && !busy ? \"<span>no list found</span>\" : \"\";\n const sent = (w.results || []).map((r) => '<span class=\"chip' + (r.ok ? \"\" : \" bad\") + '\" title=\"' + esc(r.msg) + '\">' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"</span>\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '<div class=\"wire-row\"><div class=\"wire-main\"><b>' + l.title + '</b> <small class=\"muted\">' + l.nip + \"</small><br><small>\" + l.about + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + buttons + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '<option value=\"' + esc(s.label) + '\"' + (s.label === current ? \" selected\" : \"\") + \">\" + esc(s.title) + \"</option>\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-domain=\"' + esc(d.host) + '\" data-do=\"' + act + '\">' + label + \"</button>\";\n const rows = d.ready ? \"\" : '<div class=\"scroll\"><table class=\"events\"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>' + d.records.map((r) => \"<tr><td>\" + esc(r.type) + '</td><td class=\"mono\">' + esc(r.name) + '</td><td class=\"mono\">' + esc(r.value) + \"</td><td>\" + esc(r.note) + \"</td></tr>\").join(\"\") + \"</tbody></table></div>\";\n return '<div class=\"row\"><b class=\"mono\">' + esc(d.host) + '</b><span class=\"note\">' + esc(state) + \"</span>\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '</div><label class=\"row\"><span>Destination</span><select class=\"txt\" data-domain-site=\"' + esc(d.host) + '\">' + domainOptions(d.site || \"\") + \"</select></label>\" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '<a class=\"btn\" href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\";\n const app = (label, uri) => '<a class=\"btn\" href=\"' + esc(uri) + '\">' + label + \"</a>\";\n const copy = (label, text) => '<button class=\"btn\" data-copytext=\"' + esc(text) + '\">' + label + \"</button>\";\n const row = (name, where, note, acts) => '<div class=\"app\"><div class=\"app-head\"><b>' + name + \"</b><small>\" + where + \"</small></div><p>\" + note + '</p><div class=\"app-acts\">' + acts.filter(Boolean).join(\"\") + \"</div></div>\";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '<div class=\"appgroup\"><h4>' + h + '</h4><p class=\"note\">' + note + '</p><div class=\"appgrid\">' + rows.join(\"\") + \"</div></div>\").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '<div class=\"door\"><small>' + label + '</small><img src=\"/qr.svg?text=' + enc(text) + '\" alt=\"QR code: ' + esc(label) + '\" width=\"150\" height=\"150\"></div>';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('<a href=\"' + location.origin + '/\"><img src=\"' + location.origin + '/card.svg\" alt=\"' + host + '\" width=\"600\" height=\"315\"></a>'); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n";4+export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '<span class=\"key\" data-short=\"' + short(hex) + '\" title=\"' + hex + '\"><span class=\"full\">' + hex + '</span></span>' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '<i class=\"av\" style=\"--h:' + hue(hex) + '\"></i>';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '<svg viewBox=\"0 0 24 24\"><path d=\"M6 6l12 12M18 6L6 18\"/></svg>',\n undo: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 10h10a5 5 0 0 1 0 10h-3M4 10l4-4M4 10l4 4\"/></svg>',\n check: '<svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg>',\n trash: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 7h16M6 7l1.2 13h9.6L18 7\"/></svg>',\n ban: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg>',\n banuser: '<svg viewBox=\"0 0 24 24\"><circle cx=\"10\" cy=\"8\" r=\"4\"/><path d=\"M3 21a7 7 0 0 1 11-5.7M15 15l6 6M21 15l-6 6\"/></svg>',\n eye: '<svg viewBox=\"0 0 24 24\"><path d=\"M2 12s4-6 10-6 10 6 10 6-4 6-10 6S2 12 2 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>',\n lock: '<svg viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10\" width=\"14\" height=\"10\" rx=\"2\"/><path d=\"M8 10V7a4 4 0 0 1 8 0v3\"/></svg>',\n pen: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 20l4-1 11-11-3-3L5 16l-1 4zM13 8l3 3\"/></svg>',\n people: '<svg viewBox=\"0 0 24 24\"><circle cx=\"9\" cy=\"8\" r=\"3.5\"/><circle cx=\"17\" cy=\"9\" r=\"2.5\"/><path d=\"M3 20a6 6 0 0 1 12 0M15 20a4.5 4.5 0 0 1 7-3.5\"/></svg>',\n person: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/></svg>',\n gauge: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 16a8 8 0 0 1 16 0M12 16l4-5\"/><circle cx=\"12\" cy=\"16\" r=\"1.2\"/></svg>',\n bolt: '<svg viewBox=\"0 0 24 24\"><path d=\"M13 3L5 14h6l-1 7 8-11h-6z\"/></svg>',\n copy: '<svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg>',\n pin: '<svg viewBox=\"0 0 24 24\"><path d=\"M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6zM12 15v5\"/></svg>',\n };\n const ib = (icon, label, act, id, extra) => '<button class=\"ib' + (extra ? \" \" + extra : \"\") + '\" title=\"' + label + '\" aria-label=\"' + label + '\" data-act=\"' + act + '\" data-id=\"' + esc(id) + '\">' + IC[icon] + \"</button>\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('<a href=\"' + esc(u) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(u) + '\">' + esc(label) + \"</a>\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('<span class=\"tag\">' + esc(t) + \"</span>\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"<span>\" + esc(where.join(\", \")) + \"</span>\");\n const link = (label, href) => href ? '<a href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"<span>\" + esc(info.contact) + \"</span>\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('<span class=\"sep\">|</span>'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by <b>' + key(owner) + \"</b>\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"<div><small>\" + k + \"</small><div\" + ([\"Owner\", \"NIPs\", \"Relay key\"].includes(k) ? ' class=\"mono\"' : \"\") + \">\" + (v === undefined || v === \"\" ? '<span class=\"muted\">–</span>' : v instanceof Html ? v.s : esc(v)) + \"</div></div>\").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '<span class=\"g' + (off ? \" off\" : \"\") + '\" title=\"' + label + '\"><i>' + IC[icon] + \"</i><small>\" + label + \"</small></span>\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '<span class=\"who' + (m.pubkey === me ? \" me\" : \"\") + '\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + '<span class=\"mono\">' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"</span>\" + (m.role === \"owner\" ? '<span class=\"role\">owner</span>' : \"\") + \"</span>\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"/<npub>/<repo>.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? '<span class=\"chip\">out of fuel</span> Writes are paused until someone tops up.'\n : \"Balance <b>\" + sats.toLocaleString() + \" sats</b>. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '<tr><td class=\"dim\">' + esc(fmtTime(c.at)) + '</td><td class=\"mono\" title=\"' + esc(c.payer) + '\">' + av(c.payer) + key(c.payer) + '</td><td class=\"mono r\">' + Math.floor(c.msats / 1000).toLocaleString() + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"3\" class=\"muted\">no zaps yet</td></tr>';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '<i class=\"' + cls + '\" style=\"flex:' + n + '\" title=\"' + name + \" \" + Math.round(n * 100 / total) + '%\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '<span><i class=\"' + cls + '\"></i>' + name + \" \" + Math.round(n * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '<tr data-seq=\"' + r.seq + '\"><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td class=\"mono\" title=\"' + esc(r.actor) + '\">' + av(r.actor) + key(r.actor) + '</td><td><span class=\"kind\">' + esc(r.action) + '</span></td><td class=\"mono\">' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '</td><td class=\"c\">' + esc(r.detail) + '</td></tr>').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || '<tr><td class=\"dim\" colspan=\"5\">Nothing yet</td></tr>';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' <span class=\"chip\">owner</span>' : myRole === \"owner\" ? ' <select class=\"txt role\" title=\"Role\"><option value=\"member\"' + (m.role === \"member\" ? \" selected\" : \"\") + '>member</option><option value=\"moderator\"' + (m.role === \"moderator\" ? \" selected\" : \"\") + '>moderator</option></select>' : m.role === \"moderator\" ? ' <span class=\"chip\">moderator</span>' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? '<input class=\"txt num keep\" type=\"number\" min=\"0\" placeholder=\"days\" title=\"Keep this member\\'s events for this many days; blank follows the relay rules\" value=\"' + (m.keep_days || \"\") + '\"><input class=\"txt num cap\" type=\"number\" min=\"0\" placeholder=\"KB\" title=\"Cap on stored bytes, in KB; blank is unlimited\" value=\"' + (m.max_bytes ? Math.round(m.max_bytes / 1024) : \"\") + '\">'\n : '<span class=\"dim\">' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"</span>\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '<tr data-pk=\"' + m.pubkey + '\"><td class=\"mono\" title=\"' + esc(m.pubkey) + '\" style=\"padding-left:' + (0.5 + m.depth * 1.1) + 'rem\">' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' <span class=\"dim\">via ' + esc(nameOf(m.invited_by)) + \"</span>\" : \"\") + (m.invites ? ' <span class=\"chip\" title=\"live invites\">' + m.invites + \" inv</span>\" : \"\") + '</td><td class=\"name\"><input class=\"txt name\" value=\"' + esc(m.name || \"\") + '\" placeholder=\"name\" pattern=\"[a-z0-9._-]{1,64}\"></td><td><input class=\"txt note\" value=\"' + esc(m.note || \"\") + '\" placeholder=\"note\"></td><td class=\"lim\">' + limits(m) + '</td><td class=\"dim\">' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '</td><td class=\"r\">' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"</td></tr>\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '<option value=\"' + m.pubkey + '\">' + esc(m.name ? m.name + \"@\" + host : key(m.pubkey)) + \"</option>\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + \"</a> \" + esc(i.note || \"\") + ' <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites</li>';\n const person = (r, icon, label, act) => \"<li>\" + av(r.pubkey) + '<span><b class=\"mono\" title=\"' + esc(r.pubkey) + '\">' + short(r.pubkey) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(icon, label, act, r.pubkey) + \"</li>\";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '<li class=\"empty\">nobody banned</li>';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '<li><i class=\"ev\">ip</i><span><b class=\"mono\">' + esc(r.ip) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"</li>\").join(\"\") : '<li class=\"empty\">no addresses blocked</li>';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '<li><i class=\"ev\">ev</i><span><b class=\"mono\" title=\"' + esc(r.id) + '\">' + key(r.id) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"</li>\").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '<tr><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td><span class=\"kind\">' + esc(r.type || \"report\") + '</span></td><td class=\"mono\" title=\"' + esc(r.target_pubkey) + '\">' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' <a class=\"dim\" href=\"/' + esc(r.target_event) + '\" target=\"_blank\" rel=\"noopener\">file ' + key(r.target_event) + \"</a>\" : ' <span class=\"dim\">ev ' + key(r.target_event) + (r.hidden ? ' <span class=\"tag\">hidden</span>' : \"\") + \"</span>\") : \"\") + '</td><td class=\"c\">' + esc(r.content) + '</td><td class=\"r\">' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">nothing reported</td></tr>';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '<tr><td class=\"dim\">' + esc(fmtTime(b.uploaded)) + '</td><td class=\"mono\"><a href=\"' + esc(b.url) + '\" target=\"_blank\" rel=\"noopener\">' + key(b.sha256) + '</a> <span class=\"dim\">' + esc(b.type) + '</span></td><td class=\"mono\">' + fmtBytes(b.size) + '</td><td class=\"mono\" title=\"' + esc(b.uploader) + '\">' + av(b.uploader) + key(b.uploader) + '</td><td class=\"r\">' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no uploads</td></tr>';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '<tr><td class=\"mono\" title=\"' + esc(s.author) + '\">' + av(s.author) + key(s.author) + '</td><td class=\"mono\">' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '</td><td><a href=\"' + esc(s.url) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(s.url) + '\">' + esc(s.url) + '</a></td><td class=\"r mono\">' + esc(s.paths) + (s.missing ? ' <span class=\"chip bad\" title=\"files still being mirrored\">' + esc(s.missing) + ' missing</span>' : '') + '</td><td class=\"r mono\">' + fmtBytes(s.size) + '</td><td class=\"dim\">' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '</td><td class=\"r\">' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '</td></tr>').join(\"\") : '<tr><td colspan=\"7\" class=\"muted\">no sites yet</td></tr>';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '<span class=\"tag ' + cls + '\">' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"</span>\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : '<span class=\"tag plain\">all</span>';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : '<span class=\"tag plain\">none</span>';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '<option value=\"' + v + '\"' + (cur === v ? \" selected\" : \"\") + \">\" + l + \"</option>\"; }).join(\"\");\n return \"<label><span>\" + esc(title) + \" <small>\" + esc(about) + \"</small></span><select class=\\\"txt\\\" data-feature=\\\"\" + k + \"\\\">\" + opts + \"</select></label>\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '<select class=\"txt\" data-view=\"' + esc(v.name) + '\" style=\"width:auto\">' + choices.map((c) => '<option value=\"' + c + '\"' + (c === v.trigger ? \" selected\" : \"\") + \">\" + label(c) + \"</option>\").join(\"\") + \"</select>\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"<span>\" + esc(x) + \"</span>\").join('<span class=\"sep\">|</span>');\n return '<div class=\"wire-row' + (v.on ? \"\" : \" dim\") + '\"><div class=\"wire-main\"><b>' + esc(v.name) + '</b><br><small>' + esc(v.about) + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + pick + (v.on ? '<a class=\"btn\" href=\"' + esc(v.path) + '\" target=\"_blank\" rel=\"noopener\">Open</a>' : \"\") + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '<li><i class=\"ev\">jsonl</i><span><b class=\"mono\">' + esc(d.name) + '</b> <span class=\"dim\">' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"</span></span><span>\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"</span></li>\").join(\"\") : '<li class=\"empty\">no dumps yet</li>';\n }\n async function loadBackups() {\n const list = await rpc(\"listbackups\");\n $(\"#backups-count\").textContent = list.length || \"\";\n $(\"#backups\").innerHTML = list.length ? list.map((b) => '<li><i class=\"ev\">backup</i><span><b class=\"mono\">' + esc(b.id) + '</b> <span class=\"dim\">' + fmtBytes(b.bytes) + '</span></span><span>' + ib(\"copy\", \"Download\", \"downloadbackup\", b.id) + ib(\"trash\", \"Delete\", \"deletebackup\", b.id, \"danger\") + '</span></li>').join(\"\") : '<li class=\"empty\">no backups yet</li>';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n async function downloadBackup(id) {\n const resp = await backupRequest(\"/backups/\" + encodeURIComponent(id), \"GET\");\n if (!resp.ok) throw new Error(\"backup download failed\");\n const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + '</a> <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites yet</li>';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n loadBackups().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<i class=\"' + c + '\" style=\"flex:' + b + '\" title=\"' + esc(n) + \" \" + fmtBytes(b) + '\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<span><i class=\"' + c + '\"></i>' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" <small>\" + fmtBytes(st.eventBytes) + \"</small>\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" <small>\" + fmtBytes(st.mediaBytes) + \"</small>\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '<span class=\"kind' + (k.protected ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + k.kind + \"</span> \" + esc(kindName(k.kind));\n const cells = k.protected\n ? '<td class=\"keep sys\" colspan=\"2\" title=\"The relay depends on this kind, so it is never expired or purged.\">required</td>'\n : '<td class=\"keep\"><input class=\"txt num days\" type=\"number\" min=\"0\" max=\"36500\" placeholder=\"' + placeholder + '\" value=\"' + own(k.kind) + '\"> days</td><td class=\"r\">' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"</td>\";\n return '<tr class=\"' + (k.kind === null ? \"any\" : \"\") + '\" data-kind=\"' + key + '\"><td>' + pill + '</td><td class=\"r mono\">' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '</td><td class=\"r mono\">' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '</td><td class=\"dim\">' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"</td>\" + cells + \"</tr>\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '<tr><td>' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '</td><td class=\"dim\">' + esc(fmtTime(r.created_at)) + '</td><td class=\"dim\">' + esc(fmtTime(r.saved_at)) + '</td><td class=\"r\">' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '</td></tr>').join(\"\") : '<tr><td colspan=\"4\" class=\"muted\">no older list versions yet</td></tr>';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '<tr><td class=\"dim\">' + esc(fmtTime(e.created_at)) + '</td><td><span class=\"kind' + (SYS_KINDS.has(e.kind) ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + e.kind + '</span></td><td class=\"mono\" title=\"' + e.pubkey + '\">' + av(e.pubkey) + key(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no events yet</td></tr>';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '<li><i class=\"ev\">' + (t[0] === \"e\" ? \"event\" : \"address\") + '</i><span class=\"mono\">' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"</span><span>\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"</span></li>\").join(\"\") : '<li class=\"muted\">nothing pinned</li>';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '<details><summary>Source results</summary>' + sources.map((s) => '<p><code>' + esc(s.url) + '</code>: ' + esc(s.status) + ', ' + s.stored + ' stored, ' + s.skipped + ' skipped' + (s.error || s.warning ? '<br>' + esc(s.error || s.warning) : '') + '</p>').join('') + '</details>' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"<br><small>\" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"<br>\") + \"</small>\" : \"\";\n return \"<tr><td>\" + what + \"</td><td class=\\\"mono\\\">\" + j.relays.map(esc).join(\"<br>\") + targets + \"</td><td>\" + (f.join(\", \") || \"everything\") + \"</td><td>\" + when + \"</td><td>\" + esc(res) + details + \"</td><td>\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"</td></tr>\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : '<tr><td colspan=\"6\" class=\"muted\">no jobs yet</td></tr>';\n if (myRole === \"owner\") {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '<tr><td class=\"mono\" title=\"' + esc(d.event_id) + '\">' + esc(d.event_id.slice(0, 12)) + '</td><td class=\"mono\">' + esc(d.target) + '</td><td>' + esc(d.status) + '</td><td>' + d.attempts + '</td><td>' + esc(d.error || \"\") + '</td></tr>').join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no automatic deliveries yet</td></tr>';\n } catch { /* unavailable to non-owners */ }\n }\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '<button class=\"btn\" data-preset=\"' + esc(p.name) + '\" title=\"' + esc(p.about) + '\">' + esc(p.title) + \"</button>\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '<span class=\"pill ' + cls + '\">' + esc(label) + \"</span>\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-wire=\"' + l.kind + '\" data-do=\"' + act + '\"' + (busy ? \" disabled\" : \"\") + \">\" + label + \"</button>\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"<span>\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"</span>\" : w.state && !busy ? \"<span>no list found</span>\" : \"\";\n const sent = (w.results || []).map((r) => '<span class=\"chip' + (r.ok ? \"\" : \" bad\") + '\" title=\"' + esc(r.msg) + '\">' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"</span>\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '<div class=\"wire-row\"><div class=\"wire-main\"><b>' + l.title + '</b> <small class=\"muted\">' + l.nip + \"</small><br><small>\" + l.about + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + buttons + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '<option value=\"' + esc(s.label) + '\"' + (s.label === current ? \" selected\" : \"\") + \">\" + esc(s.title) + \"</option>\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-domain=\"' + esc(d.host) + '\" data-do=\"' + act + '\">' + label + \"</button>\";\n const rows = d.ready ? \"\" : '<div class=\"scroll\"><table class=\"events\"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>' + d.records.map((r) => \"<tr><td>\" + esc(r.type) + '</td><td class=\"mono\">' + esc(r.name) + '</td><td class=\"mono\">' + esc(r.value) + \"</td><td>\" + esc(r.note) + \"</td></tr>\").join(\"\") + \"</tbody></table></div>\";\n return '<div class=\"row\"><b class=\"mono\">' + esc(d.host) + '</b><span class=\"note\">' + esc(state) + \"</span>\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '</div><label class=\"row\"><span>Destination</span><select class=\"txt\" data-domain-site=\"' + esc(d.host) + '\">' + domainOptions(d.site || \"\") + \"</select></label>\" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n async function backupRequest(path, method, body) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const hash = await sha256hex(body || \"\");\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", location.origin + path], [\"method\", method], [\"payload\", hash]] });\n return fetch(path, { method, headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), ...(body ? { \"content-type\": \"application/json\" } : {}) }, body });\n }\n $(\"#backupnow\").onclick = guard(async () => { const id = $(\"#backupid\").value.trim() || \"backup\"; const r = await rpc(\"backupnow\", id); const resp = await backupRequest(\"/backups/\" + id, \"GET\"); if (!resp.ok) throw new Error(\"backup download failed\"); const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href); toast(\"Backup downloaded\"); await loadStorage(); void r; });\n async function selectedBackup() { const f = $(\"#backupfile\").files[0]; if (!f) throw new Error(\"Choose a backup archive first.\"); if (f.size > 8 * 1024 * 1024) throw new Error(\"Backups are limited to 8 MB.\"); return { file: f, body: await f.text() }; }\n let backupPreview = null;\n $(\"#backupfile\").onchange = () => { backupPreview = null; $(\"#backup-preview\").textContent = \"\"; $(\"#backuprestore\").disabled = true; };\n $(\"#backupremote\").onclick = () => showRemote(null);\n $(\"#backuppreview\").onclick = guard(async () => { const selected = await selectedBackup(); const r = await (await backupRequest(\"/backups/preview\", \"POST\", selected.body)).json(); if (r.error) throw new Error(r.error); backupPreview = { file: selected.file, body: selected.body }; $(\"#backuprestore\").disabled = false; const s = r.result.source; $(\"#backup-preview\").innerHTML = '<p><b>Ready to restore.</b> ' + esc(String(r.result.events)) + ' events, ' + esc(String(r.result.blobs)) + ' files, ' + esc(String(r.result.git)) + ' Git objects, ' + esc(fmtBytes(r.result.bytes)) + '.</p><p>Source relay identity: <code>' + esc(s.relayIdentity || \"unknown\") + '</code></p><pre>' + esc(JSON.stringify(r.result.config, null, 2)) + '</pre>'; });\n $(\"#backuprestore\").onclick = guard(async () => { const selected = await selectedBackup(); if (!backupPreview || backupPreview.file !== selected.file || backupPreview.body !== selected.body) throw new Error(\"Preview this exact archive before restoring.\"); if (!confirm(\"Restore this archive onto this fresh relay? This cannot be undone.\")) return; const r = await (await backupRequest(\"/backups/restore\", \"POST\", selected.body)).json(); if (r.error) throw new Error(r.error); me = await signer.getPublicKey(); localStorage.setItem(\"me\", me); toast(\"Backup restored\"); await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"deletebackup\" && !confirm(\"Delete this backup?\")) return;\n if (act === \"downloadbackup\") { b.disabled = true; try { await downloadBackup(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"deletebackup\") { b.disabled = true; try { await rpc(\"deletebackup\", id); toast(\"Backup deleted\"); await loadBackups(); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '<a class=\"btn\" href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\";\n const app = (label, uri) => '<a class=\"btn\" href=\"' + esc(uri) + '\">' + label + \"</a>\";\n const copy = (label, text) => '<button class=\"btn\" data-copytext=\"' + esc(text) + '\">' + label + \"</button>\";\n const row = (name, where, note, acts) => '<div class=\"app\"><div class=\"app-head\"><b>' + name + \"</b><small>\" + where + \"</small></div><p>\" + note + '</p><div class=\"app-acts\">' + acts.filter(Boolean).join(\"\") + \"</div></div>\";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '<div class=\"appgroup\"><h4>' + h + '</h4><p class=\"note\">' + note + '</p><div class=\"appgrid\">' + rows.join(\"\") + \"</div></div>\").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '<div class=\"door\"><small>' + label + '</small><img src=\"/qr.svg?text=' + enc(text) + '\" alt=\"QR code: ' + esc(label) + '\" width=\"150\" height=\"150\"></div>';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('<a href=\"' + location.origin + '/\"><img src=\"' + location.origin + '/card.svg\" alt=\"' + host + '\" width=\"600\" height=\"315\"></a>'); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n";@@ -137,7 +137,7 @@ function take(relay: Relay, line: string, job: Job, t: number) {
137137 const r = relay.accept(e, null);138138 if (r.stored) {139139 job.stored++;140- relay.broadcast(e);140+ relay.broadcast(e, false);141141 } else if (r.msg === ERR_DUPLICATE) job.duplicates = (job.duplicates ?? 0) + 1;142142 else job.skipped++;143143 void t;@@ -6,7 +6,7 @@
66 // fuel, which is the right signal for work nobody asked a client for.77 import { hasTag, isPrivate, now, type Event } from "./event.ts";88 import type { Filter } from "./filter.ts";9-import { Socket, dial, checkPullURL, runPullRound, type PullFilter, type PullJob, type PullResult } from "./pull.ts";9+import { Socket, dial, checkPullURL, runPullRound, newPullProgress, type PullConnect, type PullSource, type PullFilter, type PullJob, type PullResult } from "./pull.ts";1010 import type { Relay } from "./relay.ts";1111 import { runMirrorRound } from "./site-mirror.ts";1212 import { runImportRound } from "./imports.ts";@@ -25,6 +25,7 @@ export interface JobResult {
2525 sent: number;2626 refused: number;2727 duplicates?: number;28+ sources?: PullSource[];2829 }2930 3031 export interface Job {@@ -43,6 +44,11 @@ export interface Job {
4344 failures: number;4445 relayIndex: number; // pull: which source is being synced4546 cursor: number; // push: last sequence number forwarded, kept across runs47+ // Newer push jobs keep independent cursors. `cursor` remains for old job48+ // readers and is the greatest cursor reached by any target.49+ targetCursors?: Record<string, number>;50+ targetStatus?: Record<string, { status: "pending" | "accepted" | "rejected"; error: string; at: number }>;51+ targetAttempts?: Record<string, number>;4652 stored: number;4753 skipped: number;4854 blobs: number;@@ -54,6 +60,7 @@ export interface Job {
5460 size?: number; // import: the object's size in bytes5561 carry?: string; // import: the partial last line of the previous round, base645662 last: JobResult | null;63+ pullSources?: PullSource[];5764 }5865 5966 export const MAX_STANDING = 5;@@ -102,7 +109,7 @@ export function checkJob(raw: unknown, relay: Relay): JobSpec | string {
102109 const every = r.every === undefined ? 0 : Number(r.every);103110 if (!(EVERY as readonly number[]).includes(every)) return "invalid: every must be 0, 1, 6 or 24 hours";104111 if (kind === "push" && relay.settings.policy.reads === "members" && filter.kinds?.some(isPrivate)) return "restricted: a members-only relay does not rebroadcast private kinds";105- return { kind, label, relays, filter, every, running: false, startedAt: 0, rounds: 0, failures: 0, relayIndex: 0, cursor: 0, stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null };112+ return { kind, label, relays, filter, every, running: false, startedAt: 0, rounds: 0, failures: 0, relayIndex: 0, cursor: 0, targetCursors: {}, targetStatus: {}, targetAttempts: {}, stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null };106113 }107114 108115 // relaysFromList reads the owner's kind 10002 stored on this relay and@@ -131,17 +138,32 @@ export async function runRound(relay: Relay, job: Job): Promise<{ more: boolean;
131138 132139 // A pull job syncs its sources one after another; each source is a pull133140 // in the sense of pull.ts, with the job's filter.134-async function runPullSourceRound(relay: Relay, job: Job): Promise<{ more: boolean; error: string }> {141+export async function runPullSourceRound(relay: Relay, job: Job, connect?: PullConnect): Promise<{ more: boolean; error: string }> {135142 if (job.relayIndex >= job.relays.length) return { more: false, error: "" };136- const sub: PullJob = { url: job.relays[job.relayIndex], startedAt: job.startedAt, rounds: 0, stored: 0, skipped: 0, blobs: 0, failures: 0 };143+ const sources = job.pullSources ??= job.relays.map((url) => ({ url, ...newPullProgress(), stored: 0, skipped: 0, blobs: 0 }));144+ const source = sources[job.relayIndex];145+ const sub: PullJob = { url: source.url, startedAt: job.startedAt, rounds: 0, stored: 0, skipped: 0, blobs: 0, failures: 0, progress: source };137146 if (job.filter.authors || job.filter.kinds || job.filter.since) sub.filter = job.filter;138- const r = await runPullRound(relay, sub);147+ const r = await runPullRound(relay, sub, connect);139148 job.rounds++;140149 job.stored += sub.stored;141150 job.skipped += sub.skipped;142151 job.blobs += sub.blobs;143- if (r.error) return r;144- if (!r.more) job.relayIndex++;152+ source.stored += sub.stored;153+ source.skipped += sub.skipped;154+ source.blobs += sub.blobs;155+ if (r.error) {156+ source.failures++;157+ const refused = /auth-required:|query refused:|files refused:/.test(r.error);158+ if (!refused && source.failures < 3) return r;159+ source.status = refused ? "refused" : "failed";160+ source.error = r.error;161+ job.relayIndex++;162+ } else {163+ source.failures = 0;164+ source.error = "";165+ if (!r.more) job.relayIndex++;166+ }145167 return { more: job.relayIndex < job.relays.length, error: "" };146168 }147169 @@ -155,34 +177,47 @@ async function runPushRound(relay: Relay, job: Job): Promise<{ more: boolean; er
155177 if (job.filter.authors) f.authors = job.filter.authors;156178 if (job.filter.kinds) f.kinds = job.filter.kinds;157179 if (job.filter.since) f.since = job.filter.since;158- const rows = relay.store.after(job.cursor, f, PUSH_BATCH, now());159- if (rows.length === 0) return { more: false, error: "" };180+ job.targetCursors ??= {};181+ job.targetStatus ??= {};182+ job.targetAttempts ??= {};160183 const membersOnly = relay.settings.policy.reads === "members";161- const events: Event[] = [];162- for (const r of rows) {163- const e = JSON.parse(r.raw) as Event;164- if (hasTag(e, "-") || (membersOnly && isPrivate(e.kind))) {165- job.skipped++;166- continue;167- }168- events.push(e);169- }170- let failed = "";171- let reached = 0;184+ let more = false, failed = "", reached = 0;185+ const legacyCursor = job.cursor;186+ // Materialize every target's starting point before any target can advance187+ // the legacy cursor. This is the compatibility bridge for old jobs.188+ for (const url of job.relays) if (job.targetCursors[url] === undefined) job.targetCursors[url] = legacyCursor;172189 for (const url of job.relays) {190+ if ((job.targetAttempts[url] ?? 0) >= 3) continue;191+ const cursor = job.targetCursors[url] ?? 0;192+ const rows = relay.store.after(cursor, f, PUSH_BATCH, now());193+ if (rows.length === 0) { job.targetStatus[url] = { status: "accepted", error: "", at: now() }; continue; }194+ more = more || rows.length === PUSH_BATCH;195+ const events: Event[] = [];196+ for (const row of rows) {197+ const e = JSON.parse(row.raw) as Event;198+ if (hasTag(e, "-") || (membersOnly && isPrivate(e.kind))) { job.skipped++; continue; }199+ events.push(e);200+ }173201 try {202+ const refusedBefore = job.refused;174203 await pushTo(relay, url, events, job);175204 reached++;205+ job.targetCursors[url] = rows[rows.length - 1].seq;206+ job.targetStatus[url] = { status: job.refused > refusedBefore ? "rejected" : "accepted", error: job.refused > refusedBefore ? "Some events were explicitly refused." : "", at: now() };207+ job.targetAttempts[url] = 0;208+ job.cursor = Math.max(job.cursor, rows[rows.length - 1].seq);176209 } catch (err) {177210 failed = url + ": " + (err instanceof Error ? err.message : String(err));211+ job.targetAttempts[url] = (job.targetAttempts[url] ?? 0) + 1;212+ job.targetStatus[url] = { status: job.targetAttempts[url] >= 3 ? "rejected" : "pending", error: failed, at: now() };213+ more = true;178214 }179215 }180- if (reached === 0 && events.length) return { more: false, error: failed };181- job.cursor = rows[rows.length - 1].seq;182- return { more: rows.length === PUSH_BATCH, error: "" };216+ if (reached === 0 && failed) return { more: true, error: failed };217+ return { more, error: "" };183218 }184219 185-async function pushTo(relay: Relay, url: string, events: Event[], job: Job) {220+async function pushTo(relay: Relay, url: string, events: Event[], job: Job): Promise<void> {186221 if (events.length === 0) return;187222 const sock = new Socket(await dial(relay, url));188223 try {@@ -193,7 +228,7 @@ async function pushTo(relay: Relay, url: string, events: Event[], job: Job) {
193228 for (const e of batch) sock.send("EVENT", e);194229 const deadline = Date.now() + PUSH_TIMEOUT_MS;195230 while (pending.size && Date.now() < deadline) {196- const m = await sock.recv();231+ const m = await sock.recv(deadline - Date.now());197232 if (m[0] !== "OK" || typeof m[1] !== "string" || !pending.has(m[1])) continue;198233 pending.delete(m[1]);199234 const msg = String(m[3] ?? "");@@ -209,7 +244,7 @@ async function pushTo(relay: Relay, url: string, events: Event[], job: Job) {
209244 job.refused += pending.size;210245 throw new Error("the relay stopped answering");211246 }212- if (refusedInARow >= REFUSALS_PER_TARGET) return;247+ if (refusedInARow >= REFUSALS_PER_TARGET) throw new Error("target refused too many events");213248 }214249 } finally {215250 sock.close();@@ -230,11 +265,16 @@ export function startRun(job: Job, t: number) {
230265 job.sent = 0;231266 job.refused = 0;232267 job.duplicates = 0;268+ if (job.kind === "push") job.targetAttempts = {};269+ if (job.kind === "pull") job.pullSources = job.relays.map((url) => ({ url, ...newPullProgress(), stored: 0, skipped: 0, blobs: 0 }));233270 }234271 235272 // finishRun closes the current run and schedules the next one.236273 export function finishRun(job: Job, error: string, t: number) {237- job.last = { finishedAt: t, error, rounds: job.rounds, stored: job.stored, skipped: job.skipped, blobs: job.blobs, sent: job.sent, refused: job.refused, duplicates: job.duplicates ?? 0 };274+ if (!error && job.kind === "push" && Object.values(job.targetStatus ?? {}).some((s) => s.status === "rejected")) error = "Some delivery targets refused events or exhausted retries; inspect target results.";275+ const incomplete = job.pullSources?.filter((s) => ["partial", "refused", "failed"].includes(s.status));276+ if (!error && incomplete?.length) error = `${incomplete.length} import source(s) incomplete; inspect Source results for details.`;277+ job.last = { finishedAt: t, error, rounds: job.rounds, stored: job.stored, skipped: job.skipped, blobs: job.blobs, sent: job.sent, refused: job.refused, duplicates: job.duplicates ?? 0, ...(job.pullSources ? { sources: structuredClone(job.pullSources) } : {}) };238278 job.running = false;239279 job.nextRun = job.every > 0 ? t + job.every * 3600 : 0;240280 }@@ -0,0 +1,82 @@
1+import { expiration, type Event } from "./event.ts";2+3+export type UnsignedEvent = { kind: number; created_at: number; tags: string[][]; content: string };4+type HistorySQL = <T extends Record<string, SqlStorageValue>>(q: string, ...args: unknown[]) => SqlStorageCursor<T>;5+6+// List history keeps a small private undo trail for the replaceable lists that7+// are difficult to recreate. It is keyed by the signing pubkey, never indexed8+// as events, and only the same signer can ask for a version back.9+export const LIST_KINDS = [3, 10002, 10003, 30003] as const;10+export const LIST_HISTORY_LIMIT = 12;11+export const LIST_HISTORY_OWNER_LIMIT = 96;12+export const LIST_HISTORY_GLOBAL_LIMIT = 4096;13+export const LIST_HISTORY_SCHEMA = `14+CREATE TABLE IF NOT EXISTS list_history (15+ owner TEXT NOT NULL,16+ kind INTEGER NOT NULL,17+ d TEXT NOT NULL DEFAULT '',18+ event_id TEXT NOT NULL,19+ created_at INTEGER NOT NULL,20+ saved_at INTEGER NOT NULL,21+ expires INTEGER NOT NULL DEFAULT 0,22+ raw TEXT NOT NULL,23+ PRIMARY KEY (owner, kind, d, event_id)24+);25+CREATE INDEX IF NOT EXISTS list_history_owner ON list_history(owner, saved_at DESC);26+CREATE INDEX IF NOT EXISTS list_history_list ON list_history(owner, kind, d, created_at DESC);27+`;28+29+const listKind = (kind: number) => (LIST_KINDS as readonly number[]).includes(kind);30+export const isListKind = listKind;31+const listD = (e: Pick<Event, "kind" | "tags">) => e.kind >= 30000 ? e.tags.find((t: Event["tags"][number]) => t[0] === "d")?.[1] ?? "" : "";32+33+// archiveCurrent records the version about to be replaced, then trims the34+// owner's trail. The current event remains available through ordinary NIP-0135+// queries; history is an internal recovery aid.36+export const archiveCurrent = (x: HistorySQL, e: Event, savedAt: number) => {37+ if (!listKind(e.kind)) return;38+ const d = listD(e);39+ x(`INSERT OR IGNORE INTO list_history(owner,kind,d,event_id,created_at,saved_at,expires,raw) VALUES(?,?,?,?,?,?,?,?)`, e.pubkey, e.kind, d, e.id, e.created_at, savedAt, expiration(e), JSON.stringify(e));40+ x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? AND kind=? AND d=? ORDER BY created_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.kind, d, e.pubkey, e.kind, d, LIST_HISTORY_LIMIT);41+ x(`DELETE FROM list_history WHERE owner=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? ORDER BY saved_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.pubkey, LIST_HISTORY_OWNER_LIMIT);42+ x(`DELETE FROM list_history WHERE event_id NOT IN (SELECT event_id FROM list_history ORDER BY saved_at DESC, event_id ASC LIMIT ?)`, LIST_HISTORY_GLOBAL_LIMIT);43+};44+45+// clearForDelete removes versions an author's NIP-09 deletion names. A list46+// deletion must not leave private old tags recoverable through this feature.47+export const clearForDelete = (x: HistorySQL, author: string, eventID: string) => {48+ x(`DELETE FROM list_history WHERE owner=? AND event_id=?`, author, eventID);49+};50+51+export const clearList = (x: HistorySQL, owner: string, kind: number, d: string, before?: number) => {52+ if (before === undefined) x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=?`, owner, kind, d);53+ else x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND created_at<=?`, owner, kind, d, before);54+};55+56+export interface ListHistoryRow { owner: string; kind: number; d: string; event_id: string; created_at: number; saved_at: number; raw: string; }57+58+export const listHistory = (x: HistorySQL, owner: string, now: number): Omit<ListHistoryRow, "owner" | "raw">[] => {59+ x(`DELETE FROM list_history WHERE owner=? AND expires>0 AND expires<=?`, owner, now);60+ return x<Omit<ListHistoryRow, "owner" | "raw">>(`SELECT kind,d,event_id,created_at,saved_at FROM list_history WHERE owner=? AND (expires=0 OR expires>?) ORDER BY saved_at DESC LIMIT ?`, owner, now, LIST_HISTORY_OWNER_LIMIT).toArray();61+};62+63+export const restoreHistory = (x: HistorySQL, owner: string, eventID: string, now: number): { draft: UnsignedEvent; diff: { addedTags: string[][]; removedTags: string[][]; contentChanged: boolean } } | string => {64+ x(`DELETE FROM list_history WHERE owner=? AND expires>0 AND expires<=?`, owner, now);65+ const row = x<{ raw: string }>(`SELECT raw FROM list_history WHERE owner=? AND event_id=? AND (expires=0 OR expires>?)`, owner, eventID, now).toArray()[0];66+ if (!row) return "not found";67+ try {68+ const event = JSON.parse(row.raw) as Event;69+ const current = x<{ created_at: number; raw: string }>(`SELECT created_at,raw FROM events WHERE pubkey=? AND kind=? AND d=?`, owner, event.kind, listD(event)).toArray()[0];70+ let currentEvent: Event | undefined;71+ try { currentEvent = current ? JSON.parse(current.raw) as Event : undefined; } catch { /* saved current rows are validated events */ }72+ const key = (t: string[]) => JSON.stringify(t);73+ const currentTags = new Set((currentEvent?.tags ?? []).map(key));74+ const oldTags = new Set(event.tags.map(key));75+ return {76+ draft: { kind: event.kind, created_at: Math.max(Math.floor(Date.now() / 1000), (current?.created_at ?? 0) + 1), tags: event.tags, content: event.content },77+ diff: { addedTags: event.tags.filter((t) => !currentTags.has(key(t))), removedTags: (currentEvent?.tags ?? []).filter((t) => !oldTags.has(key(t))), contentChanged: (currentEvent?.content ?? "") !== event.content },78+ };79+ } catch {80+ return "error: saved list is unreadable";81+ }82+};@@ -11,6 +11,7 @@ import type { Relay } from "./relay.ts";
1111 import { CODE_RE, inviteCreator, listClaims, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";1212 import { descriptor, type Blob } from "./blossom.ts";1313 import { badBlockedWord, blockedWords, policyPatch, type Policy, type Settings } from "./settings.ts";14+import { deliveryStatus } from "./delivery.ts";1415 import { applyConfig, exportConfig, parseConfig, planConfig } from "./config.ts";1516 import { isReplaceable, isProtected, validIP } from "./settings.ts";1617 import { checkPullURL } from "./pull.ts";@@ -26,6 +27,7 @@ import { addDomain, checkDomain, listDomains, removeDomain, setDomainSite } from
2627 import { verifyNIP98 } from "./auth.ts";2728 import { SITE_KINDS, checkSite, siteLabel, sitePaths } from "./sites.ts";2829 import { gitStorage } from "./git-storage.ts";30+import { createBackup, deleteBackup, listBackups } from "./backups.ts";2931 3032 // A call: the relay and the request, who is calling and as what, the3133 // parameters with their readers, and how to answer.@@ -56,6 +58,7 @@ interface Method {
5658 reads?: true;5759 // Plain members may manage their own invitations when the tree is open.5860 ownInvites?: true;61+ ownListHistory?: true;5962 run: (c: Call) => Response | Promise<Response>;6063 }6164 @@ -256,6 +259,18 @@ export const METHODS: Record<string, Method> = {
256259 return reply({ result: listClaims(relay.sql, t, role === "member" ? caller : "") });257260 },258261 },262+ listlisthistory: {263+ action: "read", reads: true, ownListHistory: true,264+ run: ({ relay, caller, t, reply }) => reply({ result: relay.store.listHistory(caller, t) }),265+ },266+ restorelist: {267+ action: "read", reads: true, ownListHistory: true,268+ run: ({ relay, caller, params, str, hex64, reply }) => {269+ if (params.length !== 1 || !hex64(str(0))) return reply({ error: "invalid: give one saved list event id" }, 400);270+ const restored = relay.store.restoreHistory(caller, str(0), now());271+ return typeof restored === "string" ? reply({ error: restored }, 404) : reply({ result: restored });272+ },273+ },259274 createclaim: {260275 action: "invites", ownInvites: true,261276 run: ({ relay, s, t, caller, role, params, str, reply }) => {@@ -618,6 +633,7 @@ export const METHODS: Record<string, Method> = {
618633 },619634 pullstatus: { action: "jobs", reads: true, run: async ({ relay, reply }) => reply({ result: await relay.pullStatus() }) },620635 listjobs: { action: "jobs", reads: true, run: async ({ relay, reply }) => reply({ result: await relay.jobs() }) },636+ deliverystatus: { action: "jobs", reads: true, run: ({ relay, reply }) => reply({ result: deliveryStatus(relay) }) },621637 addjob: {622638 action: "jobs",623639 run: async ({ relay, params, reply }) => {@@ -665,6 +681,16 @@ export const METHODS: Record<string, Method> = {
665681 },666682 },667683 dumpnow: { action: "storage", run: async ({ relay, t, reply }) => reply({ result: await writeDump(relay, t) }) },684+ backupnow: {685+ action: "storage",686+ run: async ({ relay, params, reply }) => {687+ const id = typeof params[0] === "string" && params[0] ? params[0] : `backup-${now()}`;688+ const result = await createBackup(relay, id);689+ return typeof result === "string" ? reply({ error: result }, result.startsWith("invalid:") ? 400 : 403) : reply({ result: { ...result.manifest, url: "/backups/" + id } });690+ },691+ },692+ listbackups: { action: "storage", reads: true, run: async ({ relay, reply }) => reply({ result: await listBackups(relay) }) },693+ deletebackup: { action: "storage", run: async ({ relay, str, reply }) => reply({ result: await deleteBackup(relay, str(0)) }) },668694 setsuccession: {669695 action: "transfer",670696 run: async ({ relay, s, params, str, num, hex64, reply }) => {@@ -794,8 +820,9 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
794820 // A plain member reaches their own invites when the owner opened the795821 // invite tree (memberInvites); the invite methods keep them to their own.796822 const ownInvites = role === "member" && p.memberInvites.depth > 0 && m.ownInvites;823+ const ownListHistory = role !== null && m.ownListHistory;797824 if (role === "owner") void relay.succession.seen(caller);798- if (!ownInvites && !can(role, m.action)) {825+ if (!ownInvites && !ownListHistory && !can(role, m.action)) {799826 const why = role === "moderator" ? "restricted: moderators cannot do that" : p.owner !== "" ? "restricted: not the relay owner" : s.isLeased() ? "restricted: this is a temporary relay; claim it first" : "restricted: this relay is unclaimed";800827 return reply({ error: why }, 403);801828 }@@ -1,6 +1,6 @@
1-// Pull: this relay fetches what another relay has and it lacks. One round2-// is one connection: reconcile with NIP-77, take a bounded batch of the3-// missing events, and, for a relay on this host, a batch of its files.1+// Pull: bounded history import uses NIP-77 when available and ordinary2+// NIP-01 queries otherwise. Query windows survive between alarm rounds;3+// their completion is best effort, since a relay may silently cap results.44 // The alarm calls rounds until nothing is missing (see jobs.ts), so a pull55 // survives the object sleeping, and running it again only fetches what is66 // new. An optional filter narrows the reconciliation: authors for a@@ -29,7 +29,32 @@ export interface PullJob {
2929 blobs: number;3030 failures: number;3131 filter?: PullFilter;32+ progress?: PullProgress;3233 }34+export interface PullProgress {35+ mode: "negentropy" | "query";36+ status: "pending" | "running" | "complete" | "best-effort" | "partial" | "refused" | "failed";37+ windows?: { since: number; until: number }[];38+ pages: number;39+ failures: number;40+ error: string;41+ warning: string;42+ partial: boolean;43+}44+export interface PullSource extends PullProgress {45+ url: string;46+ stored: number;47+ skipped: number;48+ blobs: number;49+}50+export const newPullProgress = (): PullProgress => ({ mode: "negentropy", status: "pending", pages: 0, failures: 0, error: "", warning: "", partial: false });51+export interface PullSocket {52+ send(...msg: unknown[]): void;53+ recv(timeout?: number): Promise<unknown[]>;54+ close(): void;55+}56+export type PullConnect = (relay: Relay, url: string) => Promise<PullSocket>;57+const connectPull: PullConnect = async (relay, url) => new Socket(await dial(relay, url));3358 export interface PullResult extends PullJob {3459 finishedAt: number;3560 error: string;@@ -40,6 +65,10 @@ const ROUND_BLOBS = 20;
4065 const IDS_PER_REQ = 100;4166 const MAX_ITEMS = 100_000;4267 const MESSAGE_TIMEOUT_MS = 20_000;68+export const QUERY_PAGE = 500;69+const QUERY_PAGES = 2048;70+const ROUND_MESSAGES = 4096;71+const ROUND_MS = 25_000;4372 4473 // localName is the relay name when the URL is one of ours, else "".4574 export function localName(url: URL, domain: string): string {@@ -81,22 +110,29 @@ export async function dial(relay: Relay, raw: string): Promise<WebSocket> {
81110 82111 // Socket turns a websocket into a queue of parsed messages.83112 export class Socket {84- private queue: unknown[][] = [];113+ private queue: { message: unknown[]; size: number }[] = [];85114 private waiters: { res: (m: unknown[]) => void; rej: (e: Error) => void }[] = [];86115 private closed: Error | null = null;116+ private queuedBytes = 0;87117 88118 constructor(private ws: WebSocket) {89119 ws.addEventListener("message", (e) => {120+ const raw = typeof e.data === "string" ? e.data : "";121+ if (raw.length > 4 * 1024 * 1024 || this.queue.length >= 2048 || this.queuedBytes + raw.length > 16 * 1024 * 1024) {122+ end("relay response exceeded the import buffer limit");123+ this.close();124+ return;125+ }90126 let m: unknown;91127 try {92- m = JSON.parse(typeof e.data === "string" ? e.data : "");128+ m = JSON.parse(raw);93129 } catch {94130 return;95131 }96132 if (!Array.isArray(m)) return;97133 const w = this.waiters.shift();98134 if (w) w.res(m);99- else this.queue.push(m);135+ else { this.queue.push({ message: m, size: raw.length }); this.queuedBytes += raw.length; }100136 });101137 const end = (why: string) => {102138 this.closed = new Error(why);@@ -110,15 +146,15 @@ export class Socket {
110146 this.ws.send(JSON.stringify(msg));111147 }112148 113- recv(): Promise<unknown[]> {149+ recv(timeout = MESSAGE_TIMEOUT_MS): Promise<unknown[]> {114150 const m = this.queue.shift();115- if (m) return Promise.resolve(m);151+ if (m) { this.queuedBytes -= m.size; return Promise.resolve(m.message); }116152 if (this.closed) return Promise.reject(this.closed);117153 return new Promise((res, rej) => {118154 const timer = setTimeout(() => {119155 this.waiters = this.waiters.filter((w) => w.res !== ok);120156 rej(new Error("the relay stopped answering"));121- }, MESSAGE_TIMEOUT_MS);157+ }, Math.max(1, Math.min(MESSAGE_TIMEOUT_MS, timeout)));122158 const ok = (m: unknown[]) => {123159 clearTimeout(timer);124160 res(m);@@ -138,31 +174,140 @@ export class Socket {
138174 139175 // runPullRound does one round of the job. more: call again. error: the140176 // round failed and nothing more was learned.141-export async function runPullRound(relay: Relay, job: PullJob): Promise<{ more: boolean; error: string }> {177+export async function runPullRound(relay: Relay, job: PullJob, connect: PullConnect = connectPull): Promise<{ more: boolean; error: string }> {142178 job.rounds++;143- let sock: Socket | null = null;179+ const progress = job.progress ??= newPullProgress();180+ progress.status = "running";181+ let sock: PullSocket | null = null;144182 try {145- sock = new Socket(await dial(relay, job.url));146- const need = await reconcile(relay, sock, job.filter ?? {});183+ sock = await connect(relay, job.url);184+ if (progress.mode === "query") return await queryRound(relay, sock, job);185+ if (progress.pages >= QUERY_PAGES) {186+ progress.status = "partial";187+ progress.warning = "Import stopped at its round budget; narrow the filter and run again.";188+ return { more: false, error: "" };189+ }190+ let need: string[];191+ try {192+ need = await reconcile(relay, sock, job.filter ?? {});193+ } catch (err) {194+ progress.mode = "query";195+ progress.warning = "NIP-77 unavailable: " + reason(err);196+ progress.windows = [{ since: job.filter?.since ?? 0, until: job.startedAt }];197+ return { more: true, error: "" };198+ }199+ progress.pages++;200+ const storedBefore = job.stored;201+ const deadline = Date.now() + ROUND_MS;147202 const want = need.slice(0, ROUND_EVENTS);148- for (let i = 0; i < want.length; i += IDS_PER_REQ) await fetchEvents(relay, sock, want.slice(i, i + IDS_PER_REQ), job);203+ for (let i = 0; i < want.length; i += IDS_PER_REQ) await fetchEvents(relay, sock, want.slice(i, i + IDS_PER_REQ), job, deadline);149204 let more = need.length > want.length;205+ if (want.length && job.stored === storedBefore) {206+ progress.partial = true;207+ progress.warning = "The source advertises missing events that it did not supply or this relay cannot admit.";208+ more = false;209+ }150210 if (!more && !job.filter) {151211 // Files come along on a whole-relay pull; a filtered pull is about events.152212 const local = localName(new URL(job.url), relay.domain);153213 if (local) more = await copyBlobs(relay, local, job);154214 }215+ if (!more) progress.status = progress.partial ? "partial" : "complete";155216 return { more, error: "" };156217 } catch (err) {157- return { more: false, error: err instanceof Error ? err.message : String(err) };218+ const error = reason(err);219+ progress.error = error;220+ return { more: false, error };158221 } finally {159222 sock?.close();160223 }161224 }162225 226+const reason = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 300);227+228+// queryRound stores one bounded time window. Full windows divide rather229+// than skipping timestamps; a full single second remains explicitly partial.230+async function queryRound(relay: Relay, sock: PullSocket, job: PullJob): Promise<{ more: boolean; error: string }> {231+ const p = job.progress!;232+ const windows = p.windows ??= [{ since: job.filter?.since ?? 0, until: job.startedAt }];233+ if (!windows.length) return { more: false, error: "" };234+ if (p.pages >= QUERY_PAGES) {235+ p.status = "partial";236+ p.warning = "Import stopped at the query page budget; narrow the time or author filter and run again.";237+ return { more: false, error: "" };238+ }239+ const window = windows[windows.length - 1];240+ const sub = "history";241+ const wire = { ...job.filter, ...window, limit: QUERY_PAGE };242+ sock.send("REQ", sub, wire);243+ const ids = new Set<string>();244+ let received = 0;245+ let invalid = false;246+ let auth = false;247+ const deadline = Date.now() + ROUND_MS;248+ for (let messages = 0; ; messages++) {249+ if (messages >= ROUND_MESSAGES || Date.now() >= deadline) throw new Error(auth ? "auth-required: source needs authentication; no user key is shared for imports" : "source did not complete its query within the import limits");250+ let m: unknown[];251+ try { m = await sock.recv(deadline - Date.now()); }252+ catch (err) { if (auth) throw new Error("auth-required: source needs authentication; no user key is shared for imports"); throw err; }253+ if (m[0] === "AUTH") auth = true;254+ if (m[0] === "EOSE" && m[1] === sub) break;255+ if (m[0] === "CLOSED" && m[1] === sub) throw new Error("query refused: " + String(m[2]).slice(0, 200));256+ if (m[0] !== "EVENT" || m[1] !== sub) continue;257+ received++;258+ if (received > QUERY_PAGE) throw new Error("source exceeded the requested event limit");259+ const event = m[2];260+ if (validate(event)) { job.skipped++; invalid = true; continue; }261+ const e = event as Event;262+ if (!matchesPull(e, job.filter) || e.created_at < window.since || e.created_at > window.until || ids.has(e.id)) {263+ job.skipped++; invalid = true; continue;264+ }265+ ids.add(e.id);266+ storePulled(relay, e, job);267+ }268+ sock.send("CLOSE", sub);269+ windows.pop();270+ p.pages++;271+ if (invalid) { p.partial = true; p.warning = "Source returned invalid, duplicate or out-of-filter events; coverage is incomplete."; }272+ if (received >= QUERY_PAGE) {273+ if (window.since >= window.until) {274+ p.partial = true;275+ p.warning = "A one-second window reached the result limit; events sharing that timestamp may be missing.";276+ } else {277+ const middle = window.since + Math.floor((window.until - window.since) / 2);278+ windows.push({ since: window.since, until: middle }, { since: middle + 1, until: window.until });279+ }280+ }281+ if (!windows.length) {282+ p.status = p.partial ? "partial" : "best-effort";283+ if (!p.partial) p.warning = "Query scan finished; the source may silently cap or omit events, so complete history is not guaranteed.";284+ }285+ return { more: windows.length > 0, error: "" };286+}287+288+// matchesPull checks source-supplied events against the requested filter.289+function matchesPull(e: Event, f?: PullFilter): boolean {290+ return !f || ((!f.authors?.length || f.authors.includes(e.pubkey)) && (!f.kinds?.length || f.kinds.includes(e.kind)) && (f.since === undefined || e.created_at >= f.since));291+}292+293+// storePulled applies normal host admission and counts each accepted event.294+function storePulled(relay: Relay, e: Event, job: PullJob) {295+ if (!relay.settings.kindAllowed(e.kind)) { job.skipped++; markRejected(job); return; }296+ const r = relay.accept(e, null);297+ if (r.stored) { job.stored++; relay.broadcast(e, false); }298+ else if (r.msg !== ERR_DUPLICATE) { job.skipped++; markRejected(job); }299+}300+301+// markRejected preserves admission gaps in the source result.302+function markRejected(job: PullJob) {303+ if (!job.progress) return;304+ job.progress.partial = true;305+ job.progress.warning = "Some source events could not be admitted by this relay; coverage is incomplete.";306+}307+163308 // reconcile runs NIP-77 as the initiator over the filter and returns the164309 // ids the other side has that we do not.165-async function reconcile(relay: Relay, sock: Socket, filter: PullFilter): Promise<string[]> {310+async function reconcile(relay: Relay, sock: PullSocket, filter: PullFilter): Promise<string[]> {166311 const f: Filter = { tags: {} };167312 const wire: Record<string, unknown> = {};168313 if (filter.authors?.length) f.authors = wire.authors = filter.authors;@@ -174,12 +319,15 @@ async function reconcile(relay: Relay, sock: Socket, filter: PullFilter): Promis
174319 const id = "pull";175320 sock.send("NEG-OPEN", id, wire, bytesToHex(neg.initiate()));176321 const need: string[] = [];177- for (;;) {178- const m = await sock.recv();322+ const deadline = Date.now() + ROUND_MS;323+ for (let messages = 0; ; messages++) {324+ if (messages >= ROUND_MESSAGES || Date.now() >= deadline) throw new Error("sync exceeded its round budget");325+ const m = await sock.recv(deadline - Date.now());179326 if (m[0] === "NEG-ERR" && m[1] === id) throw new Error("sync refused: " + String(m[2]));180327 if (m[0] !== "NEG-MSG" || m[1] !== id) continue; // AUTH challenges, notices181328 const r = neg.reconcile(hexToBytes(String(m[2])));182329 need.push(...r.need);330+ if (need.length > MAX_ITEMS) throw new Error("sync result exceeded the import limit");183331 if (r.reply === null) break;184332 sock.send("NEG-MSG", id, bytesToHex(r.reply));185333 }@@ -190,28 +338,30 @@ async function reconcile(relay: Relay, sock: Socket, filter: PullFilter): Promis
190338 // fetchEvents asks for a batch by id and stores what checks out. Signatures191339 // are verified, bans and kind rules apply, the write policy does not: the192340 // owner asked for these.193-async function fetchEvents(relay: Relay, sock: Socket, ids: string[], job: PullJob) {341+async function fetchEvents(relay: Relay, sock: PullSocket, ids: string[], job: PullJob, deadline: number) {194342 const sub = "pull-" + job.stored + "-" + job.skipped;195343 sock.send("REQ", sub, { ids });196- for (;;) {197- const m = await sock.recv();344+ const seen = new Set<string>();345+ for (let messages = 0; ; messages++) {346+ if (messages >= ROUND_MESSAGES || Date.now() >= deadline) throw new Error("source exceeded its event response budget");347+ const m = await sock.recv(deadline - Date.now());198348 if (m[0] === "EOSE" && m[1] === sub) break;199349 if (m[0] === "CLOSED" && m[1] === sub) throw new Error("query refused: " + String(m[2]));200350 if (m[0] !== "EVENT" || m[1] !== sub) continue;201351 const e = m[2] as Event;202- const f = job.filter;203- const outside = !!f && ((f.authors?.length && !f.authors.includes(e.pubkey)) || (f.kinds?.length && !f.kinds.includes(e.kind)) || (f.since && e.created_at < f.since));204- if (validate(e) || !ids.includes(e.id) || outside || !relay.settings.kindAllowed(e.kind)) {352+ if (validate(e) || !ids.includes(e.id) || !matchesPull(e, job.filter) || seen.has(e.id)) {205353 job.skipped++;354+ markRejected(job);206355 continue;207356 }208- const r = relay.accept(e, null);209- if (r.stored) {210- job.stored++;211- relay.broadcast(e);212- } else if (r.msg !== ERR_DUPLICATE) job.skipped++;357+ seen.add(e.id);358+ storePulled(relay, e, job);213359 }214360 sock.send("CLOSE", sub);361+ if (seen.size < ids.length && job.progress) {362+ job.progress.partial = true;363+ job.progress.warning = "The source did not supply all events advertised by its sync response.";364+ }215365 }216366 217367 // copyBlobs brings over a batch of the other relay's files. Returns true@@ -3,6 +3,8 @@
33 // relay.go; policy is per relay and owner-managed (see manage.ts).44 import { callbackOrigins } from "./push-policy.ts";55 import { pushTick, queuePush, nextPush, PUSH_SCHEMA } from "./push.ts";6+import { BACKUP_SCHEMA, backupBytes } from "./backups.ts";7+import { DELIVERY_SCHEMA, deliveryTick, queueDelivery } from "./delivery.ts";68 import { graspTick, isGitPath, graspCORS } from "./grasp.ts";79 import { graspBytes, holdGrasp, graspVisible } from "./grasp-state.ts";810 import { queueMirrors } from "./site-mirror.ts";@@ -156,7 +158,9 @@ export class Relay extends DurableObject<Env> {
156158 this.store.init();157159 this.settings.load();158160 this.sql.exec(PUSH_SCHEMA);159- this.store.hidden = this.settings.hiddenEvents;161+ this.sql.exec(DELIVERY_SCHEMA);162+ this.sql.exec(BACKUP_SCHEMA);163+ this.store.hidden = this.settings.hiddenEvents;160164 this.store.searchMode = () => this.settings.policy.features.search;161165 this.fuel.init();162166 this.slug = (await ctx.storage.get<string>("slug")) ?? "";@@ -498,6 +502,8 @@ export class Relay extends DurableObject<Env> {
498502 this.store.init();499503 this.settings.load();500504 this.sql.exec(PUSH_SCHEMA);505+ this.sql.exec(DELIVERY_SCHEMA);506+ this.sql.exec(BACKUP_SCHEMA);501507 this.store.hidden = this.settings.hiddenEvents;502508 this.store.searchMode = () => this.settings.policy.features.search;503509 this.fuel.init();@@ -705,6 +711,7 @@ export class Relay extends DurableObject<Env> {
705711 // A finished run is worth a word to the owner, when they asked for one.706712 const finish = (error: string) => {707713 finishRun(job, error, now());714+ error = job.last?.error ?? error;708715 const where = (job.kind === "push" ? "to " : "from ") + job.relays.join(", ");709716 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" : ""}`;710717 void notify(this, "jobs", `${job.label} ${where} ${outcome}.`, "jobs on " + this.slug);@@ -764,7 +771,7 @@ export class Relay extends DurableObject<Env> {
764771 765772 // Files and dumps both live in R2 and both cost media storage.766773 mediaBytes(): number {767- return blobBytes(this.sql) + dumpBytes(this.sql) + importBytes(this.sql) + graspBytes(this);774+ return blobBytes(this.sql) + dumpBytes(this.sql) + importBytes(this.sql) + graspBytes(this) + backupBytes(this);768775 }769776 770777 fuelStatus() {@@ -1127,6 +1134,7 @@ export class Relay extends DurableObject<Env> {
11271134 await this.teardown();11281135 return;11291136 }1137+ const deliveryAt = await deliveryTick(this);11301138 const graspAt = await graspTick(this);11311139 await this.syncSites();11321140 await queueMirrors(this);@@ -1185,6 +1193,7 @@ export class Relay extends DurableObject<Env> {
11851193 if (graspAt && graspAt < at) at = graspAt;11861194 const pushAt = nextPush(this);11871195 if (pushAt) at = Math.min(at, Math.max(pushAt, now() + 1));1196+ if (deliveryAt) at = Math.min(at, Math.max(deliveryAt, now() + 1));11881197 await this.ctx.storage.setAlarm(at * 1000 + 500);11891198 }, async () => { await this.ctx.storage.setAlarm(Date.now() + 1000); });11901199 }@@ -1226,8 +1235,9 @@ export class Relay extends DurableObject<Env> {
12261235 return [...(f.authors ?? []), ...(f.tags.p ?? [])].some((k) => parties.includes(k));12271236 }12281237 1229- broadcast(e: Event) {1238+ broadcast(e: Event, route = true) {12301239 if (!graspVisible(this, e.id)) return;1240+ if (route && queueDelivery(this, e)) this.ctx.waitUntil(this.ensureAlarm(now() + 1));12311241 if (queuePush(this, e)) this.ctx.waitUntil(this.ensureAlarm(now() + 1));12321242 const raw = canonical(e);12331243 for (const ws of this.ctx.getWebSockets()) {@@ -15,6 +15,7 @@ import { isWebAddressRequest, webAddress } from "./nipad.ts";
1515 import { verifyNIP98, whoAsks } from "./auth.ts";1616 import { checkInvite, claimInviteRequest, invitePage, termsPage } from "./invites.ts";1717 import { dumpDownload } from "./dumps.ts";18+import { backupDownload, restoreBackupRequest } from "./backups.ts";1819 import { serveView } from "./views.ts";1920 import { importUpload } from "./imports.ts";2021 import { blossom, isBlobPath } from "./blossom.ts";@@ -102,6 +103,8 @@ export const ROUTES: Route[] = [
102103 { when: post(is("/api/invites/claim")), gated: true, answer: claimInviteRequest },103104 // Dumps, views, imports (dumps.ts, views.ts, imports.ts).104105 { when: get(under("/dumps/")), answer: dumpDownload },106+ { when: get(under("/backups/")), answer: (relay, req, url) => backupDownload(relay, req, url.pathname.slice("/backups/".length)) },107+ { when: (url, req) => req.method === "POST" && (url.pathname === "/backups/restore" || url.pathname === "/backups/preview"), answer: restoreBackupRequest },105108 { when: get(under("/view/")), answer: (relay, req) => serveView(relay, req, verifyNIP98) },106109 { when: (url, req) => req.method === "PUT" && url.pathname === "/import", answer: importUpload },107110 // Files: Blossom on its paths (blossom.ts), NIP-96 on its own (nip96.ts).@@ -77,6 +77,8 @@ export interface Policy {
7777 features: Features;7878 pushCallbacks: string[]; // HTTPS origins approved by the owner and the host operator7979 letteredNips: boolean; // opt into mixed NIP-11 identifiers; push also requires them80+ // Opt-in NIP-65 routing of locally-authored public events to their relays.81+ delivery: { enabled: boolean; maxTargets: number };8082 }8183 8284 export const VIEW_NAMES = ["profiles", "relays", "calendar", "moderation", "articles", "zaps", "presence"];@@ -134,6 +136,10 @@ export function policyPatch(patch: Record<string, unknown>, cur: Policy): Partia
134136 const clean: Record<string, unknown> = {};135137 for (const k of ["name", "description", "icon", "contact"]) if (typeof patch[k] === "string") clean[k] = (patch[k] as string).slice(0, 2000);136138 Object.assign(clean, publicFields(patch), dumpFields(patch), gateFields(patch), viewFields(patch, cur.views), featureFields(patch, cur.features));139+ if (patch.delivery && typeof patch.delivery === "object") {140+ const d = patch.delivery as Record<string, unknown>;141+ clean.delivery = { enabled: typeof d.enabled === "boolean" ? d.enabled : cur.delivery.enabled, maxTargets: Number.isInteger(d.maxTargets) ? Math.max(1, Math.min(16, d.maxTargets as number)) : cur.delivery.maxTargets };142+ }137143 if (isWriteRule(patch.writes)) clean.writes = patch.writes;138144 if (patch.reads === "open" || patch.reads === "auth" || patch.reads === "members") clean.reads = patch.reads;139145 if (typeof patch.joinTerms === "string") clean.joinTerms = patch.joinTerms.slice(0, 20000);@@ -201,6 +207,7 @@ export const DEFAULT_POLICY: Policy = {
201207 features: { ...DEFAULT_FEATURES },202208 pushCallbacks: [],203209 letteredNips: false,210+ delivery: { enabled: false, maxTargets: 8 },204211 };205212 206213 export const SETTINGS_SCHEMA = `@@ -7,6 +7,7 @@ import { ftsQuery, searchTerms, type Filter } from "./filter.ts";
77 import { SITE_SCHEMA, SITE_KINDS } from "./sites.ts";88 import { HLL } from "./hll.ts";99 import { hexToBytes, type SyncItem } from "./negentropy.ts";10+import { archiveCurrent, clearForDelete, clearList, isListKind, listHistory, restoreHistory, LIST_HISTORY_SCHEMA, type ListHistoryRow } from "./list-history.ts";1011 1112 export const SCHEMA = `1213 CREATE TABLE IF NOT EXISTS events (@@ -82,10 +83,14 @@ export class Store {
8283 8384 onSitesChanged: () => void = () => {};8485 86+ private historySQL = <T extends Record<string, SqlStorageValue>>(q: string, ...args: unknown[]) => this.x<T>(q, ...args);87+8588 constructor(private sql: SqlStorage) {}8689 8790 init() {8891 this.sql.exec(SCHEMA);92+ this.sql.exec(LIST_HISTORY_SCHEMA);93+ try { this.sql.exec(`ALTER TABLE list_history ADD COLUMN expires INTEGER NOT NULL DEFAULT 0`); } catch { /* already migrated */ }8994 this.sql.exec(SITE_SCHEMA);9095 this.sql.exec(GRASP_SCHEMA);9196 }@@ -136,11 +141,19 @@ export class Store {
136141 if (isReplaceable(e.kind)) {137142 if (has(`SELECT 1 FROM events WHERE pubkey=? AND kind=? AND (created_at>? OR (created_at=? AND id<?)) LIMIT 1`, e.pubkey, e.kind, e.created_at, e.created_at, e.id))138143 return ERR_REPLACED;144+ if (isListKind(e.kind)) {145+ const current = this.x<{ raw: string }>(`SELECT raw FROM events WHERE pubkey=? AND kind=?`, e.pubkey, e.kind).toArray()[0];146+ if (current) archiveCurrent(this.historySQL, JSON.parse(current.raw) as Event, now);147+ }139148 this.x(`DELETE FROM events WHERE pubkey=? AND kind=?`, e.pubkey, e.kind);140149 } else if (isAddressable(e.kind)) {141150 d = tag(e, "d");142151 if (has(`SELECT 1 FROM events WHERE pubkey=? AND kind=? AND d=? AND (created_at>? OR (created_at=? AND id<?)) LIMIT 1`, e.pubkey, e.kind, d, e.created_at, e.created_at, e.id))143152 return ERR_REPLACED;153+ if (isListKind(e.kind)) {154+ const current = this.x<{ raw: string }>(`SELECT raw FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, e.kind, d).toArray()[0];155+ if (current) archiveCurrent(this.historySQL, JSON.parse(current.raw) as Event, now);156+ }144157 this.x(`DELETE FROM events WHERE pubkey=? AND kind=? AND d=?`, e.pubkey, e.kind, d);145158 } else if (e.kind === 5) {146159 for (const t of e.tags) {@@ -150,10 +163,13 @@ export class Store {
150163 `DELETE FROM events WHERE id=? AND (pubkey=? OR (kind=1059 AND EXISTS (SELECT 1 FROM tags WHERE tags.event_id=events.id AND name='p' AND value=?)))`,151164 t[1], e.pubkey, e.pubkey,152165 );166+ clearForDelete(this.historySQL, e.pubkey, t[1]);153167 } else if (t[0] === "a") {154168 const parts = t[1].split(":");155- if (parts.length === 3 && parts[1] === e.pubkey) {156- this.x(`DELETE FROM events WHERE kind=? AND pubkey=? AND d=? AND created_at<=?`, parseInt(parts[0], 10) || 0, e.pubkey, parts[2], e.created_at);169+ if (parts.length >= 3 && parts[1] === e.pubkey) {170+ const addressD = parts.slice(2).join(":");171+ this.x(`DELETE FROM events WHERE kind=? AND pubkey=? AND d=? AND created_at<=?`, parseInt(parts[0], 10) || 0, e.pubkey, addressD, e.created_at);172+ clearList(this.historySQL, e.pubkey, parseInt(parts[0], 10) || 0, addressD, e.created_at);157173 }158174 }159175 }@@ -178,6 +194,7 @@ export class Store {
178194 this.x(`INSERT INTO vanished(pubkey,until) VALUES(?,?) ON CONFLICT(pubkey) DO UPDATE SET until=max(until,excluded.until)`, pubkey, until);179195 this.x(`DELETE FROM events WHERE pubkey=? AND created_at<=?`, pubkey, until);180196 this.x(`DELETE FROM events WHERE kind=1059 AND id IN (SELECT event_id FROM tags WHERE name='p' AND value=?)`, pubkey);197+ this.x(`DELETE FROM list_history WHERE owner=?`, pubkey);181198 }182199 183200 // sweepExpired deletes NIP-40 expired rows and returns the next expiry, or 0.@@ -364,6 +381,7 @@ export class Store {
364381 this.x(`DELETE FROM events WHERE pubkey=?`, pubkey);365382 this.bytesByAuthor.delete(pubkey);366383 }384+ this.x(`DELETE FROM list_history WHERE owner=?`, pubkey);367385 return n;368386 }369387 @@ -376,9 +394,21 @@ export class Store {
376394 this.x(`DELETE FROM events WHERE pubkey=? AND created_at<? AND kind NOT IN (SELECT value FROM json_each(?))`, pubkey, before, list);377395 this.bytesByAuthor.delete(pubkey);378396 }397+ this.x(`DELETE FROM list_history WHERE owner=? AND created_at<?`, pubkey, before);379398 return n;380399 }381400 401+ // listHistory returns only metadata for versions signed by owner; raw event402+ // content stays behind restoreHistory and the same owner check.403+ listHistory(owner: string, now: number): Omit<ListHistoryRow, "owner" | "raw">[] {404+ return listHistory(this.historySQL, owner, now);405+ }406+407+ // restoreHistory returns a fresh unsigned draft and its current-list diff.408+ restoreHistory(owner: string, eventID: string, now: number): ReturnType<typeof restoreHistory> {409+ return restoreHistory(this.historySQL, owner, eventID, now);410+ }411+382412 // dumpPage reads a page of raw events by sequence for the JSONL dump.383413 dumpPage(afterSeq: number, limit: number): { seq: number; raw: string }[] {384414 return this.x<{ seq: number; raw: string }>(`SELECT seq, raw FROM events WHERE seq>? AND kind <> ${KIND_PUSH_REGISTRATION} ORDER BY seq LIMIT ?`, afterSeq, limit).toArray();@@ -0,0 +1,152 @@
1+// Portable backup archives cover configuration, events, site media and Git2+// bytes, while refusing tampering, the wrong signer and non-fresh targets.3+import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { describe, expect, it } from "vitest";5+import { generateSecretKey } from "nostr-tools/pure";6+import { BACKUP_MAX_BYTES, createBackup, restoreBackup } from "../../src/backups.ts";7+import { storeBlob } from "../../src/blossom.ts";8+import type { Relay } from "../../src/relay.ts";9+import { ev, nip98, pk, rpc } from "../helpers/relay.ts";10+import { WS } from "../helpers/ws.ts";11+12+describe("portable backups", () => {13+ it("serves owner-only download and keeps signed preview read-only", async () => {14+ const source = "backup-http-source.bind.ws";15+ const target = "backup-http-target.bind.ws";16+ const owner = generateSecretKey();17+ const stranger = generateSecretKey();18+ await rpc(source, owner, "claim");19+ const archive = await runInDurableObject(env.RELAY.getByName("backup-http-source"), async (relay: Relay) => {20+ await relay.store.save(ev(owner, 1, "http backup"), 1);21+ expect(typeof await createBackup(relay, "http-test")).not.toBe("string");22+ const object = await relay.media.get("backup-http-source/backups/http-test.json");23+ return new Uint8Array(await object!.arrayBuffer());24+ });25+ const downloadURL = `http://${source}/backups/http-test`;26+ expect((await SELF.fetch(downloadURL)).status).toBe(401);27+ expect((await SELF.fetch(downloadURL, { headers: { authorization: await nip98(stranger, downloadURL) } })).status).toBe(403);28+ const downloaded = await SELF.fetch(downloadURL, { headers: { authorization: await nip98(owner, downloadURL) } });29+ expect(downloaded.status).toBe(200);30+ expect(new Uint8Array(await downloaded.arrayBuffer())).toEqual(archive);31+32+ const previewURL = `http://${target}/backups/preview`;33+ const before = await SELF.fetch(`http://${target}/`, { headers: { accept: "application/nostr+json" } });34+ const preview = await SELF.fetch(previewURL, { method: "POST", headers: { authorization: await nip98(owner, previewURL, "POST", JSON.parse(new TextDecoder().decode(archive))) }, body: new TextDecoder().decode(archive) });35+ expect(preview.status).toBe(200);36+ expect((await preview.clone().json<any>()).result).toMatchObject({ preview: true, targetIsFresh: true });37+ expect((await preview.clone().json<any>()).result.events).toBeGreaterThanOrEqual(1);38+ const after = await SELF.fetch(`http://${target}/`, { headers: { accept: "application/nostr+json" } });39+ expect(await before.text()).toBe(await after.text());40+ // The preview response is metadata, not a restore capability or archive.41+ const previewBody = await preview.text();42+ const restoreWithPreview = await SELF.fetch(`http://${target}/backups/restore`, { method: "POST", headers: { authorization: await nip98(owner, `http://${target}/backups/restore`, "POST", JSON.parse(previewBody)) }, body: previewBody });43+ expect(restoreWithPreview.status).toBe(400);44+ const restoreURL = `http://${target}/backups/restore`;45+ const body = new TextDecoder().decode(archive);46+ const previewToken = await nip98(owner, previewURL, "POST", JSON.parse(body));47+ expect((await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: previewToken }, body })).status).toBe(401);48+ const restored = await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: await nip98(owner, restoreURL, "POST", JSON.parse(body)) }, body });49+ expect(restored.status, await restored.clone().text()).toBe(200);50+ expect((await restored.json<any>()).result.restored).toBe(true);51+ });52+53+ it("rejects a tampered signed body and a valid body signed by the wrong owner", async () => {54+ const source = "backup-http-tamper-source.bind.ws";55+ const target = "backup-http-tamper-target.bind.ws";56+ const owner = generateSecretKey();57+ const wrong = generateSecretKey();58+ await rpc(source, owner, "claim");59+ const archive = await runInDurableObject(env.RELAY.getByName("backup-http-tamper-source"), async (relay: Relay) => {60+ await relay.store.save(ev(owner, 1, "tamper"), 1);61+ await createBackup(relay, "tamper-http");62+ const object = await relay.media.get("backup-http-tamper-source/backups/tamper-http.json");63+ return new TextDecoder().decode(await object!.arrayBuffer());64+ });65+ const restoreURL = `http://${target}/backups/restore`;66+ const changed = archive.slice(0, -2) + (archive.endsWith("}") ? " ]" : " }");67+ const badAuth = await nip98(owner, restoreURL, "POST", JSON.parse(archive));68+ expect((await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: badAuth }, body: changed })).status).toBe(401);69+ const wrongAuth = await nip98(wrong, restoreURL, "POST", JSON.parse(archive));70+ const denied = await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: wrongAuth }, body: archive });71+ expect(denied.status).toBe(403);72+ expect(await denied.text()).toContain("backup owner");73+ });74+75+ it("caps chunked restore uploads without a Content-Length", async () => {76+ const url = "http://backup-stream-cap.bind.ws/backups/restore";77+ const response = await SELF.fetch(url, { method: "POST", headers: { authorization: await nip98(generateSecretKey(), url, "POST") }, body: new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(BACKUP_MAX_BYTES)); controller.enqueue(new Uint8Array(1)); controller.close(); } }) });78+ expect(response.status).toBe(413);79+ });80+81+ it("round trips configuration, events, a media blob and a Git object, with preview and exclusions", async () => {82+ const source = "backup-source.bind.ws";83+ const owner = generateSecretKey(), writer = generateSecretKey();84+ await rpc(source, owner, "claim");85+ await rpc(source, owner, "setpolicy", { name: "Recovered", reads: "members" });86+ const c = await WS.connect(source);87+ await c.ok(ev(writer, 1, "retained"));88+ const archive = await runInDurableObject(env.RELAY.getByName("backup-source"), async (relay: Relay) => {89+ const oldList = ev(owner, 3, "older", [], 10);90+ relay.store.save(oldList, 10);91+ relay.store.save(ev(owner, 3, "latest", [], 11), 11);92+ const held = ev(writer, 1, "hidden state");93+ relay.store.save(held, 12);94+ relay.settings.setEvent(held.id, "hide");95+ relay.sql.exec(`INSERT INTO grasp_pending(id,until) VALUES(?,?)`, held.id, 9999999999);96+ await storeBlob(relay, new TextEncoder().encode("site bytes"), "text/plain", pk(writer), 10);97+ await relay.media.put("backup-source/git/test-object", new TextEncoder().encode("git bytes"));98+ await relay.store.save(ev(owner, 30390, '{"callback":"https://secret.invalid"}'), 10);99+ const result = await createBackup(relay, "roundtrip");100+ expect(typeof result).not.toBe("string");101+ const object = await relay.media.get("backup-source/backups/roundtrip.json");102+ const bytes = new Uint8Array(await object!.arrayBuffer());103+ expect(new TextDecoder().decode(bytes)).not.toContain("secret.invalid");104+ return bytes;105+ });106+ const preview = await runInDurableObject(env.RELAY.getByName("backup-target"), async (relay: Relay) => {107+ relay.slug = "backup-target";108+ const before = Number(relay.sql.exec(`SELECT count(*) AS n FROM events`).one().n ?? 0);109+ const result = await restoreBackup(relay, archive, pk(owner));110+ expect(typeof result).not.toBe("string");111+ expect(relay.sql.exec(`SELECT count(*) AS n FROM events`).one().n).toBeGreaterThan(before);112+ return result as { blobs: number; git: number };113+ });114+ expect(preview.blobs).toBe(1);115+ expect(preview.git).toBe(1);116+ await runInDurableObject(env.RELAY.getByName("backup-target"), async (relay: Relay) => {117+ expect(relay.settings.policy.name).toBe("Recovered");118+ expect(relay.store.listHistory(pk(owner), Math.floor(Date.now() / 1000))).toHaveLength(1);119+ expect(relay.settings.hiddenEvents.size).toBe(1);120+ expect(relay.sql.exec(`SELECT count(*) n FROM grasp_pending`).one().n).toBe(1);121+ expect((await relay.media.get("backup-target/" + ""))).toBeNull();122+ expect((await relay.media.get("backup-target/" + "git/test-object"))?.size).toBe(9);123+ });124+ });125+126+ it("rejects tampering and wrong owner before changing a fresh target, and rejects a non-fresh target", async () => {127+ const source = "backup-tamper.bind.ws";128+ const owner = generateSecretKey(), wrong = generateSecretKey();129+ await rpc(source, owner, "claim");130+ const archive = await runInDurableObject(env.RELAY.getByName("backup-tamper"), async (relay: Relay) => {131+ await relay.store.save(ev(owner, 1, "safe"), 1);132+ await createBackup(relay, "tamper");133+ const object = await relay.media.get("backup-tamper/backups/tamper.json");134+ return new Uint8Array(await object!.arrayBuffer());135+ });136+ const damaged = archive.slice(); damaged[damaged.length - 4] ^= 1;137+ const result = await runInDurableObject(env.RELAY.getByName("backup-tamper-target"), async (relay: Relay) => {138+ relay.slug = "backup-tamper-target";139+ expect(await restoreBackup(relay, damaged, pk(owner))).toMatch(/^invalid:/);140+ expect(relay.settings.policy.owner).toBe("");141+ expect(await restoreBackup(relay, archive, pk(wrong))).toContain("backup owner");142+ expect(relay.settings.policy.owner).toBe("");143+ return restoreBackup(relay, archive, pk(owner));144+ });145+ expect(typeof result).not.toBe("string");146+ const nonfresh = await runInDurableObject(env.RELAY.getByName("backup-nonfresh"), async (relay: Relay) => {147+ relay.settings.update({ owner: pk(owner) });148+ return restoreBackup(relay, archive, pk(owner));149+ });150+ expect(nonfresh).toContain("fresh");151+ });152+});@@ -0,0 +1,63 @@
1+import { env, runInDurableObject } from "cloudflare:test";2+import { deliveryTick, queueDelivery } from "../../src/delivery.ts";3+import type { Relay } from "../../src/relay.ts";4+import { describe, expect, it } from "vitest";5+import { generateSecretKey } from "nostr-tools/pure";6+import { ev, pk, rpc, alarm } from "../helpers/relay.ts";7+import { WS } from "../helpers/ws.ts";8+9+describe("NIP-65 automatic delivery", () => {10+ it("retries a refused target to its finite limit and stops on private relay policy", async () => {11+ const owner = generateSecretKey(), targetOwner = generateSecretKey();12+ const source = "retry-source.bind.ws", target = "retry-target.bind.ws";13+ await rpc(source, owner, "claim"); await rpc(target, targetOwner, "claim");14+ await rpc(target, targetOwner, "setpolicy", { writes: "owner" });15+ await rpc(source, owner, "setpolicy", { delivery: { enabled: true, maxTargets: 1 } });16+ await runInDurableObject(env.RELAY.getByName("retry-source"), async (relay: Relay) => {17+ relay.store.save(ev(owner, 10002, "", [["r", "wss://" + target]]), 1);18+ const note = ev(owner, 1, "retry me"); relay.store.save(note, 1);19+ expect(queueDelivery(relay, note)).toBe(true);20+ for (let i = 0; i < 4; i++) {21+ relay.sql.exec(`UPDATE delivery_queue SET due=0`);22+ await deliveryTick(relay);23+ }24+ expect(relay.sql.exec(`SELECT status,attempts FROM delivery_queue WHERE event_id=?`, note.id).one()).toMatchObject({ status: "rejected", attempts: 4 });25+ relay.settings.update({ reads: "members" });26+ expect(queueDelivery(relay, ev(owner, 1, "private relay note"))).toBe(false);27+ });28+ });29+30+ it("routes public events to the author's write relay and exposes target status", async () => {31+ const owner = generateSecretKey();32+ const source = "auto-source.bind.ws", target = "auto-target.bind.ws";33+ await rpc(target, generateSecretKey(), "claim");34+ await rpc(source, owner, "claim");35+ await rpc(source, owner, "setpolicy", { delivery: { enabled: true, maxTargets: 4 } });36+ const targetSocket = await WS.connect(target);37+ const sourceSocket = await WS.connect(source);38+ const list = ev(owner, 10002, "", [["r", "wss://" + target, "write"]]);39+ expect((await sourceSocket.ok(list)).ok).toBe(true);40+ const note = ev(owner, 1, "routed");41+ expect((await sourceSocket.ok(note)).ok).toBe(true);42+ await alarm("auto-source");43+ expect((await targetSocket.req({ ids: [note.id] })).map((e) => e.id)).toEqual([note.id]);44+ const status = await rpc(source, owner, "deliverystatus");45+ expect(status.result).toEqual(expect.arrayContaining([expect.objectContaining({ event_id: note.id, target: "wss://" + target, status: "accepted" })]));46+ });47+48+ it("does not queue private or protected events", async () => {49+ const owner = generateSecretKey(), friend = generateSecretKey();50+ const source = "auto-filter.bind.ws", target = "auto-filter-target.bind.ws";51+ await rpc(target, generateSecretKey(), "claim");52+ await rpc(source, owner, "claim");53+ await rpc(source, owner, "setpolicy", { delivery: { enabled: true, maxTargets: 4 } });54+ const socket = await WS.connect(source);55+ expect((await socket.ok(ev(owner, 10002, "", [["r", "wss://" + target, "write"]]))).ok).toBe(true);56+ const dm = ev(owner, 4, "secret", [["p", pk(friend)]]);57+ const protectedEvent = ev(owner, 1, "protected", [["-", ""]]);58+ expect((await socket.ok(dm)).ok).toBe(true);59+ expect((await socket.ok(protectedEvent)).ok).toBe(false);60+ const status = await rpc(source, owner, "deliverystatus");61+ expect(status.result.filter((x: any) => [dm.id, protectedEvent.id].includes(x.event_id))).toEqual([]);62+ });63+});@@ -79,6 +79,9 @@ function doors(f: Fixture): { path: string; host?: string; method?: string; gate
7979 { path: "/terms", gated: false },8080 { path: "/api/join-policy", gated: false },8181 { path: "/.well-known/nostr/nip96.json", gated: false },82+ { path: "/backups/private", gated: true },83+ { path: "/backups/preview", method: "POST", gated: true },84+ { path: "/backups/restore", method: "POST", gated: true },8285 ];8386 }8487 @@ -40,6 +40,8 @@ const announcement = (sk: Uint8Array, host: string, identifier: string) => ev(sk
4040 ]);4141 4242 describe("GRASP checkpoint integration", () => {43+ // Each migration performs 260 sequential commits and retained-object scans.44+ // Hosted CI takes over 30 seconds for the growing-ref fixture.4345 it.each(["growing", "fixed"])("migrates an isolated %s-ref repository and reconciles retained storage", async (workload) => {4446 const host = `grasp-checkpoint-${workload}.bind.ws`;4547 const owner = generateSecretKey();@@ -139,7 +141,7 @@ describe("GRASP checkpoint integration", () => {
139141 expect(measurements.retained.currentBytes).toBeLessThan(measurements.retained.physicalBytes);140142 expect(measurements.retained.categories["unreferenced/records"]).toBeUndefined();141143 expect(measurements.retained.categories["unreferenced/packs"]).toBeUndefined();142- });144+ }, 90_000);143145 144146 it("retains a reservation when an immutable checkpoint write is ambiguous", async () => {145147 const host = "grasp-checkpoint-ambiguous.bind.ws";@@ -125,6 +125,43 @@ describe("rebroadcast", () => {
125125 });126126 });127127 128+describe("rebroadcast cursor safety", () => {129+ it("preserves a failed target independently and resumes it after a successful target", async () => {130+ const owner = generateSecretKey(), targetOwner = generateSecretKey();131+ const src = "mixed-source.bind.ws", good = "mixed-good.bind.ws", bad = "mixed-bad.bind.ws";132+ const events = Array.from({ length: 6 }, (_, i) => ev(owner, 1, "mixed " + i));133+ await relay(src, owner, events);134+ await relay(good, targetOwner);135+ const blocked = await relay(bad, targetOwner);136+ await rpc(bad, targetOwner, "setpolicy", { writes: "owner" });137+ const added = await rpc(src, owner, "addjob", { kind: "push", relays: ["wss://" + good, "wss://" + bad], filter: { kinds: [1] } });138+ let job = (await drive(src, owner)).find((j) => j.id === added.result.id)!;139+ expect(job.targetCursors?.["wss://" + good]).toBeGreaterThan(0);140+ expect(job.targetCursors?.["wss://" + bad]).toBe(0);141+ expect(job.last?.error).toBeTruthy();142+ await rpc(bad, targetOwner, "setpolicy", { writes: "open" });143+ await rpc(src, owner, "runjob", job.id);144+ job = (await drive(src, owner)).find((j) => j.id === added.result.id)!;145+ expect(job.last?.sent).toBe(6);146+ expect((await blocked.req({ kinds: [1] })).map((e) => e.id).sort()).toEqual(events.map((e) => e.id).sort());147+ });148+149+ it("does not advance a target past an unprocessed refusal tail", async () => {150+ const owner = generateSecretKey();151+ const src = "cursor-tail.bind.ws", target = "cursor-tail-target.bind.ws";152+ const events = Array.from({ length: 6 }, (_, i) => ev(owner, 7, "tail-" + i));153+ await relay(src, owner, events);154+ const targetOwner = generateSecretKey();155+ await relay(target, targetOwner);156+ await rpc(target, targetOwner, "setpolicy", { writes: "owner" });157+ const started = await rpc(src, owner, "addjob", { kind: "push", relays: ["wss://" + target], filter: { kinds: [7] } });158+ const jobs = await drive(src, owner);159+ const job = jobs.find((j) => j.id === started.result.id)!;160+ expect(job.targetCursors?.["wss://" + target]).toBe(0);161+ expect((await (await WS.connect(target)).req({ kinds: [7] })).length).toBe(0);162+ });163+});164+128165 describe("standing jobs", () => {129166 it("runs a recurring pull on its interval, caps standing jobs, and can be removed", async () => {130167 const owner = generateSecretKey();@@ -192,7 +192,7 @@ describe("pull from another relay", () => {
192192 expect(last.skipped).toBe(1);193193 });194194 195- it("gives up on a source that refuses to sync, and says why", async () => {195+ it("falls back from sync and records a source that also refuses ordinary queries", async () => {196196 const owner = generateSecretKey();197197 const closedHost = "closed.bind.ws";198198 await rpc(closedHost, owner, "claim");@@ -200,7 +200,9 @@ describe("pull from another relay", () => {
200200 const host = "puller.bind.ws";201201 await rpc(host, owner, "claim");202202 const last = await pull(host, owner, "wss://" + closedHost);203- expect(last.error).toMatch(/^sync refused: auth-required/);204- expect(last.rounds).toBe(3);203+ expect(last.error).toMatch(/import source.*incomplete/);204+ expect(last.rounds).toBe(2);205+ const jobs = (await rpc(host, owner, "listjobs")).result;206+ expect(jobs[0].last.sources[0]).toMatchObject({ mode: "query", status: "refused", error: expect.stringContaining("auth-required") });205207 });206208 });@@ -0,0 +1,102 @@
1+import { env, runInDurableObject } from "cloudflare:test";2+import { describe, expect, it } from "vitest";3+import { generateSecretKey } from "nostr-tools/pure";4+import type { Relay } from "../../src/relay.ts";5+import { ev, now, pk, rpc } from "../helpers/relay.ts";6+7+describe("private list recovery", () => {8+ it("keeps bounded private versions and returns an unsigned owner restore", async () => {9+ const host = "list-history.bind.ws";10+ const owner = generateSecretKey();11+ const other = generateSecretKey();12+ await rpc(host, owner, "claim");13+ const base = now();14+ await runInDurableObject(env.RELAY.getByName("list-history"), (relay: Relay) => {15+ for (let i = 0; i < 14; i++) expect(relay.store.save(ev(owner, 10002, "v" + i, [["r", "wss://relay" + i]], base + i), base + i)).toBe("");16+ expect(relay.store.save(ev(other, 10002, "other", [], base + 20), base + 20)).toBe("");17+ expect(relay.store.save(ev(owner, 10003, "bookmarks", [], base + 21), base + 21)).toBe("");18+ });19+ const history = (await rpc(host, owner, "listlisthistory")).result as any[];20+ expect(history.length).toBe(12);21+ expect(history.every((x) => x.owner === undefined && x.kind === 10002 || x.kind === 10003)).toBe(true);22+ expect((await rpc(host, other, "listlisthistory")).status).toBe(403);23+ const chosen = history.find((x) => x.kind === 10002);24+ const restored = (await rpc(host, owner, "restorelist", chosen.event_id)).result;25+ expect(restored.draft).toMatchObject({ kind: 10002, content: expect.stringMatching(/^v/) });26+ expect(restored.draft).not.toHaveProperty("sig");27+ expect(restored.draft).not.toHaveProperty("pubkey");28+ expect(restored.diff).toMatchObject({ contentChanged: true });29+ expect(restored.draft.created_at).toBeGreaterThanOrEqual(base + 14);30+ expect((await rpc(host, other, "restorelist", chosen.event_id)).status).toBe(403);31+ await runInDurableObject(env.RELAY.getByName("list-history"), (relay: Relay) => {32+ expect(relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM list_history WHERE owner=?`, pk(owner)).one().n).toBeLessThanOrEqual(96);33+ });34+ });35+36+ it("clears prior versions when the author vanishes", async () => {37+ const host = "list-history-vanish.bind.ws";38+ const owner = generateSecretKey();39+ await rpc(host, owner, "claim");40+ await runInDurableObject(env.RELAY.getByName("list-history-vanish"), (relay: Relay) => {41+ relay.store.save(ev(owner, 3, "old"), now());42+ relay.store.save(ev(owner, 3, "new"), now() + 1);43+ relay.store.vanish(pk(owner), now() + 100);44+ });45+ expect((await rpc(host, owner, "listlisthistory")).result).toEqual([]);46+ });47+48+ it("caps an owner's history across distinct bookmark sets", async () => {49+ const host = "list-history-owner-cap.bind.ws";50+ const owner = generateSecretKey();51+ await rpc(host, owner, "claim");52+ await runInDurableObject(env.RELAY.getByName("list-history-owner-cap"), (relay: Relay) => {53+ const base = now();54+ for (let i = 0; i < 100; i++) {55+ expect(relay.store.save(ev(owner, 30003, "old-" + i, [["d", "set-" + i]], base + i * 2), base + i * 2)).toBe("");56+ expect(relay.store.save(ev(owner, 30003, "new-" + i, [["d", "set-" + i]], base + i * 2 + 1), base + i * 2 + 1)).toBe("");57+ }58+ expect(relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM list_history WHERE owner=?`, pk(owner)).one().n).toBe(96);59+ });60+ });61+62+ it("keeps member history private and clears colon-containing address history on deletion", async () => {63+ const host = "list-history-privacy.bind.ws";64+ const owner = generateSecretKey();65+ const member = generateSecretKey();66+ await rpc(host, owner, "claim");67+ await rpc(host, owner, "setmember", pk(member));68+ let ownerHistoryID = "";69+ await runInDurableObject(env.RELAY.getByName("list-history-privacy"), (relay: Relay) => {70+ const base = now();71+ relay.store.save(ev(owner, 10002, "owner-old", [], base), base);72+ const ownerCurrent = ev(owner, 10002, "owner-new", [], base + 1);73+ relay.store.save(ownerCurrent, base + 1);74+ ownerHistoryID = ownerCurrent.id;75+ relay.store.save(ev(member, 10002, "member-old", [], base + 2), base + 2);76+ relay.store.save(ev(member, 10002, "member-new", [], base + 3), base + 3);77+ relay.store.save(ev(owner, 30003, "set-old", [["d", "set:with:colon"]], base + 4), base + 4);78+ relay.store.save(ev(owner, 30003, "set-new", [["d", "set:with:colon"]], base + 5), base + 5);79+ });80+ const memberHistory = (await rpc(host, member, "listlisthistory")).result as any[];81+ expect(memberHistory).toHaveLength(1);82+ expect(memberHistory[0].kind).toBe(10002);83+ expect((await rpc(host, member, "restorelist", ownerHistoryID)).status).toBe(404);84+ await runInDurableObject(env.RELAY.getByName("list-history-privacy"), (relay: Relay) => {85+ const deletion = ev(owner, 5, "", [["a", `30003:${pk(owner)}:set:with:colon`]], now() + 10);86+ expect(relay.store.save(deletion, deletion.created_at)).toBe("");87+ expect(relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM list_history WHERE owner=? AND kind=30003`, pk(owner)).one().n).toBe(0);88+ });89+ });90+91+ it("does not restore an expired saved list", async () => {92+ const host = "list-history-expiry.bind.ws";93+ const owner = generateSecretKey();94+ await rpc(host, owner, "claim");95+ await runInDurableObject(env.RELAY.getByName("list-history-expiry"), (relay: Relay) => {96+ const base = now();97+ relay.store.save(ev(owner, 10003, "expired", [["expiration", String(base - 1)]], base), base);98+ relay.store.save(ev(owner, 10003, "current", [], base + 1), base + 1);99+ });100+ expect((await rpc(host, owner, "listlisthistory")).result).toEqual([]);101+ });102+});@@ -0,0 +1,225 @@
1+import { env, runInDurableObject } from "cloudflare:test";2+import { describe, expect, it } from "vitest";3+import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";4+import type { Relay } from "../../src/relay.ts";5+import { Socket, dial, runPullRound, type PullJob, type PullSocket } from "../../src/pull.ts";6+import { runPullSourceRound, type Job } from "../../src/jobs.ts";7+import { ev, now, rpc } from "../helpers/relay.ts";8+import { WS } from "../helpers/ws.ts";9+10+async function claimed(host: string, owner: Uint8Array) {11+ await rpc(host, owner, "claim");12+ return WS.connect(host);13+}14+15+async function run(name: string, job: PullJob) {16+ return runInDurableObject(env.RELAY.getByName(name), async (relay: Relay) => runPullRound(relay, job));17+}18+19+class RefusingSocket implements PullSocket {20+ constructor(private query: boolean) {}21+ send() { /* scripted refusal */ }22+ async recv() { return (this.query ? ["CLOSED", "history", "auth-required: source refused history"] : ["NEG-ERR", "pull", "unsupported: sync is switched off"]) as unknown[]; }23+ close() { /* scripted socket */ }24+}25+26+class ScriptedSocket implements PullSocket {27+ constructor(private messages: unknown[][]) {}28+ send() { /* scripted socket */ }29+ async recv() { return this.messages.shift() ?? ["EOSE", "history"]; }30+ close() { /* scripted socket */ }31+}32+33+describe("history pull fallback", () => {34+ it("falls back from a refused NIP-77 sync and resumes ordinary queries", async () => {35+ const owner = generateSecretKey();36+ const source = "pull-query-source.bind.ws";37+ const destination = "pull-query-destination.bind.ws";38+ const sourceClient = await claimed(source, owner);39+ await rpc(source, owner, "setpolicy", { features: { sync: false } });40+ const first = ev(owner, 1, "first", [], now() - 20);41+ const second = ev(owner, 1, "second", [], now() - 10);42+ expect((await sourceClient.ok(first)).ok).toBe(true);43+ expect((await sourceClient.ok(second)).ok).toBe(true);44+ await claimed(destination, owner);45+46+ const job: PullJob = {47+ url: "wss://" + source,48+ startedAt: now(),49+ rounds: 0,50+ stored: 0,51+ skipped: 0,52+ blobs: 0,53+ failures: 0,54+ filter: { authors: [getPublicKey(owner)] },55+ };56+ const firstRound = await run("pull-query-destination", job);57+ expect(firstRound).toEqual({ more: true, error: "" });58+ expect(job.progress).toMatchObject({ mode: "query", status: "running", pages: 0 });59+ expect(job.progress?.windows).toEqual([{ since: 0, until: job.startedAt }]);60+61+ const secondRound = await run("pull-query-destination", job);62+ expect(secondRound).toEqual({ more: false, error: "" });63+ expect(job.progress).toMatchObject({ mode: "query", status: "best-effort", pages: 1, partial: false });64+ expect(job.stored).toBe(2);65+ });66+67+ it("marks a saturated one-second query window partial", async () => {68+ const owner = generateSecretKey();69+ const source = "pull-query-saturated-source.bind.ws";70+ const destination = "pull-query-saturated-destination.bind.ws";71+ await claimed(source, owner);72+ await rpc(source, owner, "setpolicy", { features: { sync: false } });73+ const timestamp = now() - 30;74+ const events: Event[] = [];75+ await runInDurableObject(env.RELAY.getByName("pull-query-saturated-source"), async (relay: Relay) => {76+ for (let i = 0; i < 501; i++) {77+ const event = ev(owner, 1, "same timestamp " + i, [], timestamp);78+ events.push(event);79+ expect(relay.store.save(event, now())).toBe("");80+ }81+ });82+ await claimed(destination, owner);83+ const job: PullJob = {84+ url: "wss://" + source,85+ startedAt: timestamp,86+ rounds: 0,87+ stored: 0,88+ skipped: 0,89+ blobs: 0,90+ failures: 0,91+ filter: { authors: [getPublicKey(owner)], since: timestamp },92+ };93+ expect((await run("pull-query-saturated-destination", job)).more).toBe(true);94+ const result = await run("pull-query-saturated-destination", job);95+ expect(result).toEqual({ more: false, error: "" });96+ expect(job.progress).toMatchObject({ mode: "query", status: "partial", pages: 1, partial: true });97+ expect(job.progress?.warning).toMatch(/one-second window reached/);98+ expect(job.stored).toBe(500);99+ expect(events).toHaveLength(501);100+ });101+102+ it("advances past a refused source and resumes the next source after JSON persistence", async () => {103+ const owner = generateSecretKey();104+ const source = "pull-refused-source.bind.ws";105+ const destination = "pull-refused-destination.bind.ws";106+ await claimed(source, owner);107+ await rpc(source, owner, "setpolicy", { features: { sync: false } });108+ const sourceClient = await WS.connect(source);109+ const note = ev(owner, 1, "from the second source", [], now() - 10);110+ expect((await sourceClient.ok(note)).ok).toBe(true);111+ await claimed(destination, owner);112+ const job = {113+ id: "pull-test",114+ kind: "pull",115+ label: "pull",116+ relays: ["wss://first.example", "wss://" + source],117+ filter: { authors: [getPublicKey(owner)] },118+ every: 0,119+ createdAt: now(),120+ nextRun: 0,121+ running: true,122+ startedAt: now(),123+ rounds: 0,124+ failures: 0,125+ relayIndex: 0,126+ cursor: 0,127+ stored: 0,128+ skipped: 0,129+ blobs: 0,130+ sent: 0,131+ refused: 0,132+ last: null,133+ } as Job;134+ let refusalRounds = 0;135+ const connect = async (relay: Relay, url: string): Promise<PullSocket> => {136+ if (url === "wss://first.example") return new RefusingSocket(refusalRounds++ > 0);137+ return new Socket(await dial(relay, url));138+ };139+ const first = await runInDurableObject(env.RELAY.getByName("pull-refused-destination"), async (relay: Relay) => runPullSourceRound(relay, job, connect));140+ expect(first).toEqual({ more: true, error: "" });141+ expect(job.relayIndex).toBe(0);142+ expect(job.pullSources?.[0]).toMatchObject({ mode: "query", status: "running" });143+ const persisted = JSON.parse(JSON.stringify(job)) as Job;144+ const refused = await runInDurableObject(env.RELAY.getByName("pull-refused-destination"), async (relay: Relay) => runPullSourceRound(relay, persisted, connect));145+ expect(refused).toEqual({ more: true, error: "" });146+ expect(persisted.relayIndex).toBe(1);147+ expect(persisted.pullSources?.[0]).toMatchObject({ status: "refused", error: expect.stringContaining("auth-required") });148+ const third = await runInDurableObject(env.RELAY.getByName("pull-refused-destination"), async (relay: Relay) => runPullSourceRound(relay, persisted));149+ expect(third).toEqual({ more: true, error: "" });150+ expect(persisted.relayIndex).toBe(1);151+ expect(persisted.pullSources?.[1]).toMatchObject({ mode: "query", status: "running" });152+ const fourth = await runInDurableObject(env.RELAY.getByName("pull-refused-destination"), async (relay: Relay) => runPullSourceRound(relay, persisted));153+ expect(fourth).toEqual({ more: false, error: "" });154+ expect(persisted.relayIndex).toBe(2);155+ expect(persisted.pullSources?.[1]).toMatchObject({ status: "best-effort", pages: 1, stored: 1 });156+ });157+158+ it("retries transient connection failures three times before advancing", async () => {159+ const owner = generateSecretKey();160+ const destination = "pull-retry-destination.bind.ws";161+ await claimed(destination, owner);162+ const job = {163+ id: "pull-retry-test", kind: "pull", label: "pull", relays: ["wss://flaky.example", "wss://next.example"], filter: {}, every: 0,164+ createdAt: now(), nextRun: 0, running: true, startedAt: now(), rounds: 0, failures: 0, relayIndex: 0, cursor: 0,165+ stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null,166+ } as Job;167+ let attempts = 0;168+ const connect = async (_relay: Relay, url: string): Promise<PullSocket> => {169+ if (url === "wss://flaky.example") { attempts++; throw new Error("temporary network failure"); }170+ return new ScriptedSocket([["NEG-ERR", "pull", "unsupported"]]);171+ };172+ for (let i = 0; i < 2; i++) {173+ const r = await runInDurableObject(env.RELAY.getByName("pull-retry-destination"), async (relay: Relay) => runPullSourceRound(relay, job, connect));174+ expect(r.error).toMatch(/temporary network failure/);175+ expect(job.relayIndex).toBe(0);176+ }177+ const third = await runInDurableObject(env.RELAY.getByName("pull-retry-destination"), async (relay: Relay) => runPullSourceRound(relay, job, connect));178+ expect(third).toEqual({ more: true, error: "" });179+ expect(attempts).toBe(3);180+ expect(job.pullSources?.[0]).toMatchObject({ status: "failed", failures: 3 });181+ expect(job.relayIndex).toBe(1);182+ });183+184+ it("skips invalid, wrong-author, and out-of-window source events", async () => {185+ const owner = generateSecretKey();186+ const stranger = generateSecretKey();187+ const destination = "pull-validation-destination.bind.ws";188+ await claimed(destination, owner);189+ const good = ev(owner, 1, "in window", [], 100);190+ const wrongAuthor = ev(stranger, 1, "wrong author", [], 100);191+ const outOfWindow = ev(owner, 1, "too old", [], 50);192+ const invalid = { ...good, content: "tampered" };193+ let round = 0;194+ const connect = async (): Promise<PullSocket> => round++ === 0195+ ? new ScriptedSocket([["NEG-ERR", "pull", "unsupported"]])196+ : new ScriptedSocket([["EVENT", "history", invalid], ["EVENT", "history", wrongAuthor], ["EVENT", "history", outOfWindow], ["EVENT", "history", good], ["EOSE", "history"]]);197+ const job: PullJob = { url: "wss://scripted.example", startedAt: 100, rounds: 0, stored: 0, skipped: 0, blobs: 0, failures: 0, filter: { authors: [getPublicKey(owner)], since: 80 } };198+ await runInDurableObject(env.RELAY.getByName("pull-validation-destination"), async (relay: Relay) => runPullRound(relay, job, connect));199+ const result = await runInDurableObject(env.RELAY.getByName("pull-validation-destination"), async (relay: Relay) => runPullRound(relay, job, connect));200+ expect(result).toEqual({ more: false, error: "" });201+ expect(job.stored).toBe(1);202+ expect(job.skipped).toBe(3);203+ expect(job.progress).toMatchObject({ status: "partial", partial: true, pages: 1 });204+ });205+206+ it("splits a saturated multi-second window and preserves the split after JSON cloning", async () => {207+ const owner = generateSecretKey();208+ const destination = "pull-split-destination.bind.ws";209+ await claimed(destination, owner);210+ const repeated = ev(owner, 1, "repeated", [], 50);211+ let round = 0;212+ const connect = async (): Promise<PullSocket> => {213+ if (round++ === 0) return new ScriptedSocket([["NEG-ERR", "pull", "unsupported"]]);214+ return new ScriptedSocket([...Array.from({ length: 500 }, () => ["EVENT", "history", repeated] as unknown[]), ["EOSE", "history"]]);215+ };216+ const job: PullJob = { url: "wss://scripted.example", startedAt: 100, rounds: 0, stored: 0, skipped: 0, blobs: 0, failures: 0, filter: { authors: [getPublicKey(owner)], since: 0 } };217+ expect((await runInDurableObject(env.RELAY.getByName("pull-split-destination"), async (relay: Relay) => runPullRound(relay, job, connect))).more).toBe(true);218+ expect((await runInDurableObject(env.RELAY.getByName("pull-split-destination"), async (relay: Relay) => runPullRound(relay, job, connect))).more).toBe(true);219+ expect(job.progress?.windows).toEqual([{ since: 0, until: 50 }, { since: 51, until: 100 }]);220+ const persisted = JSON.parse(JSON.stringify(job)) as PullJob;221+ await runInDurableObject(env.RELAY.getByName("pull-split-destination"), async (relay: Relay) => runPullRound(relay, persisted, connect));222+ expect(persisted.progress?.windows).toBeDefined();223+ expect(persisted.progress?.pages).toBe(2);224+ });225+});