Expose owner-managed policy.git limits in the console, configuration schema and NIP-11, with larger repository and relay defaults. Separate retained storage quotas from per-write limits, stream indexed Git reads under the repository fence, and support portable backups that omit Git payloads. Vendor ntig 0.6.0 with complete source provenance and add a recoverable real-Git capacity exercise. Validation: 320 tests and typecheck passed. Local and production checks rejected the restrictive policy, accepted 57 MiB through incremental pushes including a 37 MiB object, verified clone and bundle integrity, and removed the test relay.
- commit
- 6c2cfc06155b274058a386cfd63ce41867f73942
- parent
- 0795bb520ce4e9636a815dd16f1c1fc304c79622
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 5 | +- |
| M | docs/ | 2 | +- |
| M | docs/ | 65 | ++++++++-- |
| M | package-lock.json | 21 | +++- |
| M | package.json | 5 | +- |
| M | relay-config.schema.json | 20 | +++ |
| A | scripts/ | 197 | ++++++++++++++++++++++++++++++ |
| M | src/ | 47 | ++++--- |
| M | src/ | 22 | +++ |
| M | src/ | 20 | +++ |
| M | src/ | 4 | +- |
| M | src/ | 40 | +++--- |
| A | src/ | 102 | ++++++++++++++++ |
| M | src/ | 9 | +- |
| M | src/ | 138 | ++++++++++++++++----- |
| M | src/ | 3 | +- |
| M | src/ | 9 | +- |
| M | src/ | 16 | ++- |
| M | src/ | 19 | ++- |
| M | src/ | 8 | +- |
| M | src/ | 7 | +- |
| M | src/ | 33 | +++++ |
| M | src/ | 9 | +- |
| M | test/ | 13 | ++ |
| A | test/ | 63 | ++++++++++ |
| M | test/ | 38 | ++++++ |
| M | test/ | 1 | + |
| M | test/ | 32 | +++++ |
| M | vendor/ | 41 | +++--- |
| A | vendor/ | bin | |
| A | vendor/ | bin |
@@ -187,8 +187,9 @@ The database size is charged once through `eventBytes`, which includes its
187187 indexes and relay metadata. The SQLite backend removes the former R2 per-push188188 root and transaction ceiling, but it does not promise lower cost for every189189 repository. SQLite storage, compression, rows, indexes, awake time and request190-work all affect the result. Portable backups export Git through the same191-bounded section because a Durable Object database is not a portable archive.190+work all affect the result. Portable backups can include Git when it fits the191+bounded archive, or omit Git so larger repositories can be recovered with a192+client-side mirror or bundle.192193 193194 [Tangled knots](https://docs.tangled.org/knot-self-hosting-guide) provide194195 useful prior art: AT Protocol identity sits above a filesystem Git host with@@ -204,7 +204,7 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted,
204204 - `gitstorage owner identifier`: an owner-only inventory of one accepted GRASP repository. The result reports compressed and raw Git object bytes, metadata bytes, ref and receipt counts, and the relay's shared physical SQLite size. The byte breakdown is diagnostic, not another fuel charge. It never deletes data.205205 - `deleteblob sha256`.206206 - `listdumps`, `dumpnow`, `deletedump name`.207-- `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.207+- `backupnow [id]`, `listbackups`, `deletebackup id`: portable archives; owner NIP-98 downloads use `/backups/id`. Pass `{ "id": "name", "includeGit": false }` to `backupnow` when Git is too large for the bounded archive; recover large repositories separately with `git clone --mirror` or `git bundle`. 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.208208 - `listlisthistory`: private older list versions belonging to the authenticated owner, moderator or member.209209 - `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.210210 @@ -143,21 +143,55 @@ and does not promise lower cost for every repository. SQLite storage is charged
143143 through the existing events allowance. The Worker depends on the `ntig` package, whose source and package artifact144144 are recorded in [Vendored dependencies](../vendor/README.md). Its repository contract validates pack145145 structure, deltas, object dependencies, trees and commits before publication.146-The hard limits are:146+The owner can tune the Git capacity in the console's Rules → Git capacity panel,147+or in an exported configuration under `policy.git`. Values are bytes unless148+noted otherwise; omitted fields keep the current values. The editor validates149+against the same maximums used by the runtime. For example, a larger personal150+relay can set a 2 GiB retained Git allowance and 512 repositories:151+152+```json153+{154+ "format": "bind.ws/relay-config/2",155+ "policy": {156+ "git": {157+ "maxRepositories": 512,158+ "maxRelayBytes": 2147483648,159+ "maxCompressedBytes": 1073741824,160+ "maxPackBytes": 99614720,161+ "maxObjectBytes": 67108864,162+ "maxTransactionRawBytes": 67108864163+ }164+ }165+}166+```167+168+The defaults are:147169 148170 | Scope | Limit | What it controls |149171 |---|---:|---|150-| Relay | 16 repositories, 320 MiB Git storage | accepted repositories and retained compressed Git objects plus metadata across the relay |151-| Repository | 4 MiB per pack or object | one upload and one stored object |152-| Repository | 16 MiB raw, 16 MiB compressed and 16 MiB metadata | retained object payloads and their SQL records |153-| Repository | 1,024 refs, 4,096 objects and 65,536 graph edges | ref maps and verified object graphs |172+| Relay | 128 repositories, 2 GiB Git storage | accepted repositories and retained compressed Git objects plus metadata across the relay |173+| Repository | 16 MiB per pack or object | one upload and one stored object |174+| Repository | 4 GiB raw, 1 GiB compressed and 128 MiB metadata | retained object payloads and their SQL records |175+| Repository | 4,096 refs, 100,000 objects and 1,000,000 graph edges | ref maps and verified object graphs |154176 | Repository | receipt metadata within the metadata limit | retry records without a separate transaction-count ceiling |155177 178+The configurable maximums are 4,096 repositories, 6 GiB relay storage, 95 MiB179+packs, 64 MiB objects, 64 GiB raw data, 6 GiB compressed data and 1 GiB180+metadata per repository. Request limits are separately capped at 64 MiB and181+100,000 objects per transaction, with 6 GiB fetch responses. These ceilings182+protect the Worker and SQLite database; raising a relay's settings does not183+remove storage, CPU, bandwidth or fuel costs. The per-relay physical SQLite184+ceiling and backup/archive limits remain separate operational constraints.185+156186 The SQL object store reads and writes chunks, refs and receipts in native SQL157-transactions. Full clone, fetch and push load bounded Git data, while158-selective object reads use the object index. Compaction, orphan collection,159-large-pack streaming and production capacity measurements remain follow-up160-work. Expired unknown187+transactions. Clone and fetch stream selected objects from the index. Pushes remain bounded188+by the per-request pack and decoded-byte budgets; a large initial history may189+need staged pushes. Fetch currently sends complete requested reachable history,190+so incremental fetches may transfer objects the client already has. Compaction, orphan collection and191+production capacity measurements remain follow-up work. Large repositories192+should use ordinary client-side `git clone --mirror` or `git bundle` recovery;193+the portable relay backup is intentionally bounded and may omit Git payloads.194+Expired unknown161195 PR refs stay hidden when cleanup cannot commit, while immutable object history162196 keeps its bytes until the relay is torn down.163197 @@ -179,9 +213,11 @@ owns the relay. See [HTTP reference](14-http-reference.md) for the method and
179213 [Costs and margins](15-costs-and-margins.md) for its host costs and limits.180214 181215 Portable relay backups include compressed Git objects, refs and retry receipts182-within the archive's 8 MiB and 12,000-entry limits. Restore verifies repository183-identity, object hashes, dependency graphs and the receipt chain before writing184-the fresh target. See [Data and names](04-data-and-names.md).216+within the archive's 8 MiB and 12,000-entry limits. The owner can request a217+configuration/events/media backup with Git omitted when repositories are too218+large for this bounded format. Restore verifies repository identity, object219+hashes, dependency graphs and the receipt chain before writing the fresh220+target. See [Data and names](04-data-and-names.md).185221 186222 ## Proactive synchronization and archive187223 @@ -190,8 +226,9 @@ event synchronization from accepted announcement relay lists. Missing Git data
190226 is queued from signed state and accepted PR/update clone sources with an hourly191227 target subject to queued work, fuel and size limits. At most 16 Git sources are192228 considered; each connected round is bounded to 10 seconds. Failures retry after193-five minutes before returning to the hourly schedule. Packs are limited to194-4 MiB, with approximately 4.25 MiB of wire response allowance. Remote229+five minutes before returning to the hourly schedule. Packs use `policy.git.maxPackBytes` (16 MiB by default), with 1 MiB of230+additional wire response allowance. The same configured object and transaction231+bounds apply to remote verification. Remote195232 advertisements never authorize refs; signed state and accepted events are196233 rechecked under the repository fence. PR objects already uploaded to this197234 relay through GRASP-06 are copied locally into the target repository, without@@ -10,7 +10,7 @@
1010 "@noble/hashes": "^2.4.0",1111 "fflate": "0.8.3",1212 "nostr-tools": "^2.25.1",13- "ntig": "file:vendor/ntig-0.5.0.tgz"13+ "ntig": "file:vendor/ntig-0.6.0.tgz"1414 },1515 "devDependencies": {1616 "@cloudflare/vitest-pool-workers": "^0.22.0",@@ -2755,11 +2755,12 @@
27552755 "license": "MIT"27562756 },27572757 "node_modules/ntig": {2758- "version": "0.5.0",2759- "resolved": "file:vendor/ntig-0.5.0.tgz",2760- "integrity": "sha512-LuvBplFkwtDm9RYWyKY/AU3MC+/xaZ7iD+yk0pNQOhs6khRPbVhVgQo5VfiVqpC2cpviCQzI1gaqv4CNhkmGqg==",2758+ "version": "0.6.0",2759+ "resolved": "file:vendor/ntig-0.6.0.tgz",2760+ "integrity": "sha512-1ffRqrX/ab3f3sp2nYTuPmtOnoG1fnEwA9mwAud8Rk/Walnh/63nNcUWIrr/8Ouys1Vnu7tUIvEfRQquJ76ENQ==",27612761 "license": "MIT",27622762 "dependencies": {2763+ "@noble/hashes": "2.0.1",27632764 "@scure/base": "2.4.0",27642765 "fflate": "0.8.3"27652766 },@@ -2767,6 +2768,18 @@
27672768 "node": ">=22.22"27682769 }27692770 },2771+ "node_modules/ntig/node_modules/@noble/hashes": {2772+ "version": "2.0.1",2773+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",2774+ "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",2775+ "license": "MIT",2776+ "engines": {2777+ "node": ">= 20.19.0"2778+ },2779+ "funding": {2780+ "url": "https://paulmillr.com/funding/"2781+ }2782+ },27702783 "node_modules/ntig/node_modules/@scure/base": {27712784 "version": "2.4.0",27722785 "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz",@@ -21,13 +21,14 @@
2121 "dev:junk": "node scripts/dev/junk.mjs",2222 "dev:shot": "node scripts/dev/shot.mjs",2323 "dev:zaptest": "node scripts/dev/zaptest.mjs",24- "test:network": "node scripts/ops/network.mjs"24+ "test:network": "node scripts/ops/network.mjs",25+ "test:git-capacity": "node scripts/ops/git-capacity.mjs"2526 },2627 "dependencies": {2728 "@noble/hashes": "^2.4.0",2829 "fflate": "0.8.3",2930 "nostr-tools": "^2.25.1",30- "ntig": "file:vendor/ntig-0.5.0.tgz"31+ "ntig": "file:vendor/ntig-0.6.0.tgz"3132 },3233 "devDependencies": {3334 "@cloudflare/vitest-pool-workers": "^0.22.0",@@ -48,6 +48,26 @@
4848 "joinTerms": { "type": "string", "maxLength": 20000 },4949 "directoryPublic": { "type": "boolean" },5050 "maxBlobMB": { "type": "integer", "minimum": 1, "maximum": 95 },51+ "git": {52+ "type": "object",53+ "description": "GRASP Git retained-storage and request limits. Byte fields are bytes; these maxima mirror src/git-limits.ts.",54+ "additionalProperties": false,55+ "properties": {56+ "maxRepositories": { "type": "integer", "minimum": 1, "maximum": 4096, "description": "Maximum accepted repositories on this relay." },57+ "maxRelayBytes": { "type": "integer", "minimum": 1, "maximum": 6442450944, "description": "Total retained Git bytes, including compressed objects and metadata." },58+ "maxPackBytes": { "type": "integer", "minimum": 1, "maximum": 99614720, "description": "Maximum pack upload or response size." },59+ "maxObjectBytes": { "type": "integer", "minimum": 1, "maximum": 67108864, "description": "Maximum one Git object body." },60+ "maxObjects": { "type": "integer", "minimum": 1, "maximum": 500000 },61+ "maxRawBytes": { "type": "integer", "minimum": 1, "maximum": 68719476736 },62+ "maxTransactionRawBytes": { "type": "integer", "minimum": 1, "maximum": 67108864 },63+ "maxTransactionObjects": { "type": "integer", "minimum": 1, "maximum": 100000 },64+ "maxCompressedBytes": { "type": "integer", "minimum": 1, "maximum": 6442450944, "description": "Retained compressed Git object payload per repository." },65+ "maxMetadataBytes": { "type": "integer", "minimum": 1, "maximum": 1073741824 },66+ "maxRefs": { "type": "integer", "minimum": 1, "maximum": 16384 },67+ "maxGraphEdges": { "type": "integer", "minimum": 1, "maximum": 4000000 },68+ "maxFetchBytes": { "type": "integer", "minimum": 1, "maximum": 6442450944 }69+ }70+ },5171 "eventsPerMinute": { "type": "integer", "minimum": 1 },5272 "reqsPerMinute": { "type": "integer", "minimum": 1 },5373 "minPow": { "type": "integer", "minimum": 0, "maximum": 64 },@@ -0,0 +1,197 @@
1+// Disposable GRASP-01 capacity smoke. Provisions one disposable relay, applies2+// restrictive limits, proves rejection, raises policy, then proves an3+// incremental Git push and clone. A mode-0600 manifest is written before4+// network mutation for recovery.5+//6+// Run: npm run test:git-capacity7+// For a local Wrangler relay: CAPACITY_RELAY_URL=http://127.0.0.1:8787 npm run test:git-capacity8+// Recover: node scripts/ops/git-capacity.mjs cleanup <manifest.json>9+import { execFile } from "node:child_process";10+import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";11+import { homedir, tmpdir } from "node:os";12+import { join } from "node:path";13+import { randomBytes } from "node:crypto";14+import { promisify } from "node:util";15+import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";16+import { getToken } from "nostr-tools/nip98";17+import { npubEncode } from "nostr-tools/nip19";18+import WebSocket from "ws";19+20+const exec = promisify(execFile);21+const blobMiB = Number(process.env.CAPACITY_BLOB_MIB ?? 5);22+const commits = Number(process.env.CAPACITY_COMMITS ?? 4);23+const largeMiB = Number(process.env.CAPACITY_LARGE_MIB ?? 37);24+const suppliedRelayURL = process.env.CAPACITY_RELAY_URL;25+const timeoutMs = 30_000;26+if (!Number.isInteger(blobMiB) || blobMiB < 5 || blobMiB > 32) throw new Error("CAPACITY_BLOB_MIB must be 5..32");27+if (!Number.isInteger(commits) || commits < 4 || commits > 32) throw new Error("CAPACITY_COMMITS must be 4..32");28+if (!Number.isInteger(largeMiB) || largeMiB < 0 || largeMiB > 128) throw new Error("CAPACITY_LARGE_MIB must be 0..128");29+30+const http = (url) => url.replace(/^ws(s?):\/\//, "http$1://").replace(/\/$/, "");31+const ws = (url) => url.replace(/^http(s?):\/\//, "ws$1://").replace(/\/$/, "");32+const now = () => Math.floor(Date.now() / 1000);33+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));34+35+async function rpc(url, sk, method, ...params) {36+ const endpoint = `${http(url)}/`;37+ const payload = { method, params };38+ const authorization = await getToken(endpoint, "POST", (event) => finalizeEvent(event, sk), true, payload);39+ const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization }, body: JSON.stringify(payload), signal: AbortSignal.timeout(120_000) });40+ const body = await response.json();41+ if (!response.ok || body.error || body.result?.error) throw new Error(String(body.error ?? body.result?.error ?? `RPC ${response.status}`));42+ return body.result;43+}44+45+async function saveManifest(path, value) {46+ const temporary = `${path}.tmp`;47+ await writeFile(temporary, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });48+ await chmod(temporary, 0o600);49+ await rename(temporary, path);50+}51+52+async function git(cwd, ...args) {53+ const result = await exec("git", args, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, maxBuffer: 1024 * 1024, timeout: 300_000 });54+ return result.stdout.trim();55+}56+57+const repositoryPath = (npub, identifier) => `/${npub}/${encodeURIComponent(identifier)}.git`;58+const event = (sk, kind, tags, content = "", createdAt = now()) => finalizeEvent({ kind, tags, content, created_at: createdAt }, sk);59+60+async function publish(url, sk, value) {61+ const socket = new WebSocket(ws(url));62+ await new Promise((resolve, reject) => { socket.once("open", resolve); socket.once("error", reject); });63+ try {64+ const result = await new Promise((resolve, reject) => {65+ const timer = setTimeout(() => reject(new Error("timed out publishing event")), timeoutMs);66+ socket.on("message", (raw) => { const message = JSON.parse(raw.toString()); if (message[0] === "OK" && message[1] === value.id) { clearTimeout(timer); resolve(message); } });67+ socket.send(JSON.stringify(["EVENT", value]));68+ });69+ if (!result[2]) throw new Error(`event rejected: ${result[3] ?? "unknown relay error"}`);70+ } finally { socket.close(); }71+}72+73+async function waitForRepository(url) {74+ for (let attempt = 0; attempt < 60; attempt++) {75+ const response = await fetch(`${url}/info/refs?service=git-upload-pack`);76+ const text = await response.text();77+ if (response.status === 200 && text.includes("# service=git-upload-pack")) return;78+ await sleep(250);79+ }80+ throw new Error("repository did not become available");81+}82+83+async function assertNoMainRef(url) {84+ const response = await fetch(`${url}/info/refs?service=git-upload-pack`);85+ if (/[0-9a-f]{40} refs\/heads\/main(?:\x00|\n)/.test(await response.text())) throw new Error("rejected push advanced refs");86+}87+88+async function announce(url, sk, repoURL, identifier, tip, createdAt) {89+ await publish(url, sk, event(sk, 30617, [["d", identifier], ["clone", repoURL], ["relays", ws(url)], ["maintainers", getPublicKey(sk)]], "", createdAt));90+ await publish(url, sk, event(sk, 30618, [["d", identifier], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", tip]], "", createdAt + 1));91+}92+93+async function createHistory(root) {94+ await git(root, "init", "--initial-branch=main");95+ await git(root, "config", "user.email", "capacity-smoke@example.invalid");96+ await git(root, "config", "user.name", "bind.ws capacity smoke");97+ for (let index = 1; index <= commits; index++) {98+ await writeFile(`${root}/payload-${index}.bin`, randomBytes(blobMiB * 1024 * 1024));99+ await git(root, "add", ".");100+ await git(root, "commit", "-m", `capacity fixture ${index}`);101+ }102+ if (largeMiB) {103+ await writeFile(`${root}/large-payload.bin`, randomBytes(largeMiB * 1024 * 1024));104+ await git(root, "add", ".");105+ await git(root, "commit", "-m", `capacity fixture ${largeMiB} MiB object`);106+ }107+}108+109+async function pushHistory(root, repoURL, url, sk, identifier, createdAt, stopAfter) {110+ const tips = (await git(root, "rev-list", "--reverse", "HEAD")).split("\n").filter(Boolean);111+ for (const [index, tip] of tips.entries()) {112+ if (index >= stopAfter) break;113+ await publish(url, sk, event(sk, 30618, [["d", identifier], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", tip]], "", createdAt + 2 + index));114+ await git(root, "push", repoURL, `${tip}:refs/heads/main`);115+ }116+}117+118+const LOW_GIT = { maxRepositories: 16, maxRelayBytes: 320 * 1024 * 1024, maxPackBytes: 4 * 1024 * 1024, maxObjectBytes: 4 * 1024 * 1024, maxObjects: 4096, maxRawBytes: 16 * 1024 * 1024, maxTransactionRawBytes: 16 * 1024 * 1024, maxTransactionObjects: 4096, maxCompressedBytes: 16 * 1024 * 1024, maxMetadataBytes: 16 * 1024 * 1024, maxRefs: 1024, maxGraphEdges: 65536, maxFetchBytes: 16 * 1024 * 1024 };119+const HIGH_GIT = { maxRepositories: 128, maxRelayBytes: 6 * 1024 * 1024 * 1024, maxPackBytes: 95 * 1024 * 1024, maxObjectBytes: 64 * 1024 * 1024, maxObjects: 100000, maxRawBytes: 64 * 1024 * 1024 * 1024, maxTransactionRawBytes: 64 * 1024 * 1024, maxTransactionObjects: 20000, maxCompressedBytes: 6 * 1024 * 1024 * 1024, maxMetadataBytes: 1024 * 1024 * 1024, maxRefs: 4096, maxGraphEdges: 1000000, maxFetchBytes: 6 * 1024 * 1024 * 1024 };120+function assertGitPolicy(policy, expected, label) {121+ for (const [key, value] of Object.entries(expected)) if (policy?.git?.[key] !== value) throw new Error(`${label} policy did not apply ${key}`);122+}123+124+async function cleanupManifest(path) {125+ const manifest = JSON.parse(await readFile(path, "utf8"));126+ if (!manifest?.url || !manifest?.secret || !manifest?.slug || !/^(?:[a-z0-9-]+\.bind\.ws|localhost|127\.0\.0\.1)$/.test(new URL(manifest.url).hostname)) throw new Error("invalid capacity manifest");127+ const sk = Uint8Array.from(Buffer.from(manifest.secret, "hex"));128+ const policy = await rpc(manifest.url, sk, "getpolicy");129+ if (policy.owner !== getPublicKey(sk)) throw new Error("capacity manifest owner mismatch");130+ const result = await rpc(manifest.url, sk, "deleterelay", manifest.slug);131+ if (result?.deleted !== true) throw new Error("relay deletion was not acknowledged");132+ await rm(path, { force: true });133+ console.log("capacity relay deleted");134+}135+136+async function main() {137+ if (process.argv[2] === "cleanup") {138+ if (!process.argv[3] || process.argv[4]) throw new Error("usage: git-capacity.mjs cleanup <manifest.json>");139+ return cleanupManifest(process.argv[3]);140+ }141+ if (process.argv[2]) throw new Error("usage: git-capacity.mjs [cleanup <manifest.json>]");142+ const root = await mkdtemp(`${tmpdir()}/bindws-capacity-root-`);143+ const clone = await mkdtemp(`${tmpdir()}/bindws-capacity-clone-`);144+ const sk = generateSecretKey();145+ const owner = npubEncode(getPublicKey(sk));146+ const identifier = `capacity-${randomBytes(6).toString("hex")}`;147+ const slug = suppliedRelayURL ? (process.env.CAPACITY_RELAY_SLUG ?? "dev-capacity") : `capacity-${randomBytes(6).toString("hex")}`;148+ const url = suppliedRelayURL ?? `https://${slug}.bind.ws`;149+ const manifestRoot = join(homedir(), ".local", "share", "bindws", "git-capacity-tests", slug);150+ const manifestPath = join(manifestRoot, "manifest.json");151+ await mkdir(manifestRoot, { recursive: true, mode: 0o700 });152+ const manifest = { version: 1, slug, url, secret: Buffer.from(sk).toString("hex"), identifier, state: "planned" };153+ await saveManifest(manifestPath, manifest);154+ const repoURL = `${http(url)}${repositoryPath(owner, identifier)}`;155+ const createdAt = now();156+ try {157+ const claimed = await rpc(url, sk, "claim");158+ if (!claimed?.claimed || claimed.owner !== getPublicKey(sk)) throw new Error("capacity relay claim failed");159+ manifest.state = "claimed";160+ await saveManifest(manifestPath, manifest);161+ await rpc(url, sk, "setpolicy", { reads: "open", features: { grasp: true }, git: LOW_GIT });162+ assertGitPolicy(await rpc(url, sk, "getpolicy"), LOW_GIT, "restrictive");163+ await createHistory(root);164+ const tips = (await git(root, "rev-list", "--reverse", "HEAD")).split("\n").filter(Boolean);165+ await announce(url, sk, repoURL, identifier, tips[0], createdAt);166+ await waitForRepository(repoURL);167+ let rejected = false;168+ try { await pushHistory(root, repoURL, url, sk, identifier, createdAt, 1); } catch { rejected = true; }169+ if (!rejected) throw new Error("restrictive policy accepted the over-capacity push");170+ await assertNoMainRef(repoURL);171+ await rpc(url, sk, "setpolicy", { git: HIGH_GIT });172+ assertGitPolicy(await rpc(url, sk, "getpolicy"), HIGH_GIT, "raised");173+ await pushHistory(root, repoURL, url, sk, identifier, createdAt, commits + (largeMiB ? 1 : 0));174+ await waitForRepository(repoURL);175+ await git(clone, "clone", repoURL, ".");176+ await git(clone, "fsck", "--full");177+ await git(clone, "bundle", "create", "capacity-backup.bundle", "--all");178+ await git(clone, "bundle", "verify", "capacity-backup.bundle");179+ const expected = await git(root, "rev-parse", "HEAD");180+ if (await git(clone, "rev-parse", "HEAD") !== expected) throw new Error("raised-policy clone did not reach expected tip");181+ const sample = await readFile(`${clone}/${largeMiB ? "large-payload.bin" : `payload-${commits}.bin`}`);182+ const expectedBytes = (largeMiB || blobMiB) * 1024 * 1024;183+ if (sample.length !== expectedBytes) throw new Error("raised-policy clone has truncated content");184+ console.log(`PASS: restrictive policy rejected >${blobMiB} MiB; raised policy stored ${commits} x ${blobMiB} MiB${largeMiB ? ` plus ${largeMiB} MiB` : ""} and cloned cleanly`);185+ } finally {186+ try {187+ await cleanupManifest(manifestPath);188+ await rm(manifestRoot, { recursive: true, force: true });189+ } catch (error) {190+ console.error(`Cleanup requires recovery using ${manifestPath}: ${error.message}`);191+ process.exitCode = 1;192+ }193+ await Promise.all([rm(root, { recursive: true, force: true }), rm(clone, { recursive: true, force: true })]);194+ }195+}196+197+main().catch((error) => { console.error(`FAIL: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; });@@ -12,12 +12,17 @@ import { KIND_PUSH_REGISTRATION } from "./kinds.ts";
1212 import { archiveCurrent, isListKind } from "./list-history.ts";1313 import { Settings } from "./settings.ts";1414 import { exportGitBackup, prepareGitBackup, restoreGitBackup, type GitBackup, type PreparedGit } from "./git-backup.ts";15+import { DEFAULT_GIT_SQLITE_LIMITS, type GitSqliteLimits } from "./git-sqlite.ts";1516 1617 export const BACKUP_FORMAT = "bind.ws/relay-backup/1";1718 // JSON parsing, base64 expansion and integrity copies coexist in the Worker18-// heap, so the portable form stays well below the platform heap ceiling.19-export const BACKUP_MAX_BYTES = 8 * 1024 * 1024;20-export const BACKUP_MAX_OBJECTS = 12_000;19+// heap. This is an archive/operation budget, independent from retained Git20+// capacity; hosts may raise it when their execution/memory budget permits.21+export interface BackupLimits { maxBytes: number; maxEntries: number; includeGit?: boolean; gitLimits?: Readonly<GitSqliteLimits>; }22+export const DEFAULT_BACKUP_LIMITS: Readonly<BackupLimits> = Object.freeze({ maxBytes: 8 * 1024 * 1024, maxEntries: 12_000, includeGit: true, gitLimits: DEFAULT_GIT_SQLITE_LIMITS });23+// Kept as named exports for callers compiled against the original API.24+export const BACKUP_MAX_BYTES = DEFAULT_BACKUP_LIMITS.maxBytes;25+export const BACKUP_MAX_OBJECTS = DEFAULT_BACKUP_LIMITS.maxEntries;2126 export const BACKUP_SCHEMA = `CREATE TABLE IF NOT EXISTS backups (id TEXT PRIMARY KEY, bytes INTEGER NOT NULL);`;2227 export const backupBytes = (relay: Relay): number => relay.sql.exec<{ n: number }>(`SELECT coalesce(sum(bytes),0) n FROM backups`).one().n;2328 export const BACKUP_ID_RE = /^[a-z0-9][a-z0-9_-]{2,63}$/;@@ -59,7 +64,7 @@ const unsignedBytes = (archive: BackupArchive) => {
5964 6065 // createBackup snapshots the visible database and every tenant-owned media6166 // object. The cap makes the bounded in-memory archive explicit.62-export async function createBackup(relay: Relay, id: string): Promise<{ manifest: BackupArchive["manifest"]; key: string } | string> {67+export async function createBackup(relay: Relay, id: string, limits: Readonly<BackupLimits> = DEFAULT_BACKUP_LIMITS): Promise<{ manifest: BackupArchive["manifest"]; key: string } | string> {6368 if (!BACKUP_ID_RE.test(id)) return "invalid: backup id must be 3 to 64 lowercase letters, digits, dash or underscore";6469 if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return "restricted: relay is not active";6570 const owner = relay.settings.policy.owner;@@ -70,7 +75,7 @@ export async function createBackup(relay: Relay, id: string): Promise<{ manifest
7075 const reserve = (bytes: number, count = 1) => {7176 // JSON strings and base64 coexist with decoded buffers during sealing.7277 const next = estimate + Math.ceil(bytes * 2) + 256;73- if (next > BACKUP_MAX_BYTES || entries + count > BACKUP_MAX_OBJECTS) return false;78+ if (next > limits.maxBytes || entries + count > limits.maxEntries) return false;7479 estimate = next;7580 entries += count;7681 return true;@@ -101,10 +106,12 @@ export async function createBackup(relay: Relay, id: string): Promise<{ manifest
101106 blobs.push({ ...b, data: toB64(data), sha256: b.sha256, size: data.length });102107 }103108 const git: BackupArchive["git"] = [];104- const sqlGit = exportGitBackup(relay, reserve);109+ const sqlGit = limits.includeGit === false ? undefined : exportGitBackup(relay, reserve, limits.gitLimits, limits.maxEntries);105110 if (typeof sqlGit === "string") return sqlGit;106- const verifiedGit = await prepareGitBackup(sqlGit);107- if (typeof verifiedGit === "string") return verifiedGit;111+ if (sqlGit !== undefined) {112+ const verifiedGit = await prepareGitBackup(sqlGit, limits.maxEntries, limits.gitLimits);113+ if (typeof verifiedGit === "string") return verifiedGit;114+ }108115 const config = exportConfig(relay.settings, relay.slug);109116 if (!reserve(enc.encode(JSON.stringify(config)).length)) return "restricted: backup exceeds its bounded size or object limit";110117 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(), prRefs: relay.sql.exec<{ repo: string; ref: string; until: number }>(`SELECT repo,ref,until FROM grasp_pr_refs`).toArray() };@@ -113,12 +120,12 @@ export async function createBackup(relay: Relay, id: string): Promise<{ manifest
113120 state.listHistory.push(row.raw);114121 }115122 if (!reserve(enc.encode(JSON.stringify({ ...state, listHistory: [] })).length, state.hidden.length + state.hosted.length + state.pending.length + state.prRefs.length)) return "restricted: backup state exceeds its bounded size";116- 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: sqlGit.repositories.reduce((count, repo) => count + repo.objects.length, 0), archiveSha256: "" }, config, events, blobs, git, sqlGit, state } as BackupArchive;123+ 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: sqlGit?.repositories.reduce((count, repo) => count + repo.objects.length, 0) ?? 0, archiveSha256: "" }, config, events, blobs, git, ...(sqlGit ? { sqlGit } : {}), state } as BackupArchive;117124 archive.manifest.archiveSha256 = "0".repeat(64);118125 for (let i = 0; i < 4; i++) archive.manifest.bytes = bytesOf(archive).length;119126 archive.manifest.archiveSha256 = digest(unsignedBytes(archive));120127 const finalBytes = bytesOf(archive);121- if (finalBytes.length > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB; use smaller retention or separate Git repositories";128+ if (finalBytes.length > limits.maxBytes) return "restricted: backup exceeds the portable archive size; use includeGit:false with a separate Git mirror or reduce retained data";122129 relay.sql.exec(`INSERT OR REPLACE INTO backups(id,bytes) VALUES(?,?)`, id, finalBytes.length);123130 await relay.media.put(archiveKey(relay, id), finalBytes, { httpMetadata: { contentType: "application/json" } });124131 relay.meterBytes(0, finalBytes.length);@@ -138,13 +145,13 @@ export async function deleteBackup(relay: Relay, id: string) {
138145 }139146 140147 const preparedArchives = new WeakMap<BackupArchive, PreparedGit[]>();141-const checkedArchive = async (bytes: Uint8Array): Promise<BackupArchive | string> => {142- if (bytes.length > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB";148+const checkedArchive = async (bytes: Uint8Array, limits: Readonly<BackupLimits> = DEFAULT_BACKUP_LIMITS): Promise<BackupArchive | string> => {149+ if (bytes.length > limits.maxBytes) return "restricted: backup exceeds configured archive size";143150 let archive: BackupArchive;144151 try { archive = JSON.parse(dec.decode(bytes)) as BackupArchive; } catch { return "invalid: backup is not JSON"; }145152 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";146153 if (archive.manifest.archiveSha256 !== digest(unsignedBytes(archive))) return "invalid: backup integrity check failed";147- if (archive.events.length + archive.blobs.length + archive.git.length > BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached";154+ if (archive.events.length + archive.blobs.length + archive.git.length > limits.maxEntries) return "restricted: backup object limit reached";148155 for (const raw of archive.events) {149156 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"; }150157 catch { return "invalid: backup contains malformed event JSON"; }@@ -155,7 +162,7 @@ const checkedArchive = async (bytes: Uint8Array): Promise<BackupArchive | string
155162 if (state) {156163 if (!Array.isArray(state.listHistory) || !Array.isArray(state.hidden) || !Array.isArray(state.hosted) || !Array.isArray(state.pending)) return "invalid: backup state malformed";157164 if (state.prRefs !== undefined && (!Array.isArray(state.prRefs) || state.prRefs.some(r => !r || typeof r.repo !== "string" || !/^(?:pr|30617):[0-9a-f]{64}:.+$/u.test(r.repo) || new TextEncoder().encode(r.repo).length > 327 || typeof r.ref !== "string" || !/^refs\/nostr\/[0-9a-f]{64}$/u.test(r.ref) || !Number.isSafeInteger(r.until) || r.until < 0))) return "invalid: backup PR deadlines malformed";158- if (archive.events.length + archive.blobs.length + archive.git.length + (state.prRefs?.length ?? 0) + state.listHistory.length + state.hidden.length + state.hosted.length + state.pending.length > BACKUP_MAX_OBJECTS) return "restricted: backup object limit reached";165+ if (archive.events.length + archive.blobs.length + archive.git.length + (state.prRefs?.length ?? 0) + state.listHistory.length + state.hidden.length + state.hosted.length + state.pending.length > limits.maxEntries) return "restricted: backup object limit reached";159166 const hex = (v: unknown) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);160167 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";161168 for (const raw of state.listHistory) {@@ -164,7 +171,7 @@ const checkedArchive = async (bytes: Uint8Array): Promise<BackupArchive | string
164171 }165172 }166173 const usedEntries = archive.events.length + archive.blobs.length + (state?.prRefs?.length ?? 0) + (state?.listHistory.length ?? 0) + (state?.hidden.length ?? 0) + (state?.hosted.length ?? 0) + (state?.pending.length ?? 0);167- const git = await prepareGitBackup(archive.sqlGit, BACKUP_MAX_OBJECTS - usedEntries);174+ const git = await prepareGitBackup(archive.sqlGit, limits.maxEntries - usedEntries, limits.gitLimits);168175 if (typeof git === "string") return git;169176 preparedArchives.set(archive, git);170177 return archive;@@ -173,8 +180,8 @@ const checkedArchive = async (bytes: Uint8Array): Promise<BackupArchive | string
173180 // restoreBackup only accepts an unclaimed, empty target. Events are already174181 // signed and validated in the archive; direct storage preserves private data175182 // and avoids applying the target's ordinary write policy to old history.176-export async function restoreBackup(relay: Relay, bytes: Uint8Array, caller: string): Promise<unknown | string> {177- const archive = await checkedArchive(bytes);183+export async function restoreBackup(relay: Relay, bytes: Uint8Array, caller: string, limits: Readonly<BackupLimits> = DEFAULT_BACKUP_LIMITS): Promise<unknown | string> {184+ const archive = await checkedArchive(bytes, limits);178185 if (typeof archive === "string") return archive;179186 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 git_sqlite_meta UNION ALL SELECT 1 FROM git_sqlite_objects UNION ALL SELECT 1 FROM git_sqlite_catalog 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";180187 if (archive.manifest.owner !== caller) return "restricted: target signer is not the backup owner";@@ -237,16 +244,16 @@ export async function backupDownload(relay: Relay, req: Request, id: string): Pr
237244 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"` } });238245 }239246 240-async function readCapped(req: Request): Promise<Uint8Array | string> {247+async function readCapped(req: Request, limits: Readonly<BackupLimits> = DEFAULT_BACKUP_LIMITS): Promise<Uint8Array | string> {241248 const len = Number(req.headers.get("content-length") ?? 0);242- if (len > BACKUP_MAX_BYTES) return "restricted: backup exceeds 8 MiB";249+ if (len > limits.maxBytes) return "restricted: backup exceeds configured archive size";243250 if (!req.body) return new Uint8Array();244251 const reader = req.body.getReader(); const chunks: Uint8Array[] = []; let total = 0;245252 for (;;) {246253 const part = await reader.read();247254 if (part.done) break;248255 total += part.value.byteLength;249- if (total > BACKUP_MAX_BYTES) { await reader.cancel(); return "restricted: backup exceeds 8 MiB"; }256+ if (total > limits.maxBytes) { await reader.cancel(); return "restricted: backup exceeds configured archive size"; }250257 chunks.push(part.value);251258 }252259 const out = new Uint8Array(total); let at = 0;@@ -230,6 +230,28 @@
230230 </div>231231 <div class="actions"><button class="btn pri">Save rules</button></div>232232 </form>233+ <form id="git-capacity" class="block">234+ <h3>Git capacity</h3>235+ <p class="note">GRASP Git has its own retained-storage and request limits. Storage and compute still use the relay’s normal fuel and billing. Git transfer limits are independent from event and file limits.</p>236+ <div class="limits">237+ <label><span>Repositories <small>maximum on this relay</small></span><input class="txt num" type="number" name="maxRepositories" min="1" max="4096" required></label>238+ <label><span>Relay Git storage <small>GiB, including metadata</small></span><input class="txt num" type="number" name="maxRelayGiB" min="0.000001" max="6" step="0.001" required></label>239+ <label><span>Compressed repository data <small>GiB, per repository</small></span><input class="txt num" type="number" name="maxCompressedGiB" min="0.000001" max="6" step="0.001" required></label>240+ <label><span>Pack size <small>MiB</small></span><input class="txt num" type="number" name="maxPackMiB" min="0.000001" max="95" step="0.001" required></label>241+ <label><span>Object size <small>MiB</small></span><input class="txt num" type="number" name="maxObjectMiB" min="0.000001" max="64" step="0.001" required></label>242+ </div>243+ <details class="advanced"><summary>Advanced Git admission and fetch limits</summary><div class="limits">244+ <label><span>Objects <small>per repository</small></span><input class="txt num" type="number" name="maxObjects" min="1" max="500000" required></label>245+ <label><span>Raw bytes <small>per repository, GiB</small></span><input class="txt num" type="number" name="maxRawGiB" min="0.000001" max="64" step="0.001" required></label>246+ <label><span>Transaction bytes <small>MiB</small></span><input class="txt num" type="number" name="maxTransactionRawMiB" min="0.000001" max="64" step="0.001" required></label>247+ <label><span>Transaction objects</span><input class="txt num" type="number" name="maxTransactionObjects" min="1" max="100000" required></label>248+ <label><span>Metadata <small>MiB per repository</small></span><input class="txt num" type="number" name="maxMetadataMiB" min="0.000001" max="1024" step="0.001" required></label>249+ <label><span>Refs <small>per repository</small></span><input class="txt num" type="number" name="maxRefs" min="1" max="16384" required></label>250+ <label><span>Graph edges <small>per repository</small></span><input class="txt num" type="number" name="maxGraphEdges" min="1" max="4000000" required></label>251+ <label><span>Fetch response <small>GiB</small></span><input class="txt num" type="number" name="maxFetchGiB" min="0.000001" max="6" step="0.001" required></label>252+ </div></details>253+ <div class="actions"><button class="btn pri">Save Git capacity</button></div>254+ </form>233255 <div class="block">234256 <h3>Kinds</h3>235257 <p class="note">An empty allow list means every kind. Blocks always win.</p>@@ -15,6 +15,8 @@
1515 const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + "deg";1616 const av = (hex) => '<i class="av" style="--h:' + hue(hex) + '"></i>';1717 const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + " KB" : n < 1e9 ? (n / 1e6).toFixed(1) + " MB" : (n / 1e9).toFixed(2) + " GB";18+ const MiB = 1024 * 1024, GiB = 1024 * MiB;19+ const units = (n, divisor) => n == null ? "" : (n / divisor).toFixed(3).replace(/0+$/, "").replace(/\.$/, "");1820 const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + " min" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + " h";1921 const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);2022 const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "–";@@ -332,6 +334,13 @@
332334 fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(", "); fa.guestReplies.checked = !!p.guestReplies;333335 $("#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;334336 fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;337+ const g = p.git || {}, gf = $("#git-capacity");338+ if (gf) {339+ gf.maxRepositories.value = g.maxRepositories || ""; gf.maxRelayGiB.value = units(g.maxRelayBytes, GiB); gf.maxCompressedGiB.value = units(g.maxCompressedBytes, GiB);340+ gf.maxPackMiB.value = units(g.maxPackBytes, MiB); gf.maxObjectMiB.value = units(g.maxObjectBytes, MiB); gf.maxObjects.value = g.maxObjects || "";341+ gf.maxRawGiB.value = units(g.maxRawBytes, GiB); gf.maxTransactionRawMiB.value = units(g.maxTransactionRawBytes, MiB); gf.maxTransactionObjects.value = g.maxTransactionObjects || "";342+ gf.maxMetadataMiB.value = units(g.maxMetadataBytes, MiB); gf.maxRefs.value = g.maxRefs || ""; gf.maxGraphEdges.value = g.maxGraphEdges || ""; gf.maxFetchGiB.value = units(g.maxFetchBytes, GiB);343+ }335344 renderFeatures(p.features || {});336345 $("#push-policy-form").elements.origins.value = (p.pushCallbacks || []).join("\n");337346 $("#push-policy-form").elements.lettered.checked = !!p.letteredNips;@@ -818,6 +827,17 @@
818827 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 });819828 toast("Rules saved"); await loadInfo();820829 });830+ $("#git-capacity").onsubmit = guard(async (ev) => {831+ const f = ev.target, bytes = (name, divisor) => Math.round(Number(f[name].value) * divisor);832+ const git = {833+ maxRepositories: Math.floor(Number(f.maxRepositories.value)), maxRelayBytes: bytes("maxRelayGiB", GiB), maxCompressedBytes: bytes("maxCompressedGiB", GiB),834+ maxPackBytes: bytes("maxPackMiB", MiB), maxObjectBytes: bytes("maxObjectMiB", MiB), maxObjects: Math.floor(Number(f.maxObjects.value)), maxRawBytes: bytes("maxRawGiB", GiB),835+ maxTransactionRawBytes: bytes("maxTransactionRawMiB", MiB), maxTransactionObjects: Math.floor(Number(f.maxTransactionObjects.value)), maxMetadataBytes: bytes("maxMetadataMiB", MiB),836+ maxRefs: Math.floor(Number(f.maxRefs.value)), maxGraphEdges: Math.floor(Number(f.maxGraphEdges.value)), maxFetchBytes: bytes("maxFetchGiB", GiB),837+ };838+ policy = await rpc("setpolicy", { git });839+ toast("Git capacity saved"); await loadInfo();840+ });821841 $("#notify").onsubmit = guard(async (ev) => {822842 const f = ev.target;823843 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 } });@@ -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 <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";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 <form id=\"git-capacity\" class=\"block\">\n <h3>Git capacity</h3>\n <p class=\"note\">GRASP Git has its own retained-storage and request limits. Storage and compute still use the relay’s normal fuel and billing. Git transfer limits are independent from event and file limits.</p>\n <div class=\"limits\">\n <label><span>Repositories <small>maximum on this relay</small></span><input class=\"txt num\" type=\"number\" name=\"maxRepositories\" min=\"1\" max=\"4096\" required></label>\n <label><span>Relay Git storage <small>GiB, including metadata</small></span><input class=\"txt num\" type=\"number\" name=\"maxRelayGiB\" min=\"0.000001\" max=\"6\" step=\"0.001\" required></label>\n <label><span>Compressed repository data <small>GiB, per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxCompressedGiB\" min=\"0.000001\" max=\"6\" step=\"0.001\" required></label>\n <label><span>Pack size <small>MiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxPackMiB\" min=\"0.000001\" max=\"95\" step=\"0.001\" required></label>\n <label><span>Object size <small>MiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxObjectMiB\" min=\"0.000001\" max=\"64\" step=\"0.001\" required></label>\n </div>\n <details class=\"advanced\"><summary>Advanced Git admission and fetch limits</summary><div class=\"limits\">\n <label><span>Objects <small>per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxObjects\" min=\"1\" max=\"500000\" required></label>\n <label><span>Raw bytes <small>per repository, GiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxRawGiB\" min=\"0.000001\" max=\"64\" step=\"0.001\" required></label>\n <label><span>Transaction bytes <small>MiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxTransactionRawMiB\" min=\"0.000001\" max=\"64\" step=\"0.001\" required></label>\n <label><span>Transaction objects</span><input class=\"txt num\" type=\"number\" name=\"maxTransactionObjects\" min=\"1\" max=\"100000\" required></label>\n <label><span>Metadata <small>MiB per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxMetadataMiB\" min=\"0.000001\" max=\"1024\" step=\"0.001\" required></label>\n <label><span>Refs <small>per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxRefs\" min=\"1\" max=\"16384\" required></label>\n <label><span>Graph edges <small>per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxGraphEdges\" min=\"1\" max=\"4000000\" required></label>\n <label><span>Fetch response <small>GiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxFetchGiB\" min=\"0.000001\" max=\"6\" step=\"0.001\" required></label>\n </div></details>\n <div class=\"actions\"><button class=\"btn pri\">Save Git capacity</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 $(\"#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";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 MiB = 1024 * 1024, GiB = 1024 * MiB;\n const units = (n, divisor) => n == null ? \"\" : (n / divisor).toFixed(3).replace(/0+$/, \"\").replace(/\\.$/, \"\");\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 const g = p.git || {}, gf = $(\"#git-capacity\");\n if (gf) {\n gf.maxRepositories.value = g.maxRepositories || \"\"; gf.maxRelayGiB.value = units(g.maxRelayBytes, GiB); gf.maxCompressedGiB.value = units(g.maxCompressedBytes, GiB);\n gf.maxPackMiB.value = units(g.maxPackBytes, MiB); gf.maxObjectMiB.value = units(g.maxObjectBytes, MiB); gf.maxObjects.value = g.maxObjects || \"\";\n gf.maxRawGiB.value = units(g.maxRawBytes, GiB); gf.maxTransactionRawMiB.value = units(g.maxTransactionRawBytes, MiB); gf.maxTransactionObjects.value = g.maxTransactionObjects || \"\";\n gf.maxMetadataMiB.value = units(g.maxMetadataBytes, MiB); gf.maxRefs.value = g.maxRefs || \"\"; gf.maxGraphEdges.value = g.maxGraphEdges || \"\"; gf.maxFetchGiB.value = units(g.maxFetchBytes, GiB);\n }\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 $(\"#git-capacity\").onsubmit = guard(async (ev) => {\n const f = ev.target, bytes = (name, divisor) => Math.round(Number(f[name].value) * divisor);\n const git = {\n maxRepositories: Math.floor(Number(f.maxRepositories.value)), maxRelayBytes: bytes(\"maxRelayGiB\", GiB), maxCompressedBytes: bytes(\"maxCompressedGiB\", GiB),\n maxPackBytes: bytes(\"maxPackMiB\", MiB), maxObjectBytes: bytes(\"maxObjectMiB\", MiB), maxObjects: Math.floor(Number(f.maxObjects.value)), maxRawBytes: bytes(\"maxRawGiB\", GiB),\n maxTransactionRawBytes: bytes(\"maxTransactionRawMiB\", MiB), maxTransactionObjects: Math.floor(Number(f.maxTransactionObjects.value)), maxMetadataBytes: bytes(\"maxMetadataMiB\", MiB),\n maxRefs: Math.floor(Number(f.maxRefs.value)), maxGraphEdges: Math.floor(Number(f.maxGraphEdges.value)), maxFetchBytes: bytes(\"maxFetchGiB\", GiB),\n };\n policy = await rpc(\"setpolicy\", { git });\n toast(\"Git capacity 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";@@ -7,7 +7,7 @@ import { bytesToHex } from "@noble/hashes/utils.js";
77 import { unzlibSync } from "fflate";88 import { objectLinks, validateRefName, repositoryStoragePrefix, repositoryAddress, type GitObjectType, type RefUpdate } from "ntig";99 import { npubEncode } from "nostr-tools/nip19";10-import { DEFAULT_GIT_SQLITE_LIMITS as LIMITS } from "./git-sqlite.ts";10+import { DEFAULT_GIT_SQLITE_LIMITS as LIMITS, type GitSqliteLimits } from "./git-sqlite.ts";1111 import { gitSql } from "./git-catalog.ts";1212 import type { Relay } from "./relay.ts";1313 @@ -26,21 +26,21 @@ export type GitBackup = { format: "bind.ws/git-sqlite/1"; repositories: Reposito
2626 export type PreparedGit = { repo: Repository; objects: { row: ObjectRow; chunks: Uint8Array[]; links: ReturnType<typeof objectLinks> }[]; raw: number; compressed: number; metadata: number; tip: string | null };2727 2828 // exportGitBackup reserves archive memory before reading payloads from SQLite.29-export function exportGitBackup(relay: Relay, reserve: (bytes: number, entries?: number) => boolean): GitBackup | string {29+export function exportGitBackup(relay: Relay, reserve: (bytes: number, entries?: number) => boolean, limits: Readonly<GitSqliteLimits> = LIMITS, maxEntries = 12000): GitBackup | string {3030 const result: GitBackup = { format: "bind.ws/git-sqlite/1", repositories: [] };31- for (const row of gitSql<{ repository: string; compressed_bytes: number; metadata_bytes: number; object_count: number }>(relay, "SELECT repository,compressed_bytes,metadata_bytes,object_count FROM git_sqlite_meta ORDER BY repository LIMIT 12001").toArray()) {31+ for (const row of gitSql<{ repository: string; compressed_bytes: number; metadata_bytes: number; object_count: number }>(relay, "SELECT repository,compressed_bytes,metadata_bytes,object_count FROM git_sqlite_meta ORDER BY repository LIMIT ?", maxEntries + 1).toArray()) {3232 if (!reserve(row.compressed_bytes + row.metadata_bytes, row.object_count + 1)) return "restricted: Git backup exceeds its bounded size or object limit";3333 const identity = gitSql<{ owner: string; identifier: string; alternative: number }>(relay, "SELECT owner,identifier,alternative FROM git_sqlite_catalog WHERE repository=?", row.repository).toArray()[0];3434 if (!identity) return "error: Git repository identity is missing";3535 const repo: Repository = { key: row.repository, owner: identity.owner, identifier: identity.identifier, alternative: !!identity.alternative, objects: [], refs: {}, receipts: [] };36- for (const object of gitSql<{ oid: string; type: GitObjectType; size: number }>(relay, "SELECT oid,type,raw_size AS size FROM git_sqlite_objects WHERE repository=? ORDER BY oid LIMIT 4097", row.repository).toArray()) {37- const chunks = gitSql<{ data: ArrayBuffer }>(relay, "SELECT data FROM git_sqlite_object_chunks WHERE repository=? AND oid=? ORDER BY chunk LIMIT 10", row.repository, object.oid).toArray().map(chunk => base64(new Uint8Array(chunk.data)));36+ for (const object of gitSql<{ oid: string; type: GitObjectType; size: number }>(relay, "SELECT oid,type,raw_size AS size FROM git_sqlite_objects WHERE repository=? ORDER BY oid LIMIT ?", row.repository, limits.maxObjects + 1).toArray()) {37+ const chunks = gitSql<{ data: ArrayBuffer }>(relay, "SELECT data FROM git_sqlite_object_chunks WHERE repository=? AND oid=? ORDER BY chunk LIMIT ?", row.repository, object.oid, Math.ceil((limits.maxObjectBytes + 65536) / limits.chunkBytes) + 1).toArray().map(chunk => base64(new Uint8Array(chunk.data)));3838 if (!reserve(0, chunks.length)) return "restricted: Git backup object limit reached";3939 repo.objects.push({ ...object, chunks });4040 }41- const refs = gitSql<{ name: string; oid: string }>(relay, "SELECT name,oid FROM git_sqlite_refs WHERE repository=? ORDER BY name LIMIT 1025", row.repository).toArray();41+ const refs = gitSql<{ name: string; oid: string }>(relay, "SELECT name,oid FROM git_sqlite_refs WHERE repository=? ORDER BY name LIMIT ?", row.repository, limits.maxRefs + 1).toArray();4242 repo.refs = Object.fromEntries(refs.map(ref => [ref.name, ref.oid]));43- repo.receipts = gitSql<{ id: string; sequence: number; requestHash: string; recordHash: string; packHash: string | null; updates: string }>(relay, "SELECT id,sequence,request_hash AS requestHash,record_hash AS recordHash,pack_hash AS packHash,updates FROM git_sqlite_receipts WHERE repository=? ORDER BY sequence LIMIT 12001", row.repository).toArray().map(receipt => ({ ...receipt, updates: JSON.parse(receipt.updates) as RefUpdate[] }));43+ repo.receipts = gitSql<{ id: string; sequence: number; requestHash: string; recordHash: string; packHash: string | null; updates: string }>(relay, "SELECT id,sequence,request_hash AS requestHash,record_hash AS recordHash,pack_hash AS packHash,updates FROM git_sqlite_receipts WHERE repository=? ORDER BY sequence LIMIT ?", row.repository, maxEntries + 1).toArray().map(receipt => ({ ...receipt, updates: JSON.parse(receipt.updates) as RefUpdate[] }));4444 if (!reserve(0, refs.length + repo.receipts.length)) return "restricted: Git backup object limit reached";4545 result.repositories.push(repo);4646 }@@ -49,7 +49,7 @@ export function exportGitBackup(relay: Relay, reserve: (bytes: number, entries?:
4949 5050 // prepareGitBackup derives links from verified bodies and replays the receipt5151 // chain so neither forged metadata nor a different namespace survives restore.52-export async function prepareGitBackup(input: unknown, maxEntries = 12000): Promise<PreparedGit[] | string> {52+export async function prepareGitBackup(input: unknown, maxEntries = 12000, limits: Readonly<GitSqliteLimits> = LIMITS): Promise<PreparedGit[] | string> {5353 if (input === undefined) return [];5454 try {5555 const backup = input as GitBackup;@@ -57,36 +57,36 @@ export async function prepareGitBackup(input: unknown, maxEntries = 12000): Prom
5757 const result: PreparedGit[] = [], keys = new Set<string>();5858 let entries = 0;5959 for (const repo of backup.repositories) {60- if (!repo || !hash.test(repo.key) || keys.has(repo.key) || !hash.test(repo.owner) || typeof repo.identifier !== "string" || typeof repo.alternative !== "boolean" || !Array.isArray(repo.objects) || repo.objects.length > LIMITS.maxObjects || !Array.isArray(repo.receipts) || !repo.refs || typeof repo.refs !== "object" || Array.isArray(repo.refs)) throw new Error();60+ if (!repo || !hash.test(repo.key) || keys.has(repo.key) || !hash.test(repo.owner) || typeof repo.identifier !== "string" || typeof repo.alternative !== "boolean" || !Array.isArray(repo.objects) || repo.objects.length > limits.maxObjects || !Array.isArray(repo.receipts) || !repo.refs || typeof repo.refs !== "object" || Array.isArray(repo.refs)) throw new Error();6161 const prefix = await repositoryStoragePrefix(repositoryAddress(npubEncode(repo.owner), repo.identifier), repo.alternative);6262 if (prefix.split("/").filter(Boolean).at(-1) !== repo.key) throw new Error("namespace");6363 keys.add(repo.key);6464 entries += 1 + repo.objects.length + Object.keys(repo.refs).length + repo.receipts.length;65- if (entries > maxEntries || Object.keys(repo.refs).length > LIMITS.maxRefs) throw new Error("limit");65+ if (entries > maxEntries || Object.keys(repo.refs).length > limits.maxRefs) throw new Error("limit");6666 const prepared: PreparedGit = { repo, objects: [], raw: 0, compressed: 0, metadata: 0, tip: null };6767 const objects = new Map<string, { type: GitObjectType; links: ReturnType<typeof objectLinks> }>();6868 let edges = 0;6969 for (const row of repo.objects) {70- if (!row || !hex.test(row.oid) || objects.has(row.oid) || !["blob", "tree", "commit", "tag"].includes(row.type) || !Number.isSafeInteger(row.size) || row.size < 0 || row.size > LIMITS.maxObjectBytes || !Array.isArray(row.chunks) || !row.chunks.length || row.chunks.length > 9) throw new Error();70+ if (!row || !hex.test(row.oid) || objects.has(row.oid) || !["blob", "tree", "commit", "tag"].includes(row.type) || !Number.isSafeInteger(row.size) || row.size < 0 || row.size > limits.maxObjectBytes || !Array.isArray(row.chunks) || !row.chunks.length || row.chunks.length > Math.ceil((limits.maxObjectBytes + 65536) / limits.chunkBytes)) throw new Error();7171 prepared.raw += row.size; entries += row.chunks.length;72- if (prepared.raw > LIMITS.maxRawBytes || entries > maxEntries) throw new Error("limit");72+ if (prepared.raw > limits.maxRawBytes || entries > maxEntries) throw new Error("limit");7373 const chunks = row.chunks.map(value => {74- if (typeof value !== "string" || value.length > Math.ceil(LIMITS.chunkBytes / 3) * 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error();74+ if (typeof value !== "string" || value.length > Math.ceil(limits.chunkBytes / 3) * 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error();7575 const bytes = decoded(value);76- if (!bytes.length || bytes.length > LIMITS.chunkBytes) throw new Error();76+ if (!bytes.length || bytes.length > limits.chunkBytes) throw new Error();7777 return bytes;7878 });7979 const compressedSize = chunks.reduce((total, chunk) => total + chunk.length, 0);80- if (compressedSize > LIMITS.maxObjectBytes + 65536) throw new Error("limit");80+ if (compressedSize > limits.maxObjectBytes + 65536) throw new Error("limit");8181 prepared.compressed += compressedSize;82- if (prepared.compressed > LIMITS.maxCompressedBytes) throw new Error("limit");82+ if (prepared.compressed > limits.maxCompressedBytes) throw new Error("limit");8383 const compressed = new Uint8Array(compressedSize); let at = 0;8484 for (const chunk of chunks) { compressed.set(chunk, at); at += chunk.length; }8585 const data = unzlibSync(compressed, { out: new Uint8Array(row.size + 1) });8686 if (data.length !== row.size || bytesToHex(sha1.create().update(enc.encode(`${row.type} ${data.length}\0`)).update(data).digest()) !== row.oid) throw new Error("hash");87- const links = objectLinks({ oid: row.oid, type: row.type, data }, LIMITS.maxGraphEdges - edges);87+ const links = objectLinks({ oid: row.oid, type: row.type, data }, limits.maxGraphEdges - edges);8888 edges += links.length;89- if (edges > LIMITS.maxGraphEdges) throw new Error("limit");89+ if (edges > limits.maxGraphEdges) throw new Error("limit");9090 objects.set(row.oid, { type: row.type, links });9191 prepared.objects.push({ row, chunks, links });9292 prepared.metadata += size(repo.key) + 80 + chunks.length * (size(repo.key) + 48) + links.length * (size(repo.key) + 96);@@ -94,7 +94,7 @@ export async function prepareGitBackup(input: unknown, maxEntries = 12000): Prom
9494 for (const object of objects.values()) for (const link of object.links) if (objects.get(link.oid)?.type !== link.type) throw new Error("graph");9595 const refs = new Map<string, string>(), ids = new Set<string>();9696 for (const [index, receipt] of repo.receipts.entries()) {97- if (!receipt || !/^[a-zA-Z0-9_.:-]{1,128}$/.test(receipt.id) || ids.has(receipt.id) || receipt.sequence !== index + 1 || !hash.test(receipt.requestHash) || !hash.test(receipt.recordHash) || (receipt.packHash !== null && !hash.test(receipt.packHash)) || !Array.isArray(receipt.updates) || !receipt.updates.length || receipt.updates.length > LIMITS.maxRefs) throw new Error("receipt");97+ if (!receipt || !/^[a-zA-Z0-9_.:-]{1,128}$/.test(receipt.id) || ids.has(receipt.id) || receipt.sequence !== index + 1 || !hash.test(receipt.requestHash) || !hash.test(receipt.recordHash) || (receipt.packHash !== null && !hash.test(receipt.packHash)) || !Array.isArray(receipt.updates) || !receipt.updates.length || receipt.updates.length > limits.maxRefs) throw new Error("receipt");9898 ids.add(receipt.id);9999 const names = new Set<string>();100100 let previous = "";@@ -116,7 +116,7 @@ export async function prepareGitBackup(input: unknown, maxEntries = 12000): Prom
116116 for (let index = 2; index < parts.length; index++) if (refs.has(parts.slice(0, index).join("/"))) throw new Error("refs");117117 prepared.metadata += size(repo.key) + size(name) + 40;118118 }119- if (prepared.metadata > LIMITS.maxMetadataBytes) throw new Error("limit");119+ if (prepared.metadata > limits.maxMetadataBytes) throw new Error("limit");120120 result.push(prepared);121121 }122122 return result;@@ -0,0 +1,102 @@
1+// Retained Git capacity is separate from the work admitted in one request.2+// Values are bytes (not decimal MB); the console presents MiB/GiB.3+export interface GitLimits {4+ maxRepositories: number;5+ maxRelayBytes: number;6+ maxPackBytes: number;7+ maxObjectBytes: number;8+ maxObjects: number;9+ maxRawBytes: number;10+ maxTransactionRawBytes: number;11+ maxTransactionObjects: number;12+ maxCompressedBytes: number;13+ maxMetadataBytes: number;14+ maxRefs: number;15+ maxGraphEdges: number;16+ maxFetchBytes: number;17+}18+19+export const MiB = 1024 * 1024;20+export const GiB = 1024 * MiB;21+22+export const DEFAULT_GIT_LIMITS: Readonly<GitLimits> = Object.freeze({23+ maxRepositories: 128,24+ maxRelayBytes: 2 * GiB,25+ maxPackBytes: 16 * MiB,26+ maxObjectBytes: 16 * MiB,27+ maxObjects: 100_000,28+ maxRawBytes: 4 * GiB,29+ maxTransactionRawBytes: 32 * MiB,30+ maxTransactionObjects: 20_000,31+ maxCompressedBytes: GiB,32+ maxMetadataBytes: 128 * MiB,33+ maxRefs: 4096,34+ maxGraphEdges: 1_000_000,35+ maxFetchBytes: 2 * GiB,36+});37+38+// Keep headroom under the 10 GiB physical SQLite database ceiling. Request39+// bounds are independent of that allowance and remain subject to memory/CPU.40+export const MAX_GIT_LIMITS: Readonly<GitLimits> = Object.freeze({41+ maxRepositories: 4096,42+ maxRelayBytes: 6 * GiB,43+ maxPackBytes: 95 * MiB,44+ maxObjectBytes: 64 * MiB,45+ maxObjects: 500_000,46+ maxRawBytes: 64 * GiB,47+ maxTransactionRawBytes: 64 * MiB,48+ maxTransactionObjects: 100_000,49+ maxCompressedBytes: 6 * GiB,50+ maxMetadataBytes: GiB,51+ maxRefs: 16_384,52+ maxGraphEdges: 4_000_000,53+ maxFetchBytes: 6 * GiB,54+});55+56+// Reject a malformed patch as a unit rather than silently accepting a typo57+// or making an owner believe a higher limit was applied. Empty patches are58+// valid, and omitted fields preserve the current relay's choices.59+export function gitLimitPatch(raw: unknown, current: Readonly<GitLimits> = DEFAULT_GIT_LIMITS): GitLimits | undefined {60+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;61+ const next = { ...current };62+ for (const [key, value] of Object.entries(raw)) {63+ if (!Object.hasOwn(MAX_GIT_LIMITS, key)) return undefined;64+ const field = key as keyof GitLimits;65+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_GIT_LIMITS[field]) return undefined;66+ next[field] = value;67+ }68+ return next;69+}70+71+export function gitPackLimits(limits: Readonly<GitLimits>) {72+ return {73+ maxPackBytes: limits.maxPackBytes,74+ maxObjectBytes: limits.maxObjectBytes,75+ maxTotalObjectBytes: limits.maxTransactionRawBytes,76+ maxObjects: limits.maxTransactionObjects,77+ maxPacks: 1,78+ maxTotalPackBytes: limits.maxPackBytes,79+ };80+}81+82+// Lower intake quotas without stranding objects already stored under a larger83+// policy. Fetch still has its own explicit response-byte limit and platform84+// object/graph bounds; the backend enforces current quotas on each new write.85+export function gitHttpLimits(limits: Readonly<GitLimits>) {86+ return {87+ maxBodyBytes: limits.maxPackBytes + 256 * 1024,88+ maxResponseBytes: limits.maxFetchBytes,89+ maxRefs: MAX_GIT_LIMITS.maxRefs,90+ maxObjects: MAX_GIT_LIMITS.maxObjects,91+ maxGraphEdges: MAX_GIT_LIMITS.maxGraphEdges,92+ gitLimits: gitPackLimits(limits),93+ fetchLimits: {94+ maxPackBytes: limits.maxFetchBytes,95+ maxObjectBytes: MAX_GIT_LIMITS.maxObjectBytes,96+ maxTotalObjectBytes: MAX_GIT_LIMITS.maxRawBytes,97+ maxObjects: MAX_GIT_LIMITS.maxObjects,98+ maxPacks: 1,99+ maxTotalPackBytes: limits.maxFetchBytes,100+ },101+ };102+}@@ -1,5 +1,6 @@
11 // Git object reads share one bounded view. Indexed repositories fetch by ID;22 // legacy repositories decode their bounded retained pack set once per view.3+import { DEFAULT_GIT_LIMITS, type GitLimits } from "./git-limits.ts";34 import { sha1 } from "@noble/hashes/legacy.js";45 import { bytesToHex } from "@noble/hashes/utils.js";56 import { readObjects, objectLinks, LimitError, IntegrityError, type GitRepository, type GitObject, type GitObjectInfo } from "ntig";@@ -18,20 +19,20 @@ export async function gitObjects(repository: GitRepository) {
1819 1920 // gitGraph reads only the requested dependency graph, with the same decoded2021 // byte budget as a received pack. It also serves local GRASP-06 reconciliation.21-export async function gitGraph(repository: GitRepository, roots: readonly string[]): Promise<GitObject[]> {22+export async function gitGraph(repository: GitRepository, roots: readonly string[], limits: Readonly<GitLimits> = DEFAULT_GIT_LIMITS): Promise<GitObject[]> {2223 const reader = await gitObjects(repository);2324 const seen = new Map<string, GitObjectInfo["type"]>(), queue = roots.map(oid => ({ oid, type: undefined as GitObjectInfo["type"] | undefined })), result: GitObject[] = [];2425 let bytes = 0, edges = 0;2526 while (queue.length) {2627 const { oid, type } = queue.pop()!;2728 if (seen.has(oid)) { if (type && seen.get(oid) !== type) throw new IntegrityError("Git dependency type mismatch"); continue; }28- if (seen.size >= 4096) throw new LimitError("Git graph object limit reached");29+ if (seen.size >= limits.maxTransactionObjects) throw new LimitError("Git graph object limit reached");2930 const info = await reader.info(oid);3031 if (!info || info.oid !== oid || (type && info.type !== type)) throw new IntegrityError("Git graph dependency is missing");31- if (!Number.isSafeInteger(info.size) || info.size < 0 || info.size > 4 * 1024 * 1024 || bytes + info.size > 16 * 1024 * 1024) throw new LimitError("Git graph byte limit reached");32+ if (!Number.isSafeInteger(info.size) || info.size < 0 || info.size > limits.maxObjectBytes || bytes + info.size > limits.maxTransactionRawBytes) throw new LimitError("Git graph byte limit reached");3233 bytes += info.size;3334 edges += info.links.length;34- if (edges > 65_536) throw new LimitError("Git graph edge limit reached");35+ if (edges > limits.maxGraphEdges) throw new LimitError("Git graph edge limit reached");3536 const object = await reader.get(oid);3637 if (!object || object.oid !== oid || object.type !== info.type || object.data.length !== info.size) throw new IntegrityError("Git graph changed during read");3738 if (bytesToHex(sha1.create().update(new TextEncoder().encode(`${object.type} ${object.data.length}\0`)).update(object.data).digest()) !== oid) throw new IntegrityError("Git object hash mismatch");@@ -13,7 +13,8 @@ import { decodePack, objectLinks, type GitObject, type GitObjectType } from "nti
1313 import { sha256, validateRefName } from "ntig";1414 import { sha1 } from "@noble/hashes/legacy.js";1515 import { bytesToHex } from "@noble/hashes/utils.js";16-import { zlibSync, unzlibSync } from "fflate";16+import { Unzlib, zlibSync } from "fflate";17+import { DEFAULT_GIT_LIMITS, MAX_GIT_LIMITS } from "./git-limits.ts";1718 1819 // The object store keeps one compressed, content-addressed row per Git object.1920 // Chunks keep every SQLite value below the production BLOB limit, while refs@@ -72,13 +73,26 @@ CREATE TABLE IF NOT EXISTS git_sqlite_meta (
7273 CREATE INDEX IF NOT EXISTS git_sqlite_receipts_sequence ON git_sqlite_receipts(repository, sequence);7374 `;7475 76+// Storage safety is separate from tenant intake policy. Lowering77+// maxObjectBytes must not make previously accepted objects unreadable.78+export const GIT_SQLITE_PHYSICAL_OBJECT_BYTES = 64 * 1024 * 1024;79+7580 export interface GitSqliteLimits {81+ /** Maximum bytes accepted in one incoming pack request. */7682 maxPackBytes: number;83+ /** Maximum uncompressed size of one retained Git object and one decode. */7784 maxObjectBytes: number;85+ /** Maximum retained objects in this repository. */7886 maxObjects: number;87+ /** Maximum retained uncompressed object bytes in this repository. */7988 maxRawBytes: number;89+ /** Maximum uncompressed bytes decoded in one commit operation. */8090 maxTransactionRawBytes: number;91+ /** Maximum objects decoded in one commit operation. */92+ maxTransactionObjects: number;93+ /** Maximum retained compressed object bytes in this repository. */8194 maxCompressedBytes: number;95+ /** Maximum retained index, refs and receipt metadata bytes. */8296 maxMetadataBytes: number;8397 maxRefs: number;8498 maxGraphEdges: number;@@ -86,15 +100,18 @@ export interface GitSqliteLimits {
86100 }87101 88102 export const DEFAULT_GIT_SQLITE_LIMITS: Readonly<GitSqliteLimits> = Object.freeze({89- maxPackBytes: 4 * 1024 * 1024,90- maxObjectBytes: 4 * 1024 * 1024,91- maxObjects: 4096,92- maxRawBytes: 16 * 1024 * 1024,93- maxTransactionRawBytes: 16 * 1024 * 1024,94- maxCompressedBytes: 16 * 1024 * 1024,95- maxMetadataBytes: 16 * 1024 * 1024,96- maxRefs: 1024,97- maxGraphEdges: 65_536,103+ // Repository retention is intentionally much larger than one transport104+ // operation. Hosts may lower or raise these values through policy.105+ maxPackBytes: DEFAULT_GIT_LIMITS.maxPackBytes,106+ maxObjectBytes: DEFAULT_GIT_LIMITS.maxObjectBytes,107+ maxObjects: DEFAULT_GIT_LIMITS.maxObjects,108+ maxRawBytes: DEFAULT_GIT_LIMITS.maxRawBytes,109+ maxTransactionRawBytes: DEFAULT_GIT_LIMITS.maxTransactionRawBytes,110+ maxTransactionObjects: DEFAULT_GIT_LIMITS.maxTransactionObjects,111+ maxCompressedBytes: DEFAULT_GIT_LIMITS.maxCompressedBytes,112+ maxMetadataBytes: DEFAULT_GIT_LIMITS.maxMetadataBytes,113+ maxRefs: DEFAULT_GIT_LIMITS.maxRefs,114+ maxGraphEdges: DEFAULT_GIT_LIMITS.maxGraphEdges,98115 chunkBytes: 512 * 1024,99116 });100117 @@ -105,8 +122,6 @@ const OID = /^[0-9a-f]{40}$/;
105122 const ZERO = "0".repeat(40);106123 107124 const asBuffer = (bytes: Uint8Array): Bytes => bytes.slice().buffer;108-const asBytes = (value: ArrayBuffer | Uint8Array): Uint8Array =>109- value instanceof Uint8Array ? new Uint8Array(value) : new Uint8Array(value);110125 111126 export interface GitSqliteCommitChange {112127 id: string;@@ -119,6 +134,8 @@ export interface GitSqliteOptions {
119134 sql: Sql;120135 repository?: string;121136 limits?: Partial<GitSqliteLimits>;137+ /** Copy incoming packs before awaiting so callers may safely reuse them. */138+ capturePack?: boolean;122139 transactionSync: <T>(closure: () => T) => T;123140 meter?: (event: { rowsRead: number; rowsWritten: number; rawBytes: number; compressedBytes: number }) => void;124141 quota?: (event: { rawBytes: number; compressedBytes: number; metadataBytes: number }) => void;@@ -138,6 +155,7 @@ export class GitSqliteRepository implements GitRepository {
138155 readonly #quota?: GitSqliteOptions["quota"];139156 readonly #onCommit?: GitSqliteOptions["onCommit"];140157 readonly #afterCommit?: GitSqliteOptions["afterCommit"];158+ readonly #capturePack: boolean;141159 142160 constructor(options: GitSqliteOptions) {143161 this.#sql = options.sql;@@ -146,13 +164,14 @@ export class GitSqliteRepository implements GitRepository {
146164 this.limits = Object.freeze({ ...DEFAULT_GIT_SQLITE_LIMITS, ...options.limits });147165 for (const value of Object.values(this.limits))148166 if (!Number.isSafeInteger(value) || value < 1) throw new Error("Invalid Git SQLite limit");149- if (this.limits.chunkBytes > 512 * 1024 || this.limits.maxObjectBytes > 4 * 1024 * 1024) throw new Error("Git SQLite limits exceed platform bounds");167+ if (this.limits.chunkBytes > 512 * 1024) throw new Error("Git SQLite chunk limit exceeds platform bounds");150168 if (!options.transactionSync) throw new Error("transactionSync is required for Git SQLite storage");151169 this.#transaction = options.transactionSync;152170 this.#meter = options.meter;153171 this.#quota = options.quota;154172 this.#onCommit = options.onCommit;155173 this.#afterCommit = options.afterCommit;174+ this.#capturePack = options.capturePack ?? true;156175 157176 }158177 @@ -169,15 +188,23 @@ export class GitSqliteRepository implements GitRepository {
169188 async load(): Promise<Snapshot> {170189 const refs = await this.loadRefs();171190 const packs: Uint8Array[] = [];172- // Compatibility callers receive a bounded pack. HTTP and reconciliation173- // use indexed reads; backups export the compressed SQL rows directly.191+ // Indexed callers use getObject/getObjectInfo and do not need a complete192+ // repository pack. Keep the legacy pack view bounded by operation limits;193+ // a large repository therefore remains readable instead of failing merely194+ // because its retained history exceeds one request's decode budget.174195 const meta = this.#meta();175- if (meta.rawBytes > this.limits.maxTransactionRawBytes || meta.objectCount > 4096) throw new LimitError("Git SQLite snapshot exceeds limit");176- const ids = this.#exec<{ oid: string }>("SELECT oid FROM git_sqlite_objects WHERE repository=? ORDER BY oid LIMIT 4097", this.prefix).toArray();177- if (ids.length > 4096) throw new LimitError("Git snapshot object limit exceeded");196+ if (meta.rawBytes > this.limits.maxTransactionRawBytes || meta.objectCount > this.limits.maxTransactionObjects) return { ...refs, records: [], packs: [] };197+ const legacy = this.#exec<{ maxRaw: number | null; raw: number; objects: number }>("SELECT MAX(raw_size) AS maxRaw, COALESCE(SUM(raw_size),0) AS raw, COUNT(*) AS objects FROM git_sqlite_objects WHERE repository=?", this.prefix).toArray()[0];198+ if (!legacy || (legacy.maxRaw ?? 0) > 4 * 1024 * 1024 || legacy.raw > 16 * 1024 * 1024 || legacy.objects > 4096) return { ...refs, records: [], packs: [] };199+ const ids = this.#exec<{ oid: string }>("SELECT oid FROM git_sqlite_objects WHERE repository=? ORDER BY oid LIMIT ?", this.prefix, this.limits.maxTransactionObjects + 1).toArray();200+ if (ids.length > this.limits.maxTransactionObjects) return { ...refs, records: [], packs: [] };178201 const objects: GitObject[] = [];179202 for (const { oid } of ids) objects.push((await this.getObject(oid))!);180- if (objects.length) packs.push(await this.#pack(objects));203+ if (objects.length) {204+ const pack = await this.#pack(objects);205+ if (!pack || pack.length > this.limits.maxPackBytes) return { ...refs, records: [], packs: [] };206+ packs.push(pack);207+ }181208 return { ...refs, records: [], packs };182209 }183210 @@ -194,8 +221,11 @@ export class GitSqliteRepository implements GitRepository {
194221 if (!OID.test(oid)) throw new IntegrityError("Invalid object ID");195222 const row = this.#exec<{ type: GitObjectType; raw_size: number }>("SELECT type,raw_size FROM git_sqlite_objects WHERE repository=? AND oid=?", this.prefix, oid).toArray()[0];196223 if (!row) return null;197- const links = this.#exec<{ link_oid: string; link_type: GitObjectType }>("SELECT link_oid,link_type FROM git_sqlite_object_links WHERE repository=? AND oid=? ORDER BY position LIMIT ?", this.prefix, oid, this.limits.maxGraphEdges + 1).toArray().map((link) => ({ oid: link.link_oid, type: link.link_type }));198- if (!["blob", "tree", "commit", "tag"].includes(row.type) || !Number.isSafeInteger(row.raw_size) || row.raw_size < 0 || row.raw_size > this.limits.maxObjectBytes || links.length > this.limits.maxGraphEdges || links.some(link => !OID.test(link.oid) || !["blob", "tree", "commit", "tag"].includes(link.type))) throw new IntegrityError("Corrupt Git object metadata");224+ // This is a read of retained data. A later policy may lower the intake225+ // graph budget, but that must not strand objects accepted under an older226+ // policy; use the physical safety bound for indexed reads.227+ const links = this.#exec<{ link_oid: string; link_type: GitObjectType }>("SELECT link_oid,link_type FROM git_sqlite_object_links WHERE repository=? AND oid=? ORDER BY position LIMIT ?", this.prefix, oid, MAX_GIT_LIMITS.maxGraphEdges + 1).toArray().map((link) => ({ oid: link.link_oid, type: link.link_type }));228+ if (!["blob", "tree", "commit", "tag"].includes(row.type) || !Number.isSafeInteger(row.raw_size) || row.raw_size < 0 || row.raw_size > GIT_SQLITE_PHYSICAL_OBJECT_BYTES || links.length > MAX_GIT_LIMITS.maxGraphEdges || links.some(link => !OID.test(link.oid) || !["blob", "tree", "commit", "tag"].includes(link.type))) throw new IntegrityError("Corrupt Git object metadata");199229 return { oid, type: row.type, size: row.raw_size, links };200230 }201231 @@ -234,7 +264,11 @@ export class GitSqliteRepository implements GitRepository {
234264 if (update.new !== null && (!OID.test(update.new) || update.new === ZERO)) throw new IntegrityError("Invalid new ref");235265 }236266 if (new Set(updates.map((update) => update.name)).size !== updates.length) throw new IntegrityError("Duplicate ref update");237- const pack = request.pack?.slice();267+ // HTTP/GRASP adapters normally own an immutable body buffer for the268+ // duration of this call. They can opt out of this defensive copy to avoid269+ // retaining a second large pack while decode is in progress. The default270+ // remains copy-on-entry for direct callers and existing retry semantics.271+ const pack = request.pack ? (this.#capturePack ? request.pack.slice() : request.pack) : undefined;238272 if (pack && pack.length > this.limits.maxPackBytes) throw new LimitError("pack exceeds limit");239273 const packHash = pack ? await sha256(pack) : null;240274 const digest = await sha256(new TextEncoder().encode(JSON.stringify({ id, updates, pack: packHash })));@@ -249,7 +283,7 @@ export class GitSqliteRepository implements GitRepository {
249283 const decoded = pack ? await decodePack(pack, {250284 maxPackBytes: this.limits.maxPackBytes,251285 maxObjectBytes: this.limits.maxObjectBytes,252- maxObjects: Math.min(4096, this.limits.maxObjects),286+ maxObjects: this.limits.maxTransactionObjects,253287 maxTotalObjectBytes: this.limits.maxTransactionRawBytes,254288 }, undefined, (oid) => this.getObject(oid)) : [];255289 if (decoded.length > this.limits.maxObjects) throw new LimitError("Too many objects");@@ -284,7 +318,7 @@ export class GitSqliteRepository implements GitRepository {
284318 // Payload accounting includes repeated SQL keys and link/ref/receipt values.285319 // The physical database meter separately includes pages and indexes.286320 const utf8 = (value: string) => new TextEncoder().encode(value).length;287- const objectMetadata = stored.reduce((sum, item) => sum + utf8(this.prefix) + 80 + Math.ceil(item.compressedSize / this.limits.chunkBytes) * (utf8(this.prefix) + 48) + objectLinks(item.object).length * (utf8(this.prefix) + 96), 0);321+ const objectMetadata = stored.reduce((sum, item) => sum + utf8(this.prefix) + 80 + Math.ceil(item.compressedSize / this.limits.chunkBytes) * (utf8(this.prefix) + 48) + objectLinks(item.object, this.limits.maxGraphEdges).length * (utf8(this.prefix) + 96), 0);288322 const refMetadata = updates.reduce((sum, update) => sum + (update.new === null ? -1 : update.old === null ? 1 : 0) * (utf8(this.prefix) + utf8(update.name) + 40), 0);289323 const metadataBytes = objectMetadata + refMetadata + utf8(this.prefix) + utf8(JSON.stringify(updates)) + utf8(id) + 160;290324 if (rawBytes > this.limits.maxTransactionRawBytes) throw new LimitError("Transaction raw-byte limit exceeded");@@ -310,7 +344,7 @@ export class GitSqliteRepository implements GitRepository {
310344 if (meta.sequence !== capturedSequence || meta.tip !== capturedTip || JSON.stringify(this.#refs()) !== JSON.stringify(capturedRefs)) throw new ConflictError("Repository changed during Git preparation");311345 if (meta.objectCount + stored.length > this.limits.maxObjects || meta.rawBytes + rawBytes > this.limits.maxRawBytes || meta.compressedBytes + compressedBytes > this.limits.maxCompressedBytes || meta.metadataBytes + metadataBytes > this.limits.maxMetadataBytes) throw new LimitError("Repository storage limit exceeded");312346 const edgeCount = this.#exec<{ count: number }>("SELECT count(*) AS count FROM git_sqlite_object_links WHERE repository=?", this.prefix).toArray()[0].count;313- const addedEdges = stored.reduce((count, item) => count + objectLinks(item.object).length, 0);347+ const addedEdges = stored.reduce((count, item) => count + objectLinks(item.object, Math.max(0, this.limits.maxGraphEdges - count)).length, 0);314348 if (edgeCount + addedEdges > this.limits.maxGraphEdges) throw new LimitError("Repository graph edge limit exceeded");315349 beforeCommit?.();316350 const changeObjects: Array<GitSqliteCommitChange["objects"][number]> = [];@@ -319,7 +353,7 @@ export class GitSqliteRepository implements GitRepository {
319353 let chunk = 0;320354 for (let position = 0; position < item.compressed.length; position += this.limits.chunkBytes)321355 this.#exec("INSERT INTO git_sqlite_object_chunks(repository,oid,chunk,data) VALUES(?,?,?,?)", this.prefix, item.object.oid, chunk++, asBuffer(item.compressed.subarray(position, position + this.limits.chunkBytes)));322- for (const [position, link] of objectLinks(item.object).entries()) this.#exec("INSERT INTO git_sqlite_object_links(repository,oid,position,link_oid,link_type) VALUES(?,?,?,?,?)", this.prefix, item.object.oid, position, link.oid, link.type);356+ for (const [position, link] of objectLinks(item.object, this.limits.maxGraphEdges).entries()) this.#exec("INSERT INTO git_sqlite_object_links(repository,oid,position,link_oid,link_type) VALUES(?,?,?,?,?)", this.prefix, item.object.oid, position, link.oid, link.type);323357 changeObjects.push({ oid: item.object.oid, type: item.object.type, rawSize: item.rawSize, compressedSize: item.compressedSize });324358 }325359 for (const update of updates) {@@ -364,19 +398,51 @@ export class GitSqliteRepository implements GitRepository {
364398 #refs() { return Object.fromEntries(this.#exec<{ name: string; oid: string }>("SELECT name,oid FROM git_sqlite_refs WHERE repository=? ORDER BY name", this.prefix).toArray().map((row) => [row.name, row.oid])); }365399 #tipAt(sequence: number) { return this.#exec<{ record_hash: string }>("SELECT record_hash FROM git_sqlite_receipts WHERE repository=? AND sequence=?", this.prefix, sequence).toArray()[0]?.record_hash ?? null; }366400 #read(oid: string, rawSize: number): Uint8Array {367- if (rawSize < 0 || rawSize > this.limits.maxObjectBytes) throw new LimitError("Git object exceeds limit");401+ if (!Number.isSafeInteger(rawSize) || rawSize < 0 || rawSize > GIT_SQLITE_PHYSICAL_OBJECT_BYTES) throw new LimitError("Git object exceeds storage safety limit");368402 const meta = this.#exec<{ compressed_size: number; chunks: number }>("SELECT compressed_size,chunks FROM git_sqlite_objects WHERE repository=? AND oid=?", this.prefix, oid).toArray()[0];369- if (!meta || meta.chunks < 1 || meta.chunks > Math.ceil((this.limits.maxObjectBytes + 65536) / this.limits.chunkBytes) || meta.compressed_size > this.limits.maxObjectBytes + 65536) throw new IntegrityError("Corrupt Git object metadata");370- const rows = this.#exec<{ chunk: number; data: ArrayBuffer }>("SELECT chunk,data FROM git_sqlite_object_chunks WHERE repository=? AND oid=? ORDER BY chunk LIMIT ?", this.prefix, oid, Math.ceil((this.limits.maxObjectBytes + 65536) / this.limits.chunkBytes) + 1).toArray();371- if (rows.length !== meta.chunks || rows.some((row, index) => row.chunk !== index || row.data.byteLength > this.limits.chunkBytes)) throw new IntegrityError("Corrupt Git object chunks");372- const compressed = join(rows.map((row) => asBytes(row.data)));373- if (compressed.length !== meta.compressed_size) throw new IntegrityError("Corrupt Git object size");374- const data = unzlibSync(compressed, { out: new Uint8Array(rawSize + 1) });375- if (data.length !== rawSize) throw new IntegrityError("Corrupt Git object");403+ const maxCompressed = GIT_SQLITE_PHYSICAL_OBJECT_BYTES + 65536;404+ const maxChunks = Math.ceil(maxCompressed / this.limits.chunkBytes);405+ if (!meta || !Number.isSafeInteger(meta.chunks) || meta.chunks < 1 || meta.chunks > maxChunks || !Number.isSafeInteger(meta.compressed_size) || meta.compressed_size < 1 || meta.compressed_size > maxCompressed) throw new IntegrityError("Corrupt Git object metadata");406+407+ // Feed SQLite chunks directly into the streaming zlib decoder. Keeping a408+ // single chunk in flight avoids materializing compressed+raw copies of a409+ // large object (which exceeds a Worker memory budget around 64 MiB).410+ const data = new Uint8Array(rawSize);411+ let written = 0;412+ let compressedBytes = 0;413+ let failed: unknown;414+ const unzlib = new Unzlib((part) => {415+ if (failed) return;416+ if (written + part.length > data.length) {417+ failed = new IntegrityError("Corrupt Git object: decompressed size exceeds metadata");418+ return;419+ }420+ data.set(part, written);421+ written += part.length;422+ });423+ try {424+ for (let chunk = 0; chunk < meta.chunks; chunk++) {425+ const row = this.#exec<{ chunk: number; data: ArrayBuffer }>("SELECT chunk,data FROM git_sqlite_object_chunks WHERE repository=? AND oid=? AND chunk=?", this.prefix, oid, chunk).toArray()[0];426+ if (!row || row.chunk !== chunk || row.data.byteLength < 1 || row.data.byteLength > this.limits.chunkBytes) throw new IntegrityError("Corrupt Git object chunks");427+ compressedBytes += row.data.byteLength;428+ if (compressedBytes > meta.compressed_size) throw new IntegrityError("Corrupt Git object size");429+ unzlib.push(new Uint8Array(row.data), chunk === meta.chunks - 1);430+ }431+ } catch (error) {432+ failed = error;433+ }434+ if (failed) throw failed instanceof IntegrityError ? failed : new IntegrityError(`Corrupt Git object compression: ${failed instanceof Error ? failed.message : String(failed)}`);435+ if (compressedBytes !== meta.compressed_size || written !== rawSize) throw new IntegrityError("Corrupt Git object size");376436 return data;377437 }378- async #pack(objects: readonly GitObject[]) { const { encodePack } = await import("ntig"); return encodePack(objects); }438+ async #pack(objects: readonly GitObject[]): Promise<Uint8Array | null> {439+ // ntig's compatibility encoder has fixed conservative bounds. Indexed440+ // callers handle larger retained objects; don't hydrate those objects441+ // just to build a snapshot that cannot be encoded.442+ if (objects.length > 4096 || objects.some((object) => object.data.length > 4 * 1024 * 1024) || objects.reduce((total, object) => total + object.data.length, 0) > 16 * 1024 * 1024) return null;443+ const { encodePack } = await import("ntig");444+ return encodePack(objects);445+ }379446 }380447 381448 const equal = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((value, index) => value === b[index]);382-const join = (parts: readonly Uint8Array[]) => { const result = new Uint8Array(parts.reduce((size, part) => size + part.length, 0)); let position = 0; for (const part of parts) { result.set(part, position); position += part.length; } return result; };@@ -2,7 +2,6 @@
22 // physical byte meter already includes these objects, refs and retry receipts.33 import { repository } from "./grasp-state.ts";44 import { gitStoragePrefix } from "./grasp.ts";5-import { DEFAULT_GIT_SQLITE_LIMITS } from "./git-sqlite.ts";65 import { featureOn } from "./settings.ts";76 import { now } from "./event.ts";87 import type { Relay } from "./relay.ts";@@ -27,6 +26,6 @@ export async function gitStorage(relay: Relay, owner: string, identifier: string
2726 const counts = relay.sql.exec<{ refs: number; receipts: number }>("SELECT (SELECT count(*) FROM git_sqlite_refs WHERE repository=?) AS refs, (SELECT count(*) FROM git_sqlite_receipts WHERE repository=?) AS receipts", namespace, namespace);2827 const { refs, receipts } = counts.one();2928 relay.meterPush(cursor.rowsRead + counts.rowsRead, cursor.rowsWritten + counts.rowsWritten);30- return { body: { result: { repository: { owner, identifier, announcement: repo.id }, backend: "sqlite", objects, refs, receipts, physicalDatabaseBytes: relay.eventBytes(), operations: { gets: 0, lists: 0 }, limits: DEFAULT_GIT_SQLITE_LIMITS, cooldownSeconds: 60, capturedAt: now() } }, status: 200 };29+ return { body: { result: { repository: { owner, identifier, announcement: repo.id }, backend: "sqlite", objects, refs, receipts, physicalDatabaseBytes: relay.eventBytes(), operations: { gets: 0, lists: 0 }, limits: relay.settings.policy.git, cooldownSeconds: 60, capturedAt: now() } }, status: 200 };3130 }, () => failure("restricted: relay operation in progress; retry", 429));3231 }@@ -1,6 +1,7 @@
11 // GRASP-02 Git reconciliation uses signed event targets and one bounded source22 // attempt per alarm. Durable due times survive restarts and rotate failed sources.33 import { fetchGitPack, encodePack, sha256, classifyError } from "ntig";4+import { gitPackLimits } from "./git-limits.ts";45 import { now, tag, type Event } from "./event.ts";56 import { featureOn } from "./settings.ts";67 import { KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "./kinds.ts";@@ -81,8 +82,8 @@ async function reconcile(relay: Relay, task: Task, attempt: number): Promise<boo
8182 const local = await gitRepository(relay, address, true);8283 const ref = `refs/nostr/${task.event.id}`;8384 if ((await local.loadRefs()).refs[ref] === tag(task.event, "c")) {84- const available = await gitGraph(local, missing);85- pack = await encodePack(available);85+ const available = await gitGraph(local, missing, relay.settings.policy.git);86+ pack = await encodePack(available, gitPackLimits(relay.settings.policy.git));8687 }8788 }8889 }@@ -92,6 +93,10 @@ async function reconcile(relay: Relay, task: Task, attempt: number): Promise<boo
9293 const source = sources[attempt % sources.length];9394 const fetched = await fetchGitPack(source, {9495 wants: missing,96+ maxPackBytes: relay.settings.policy.git.maxPackBytes,97+ maxResponseBytes: relay.settings.policy.git.maxPackBytes + 1024 * 1024,98+ maxRefs: relay.settings.policy.git.maxRefs,99+ gitLimits: gitPackLimits(relay.settings.policy.git),95100 fetch: request => sourceFetch(relay, request),96101 timeoutMs: 10_000,97102 observe: event => relay.meterBytes(event.responseBytes, event.requestBytes),@@ -1,5 +1,6 @@
11 // GRASP's accepted Nostr authority and purgatory. Git objects and refs live22 // in the SQLite backend; these tables record visibility and cleanup deadlines.3+import { MAX_GIT_LIMITS } from "./git-limits.ts";34 import { now, tag, tagValues, type Event } from "./event.ts";45 import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "./kinds.ts";56 import { featureOn } from "./settings.ts";@@ -7,7 +8,7 @@ import { parseRepositoryAnnouncement, parseRepositoryState, recursiveMaintainers
78 import { npubEncode } from "nostr-tools/nip19";89 import type { Relay } from "./relay.ts";910 10-export const MAX_REPOSITORIES = 16;11+1112 export const PURGATORY_SECONDS = 1800;1213 export const PR_REF_SECONDS = 1200;1314 export const GRASP_SCHEMA = `@@ -25,10 +26,17 @@ CREATE TABLE IF NOT EXISTS grasp_sync_status (id INTEGER PRIMARY KEY CHECK(id=1)
2526 // graspEvents includes purgatory for write authority, but never expired or2627 // moderated records. A pending signed state authorizes the data it awaits.2728 export function graspEvents(relay: Relay, kind: number): Event[] {28- return relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=? AND (expires=0 OR expires>?) AND NOT EXISTS (SELECT 1 FROM grasp_pending WHERE grasp_pending.id=events.id AND until<=?) ORDER BY created_at DESC,id ASC LIMIT 1025`, kind, now(), now()).toArray().map((r) => JSON.parse(r.raw) as Event).filter((e) => !relay.settings.isEventHidden(e.id) && !relay.settings.isEventBanned(e.id) && !relay.settings.isBanned(e.pubkey));29+ return relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=? AND (expires=0 OR expires>?) AND NOT EXISTS (SELECT 1 FROM grasp_pending WHERE grasp_pending.id=events.id AND until<=?) ORDER BY created_at DESC,id ASC LIMIT ?`, kind, now(), now(), MAX_GIT_LIMITS.maxRepositories * 4 + 1).toArray().map((r) => JSON.parse(r.raw) as Event).filter((e) => !relay.settings.isEventHidden(e.id) && !relay.settings.isEventBanned(e.id) && !relay.settings.isBanned(e.pubkey));2930 }3031 export const announcements = (relay: Relay): RepositoryAnnouncement[] => graspEvents(relay, KIND_REPO).flatMap((e) => { const p = parseRepositoryAnnouncement(e); return p.value ? [p.value] : []; });31-export const hostedAnnouncements = (relay: Relay): RepositoryAnnouncement[] => announcements(relay).filter((r) => relay.sql.exec(`SELECT 1 FROM grasp_hosted WHERE id=?`, r.id).toArray().length > 0);32+// Query admitted repositories directly: companion announcements or a lower33+// intake quota must not evict an existing repository from the discovery window.34+export const hostedAnnouncements = (relay: Relay): RepositoryAnnouncement[] => relay.sql.exec<{ raw: string }>(`SELECT events.raw FROM grasp_hosted JOIN events ON events.id=grasp_hosted.id WHERE events.kind=? AND (events.expires=0 OR events.expires>?) ORDER BY events.created_at DESC,events.id`, KIND_REPO, now()).toArray().flatMap(row => {35+ const event = JSON.parse(row.raw) as Event;36+ if (relay.settings.isEventHidden(event.id) || relay.settings.isEventBanned(event.id) || relay.settings.isBanned(event.pubkey)) return [];37+ const parsed = parseRepositoryAnnouncement(event);38+ return parsed.value ? [parsed.value] : [];39+});3240 export const repository = (relay: Relay, owner: string, identifier: string) => hostedAnnouncements(relay).find((r) => r.owner === owner && r.identifier === identifier) ?? null;3341 export function repositoryState(relay: Relay, repo: RepositoryAnnouncement) {3442 const states = graspEvents(relay, KIND_REPO_STATE).flatMap((e) => { const p = parseRepositoryState(e); return p.value ? [p.value] : []; });@@ -63,7 +71,7 @@ export function graspGate(relay: Relay, e: Event, host: string): string {
6371 const all = announcements(relay);6472 const related = hostedAnnouncements(relay).some((root) => root.owner !== e.pubkey && root.identifier === r.identifier && recursiveMaintainers(root, all)?.has(e.pubkey));6573 if (!listed && !related && !featureOn(relay.settings.policy, "grasp05")) return "restricted: repository clone and relays tags must name this service";66- if ((listed || featureOn(relay.settings.policy, "grasp05")) && !repository(relay, r.owner, r.identifier) && hostedAnnouncements(relay).length >= MAX_REPOSITORIES) return `restricted: at most ${MAX_REPOSITORIES} repositories per relay`;74+ if ((listed || featureOn(relay.settings.policy, "grasp05")) && !repository(relay, r.owner, r.identifier) && hostedAnnouncements(relay).length >= relay.settings.policy.git.maxRepositories) return `restricted: at most ${relay.settings.policy.git.maxRepositories} repositories per relay`;6775 }6876 if (e.kind === KIND_REPO_STATE) {6977 const p = parseRepositoryState(e);@@ -14,6 +14,7 @@ import { syncGitTick } from "./grasp-git-sync.ts";
1414 import { graspSyncTick } from "./grasp-sync.ts";1515 import { GitSqliteRepository } from "./git-sqlite.ts";1616 import { sqliteGitBytes, gitSql } from "./git-catalog.ts";17+import { gitHttpLimits, MAX_GIT_LIMITS } from "./git-limits.ts";1718 import { gitObjects } from "./git-objects.ts";1819 1920 const CORS = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST", "access-control-allow-headers": "Content-Type, Authorization, Git-Protocol, X-Git-Request-Id" };@@ -38,6 +39,10 @@ export async function gitRepository(relay: Relay, repo: RepositoryAnnouncement,
3839 let released: Event[] = [];3940 const sql = new GitSqliteRepository({4041 sql: relay.sql, repository: namespace,42+ limits: relay.settings.policy.git,43+ // Authority wrappers capture external input; sync builds its own packs.44+ // The backend can borrow those immutable buffers for the commit duration.45+ capturePack: false,4146 transactionSync: work => relay.storage.transactionSync(work),4247 meter: counts => relay.meterPush(counts.rowsRead, counts.rowsWritten),4348 quota: () => {@@ -47,7 +52,7 @@ export async function gitRepository(relay: Relay, repo: RepositoryAnnouncement,
4752 if (fuel.outOfFuel) throw new LimitError("Relay storage or fuel limit reached");4853 const cap = relay.settings.limitsOf(repo.owner)?.cap ?? 0;4954 if (cap && sqliteGitBytes(relay, repo.owner, namespace) + relay.store.authorBytes(repo.owner) > cap) throw new LimitError("Repository owner's storage cap reached");50- if (sqliteGitBytes(relay) > 320 * 1024 * 1024) throw new LimitError("Relay Git storage quota reached");55+ if (sqliteGitBytes(relay) > relay.settings.policy.git.maxRelayBytes) throw new LimitError("Relay Git storage quota reached");5156 },5257 onCommit: change => {5358 gitSql(relay, "INSERT OR IGNORE INTO git_sqlite_catalog(repository,owner,identifier,alternative) VALUES(?,?,?,?)", namespace, repo.owner, repo.identifier, alternativePRs ? 1 : 0);@@ -156,8 +161,8 @@ function trackPrRefs(relay: Relay, repo: RepositoryAnnouncement, updates: readon
156161 if (alternative) {157162 const existing = gitSql<{ ref: string }>(relay, "SELECT ref FROM grasp_pr_refs WHERE repo=?", coordinate).toArray();158163 const namespaces = gitSql<{ n: number }>(relay, "SELECT count(DISTINCT repo) AS n FROM grasp_pr_refs WHERE repo LIKE 'pr:%'").one().n;159- if (!existing.length && namespaces >= 16) throw new LimitError("Alternative PR repository quota reached");160- if (new Set([...existing.map(row => row.ref), ...updates.filter(u => u.new !== null).map(u => u.name)]).size > 1024) throw new LimitError("Alternative PR ref quota reached");164+ if (!existing.length && namespaces >= relay.settings.policy.git.maxRepositories) throw new LimitError("Alternative PR repository quota reached");165+ if (new Set([...existing.map(row => row.ref), ...updates.filter(u => u.new !== null).map(u => u.name)]).size > relay.settings.policy.git.maxRefs) throw new LimitError("Alternative PR ref quota reached");161166 }162167 for (const update of updates) {163168 if (!/^refs\/nostr\/[0-9a-f]{64}$/.test(update.name)) continue;@@ -209,6 +214,7 @@ export function authorizedRepository(relay: Relay, repo: RepositoryAnnouncement,
209214 commit: request => wal.commit(request, () => checkAuthority(relay, repo, request.updates)),210215 };211216 const authorized = createAcceptedStateRepository(checked, {217+ maxRefs: MAX_GIT_LIMITS.maxRefs,212218 lookupState: async () => { const state = repositoryState(relay, repo); return state ? { eventId: state.id, refs: state.refs, head: state.head } : null; },213219 lookupPrTip: async (id) => prTip(relay, repo, id),214220 allowUnknownPrRefs: true,@@ -240,7 +246,10 @@ export async function grasp(relay: Relay, req: Request, url: URL): Promise<Respo
240246 const limited = relay.ipLimit(req.headers.get("x-relay-ip") || "unknown", req.method === "POST" ? "events" : "reqs");241247 if (limited) return answer(limited, 429);242248 if (relay.fuelStatus().outOfFuel) return answer("restricted: relay storage or fuel limit reached", 403);243- return relay.repositoryAccess.run("git", async () => {249+ const access = relay.repositoryAccess;250+ const run = req.method === "POST" && url.pathname.endsWith("/git-upload-pack")251+ ? access.response.bind(access) : access.run.bind(access);252+ return run("git", async () => {244253 try {245254 if (parsed.endpoint === "root") {246255 if (req.method !== "GET" && req.method !== "HEAD") return answer("invalid: method not allowed", 405);@@ -252,7 +261,7 @@ export async function grasp(relay: Relay, req: Request, url: URL): Promise<Respo
252261 }253262 const wal = await gitRepository(relay, repo, parsed.alternativePRs);254263 const response = await wal.withReadSession(async (scopedWal) => {255- const handler = createGitHandler(parsed.alternativePRs ? alternativePrRepository(relay, repo, scopedWal, url.host) : authorizedRepository(relay, repo, scopedWal), { prefix: parsed.prefix, authorizePush: () => true, observe: (event) => relay.meterBytes(event.requestBytes, event.responseBytes) });264+ const handler = createGitHandler(parsed.alternativePRs ? alternativePrRepository(relay, repo, scopedWal, url.host) : authorizedRepository(relay, repo, scopedWal), { ...gitHttpLimits(relay.settings.policy.git), prefix: parsed.prefix, authorizePush: () => true, observe: (event) => relay.meterBytes(event.requestBytes, event.responseBytes) });256265 return await handler(req);257266 });258267 const headers = new Headers(response.headers);@@ -27,7 +27,7 @@ import { addDomain, checkDomain, listDomains, removeDomain, setDomainSite } from
2727 import { verifyNIP98 } from "./auth.ts";2828 import { SITE_KINDS, checkSite, siteLabel, sitePaths } from "./sites.ts";2929 import { gitStorage } from "./git-storage.ts";30-import { createBackup, deleteBackup, listBackups } from "./backups.ts";30+import { createBackup, deleteBackup, listBackups, DEFAULT_BACKUP_LIMITS } from "./backups.ts";3131 3232 // A call: the relay and the request, who is calling and as what, the3333 // parameters with their readers, and how to answer.@@ -170,6 +170,7 @@ export const METHODS: Record<string, Method> = {
170170 run: async ({ relay, s, p, params, reply }) => {171171 const patch = params[0] && typeof params[0] === "object" ? (params[0] as Record<string, unknown>) : {};172172 const clean = policyPatch(patch, p);173+ if (patch.git !== undefined && !clean.git) return reply({ error: "invalid: Git limits must be positive integers within the supported ranges" }, 400);173174 s.update(clean);174175 // A read rule that tightened ends the subscriptions it no longer admits.175176 relay.enforceReads();@@ -687,8 +688,9 @@ export const METHODS: Record<string, Method> = {
687688 backupnow: {688689 action: "storage",689690 run: async ({ relay, params, reply }) => {690- const id = typeof params[0] === "string" && params[0] ? params[0] : `backup-${now()}`;691- const result = await createBackup(relay, id);691+ const options = params[0] && typeof params[0] === "object" ? params[0] as Record<string, unknown> : {};692+ const id = typeof params[0] === "string" && params[0] ? params[0] : typeof options.id === "string" && options.id ? options.id : `backup-${now()}`;693+ const result = await createBackup(relay, id, { ...DEFAULT_BACKUP_LIMITS, includeGit: options.includeGit !== false });692694 return typeof result === "string" ? reply({ error: result }, result.startsWith("invalid:") ? 400 : 403) : reply({ result: { ...result.manifest, url: "/backups/" + id } });693695 },694696 },@@ -6,6 +6,7 @@ import type { Relay } from "./relay.ts";
66 import { now } from "./event.ts";77 import { SUCCESSION_WARN_DAYS, featureOn, FEATURE_NAMES, type Feature } from "./settings.ts";88 import { SITE_KINDS } from "./sites.ts";9+import { MiB, GiB } from "./git-limits.ts";910 import { KIND_REPO } from "./kinds.ts";1011 import { nip11Views } from "./views.ts";1112 @@ -37,6 +38,8 @@ export function supportedNips(relay: Relay): NipIdentifier[] {
3738 export const SOFTWARE = "https://bind.ws";3839 export const VERSION = "0.1.0";3940 41+const capacity = (n: number) => n % GiB === 0 ? `${n / GiB} GiB` : n % MiB === 0 ? `${n / MiB} MiB` : `${n} bytes`;42+4043 export function nip11(relay: Relay, host: string) {4144 const p = relay.settings.policy;4245 const doc: Record<string, unknown> = {@@ -94,7 +97,9 @@ export function nip11(relay: Relay, host: string) {
9497 : access[p.writes];9598 const guests = p.writes !== "open" && p.openKinds.length ? ` Guest write exceptions apply to kinds ${p.openKinds.join(", ")}.` : "";9699 const listing = featureOn(p, "grasp05") ? "Archive preview accepts repository announcements without naming this service in clone and relays." : "Repository announcements name this service in clone and relays.";97- doc.repo_acceptance_criteria = `${eligibility} ${listing} State and collaboration events follow the relay write rule (${p.writes}) and kind rules; bans, proof of work and fuel apply.${guests} At most 16 repositories, 4 MiB per pack, 4 MiB per object, 16 MiB decoded per operation, and 16 MiB compressed objects plus 16 MiB metadata per repository. Git storage shares the relay SQLite allowance.`;100+ const git = p.git;101+ doc.git_limits = { ...git };102+ doc.repo_acceptance_criteria = `${eligibility} ${listing} State and collaboration events follow the relay write rule (${p.writes}) and kind rules; bans, proof of work and fuel apply.${guests} At most ${git.maxRepositories} repositories, ${capacity(git.maxPackBytes)} per incoming pack, ${capacity(git.maxObjectBytes)} per new object, and ${capacity(git.maxTransactionRawBytes)} decoded per write operation. Each repository may retain ${git.maxObjects} objects, ${capacity(git.maxRawBytes)} raw content, ${capacity(git.maxCompressedBytes)} compressed objects and ${capacity(git.maxMetadataBytes)} metadata. Total Git storage is limited to ${capacity(git.maxRelayBytes)} and shares the relay SQLite allowance. Limits are owner-configurable; storage and fuel charges still apply.`;98103 if (p.writes !== "open" || p.blockedWords.length) doc.curation = "The relay's configured write rule and blocked topics apply to Nostr repository and collaboration events.";99104 }100105 if (featureOn(p, "sites")) doc.nsites = {@@ -23,6 +23,39 @@ export class RepositoryAccess {
2323 finally { this.release(lease); }2424 }2525 26+ // A streamed Git response still reads repository state after fetch returns.27+ // Carry authority into each pull, and release it on EOF, error or disconnect.28+ async response(kind: Kind, work: () => Promise<Response>, refused: () => Response): Promise<Response> {29+ if (this.blocked) return refused();30+ const lease = this.active ??= { kind, holders: 0 };31+ lease.holders++;32+ let released = false;33+ const finish = () => {34+ if (!released) { released = true; this.release(lease); }35+ };36+ try {37+ const response = await this.context.run(lease, work);38+ if (!response.body) { finish(); return response; }39+ const reader = response.body.getReader();40+ const body = new ReadableStream<Uint8Array>({41+ pull: controller => this.context.run(lease, async () => {42+ try {43+ const result = await reader.read();44+ if (result.done) { reader.releaseLock(); controller.close(); finish(); }45+ else controller.enqueue(result.value);46+ } catch (error) {47+ try { await reader.cancel(error); } catch { /* Preserve the read error. */ }48+ finally { reader.releaseLock(); controller.error(error); finish(); }49+ }50+ }),51+ cancel: reason => this.context.run(lease, async () => {52+ try { await reader.cancel(reason); } finally { reader.releaseLock(); finish(); }53+ }),54+ }, { highWaterMark: 0 });55+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });56+ } catch (error) { finish(); throw error; }57+ }58+2659 sync<T>(kind: Kind, work: () => T, refused: () => T): T {2760 if (this.blocked) return refused();2861 const lease = this.active ??= { kind, holders: 0 };@@ -1,4 +1,5 @@
11 // Per-relay settings and policy, kept in the same SQLite database.2+import { DEFAULT_GIT_LIMITS, gitLimitPatch, type GitLimits } from "./git-limits.ts";23 import { callbackOrigins } from "./push-policy.ts";34 import { notifySettings, type NotifySettings } from "./notify.ts";45 import type { CustomHost } from "./domains.ts";@@ -57,6 +58,7 @@ export interface Policy {
5758 joinTerms: string; // shown before an invite is accepted (markdown-ish plain text)5859 directoryPublic: boolean; // whether the people directory is shown to visitors5960 maxBlobMB: number; // Blossom upload size cap61+ git: GitLimits; // per-relay Git storage and transfer capacity6062 eventsPerMinute: number; // per-connection write rate6163 reqsPerMinute: number; // per-connection query rate6264 minPow: number;@@ -162,6 +164,10 @@ export function policyPatch(patch: Record<string, unknown>, cur: Policy): Partia
162164 const notifyPatch = notifySettings(patch.notify, cur.notify);163165 if (notifyPatch) clean.notify = notifyPatch;164166 Object.assign(clean, limitFields(patch));167+ if (patch.git !== undefined) {168+ const git = gitLimitPatch(patch.git, cur.git);169+ if (git) clean.git = git;170+ }165171 return clean as Partial<Policy>;166172 }167173 @@ -205,6 +211,7 @@ export const DEFAULT_POLICY: Policy = {
205211 joinTerms: "",206212 directoryPublic: true,207213 maxBlobMB: 25,214+ git: { ...DEFAULT_GIT_LIMITS },208215 eventsPerMinute: 120,209216 reqsPerMinute: 240,210217 minPow: 0,@@ -454,7 +461,7 @@ export class Settings {
454461 const row = this.sql.exec<{ value: string }>(`SELECT value FROM settings WHERE key='policy'`).toArray()[0];455462 if (row) {456463 const stored = JSON.parse(row.value) as Partial<Policy>;457- this.policy = { ...DEFAULT_POLICY, ...stored, features: { ...DEFAULT_FEATURES, ...(stored.features ?? {}) } };464+ this.policy = { ...DEFAULT_POLICY, ...stored, git: gitLimitPatch(stored.git) ?? { ...DEFAULT_GIT_LIMITS }, features: { ...DEFAULT_FEATURES, ...(stored.features ?? {}) } };458465 // Views were on or off before they had triggers.459466 const views: Record<string, ViewSetting> = {};460467 for (const [k, v] of Object.entries((stored.views ?? {}) as Record<string, unknown>)) {@@ -10,6 +10,19 @@ import { ev, nip98, pk, rpc } from "../helpers/relay.ts";
1010 import { WS } from "../helpers/ws.ts";1111 1212 describe("portable backups", () => {13+ it("can omit Git payloads while retaining the legacy bounded archive format", async () => {14+ const owner = generateSecretKey();15+ const result = await runInDurableObject(env.RELAY.getByName("backup-no-git"), async (relay: Relay) => {16+ relay.settings.update({ owner: pk(owner) });17+ const created = await createBackup(relay, "no-git", { maxBytes: BACKUP_MAX_BYTES, maxEntries: 12_000, includeGit: false });18+ if (typeof created === "string") throw new Error(created);19+ const object = await relay.media.get(created.key);20+ return JSON.parse(new TextDecoder().decode(await object!.arrayBuffer()));21+ });22+ expect(result.sqlGit).toBeUndefined();23+ expect(result.manifest.git).toBe(0);24+ });25+1326 it("serves owner-only download and keeps signed preview read-only", async () => {1427 const source = "backup-http-source.bind.ws";1528 const target = "backup-http-target.bind.ws";@@ -0,0 +1,63 @@
1+import { SELF, env, runInDurableObject } from "cloudflare:test";2+import { describe, it, expect } from "vitest";3+import { generateSecretKey } from "nostr-tools/pure";4+import type { Relay } from "../../src/relay.ts";5+import { DEFAULT_GIT_LIMITS, GiB, MiB } from "../../src/git-limits.ts";6+import { Settings } from "../../src/settings.ts";7+import { rpc } from "../helpers/relay.ts";8+9+describe("per-relay Git capacity", () => {10+ it("preserves partial changes through storage and config export while advertising the effective limits", async () => {11+ const host = "git-capacity.bind.ws", other = "git-capacity-other.bind.ws";12+ const owner = generateSecretKey();13+ await rpc(host, owner, "claim");14+ await rpc(other, owner, "claim");15+ await rpc(host, owner, "setpolicy", { features: { grasp: true }, git: { maxRepositories: 256, maxRelayBytes: 6 * GiB } });16+ const changed = await rpc(host, owner, "setpolicy", { git: { maxObjectBytes: 64 * MiB } });17+ expect(changed.status).toBe(200);18+ expect(changed.result.git).toEqual({ ...DEFAULT_GIT_LIMITS, maxRepositories: 256, maxRelayBytes: 6 * GiB, maxObjectBytes: 64 * MiB });19+ expect((await rpc(other, owner, "getpolicy")).result.git).toEqual(DEFAULT_GIT_LIMITS);20+ const doc = await (await SELF.fetch(`https://${host}/`, { headers: { accept: "application/nostr+json" } })).json<Record<string, unknown>>();21+ expect(doc.git_limits).toEqual(changed.result.git);22+ expect(doc.repo_acceptance_criteria).toContain("256 repositories");23+ expect(doc.repo_acceptance_criteria).toContain("64 MiB per new object");24+ const exported = (await rpc(host, owner, "exportconfig")).result;25+ expect((await rpc(other, owner, "importconfig", exported)).status).toBe(200);26+ expect((await rpc(other, owner, "getpolicy")).result.git).toEqual(changed.result.git);27+ await runInDurableObject(env.RELAY.getByName("git-capacity"), async (instance) => {28+ const settings = new Settings((instance as Relay).sql);29+ settings.load();30+ expect(settings.policy.git).toEqual(changed.result.git);31+ });32+ });33+34+ it("refuses unauthorized and malformed settings without applying part of the patch", async () => {35+ const host = "git-capacity-invalid.bind.ws", owner = generateSecretKey();36+ await rpc(host, owner, "claim");37+ expect((await rpc(host, generateSecretKey(), "setpolicy", { git: { maxRepositories: 512 } })).status).toBe(403);38+ for (const git of [{ maxRepositories: -1 }, { maxPackBytes: 0 }, { maxRelayBytes: 10 * GiB }, { maxObjects: 1.5 }, { maxObjectBytes: "64" }, { maxRepositories: 256, typo: 1 }]) {39+ expect((await rpc(host, owner, "setpolicy", { name: "should not change", git })).status).toBe(400);40+ }41+ const policy = (await rpc(host, owner, "getpolicy")).result;42+ expect(policy.git).toEqual(DEFAULT_GIT_LIMITS);43+ expect(policy.name).not.toBe("should not change");44+ const plan = (await rpc(host, owner, "importconfig", { format: "bind.ws/relay-config/2", policy: { git: { maxObjects: 0 } } }, { dryRun: true })).result;45+ expect(plan.warnings).toContain("policy.git: value not accepted");46+ expect(plan.changes.policy).toEqual([]);47+ });48+49+ it("loads older saved policies with the new defaults and keeps valid overrides", async () => {50+ await runInDurableObject(env.RELAY.getByName("git-capacity-upgrade"), async (instance) => {51+ const relay = instance as Relay;52+ relay.sql.exec("INSERT INTO settings(key,value) VALUES('policy',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", JSON.stringify({ name: "Older relay" }));53+ const settings = new Settings(relay.sql);54+ settings.load();55+ expect(settings.policy.git).toEqual(DEFAULT_GIT_LIMITS);56+ expect(settings.policy.name).toBe("Older relay");57+ settings.update({ git: { ...settings.policy.git, maxRepositories: 300 } });58+ const reloaded = new Settings(relay.sql);59+ reloaded.load();60+ expect(reloaded.policy.git.maxRepositories).toBe(300);61+ });62+ });63+});@@ -1,6 +1,7 @@
11 import { env, runInDurableObject } from "cloudflare:test";22 import { describe, expect, it } from "vitest";33 import { encodePack, type GitObject } from "ntig";4+import { zlibSync } from "fflate";45 import { GitSqliteRepository, GIT_SQLITE_SCHEMA } from "../../src/git-sqlite.ts";56 import type { Relay } from "../../src/relay.ts";67 @@ -236,6 +237,19 @@ describe("GitSqliteRepository limits and transaction behavior", () => {
236237 });237238 });238239 240+ it("keeps retained capacity separate from one-operation decode capacity", async () => {241+ await runInDurableObject(env.RELAY.getByName("git-sqlite-operation-limits"), async (relay: Relay) => {242+ const next = await commitObjects("operation-limits", null);243+ const retained = repo(relay, "operation-limits", { limits: { maxTransactionObjects: 8, maxTransactionRawBytes: 1024 * 1024 } });244+ await retained.commit({ id: "seed", updates: [{ name: "refs/heads/main", old: null, new: next.commit }], pack: next.pack });245+ const bounded = repo(relay, "operation-limits", { limits: { maxTransactionObjects: 2, maxTransactionRawBytes: 1024 * 1024 } });246+ const snapshot = await bounded.load();247+ expect(snapshot.refs["refs/heads/main"]).toBe(next.commit);248+ expect(snapshot.packs).toHaveLength(0);249+ expect(await bounded.getObject(next.commit)).not.toBeNull();250+ });251+ });252+239253 it("round trips a near-4 MiB incompressible blob through multiple chunks", async () => {240254 await runInDurableObject(env.RELAY.getByName("git-sqlite-large-object"), async (relay: Relay) => {241255 const blob = new Uint8Array(4 * 1024 * 1024);@@ -248,4 +262,28 @@ describe("GitSqliteRepository limits and transaction behavior", () => {
248262 expect(relay.sql.exec<{ chunks: number }>("SELECT chunks FROM git_sqlite_objects WHERE repository=? AND oid=?", "large", stored!.oid).one().chunks).toBeGreaterThan(1);249263 });250264 });265+266+ it("round trips a 37 MiB object through indexed chunk reads", async () => {267+ await runInDurableObject(env.RELAY.getByName("git-sqlite-large-indexed-object"), async (relay: Relay) => {268+ const blob = new Uint8Array(37 * 1024 * 1024);269+ const repository = repo(relay, "large-indexed", {270+ limits: {271+ maxPackBytes: 64 * 1024 * 1024,272+ maxObjectBytes: 64 * 1024 * 1024,273+ maxTransactionRawBytes: 64 * 1024 * 1024,274+ maxRawBytes: 64 * 1024 * 1024,275+ maxCompressedBytes: 64 * 1024 * 1024,276+ },277+ });278+ const oid = await objectId("blob", blob);279+ const compressed = zlibSync(blob);280+ const chunkBytes = 512 * 1024;281+ relay.sql.exec("INSERT INTO git_sqlite_objects(repository,oid,type,raw_size,compressed_size,chunks) VALUES(?,?,?,?,?,?)", "large-indexed", oid, "blob", blob.length, compressed.length, Math.ceil(compressed.length / chunkBytes));282+ for (let position = 0, chunk = 0; position < compressed.length; position += chunkBytes, chunk++) relay.sql.exec("INSERT INTO git_sqlite_object_chunks(repository,oid,chunk,data) VALUES(?,?,?,?)", "large-indexed", oid, chunk, compressed.subarray(position, position + chunkBytes).slice().buffer);283+ const stored = await repository.getObject(oid);284+ expect(stored?.data.length).toBe(blob.length);285+ expect(stored?.data[0]).toBe(0);286+ expect(stored?.data[blob.length - 1]).toBe(0);287+ });288+ }, 30_000);251289 });@@ -159,6 +159,7 @@ describe("GRASP event synchronization", () => {
159159 160160 it("archives unlisted announcements only with 05 enabled and counts them toward the quota", async () => {161161 await scope("grasp-sync-archive", async (relay, owner, host) => {162+ relay.settings.update({ git: { ...relay.settings.policy.git, maxRepositories: 16 } });162163 const maintainer = generateSecretKey();163164 const announcement = (n: number) => ev(owner, KIND_REPO, "", [["d", `archive-${n}`], ["maintainers", pk(maintainer)], ["clone", `https://git.example/archive-${n}.git`], ["relays", SOURCE]]);164165 expect(relay.accept(announcement(0), relay.virtualConn(host, pk(owner))).ok).toBe(false);@@ -9,6 +9,38 @@ const latch = () => {
99 const refused = () => "refused";1010 1111 describe("Repository admission", () => {12+ it("keeps streamed reads fenced until EOF and restores authority during pulls", async () => {13+ const access = new RepositoryAccess();14+ let pulls = 0;15+ const response = await access.response("git", async () => new Response(new ReadableStream({16+ pull(controller) {17+ expect(access.owned).toBe(true);18+ if (pulls++ === 0) controller.enqueue(new TextEncoder().encode("pack"));19+ else controller.close();20+ },21+ }, { highWaterMark: 0 })), () => new Response("busy", { status: 429 }));22+ expect(access.blocked).toBe(true);23+ expect(access.sync("event", () => "unexpected", refused)).toBe("refused");24+ expect(await response.text()).toBe("pack");25+ expect(access.busy).toBe(false);26+ });27+28+ it("releases streaming authority after client cancellation and stream errors", async () => {29+ const access = new RepositoryAccess();30+ let canceled = false;31+ const response = await access.response("git", async () => new Response(new ReadableStream({32+ cancel() { expect(access.owned).toBe(true); canceled = true; },33+ }, { highWaterMark: 0 })), () => new Response("busy"));34+ await response.body!.cancel();35+ expect(canceled).toBe(true);36+ expect(access.busy).toBe(false);37+ const broken = await access.response("git", async () => new Response(new ReadableStream({38+ pull() { throw new Error("read failure"); },39+ }, { highWaterMark: 0 })), () => new Response("busy"));40+ await expect(broken.text()).rejects.toThrow("read failure");41+ expect(access.busy).toBe(false);42+ });43+1244 it("keeps unrelated operations out across awaits and permits owned nested work", async () => {1345 const access = new RepositoryAccess();1446 const entered = latch();@@ -1,37 +1,34 @@
11 # Vendored dependencies22 3-`ntig-0.5.0.tgz` supplies Git pack validation, Smart HTTP and Nostr repository3+`ntig-0.6.0.tgz` supplies Git pack validation, Smart HTTP and Nostr repository44 policy wrappers. Bindws stores Git objects, refs and retry receipts in its55 relay Durable Object's SQLite database. The package's R2 adapters remain66 available to other ntig consumers; bindws does not use them for Git storage.77 8-This is a bindws-local extension of the preserved 0.4.0 source snapshot,9-not a claim that version 0.5.0 has been published upstream. The extension10-adds indexed object metadata and selective object reads to the repository11-contract, forwards them through the Nostr policy wrappers, and resolves12-thin-pack bases on demand. Object counts, graph edges and decoded bytes13-remain bounded before payload reads.8+This local capacity extension preserves the indexed reader, lazy thin-pack9+base resolution and authority behavior from the preserved 0.5.0 source. It10+adds configurable transfer limits, streamed indexed downloads, incremental11+hashing and the large-push HTTP probe. It includes the sibling checkout's12+preexisting development changes; this is not an upstream registry release.1413 1514 | Fact | Value |1615 |---|---|1716 | Source repository | [FelineStateMachine/ntig](https://github.com/FelineStateMachine/ntig) |18-| Source base | Preserved `ntig-source-0.4.0.tar.gz`, based on `caa8258e6cf6ec3220b89312d66860258e096f0f` with indexed reader extension changes |19-| Local extension commit | `1b2ea13880e9217bd240f3a32f9cdde05ab60e92` |20-| Source archive | `ntig-source-0.5.0.tar.gz`; `SOURCE-SNAPSHOT.json` records the source file hashes |21-| Package SHA-256 | `c0c2490987ea48215ae435ab138925179a0cb6a7557c51808c8859a20c20a00b` |22-| Source archive SHA-256 | `1701de439e7d4b00dd75984907046a796c5a8f8ff9b53c990a8b707b0b7c0dae` |17+| Checkout base | `caa8258e6cf6ec3220b89312d66860258e096f0f`, plus the preserved 0.5.0 behavior and local changes |18+| Source commit | [`52145156e06cd1d8e360c067bc0271f85d36dbd9`](https://github.com/FelineStateMachine/ntig/commit/52145156e06cd1d8e360c067bc0271f85d36dbd9) |19+| Source archive | `ntig-source-0.6.0.tar.gz`; `SOURCE-SNAPSHOT.json` records the complete source file hashes |20+| Package SHA-256 | `7d517c5acd0de2ecca044dc744a5af275ce3710089be698d9d3aa816c1aac94f` |21+| Source archive SHA-256 | `9f6818e9e603fd1f383141f8a8a481371a08863fa351fddb5a8a73fc39b8e52a` |2322 | License | MIT, included in both archives |24-| Supplied toolchain | Node 26.8.1, npm 11.19.0 |2523 | Library build | `npm ci --ignore-scripts`, then `npm run build:library` in the extracted source |2624 | Package build | `npm pack` in the extracted source |27-| Library checks | `npm run check`, `npm run format:check`, `npm run pack:check` |25+| Library validation | Typecheck, full tests, package consumer/source-map/reproducibility checks |2826 29-The lockfile pins the installed package archive. The complete source and tests30-are preserved beside it, so the build needs neither a sibling checkout nor a31-registry publication. The source's `docs/INTEGRATION.md` describes the exported32-contracts. Its WAL and checkpoint documentation concerns the library's R233-backend, not bindws's SQLite storage.27+The lockfile pins the package archive. The complete source and tests are28+preserved beside it, so building requires neither a sibling checkout nor a29+registry publication. `docs/INTEGRATION.md` describes the exported contracts.30+The library's WAL and checkpoint documentation concerns its R2 backend.3431 35-Earlier versioned archives remain as source provenance. Installing this36-package performs no data migration. Bindws's current storage limits and37-admission rules are in [GRASP-01 Git hosting](../docs/22-grasp-01-git-hosting.md).32+Earlier versioned archives remain as provenance and rollback inputs.33+Installing this package performs no data migration. Runtime defaults and34+owner-configurable bounds are in [GRASP-01 Git hosting](../docs/22-grasp-01-git-hosting.md).Binary file.
Binary file.