* gitignore: a working handoff note stays out of the tree * sites: root, named and snapshot manifests pass shape and aggregate checks; a durable outbox keeps their hostname index in step with event lifetime * routes: canonical site hostnames reach only their files, under the relay read rule; hashes, index pages, fallbacks and conditional requests follow the manifest * sites: missing files come from manifest and author Blossom servers, verified before a proxy response or a metered mirror job stores them * console: sites list their URLs, local sizes, missing files and expiry; the site template admits member publishers and mirror jobs report file counts * sites: only manifest changes wake the index worker, and sibling fetches keep their timeout and redirect settings * sites: browser sign-in and custom domains follow the relay read rule; host-only cookies, signed challenges and local domain records keep each site isolated * tidy: style is recorded in STYLE.md, and site indexing, discovery and sign-in read the shared kind registry * test: canonical site hosts serve signed root, named and snapshot manifests through the public protocol, with index pages, fallbacks and file metadata * docs: static sites have a publishing and access guide, HTTP reference and tracked draft specification, with mirroring limits and custom-domain behavior
- commit
- dabad98403a196ac41a44a9bd726ce8b35e54e19
- parent
- db08d3502ec8c3b65637d0a80c6f65a9af6b0421
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -53,6 +53,10 @@ It runs on Cloudflare Workers with one Durable Object per name, SQLite inside it
5353 - [Develop and extend](docs/12-develop-extend.md): layout, tests, adding methods and NIPs, the console, the templates.5454 - [Costs and margins](docs/15-costs-and-margins.md): what a relay costs the host, what fuel charges, the weekly check against the bitcoin price.5555 56+### Draft NIPs57+58+- [NIP-5A static websites](docs/20-nip-5a-static-websites.md): publish and serve static sites from signed Nostr manifests.59+5660 ## Quick start5761 5862 ```@@ -0,0 +1,171 @@
1+# Style2+3+How this repository writes things down: prose, commits, comments, code and4+tests. Every rule is drawn from what is already here, so this file describes5+the repository rather than an opinion about it.6+7+## The through-line8+9+**Say what is true, not what was done or what to do.** Present indicative,10+active, no hedging. This one habit produces most of the rest of this file.11+12+| Instead of | Write |13+|---|---|14+| Add a catalog table to the templates README | the README is a catalog, one line per template on when to choose it |15+| You can switch features off | a relay's information document lists what its owner left on |16+| Store the blob and return it | storeBlob puts bytes in R2 and a row in the table unless the hash is already there |17+18+The reason travels with the fact, in the same sentence, when the reason is not19+obvious:20+21+> it is a generated module rather than a text-module rule in `wrangler.jsonc`22+> because celld deploy refuses a config with `rules`23+24+> the celld suite runs on demand in its own workflow, since the runner's25+> timing makes the sync test flaky on every push26+27+Sentences are short. Fragments are fine when they are deliberate. There is no28+marketing voice, no filler, and no sentence whose only job is to introduce the29+next one.30+31+## Commits32+33+The form is `<area>: <what is now true>`, lowercase after the colon.34+35+```36+views: a view's setting is its trigger or off; the owner picks on write, hourly or daily for a stored view and on or off for presence, the scheduler and the write-time republish read it, and true or false still mean the default and off37+kinds: every special event kind in one module, by NIP; 30078 had three names in three files, and the NIP-46 and report kinds lived in relay.ts38+docs: main takes commits, not pull requests39+```40+41+- **The area** is the module, folder or concern the change belongs to:42+ `views`, `kinds`, `config`, `routes`, `console`, `docs`, `test`, `ci`,43+ `host`, `rules`, `relay-templates`, `scripts`, `tidy`.44+- **No imperative mood and no Conventional Commits.** There is no `feat:`,45+ `fix:`, `chore:`, and no "add", "update" or "refactor" opening a subject.46+- **Length is not a constraint.** The median subject is 75 characters and the47+ longest is 332. Clauses join with commas and semicolons. Say the whole48+ thing.49+- **The body stays empty.** One commit in 167 has one. If a subject needs a50+ body, it is usually two commits.51+- **Say why when why is not obvious**, in the same subject.52+- **No attribution or co-author trailers.**53+54+Each commit typechecks on its own.55+56+## Documentation57+58+- `docs/` is numbered with two digits: `00` to `05` for users, `10` to `16`59+ for developers, `20` and up for draft NIPs the repository implements ahead60+ of upstream.61+- Each file opens with frontmatter naming its `title` and its `audience`62+ (`user` or `developer`).63+- `README.md` is the front door. It links every doc with a one-line gloss of64+ what that doc answers.65+- Cross-link by relative path, using the target's own title as the link text.66+- User docs use "you" and "your relay". Developer docs describe the system in67+ the third person.68+- A tutorial names an npm script, never `node` and a path. Every tool has an69+ npm name, and the docs, the file headers and the workflow use it.70+- Anything with more than about three parallel facts becomes a table.71+72+## Comments73+74+**Module headers** say what the module is, why it exists, and what its surface75+looks like. A list of routes or a small diagram belongs here.76+77+```ts78+// The relay card: something a name can be linked to from a profile.79+//80+// GET /card.json the facts: name, owner, members, rules, fuel, naddr, nprofile81+// GET /card.svg an open graph sized picture with a QR of the naddr82+//83+// Cards are public and cached for five minutes. Unclaimed and leased relays84+// get a smaller card that says so.85+```86+87+**Function comments open with the function's own name**, then say what it88+does and what it returns.89+90+```ts91+// blobBlocked says whether a hash was removed by a moderator: a resolved92+// report puts the sha256 on the banned id list, so it cannot come back93+// through any door.94+```95+96+A comment that restates its code is deleted. A comment that records a97+constraint, a reason or a surprise is kept.98+99+## Code100+101+- TypeScript with explicit `.ts` in import paths.102+- **A function returns `""` on success and the reason on failure**, rather103+ than throwing or returning a bare boolean. Callers read the reason and pass104+ it on.105+- **Reasons carry a NIP-01 prefix.** The vocabulary in use, by frequency:106+ `invalid:`, `restricted:`, `blocked:`, `error:`, `auth-required:`,107+ `unsupported:`, `duplicate:`, `pow:`, and the bare `not found`. Do not108+ invent a new prefix without a reason.109+- **One registry per concern, read by everything that needs it.** `ROUTES`110+ in `routes.ts`, `METHODS` in `manage.ts`, `VIEWS` in `views.ts`,111+ `FEATURE_NAMES` in `settings.ts`, every special kind in `kinds.ts`, the112+ presets in `presets.ts`. The failure this prevents is on the record: three113+ lists in three files that had to agree.114+- Small helpers are arrow consts. Long lines are fine.115+- **Modules are extracted from `relay.ts` when it grows.** The gates, fuel,116+ views, succession, invites, routes and the information document each left it117+ for a module of their own.118+- Generated files (`src/gen/`) are committed, and `npm run typecheck` fails119+ when one is stale.120+- Unused locals fail the typecheck.121+122+## The console123+124+Already written down in `docs/12-develop-extend.md`, repeated here because it125+is style:126+127+- No middle dots.128+- No purposeless subtext.129+- Tables scroll inside their card on narrow screens.130+- Decorative elements are not selectable.131+- A multi-field form uses the labelled grid, not one row of inputs.132+- The copy above a block is one sentence.133+- New pages stay inside the shared shell in `ui.ts`.134+135+## Tests136+137+- `test/unit` is pure functions on node. `test/object` runs in workerd, one138+ file per feature or module. `test/conformance` is black box, one file per139+ NIP.140+- **A test file is named after the module it exercises**, not the NIP. A NIP141+ number names a file only where the feature has no other name (`nip05`,142+ `nip11`, `nip66`).143+- Test descriptions are full sentences saying what holds: "are all on by144+ default, and setpolicy takes a map that survives export and import".145+- `test/object/exposure.test.ts` walks every door as a stranger and as a146+ signed-in non-member. **Every new path is added to its list.**147+148+## Typography149+150+ASCII punctuation only. The README, every doc and every source file contain151+zero em-dashes, en-dashes, middle dots and smart quotes. This is measured, not152+aspirational.153+154+- Never the middle dot `U+00B7`, in any file, for any purpose. Use a comma, a155+ hyphen or a pipe.156+- No em-dash or en-dash. Start a new sentence, or use a comma or a colon.157+- Straight quotes and apostrophes.158+159+## Catalogs that must not drift160+161+Several lists are load-bearing, and some are enforced by the build. Adding a162+file means adding its row.163+164+| Catalog | Where | Enforced |165+|---|---|---|166+| one row per template | `relay-templates/README.md` | yes, the build fails without it |167+| one link per doc | `README.md` | by review |168+| one entry per generated file | `src/gen/` staleness check | yes, in `npm run typecheck` |169+| every door | `test/object/exposure.test.ts` | by review |170+| every special kind | `src/kinds.ts` | by review |171+| the supported NIP list | `src/nip11.ts` | by review |@@ -27,8 +27,43 @@ Your relay can answer at a hostname you own, such as `wss://relay.example.com`.
2727 2828 Once the record resolves, the certificate is issued on its own. **Check** asks where it stands; the hostname is live when both it and its certificate read active. **Remove** deletes the hostname and its certificate. Up to three hostnames per relay. Everything else stays the same: same events, same members, same console, reachable by either name. Hostnames survive a transfer and are removed with the relay.2929 30+When you add a hostname, the Identity tab lets you choose its destination:31+your relay, a root site, a named site or a snapshot. You can change that32+choice later. The management calls are `adddomain(host, site?)` and33+`setdomainsite(host, site?)`; omit the optional site or send an empty value to34+target your relay. `listdomains` returns the selected `site` label.35+3036 This needs the host to have switched custom domains on. If they have not, the block says so.3137 38+## Static websites39+40+With the **Sites** feature enabled, the relay can host NIP-5A static websites41+published by tools such as nsyte and nsite-cli. A root site uses42+`https://<npub>.bind.ws`; a named site uses43+`https://<pubkeyB36><dTag>.bind.ws`; and a snapshot uses44+`https://v<snapshotIdB36>.bind.ws`. Site URLs are separate origins with only45+the site door, so they do not open the relay console, websocket or file API.46+47+The site's access follows **Rules > Reads**. Directories use `index.html`,48+and a missing path falls back to `/404.html`; a missing manifest or unavailable49+file returns 404. The relay verifies each file's hash before serving it. Site50+manifests can expire with NIP-40, and the relay's retention rules also apply.51+You can point a custom domain at a site from the domain controls. A52+missing local blob is fetched from the manifest's public HTTPS servers and53+then verified; with mirroring on, that fetch is normally done ahead of the54+first visit.55+56+The edge can cache a custom-host mapping for up to 60 seconds. A local site57+record validates the selected label before serving it, so a stale mapping58+cannot send a custom hostname to a removed site.59+60+When reads require sign-in, a browser visit that accepts HTML shows a NIP-0761+sign-in page. Your signer answers a five-minute challenge, and your relay62+sets a Secure, HttpOnly, SameSite=Lax `__Host-nsite` cookie for seven days.63+Your relay checks that cookie against the read rule on every request. Scripts64+send an exact NIP-98 Authorization header instead. Site sign-in uses no65+password.66+3267 ## Media3368 3469 Your relay stores images and files too. Who may upload follows the write rule: anyone, members or only you. The largest upload is set on the Rules tab, 25 MB by default and up to 95. Files count as files stored for fuel.@@ -8,12 +8,12 @@ audience: developer
88 One Cloudflare Worker, one Durable Object per relay name, SQLite inside each object and R2 beside it.99 1010 ```11-client ──wss/https──▶ worker ──host→object──▶ durable object "<name>"12- │ KV: custom host → name ├ sqlite: events, tags, search, members, rules, jobs, usage13- │ ├ sockets: live subscriptions (hibernating)14- │ ├ alarm: sweep, retention, fuel, dumps, jobs, succession15- │ ├ r2: blobs and dumps under <name>/16- │ └ outbound sockets: pulls, pushes, notifications11+client --wss/https--> worker --host->object--> durable object "<name>"12+ | KV: custom host -> name | sqlite: events, tags, search, members, rules, jobs, usage13+ | | sockets: live subscriptions (hibernating)14+ | | alarm: sweep, retention, fuel, dumps, jobs, succession15+ | | r2: blobs and dumps under <name>/16+ | + outbound sockets: pulls, pushes, notifications1717 ```1818 1919 ## Routing and states@@ -142,6 +142,20 @@ Wiring the relay into the owner's lists (kinds 10002, 10050, 10007, 10063) happe
142142 143143 The console is one page: markup, styles and script are the three files in `src/console`, folded into a generated module that `dashboard.ts` serves. Its signer is NIP-07 when present or a NIP-46 session over the relay itself, using a bundle of nostr-tools served at `/signer.js` and loaded only when someone picks remote signing.144144 145+`sites.ts` and `site-mirror.ts` implement NIP-5A manifests and static-site146+serving. The worker147+parses a site's single-label hostname before normal relay-name routing and148+uses the `HOSTS` KV namespace to find the relay holding its manifest. A SQL149+outbox keeps event mutations and index writes together; the alarm retries150+pending KV updates and removes mappings for deletion or expiry. The site door151+looks up root, named or snapshot manifests, resolves directory paths to152+`index.html`, verifies the R2 object's SHA-256, and meters the response. A153+local cache miss can proxy a verified file from public sources in the154+manifest and the author's kind 10063, bounded by `min(maxBlobMB, 32 MiB)`.155+The mirror queue creates one bounded alarm job per manifest; deletion cancels156+pending work. Site requests carry `x-relay-site`, so a site origin cannot157+reach relay doors.158+145159 ## Views146160 147161 `src/views.ts` is a registry of folds. Each view names a trigger, an audience and a fold that returns tags and content; hourly views add a fingerprint so nothing is republished when the inputs did not move. The record is a kind 30078 signed by the identity key on the same strictly increasing clock as the group state, `d` = `bind.ws/view/<name>`, stored and broadcast through `emit` like the roster, taken down when the view is switched off, empty, or no longer public. The rows a run wrote are measured around the fold from the store's write counter and kept as the last 60 runs, which the console and the digest read.@@ -9,7 +9,7 @@ audience: developer
99 1010 ```1111 src/12- index.ts worker entry: hostname → object, POST /lease12+ index.ts worker entry: hostname -> object, POST /lease1313 edge.ts what the edge provides, with and without Cloudflare: the client's address, the lease limit, the hostname map1414 names.ts valid names, reserved names, lease names1515 pull.ts copy another relay in: NIP-77 as the initiator, one round per connection@@ -32,6 +32,9 @@ src/
3232 dumps.ts scheduled JSONL dumps to R23333 notify.ts NIP-17 messages from the relay to its owner3434 pages.ts notes and articles as pages, the Atom feed35+ sites.ts NIP-5A manifest validation, label parsing and site serving36+ site-mirror.ts proxy and alarm-driven copies of missing site blobs37+ site-auth.ts NIP-07 challenge and relay-signed site cookie3538 card.ts the status card, signed and as SVG3639 qr.ts a QR encoder for the card and the console3740 fuel.ts meters, prices, receipts, LNURL@@ -108,6 +111,12 @@ Most NIPs land as their own module, wired in with one import and one row in `rou
108111 | 96 files | `nip96.ts` over `blossom.ts` | one row in `routes.ts` |109112 | 11 extras | `settings.ts` fields, `nip11.ts` | the NIP-11 row in `routes.ts` |110113 114+A draft NIP implemented ahead of upstream also gets a `docs/20-nip-<name>.md`115+file. Record the upstream commit and date it tracks, its user-facing usage,116+the relay-specific additions and limits, and what to watch upstream. Draft117+NIPs remain out of `supported_nips` unless the NIP explicitly defines relay118+advertising for them.119+111120 A NIP that changes the query surface touches `store.ts`. Add the number to `SUPPORTED_NIPS` in `nip11.ts` only when the NIP says relays advertise it, with a word in the comment there for the less obvious ones. Add a file named after the NIP in `test/conformance` so the behavior is checked from outside, and a Durable Object test in `test/object`, named after the module it exercises; a NIP number names a test file only where the feature has no other name (`nip05`, `nip11`, `nip66`).112121 113122 ## The signer bundle@@ -34,6 +34,47 @@ Any request with `Accept: application/nostr+json` answers the NIP-11 document, w
3434 3535 A door that belongs to a feature the owner switched off (Relay configuration, Rules) answers 404: names, files (Blossom and NIP-96), pages and the feed. `supported_nips` drops the numbers of features that are off.3636 37+## NIP-5A sites38+39+Site hostnames are `https://<npub>.<domain>`,40+`https://<pubkeyB36><dTag>.<domain>` and41+`https://v<snapshotIdB36>.<domain>`. The label is resolved to the relay that42+holds the manifest, and the request reaches only the site door. Site hosting43+follows the `sites` feature and the relay's read rule.44+45+| Path | Method | Auth | Answers | Status |46+|---|---|---|---|---|47+| `/` or a site path | GET, HEAD | the read rule | the manifest's file; directory paths select `index.html` | 200; 304 with a matching ETag; 401/403 by the read rule; 404 missing manifest, path or blob |48+| `/` or a site path | other methods or websocket upgrade | none | method error | 405 |49+| `/.well-known/nsite/auth` | GET | none, or NIP-98 when continuing an API session | NIP-07 sign-in HTML with a five-minute challenge | 401; 429 too many outstanding challenges |50+| `/.well-known/nsite/auth` | POST | exact NIP-98 for this URL, method and body; body contains the signed kind 22242 challenge response | sets the seven-day `__Host-nsite` cookie | 204; 401 invalid or expired proof; 403 read rule; 413 body over 32 KiB |51+52+Site responses include `Content-Type`, `Content-Length` when supplied by the53+source, an ETag equal to the file hash, `X-Content-Type-Options: nosniff`, and54+a cache policy based on the read rule. A missing path uses `/404.html` when55+the manifest contains it; a missing manifest, unavailable file, or failed56+hash check returns a plain 404. On a local cache miss the door may proxy the57+file from up to ten manifest servers followed by up to ten author kind 1006358+servers, subject to a cap of `min(maxBlobMB, 32 MiB)`. Only public HTTPS URLs59+and checked redirects are used, and caller credentials are not forwarded.60+61+The sign-in cookie is Secure, HttpOnly and SameSite=Lax. The site door checks62+it against the current read rule on every request. A caller can use exact63+NIP-98 directly instead of a cookie.64+65+## Custom domains66+67+These NIP-86 methods are owner-only and use the relay's normal NIP-98 RPC68+endpoint. The optional site label uses the same root, named-site and snapshot69+grammar as the hostname forms above. An omitted or empty label targets the70+relay itself. `listdomains` includes the current `site` field.71+72+| Method | Parameters | Answers | Status |73+|---|---|---|---|74+| `adddomain` | `host`, optional `site` label | creates the custom hostname and maps it to the relay or selected site | 200; 400 invalid or duplicate; 403; 502 Cloudflare failure |75+| `setdomainsite` | `host`, optional `site` label | changes an existing hostname's destination | 200; 400 invalid host or site; 403 |76+| `listdomains` | none | custom host records, including `site` when selected | 200; 403 |77+3778 ## Bridge3879 3980 Signed with NIP-98 over the exact URL, method and body. Same gates as a socket: the signer's bans, the read rule and the write rules apply, and the address is rate limited at four times the per-connection allowance.@@ -0,0 +1,121 @@
1+---2+title: NIP-5A static websites3+audience: user4+---5+6+# NIP-5A static websites7+8+Status: `draft` and `optional` upstream. bind.ws follows [`5A.md` at9+commit 5d6b432267d4046464490b1923b96844ac4559d0](https://raw.githubusercontent.com/nostr-protocol/nips/5d6b432267d4046464490b1923b96844ac4559d0/5A.md),10+last checked 2026-09-04.11+12+## Use a relay as a site host13+14+NIP-5A stores a website as a signed Nostr event. Each `path` tag maps an15+absolute filename to the SHA-256 hash of a file on Blossom. The relay stores16+the event and files, then serves the site from a single-label hostname.17+18+| Kind | Address | Purpose |19+|---|---|---|20+| 15128 | `https://<npub>.bind.ws` | author's root site |21+| 35128 | `https://<pubkeyB36><dTag>.bind.ws` | named site |22+| 5128 | `https://v<snapshotIdB36>.bind.ws` | manifest snapshot |23+24+Publish with an existing NIP-5A client such as nsyte or nsite-cli. The relay25+does not provide a publishing CLI. A path must be an absolute filename with26+an extension and a lowercase 64-character SHA-256 hash. The optional `x` tag27+is checked against the order-independent aggregate of the `path` tags.28+29+The `pubkeyB36` and `snapshotIdB36` portions encode the 32-byte value in30+lowercase base36 and are exactly 50 characters wide. This fixed width keeps31+leading zero bytes, resolving the draft's conflict between "no padding" and32+"always exactly 50 characters." Kind 34128 is deprecated by NIP-5A and is not33+hosted by the NIP-5A site door; generic relay event policy may still accept it.34+35+Directories select `index.html`: `/` selects `/index.html`, and `/blog/`36+selects `/blog/index.html`. A missing path selects the site's `/404.html` when37+present and otherwise returns 404. A missing manifest, missing file, hash38+mismatch, unavailable blob, or failed remote fetch returns a plain 404.39+Responses forward the stored content type and length, use the blob hash as the ETag,40+and verify the blob before serving it.41+42+The relay's Rules > Reads setting applies to sites. Open reads make a site43+public; signed-in and members-only reads require the same admission as the44+relay's other read doors. Site hosting is enabled by default and can be45+switched off under the `sites` feature. NIP-40 `expiration` and the relay's46+retention policy determine lifetime.47+48+Snapshots copy the source `path` tags, carry one matching aggregate `x` tag49+and one `a` tag naming the source. Copies retain their immediate parent in50+`a` and their lineage origin in `A`.51+52+## Sign-in53+54+When your relay's read rule admits everyone, site requests are public. When55+the rule requires sign-in, a browser GET that accepts HTML receives a NIP-0756+sign-in page. The page creates a five-minute challenge and asks your browser57+signer to sign kind 22242 with the site origin and challenge. It then posts58+that event as JSON to `/.well-known/nsite/auth`, with an exact NIP-98 proof for59+the POST URL, method and body.60+61+After verification, your relay signs a host-only `__Host-nsite` cookie. The62+cookie is Secure, HttpOnly, SameSite=Lax, and lasts seven days. Every site63+request checks the cookie against the current read rule. API clients can use64+an exact NIP-98 Authorization header directly. Passwords are not part of the65+site protocol.66+67+## Relay additions68+69+The NIP-11 information document exposes an `nsites` object while site hosting70+is enabled:71+72+```json73+{74+ "host": "bind.ws",75+ "kinds": [15128, 35128, 5128],76+ "root": "https://<npub>.bind.ws",77+ "named": "https://<pubkeyB36><dTag>.bind.ws",78+ "snapshot": "https://v<snapshotIdB36>.bind.ws"79+}80+```81+82+The relay keeps a KV index from each site label to the relay holding its83+manifest. It also mirrors missing blobs into its own bucket when84+`sites.mirror` is on (the default), checking hashes before storing them.85+Mirroring is one job per manifest. It tries up to ten `server` tags from the86+manifest and then up to ten servers from the author's kind 10063. Sources87+must be public HTTPS URLs (or a local sibling relay), redirects are checked88+again, credentials are never forwarded, and the size cap is the smaller of89+the relay's `maxBlobMB` and 32 MiB. Bad hashes, failed checks, and oversized90+files are never stored. Remote bytes and served bytes count toward fuel.91+92+If mirroring is disabled, the site door still proxies a cache miss using the93+same checks and cap. A file is retried at most three times; a failed job stops94+and can be rerun. Deleting or replacing a manifest stops its pending mirror95+work. A local R2 blob is also available through the relay's implicit Blossom96+provider, so a manifest need not contain server tags.97+98+Sites have their own origin. A site hostname reaches only the site door and99+does not expose the relay console, websocket, management methods or Blossom100+API. A custom domain may be pointed at a relay or one of its sites from the101+owner's domain controls; the same read rule remains in force. The management102+calls are `adddomain(host, site?)` and `setdomainsite(host, site?)`, where an103+omitted or empty site selects the relay itself. `listdomains` returns the104+selected site label. The console offers relay, root, named-site and snapshot105+destinations.106+107+The edge mapping may remain cached for up to 60 seconds. The local record108+checks that the selected site still exists before it serves a request, so a109+stale mapping cannot serve a removed target.110+111+## Deliberate limits112+113+bind.ws does not advertise NIP-5A in `supported_nips`: NIP-11 still defines114+that field as integer identifiers, while 5A is a lettered draft and does not115+ask relays to advertise it. The relay advertises `nsites` instead. Follow116+upstream [pull request 1621](https://github.com/nostr-protocol/nips/pull/1621)117+for the eventual representation.118+119+The implementation tracks upstream changes to the label grammar, aggregate120+hash, snapshot and copy tags, and the status of kind 34128. If those change,121+the parser, host templates and this document must change together.@@ -74,7 +74,15 @@
7474 "names": { "type": "boolean" },7575 "files": { "type": "boolean" },7676 "pages": { "type": "boolean" },77- "signer": { "type": "boolean" }77+ "signer": { "type": "boolean" },78+ "sites": {79+ "oneOf": [80+ { "type": "boolean" },81+ { "type": "object", "additionalProperties": false, "properties": {82+ "enabled": { "type": "boolean" }, "mirror": { "type": "boolean" }83+ } }84+ ]85+ }7886 }7987 }8088 }@@ -0,0 +1,27 @@
1+// A static website host: publishers post NIP-5A manifests and upload their2+// files through Blossom. The relay mirrors files named by each manifest so3+// the site's hostname remains available even when the publisher's server is4+// offline. Site manifests have no special retention rule and follow the5+// relay's normal event lifetime. Members are the publishers on this relay.6+{7+ "$schema": "https://bind.ws/relay-config.schema.json",8+ "format": "bind.ws/relay-config/2",9+ "template": {10+ "title": "Sites",11+ "about": "A static website host. Members publish NIP-5A manifests and files; the relay mirrors missing files into its own Blossom store."12+ },13+ "policy": {14+ "writes": "allowlist",15+ "reads": "open",16+ "directoryPublic": true,17+ "features": {18+ "files": true,19+ "sites": { "enabled": true, "mirror": true }20+ }21+ },22+ "kinds": {23+ "allow": [],24+ "block": []25+ },26+ "retention": []27+}@@ -17,6 +17,7 @@ copy as the start of your own.
1717 | [articles](08-articles.jsonc) | long-form only, yours or mirrored daily from a source. |1818 | [dm](09-dm.jsonc) | it is a drop box for private messages: anyone leaves a gift wrap, only members read. |1919 | [quiet](10-quiet.jsonc) | it should cost nothing and announce nothing: members only, every optional feature off, switched on one at a time as needed. |20+| [site](11-site.jsonc) | it hosts NIP-5A static websites: members publish manifests and files, with mirroring on. |2021 2122 Each file says more in its comment: what the template does, which Haven relay2223 it mirrors, and why its kinds are what they are.@@ -113,7 +113,7 @@ export function blobBytes(sql: SqlStorage): number {
113113 // fetchOrigin gets a blob for mirroring. Our own relays are reached through114114 // their object, which also works in wrangler dev and tests; anything else is115115 // fetched over https. Returns a Response or a reason.116-async function fetchOrigin(relay: Relay, raw: string): Promise<Response | string> {116+export async function fetchOrigin(relay: Relay, raw: string, init?: RequestInit): Promise<Response | string> {117117 let u: URL;118118 try {119119 u = new URL(raw);@@ -122,16 +122,20 @@ async function fetchOrigin(relay: Relay, raw: string): Promise<Response | string
122122 }123123 const local = localName(u, relay.domain);124124 try {125- if (local) return await relay.relays.getByName(local).fetch(u.href, { headers: { "x-relay-name": local } });125+ if (local) {126+ const headers = new Headers(init?.headers);127+ headers.set("x-relay-name", local);128+ return await relay.relays.getByName(local).fetch(u.href, { ...init, headers });129+ }126130 if (u.protocol !== "https:") return "invalid: only https urls can be mirrored";127- return await fetch(u.href, { redirect: "follow" });131+ return await relay.fetcher(u.href, { redirect: "follow", ...init });128132 } catch (err) {129133 return "error: could not fetch the origin: " + (err instanceof Error ? err.message : String(err));130134 }131135 }132136 133137 // readCapped collects a body, giving up as soon as it passes max bytes.134-async function readCapped(body: ReadableStream<Uint8Array> | null, max: number): Promise<Uint8Array | "too big"> {138+export async function readCapped(body: ReadableStream<Uint8Array> | null, max: number): Promise<Uint8Array | "too big"> {135139 if (!body) return new Uint8Array();136140 const parts: Uint8Array[] = [];137141 let total = 0;@@ -254,8 +254,8 @@
254254 </form>255255 <div class="block">256256 <h3>Your own domain</h3>257- <p class="note">Point a hostname you control at this relay, such as <span class="mono">relay.example.com</span>. Add it here, then create the record shown at your DNS. The certificate is issued on its own once the record resolves.</p>258- <form id="adddomain" class="addrow"><input class="txt mono" name="host" placeholder="relay.example.com" required><button class="btn">Add</button></form>257+ <p class="note">Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.</p>258+ <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>259259 <div id="domains"></div>260260 <p class="note" id="domainnote"></p>261261 </div>@@ -291,6 +291,11 @@
291291 <h3>Files <span class="count" id="blobs-count"></span></h3>292292 <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>293293 </div>294+ <div class="block">295+ <h3>Sites <span class="count" id="sites-count"></span></h3>296+ <p class="note">Published NIP-5A manifests and the hostnames where they are served.</p>297+ <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>298+ </div>294299 <div class="block">295300 <h3>Dumps <span class="count" id="dumps-count"></span></h3>296301 <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>@@ -349,7 +349,7 @@
349349 $("#audit-more").onclick = guard(async () => { const last = $("#audit tbody tr:last-child"); await loadAudit(last ? +last.dataset.seq : 0); });350350 async function loadLists() {351351 loadAudit(0);352- const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks] = await Promise.all([rpc("listmembers"), rpc("listbannedpubkeys"), rpc("listbannedevents"), rpc("listallowedkinds"), rpc("listblockedkinds"), rpc("listinvites"), rpc("listreports"), rpc("listblobs", 100), rpc("listblockedips")]);352+ 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")]);353353 const members = mem.members;354354 window.__members = members;355355 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>' : "";@@ -383,6 +383,8 @@
383383 $("#reports-count").textContent = reports.length || ""; $("#tc-reports").textContent = reports.length || "";384384 $("#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>';385385 $("#blobs-count").textContent = blobs.length || "";386+ $("#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>';387+ $("#sites-count").textContent = sites.length || "";386388 const tag = (k, cls) => '<span class="tag ' + cls + '">' + k + ib("x", "Remove rule", "unrulekind", String(k)) + "</span>";387389 $("#kinds-allow").innerHTML = allow.length ? allow.map((k) => tag(k, "ok")).join("") : '<span class="tag plain">all</span>';388390 $("#kinds-block").innerHTML = block.length ? block.map((k) => tag(k, "blk")).join("") : '<span class="tag plain">none</span>';@@ -403,19 +405,20 @@
403405 ["names", "Names", "NIP-05 addresses under this relay's domain."],404406 ["files", "Files", "Blossom and NIP-96: uploads, downloads and listings."],405407 ["pages", "Pages and feed", "Notes and articles as pages, and the Atom feed."],408+ ["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"]],406409 ["signer", "Signer traffic", "NIP-46 remote signing carried for anyone, never stored."],407410 ];408411 function renderFeatures(f) {409412 $("#features").innerHTML = FEATURES.map(([k, title, about, modes]) => {410- const cur = modes ? String(f[k] || "prose") : String(f[k] !== false);413+ const cur = k === "sites" ? (f.sites?.enabled === false ? "off" : f.sites?.mirror === false ? "proxy" : "mirror") : modes ? String(f[k] || "prose") : String(f[k] !== false);411414 const opts = (modes || ["true:on", "false:off"]).map((m) => { const [v, l] = m.split(":"); return '<option value="' + v + '"' + (cur === v ? " selected" : "") + ">" + l + "</option>"; }).join("");412415 return "<label><span>" + esc(title) + " <small>" + esc(about) + "</small></span><select class=\"txt\" data-feature=\"" + k + "\">" + opts + "</select></label>";413416 }).join("");414417 }415418 $("#features").addEventListener("change", guard(async (ev) => {416419 const sel = ev.target.closest("select[data-feature]"); if (!sel) return;417- const k = sel.dataset.feature, v = k === "search" ? sel.value : sel.value === "true";418- policy = await rpc("setpolicy", { features: { [k]: v } });420+ const k = sel.dataset.feature, v = k === "search" || k === "sites" ? sel.value : sel.value === "true";421+ policy = await rpc("setpolicy", { features: { [k]: k === "sites" ? { enabled: v !== "off", mirror: v === "mirror" } : v } });419422 toast(k === "search" ? "Search: " + v : (v ? "Switched on " : "Switched off ") + k); await loadInfo();420423 }));421424 @@ -560,14 +563,14 @@
560563 // Jobs run in the background; the table follows them while the tab is open.561564 let jobsTimer = 0;562565 const fmtJob = (j) => {563- const what = j.kind === "import" ? "import" : j.label === "backfill" ? "fetch my history" : j.kind === "pull" ? "pull" : "rebroadcast";566+ const what = j.kind === "mirror" ? "mirror site" : j.kind === "import" ? "import" : j.label === "backfill" ? "fetch my history" : j.kind === "pull" ? "pull" : "rebroadcast";564567 const f = [];565568 if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? "my events" : j.filter.authors.length + " authors");566569 if (j.filter.kinds) f.push("kinds " + j.filter.kinds.join(", "));567570 if (j.filter.since) f.push("since " + fmtDay(j.filter.since));568571 const when = j.every ? "every " + (j.every === 24 ? "day" : j.every + " h") + (j.nextRun ? ", next " + fmtTime(j.nextRun) : "") : "once";569572 const l = j.last;570- const count = (stored, blobs, sent, refused) => (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" : ""));573+ 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" : ""));571574 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);572575 return "<tr><td>" + what + "</td><td class=\"mono\">" + j.relays.map(esc).join("<br>") + "</td><td>" + (f.join(", ") || "everything") + "</td><td>" + when + "</td><td>" + esc(res) + "</td><td>" + (j.running ? "" : ib("undo", "Run now", "runjob", j.id)) + ib("x", "Remove", "removejob", j.id) + "</td></tr>";573576 };@@ -721,24 +724,31 @@
721724 });722725 723726 // ---- custom domains: this relay under a hostname the owner controls ----727+ let domainSites = [];728+ 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("");724729 let domains = null; // null: not enabled on this host725730 function renderDomains() {726731 $("#domains").innerHTML = (domains || []).map((d) => {727732 const state = d.ready ? "active" : "hostname " + d.status.replace(/_/g, " ") + ", certificate " + d.sslStatus.replace(/_/g, " ");728733 const btn = (act, cls, label) => '<button class="btn ' + cls + '" data-domain="' + esc(d.host) + '" data-do="' + act + '">' + label + "</button>";729734 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>";730- 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>" + rows;735+ 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;731736 }).join("");732737 }733738 async function loadDomains() {734- try { domains = await rpc("listdomains"); $("#domainnote").textContent = domains.length ? "" : "No custom domain yet."; $("#adddomain").classList.remove("hidden"); }739+ 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"); }735740 catch (e) { domains = null; $("#domainnote").textContent = /^unsupported/.test(e.message) ? "Custom domains are not enabled on this host." : e.message; $("#adddomain").classList.add("hidden"); }736741 renderDomains();737742 }738743 $("#adddomain").onsubmit = guard(async (ev) => {739744 const f = ev.target;740- await rpc("adddomain", f.host.value.trim()); f.reset(); toast("Domain added; now create the CNAME"); await loadDomains();745+ await rpc("adddomain", f.host.value.trim(), f.site.value); f.reset(); toast("Domain added; now create the CNAME"); await loadDomains();741746 });747+ $("#domains").addEventListener("change", guard(async (ev) => {748+ const select = ev.target.closest("select[data-domain-site]"); if (!select) return;749+ await rpc("setdomainsite", select.dataset.domainSite, select.value);750+ toast("Domain destination saved"); await loadDomains();751+ }));742752 $("#domains").addEventListener("click", async (ev) => {743753 const b = ev.target.closest("button[data-domain]"); if (!b) return;744754 const host = b.dataset.domain;@@ -8,10 +8,15 @@
88 // follow the request host.99 import type { Fetcher } from "./fuel.ts";1010 import type { Relay } from "./relay.ts";11+import { parseSite } from "./sites.ts";12+import { validName } from "./names.ts";1113 1214 export interface CustomHost {1315 host: string;1416 id: string; // Cloudflare's custom hostname id17+ // An optional NIP-5A site label served on this hostname. Older entries do18+ // not have this field and continue to address the relay itself.19+ site?: string;1520 at: number; // when last checked1621 status: string; // Cloudflare's hostname status, active when it proxies1722 sslStatus: string; // certificate status, active when issued@@ -109,6 +114,7 @@ function parseState(r: Record<string, unknown>): HostnameState {
109114 // DomainView is what the console and API callers get.110115 export interface DomainView {111116 host: string;117+ site?: string;112118 status: string;113119 sslStatus: string;114120 ready: boolean;@@ -119,7 +125,7 @@ export interface DomainView {
119125 function view(h: CustomHost, target: string, state: HostnameState | null): DomainView {120126 const records: DomainView["records"] = [{ type: "CNAME", name: h.host, value: target, note: "required; the certificate follows once it resolves" }];121127 if (state?.ownership && state.status !== "active") records.push({ type: "TXT", name: state.ownership.name, value: state.ownership.value, note: "optional; activates the hostname before you switch the CNAME" });122- return { host: h.host, status: h.status, sslStatus: h.sslStatus, ready: h.status === "active" && h.sslStatus === "active", checkedAt: h.at, records };128+ return { host: h.host, site: h.site, status: h.status, sslStatus: h.sslStatus, ready: h.status === "active" && h.sslStatus === "active", checkedAt: h.at, records };123129 }124130 125131 function stored(relay: Relay): CustomHost[] {@@ -130,31 +136,73 @@ function save(relay: Relay, hosts: CustomHost[]) {
130136 relay.settings.update({ customHosts: hosts });131137 }132138 139+export interface CustomTarget {140+ name: string;141+ site?: string;142+}143+144+// customTarget reads both the old plain relay name and a mapping with a145+// site label, so existing custom domains keep their relay door.146+export function customTarget(value: string | null): CustomTarget | null {147+ if (!value) return null;148+ if (validName(value)) return { name: value };149+ try {150+ const target = JSON.parse(value) as { name?: unknown; site?: unknown };151+ if (typeof target.name !== "string" || !validName(target.name)) return null;152+ if (target.site !== undefined && (typeof target.site !== "string" || !parseSite(target.site))) return null;153+ return { name: target.name, ...(target.site === undefined ? {} : { site: target.site }) };154+ } catch {155+ return null;156+ }157+}158+159+function targetValue(name: string, site?: string): string {160+ return site === undefined ? name : JSON.stringify({ name, site });161+}162+133163 // addDomain registers a hostname with Cloudflare, then maps it in KV and134164 // remembers it. KV is written only after Cloudflare accepted the hostname.135-export async function addDomain(relay: Relay, raw: string): Promise<DomainView | string> {165+export async function addDomain(relay: Relay, raw: string, site?: string): Promise<DomainView | string> {136166 const api = relay.hostnames;137167 const kv = relay.hosts;138168 if (!api || !kv) return UNSUPPORTED;139169 const c = checkHostname(raw, relay.domain);140170 if ("error" in c) return c.error;171+ if (site !== undefined && !parseSite(site)) return "invalid: not a valid site hostname";141172 const hosts = stored(relay);142173 if (hosts.some((h) => h.host === c.host)) return "duplicate: that hostname is already on this relay";143174 if (hosts.length >= MAX_CUSTOM_HOSTS) return `restricted: at most ${MAX_CUSTOM_HOSTS} custom domains per relay`;144- const taken = await kv.get(c.host);145- if (taken && taken !== relay.slug) return "restricted: that hostname points at another relay";175+ const taken = customTarget(await kv.get(c.host));176+ if (taken && taken.name !== relay.slug) return "restricted: that hostname points at another relay";146177 let state: HostnameState;147178 try {148179 state = await api.create(c.host);149180 } catch (err) {150181 return "error: " + (err instanceof Error ? err.message : String(err));151182 }152- await kv.put(c.host, relay.slug);153- const h: CustomHost = { host: c.host, id: state.id, at: Math.floor(Date.now() / 1000), status: state.status, sslStatus: state.sslStatus };183+ await kv.put(c.host, targetValue(relay.slug, site));184+ const h: CustomHost = { host: c.host, ...(site === undefined ? {} : { site }), id: state.id, at: Math.floor(Date.now() / 1000), status: state.status, sslStatus: state.sslStatus };154185 save(relay, [...hosts, h]);155186 return view(h, relay.cnameTarget, state);156187 }157188 189+// setDomainSite changes a registered hostname's destination. Its existing190+// certificate and DNS records stay valid because only the routing changes.191+export async function setDomainSite(relay: Relay, raw: string, site?: string): Promise<DomainView | string> {192+ if (!relay.hostnames || !relay.hosts) return UNSUPPORTED;193+ const host = raw.trim().toLowerCase();194+ const hosts = stored(relay);195+ const h = hosts.find((x) => x.host === host);196+ if (!h) return "invalid: no such custom domain on this relay";197+ if (site !== undefined && !parseSite(site)) return "invalid: not a valid site hostname";198+ const taken = customTarget(await relay.hosts.get(host));199+ if (taken && taken.name !== relay.slug) return "restricted: that hostname points at another relay";200+ await relay.hosts.put(host, targetValue(relay.slug, site));201+ const fresh = { ...h, site };202+ save(relay, hosts.map((x) => x.host === host ? fresh : x));203+ return view(fresh, relay.cnameTarget, null);204+}205+158206 // checkDomain asks Cloudflare where the hostname stands and remembers it.159207 export async function checkDomain(relay: Relay, raw: string): Promise<DomainView | string> {160208 const api = relay.hostnames;@@ -189,7 +237,7 @@ export async function removeDomain(relay: Relay, raw: string): Promise<string> {
189237 return "error: " + (err instanceof Error ? err.message : String(err));190238 }191239 const kv = relay.hosts;192- if (kv && (await kv.get(host)) === relay.slug) await kv.delete(host);240+ if (kv && customTarget(await kv.get(host))?.name === relay.slug) await kv.delete(host);193241 save(relay, hosts.filter((x) => x.host !== host));194242 return "";195243 }@@ -209,7 +257,7 @@ export async function forgetDomains(relay: Relay): Promise<void> {
209257 }210258 try {211259 const kv = relay.hosts;212- if (kv && (await kv.get(h.host)) === relay.slug) await kv.delete(h.host);260+ if (kv && customTarget(await kv.get(h.host))?.name === relay.slug) await kv.delete(h.host);213261 } catch (err) {214262 console.log(`custom hostname ${h.host} not removed from KV: ${err instanceof Error ? err.message : String(err)}`);215263 }@@ -3,8 +3,10 @@
33 // the custom hostname map, and the answer to a proxy asking whether a44 // hostname belongs here. Hosting without Cloudflare is55 // docs/16-hosting-without-cloudflare.md.6+import { customTarget } from "./domains.ts";67 import type { Env } from "./relay.ts";78 import { Bucket } from "./ratelimit.ts";9+import { parseSite, siteKey } from "./sites.ts";810 import { RESERVED, validName } from "./names.ts";911 1012 // Custom domains map to relay names through KV. Looked up once a minute per@@ -26,6 +28,16 @@ export async function customHost(env: Pick<Env, "HOSTS">, host: string): Promise
2628 return name;2729 }2830 31+// Site routes are read through KV without caching misses: publishers often32+// open the URL immediately after the relay acknowledges their event.33+export async function siteHost(env: Pick<Env, "HOSTS">, label: string): Promise<string | null> {34+ if (!parseSite(label) || !env.HOSTS) return null;35+ try {36+ const value = await env.HOSTS.get<{ name: string }>(siteKey(label), "json");37+ return value && validName(value.name) ? value.name : null;38+ } catch { return null; }39+}40+2941 // clientIP is the address the edge saw. On Cloudflare that is3042 // cf-connecting-ip, which the edge sets and nothing a client sends can3143 // forge. Elsewhere it is the header CLIENT_IP_HEADER names, written by the@@ -78,9 +90,9 @@ export async function hostnameKnown(env: Pick<Env, "DOMAIN" | "HOSTS">, raw: str
7890 if (host === domain || host === "www." + domain) return true;7991 if (host.endsWith("." + domain)) {8092 const name = host.slice(0, -(domain.length + 1));81- return validName(name) || RESERVED.has(name);93+ return validName(name) || RESERVED.has(name) || (await siteHost(env, name)) !== null;8294 }83- return host !== "" && (await customHost(env, host)) !== null;95+ return host !== "" && customTarget(await customHost(env, host)) !== null;8496 }8597 8698 // hostOf lower-cases the hostname of a URL, ignoring scheme, port and path.@@ -4,6 +4,7 @@
44 // subscription's filters must clear for the socket that opened it: the55 // read rule, and the private kinds only their parties may see. Both answer66 // "" to let through, or the reason in NIP-01's prefix: form.7+import { checkSite } from "./sites.ts";78 import type { Relay, ConnState } from "./relay.ts";89 import { expiration, hasTag, isPrivate, tagValues, type Event } from "./event.ts";910 import type { Filter } from "./filter.ts";@@ -14,6 +15,8 @@ import { featureOn } from "./settings.ts";
1415 // writeGate is what every write must pass, whoever sends it: shape, bans, the1516 // relay's state, fuel, and the one-group rule. "" lets it through.1617 export function writeGate(relay: Relay, e: Event, conn: ConnState | null, t: number): string {18+ const siteError = checkSite(e);19+ if (siteError) return siteError;1720 const p = relay.settings.policy;1821 if (e.kind === KIND_AUTH) return "blocked: kind 22242 is only accepted inside an AUTH message";1922 if (p.maxFuture > 0 && e.created_at > t + p.maxFuture) return "invalid: event creation date is too far off from the current time";@@ -1,4 +1,4 @@
11 // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console.2-export const CONSOLE_HTML = "<main>\n <header class=\"mast\">\n <div class=\"banner hidden\" id=\"banner\"><img id=\"bannerimg\" alt=\"\"></div>\n <div class=\"wordmark\"><img id=\"iconimg\" class=\"icon hidden\" alt=\"\"><h1 id=\"title\"></h1></div>\n <div class=\"left\"><div class=\"care\" id=\"care\"></div></div>\n <div class=\"right\">\n <span class=\"urlrow\"><code class=\"pill\" id=\"url\"></code><button class=\"ib\" id=\"copy\" title=\"Copy relay URL\" aria-label=\"Copy relay URL\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></span>\n <span class=\"owner\"><i class=\"av hidden\" id=\"owner-av\"></i><span id=\"ownerline\"></span><em id=\"who\"></em>\n <button id=\"signin\" class=\"btn hidden\">Sign in with extension</button>\n <button id=\"signin46\" class=\"btn hidden\">Remote signer</button>\n <button id=\"signout\" class=\"ib hidden\" title=\"Sign out\" aria-label=\"Sign out\"><svg viewBox=\"0 0 24 24\"><path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4M15 8l5 4-5 4M20 12H9\"/></svg></button>\n </span>\n </div>\n </header>\n\n <section id=\"unclaimed\" class=\"hidden\">\n <h2>Nobody owns this relay yet.</h2>\n <p class=\"lead\">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>\n <div class=\"row\"><button id=\"claim\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"claimnote\">Use a remote signer</button><span class=\"note\" id=\"claimnote\"></span></div>\n </section>\n\n <section id=\"leased\" class=\"hidden\">\n <h2>A temporary relay, for now.</h2>\n <p class=\"lead\">Anyone can read and write here until <b id=\"lease-until\"></b>. Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.</p>\n <div class=\"row\"><button id=\"claimlease\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"leasenote\">Use a remote signer</button><span class=\"note\" id=\"leasenote\"></span></div>\n </section>\n\n <section id=\"remotesec\" class=\"hidden\">\n <h2>Connect a remote signer.</h2>\n <p class=\"lead\">Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the <span class=\"mono\">bunker://</span> URL the app gives you.</p>\n <div class=\"row\"><a id=\"nclink\" class=\"btn pri\" href=\"#\">Open in your signer app</a><button id=\"nccopy\" class=\"btn\">Copy link</button><span class=\"note\" id=\"ncnote\"></span></div>\n <form id=\"bunkerform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"bunker://…\" required><button class=\"btn\">Connect</button></form>\n <img id=\"ncqr\" class=\"hidden\" alt=\"QR code of the signer link\" width=\"180\" height=\"180\">\n <p class=\"note\" id=\"remotenote\"></p>\n </section>\n\n <section id=\"foldsec\" class=\"folds\">\n <div id=\"frontsec\" class=\"hidden\">\n <p class=\"lead hidden\" id=\"desc\"></p>\n <p class=\"metarow hidden\" id=\"metarow\"></p>\n </div>\n <details class=\"disclosure\" id=\"aboutsec\" name=\"page\"><summary>About, for clients</summary><div id=\"about\" class=\"about\"></div></details>\n <details class=\"disclosure\" id=\"connectsec\" name=\"page\"><summary>Connect</summary>\n <div class=\"doors\">\n <div class=\"door\"><small>nostr relay</small><div class=\"v\"><span id=\"d-ws\"></span><button class=\"ib\" data-copy=\"ws\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>Blossom media</small><div class=\"v\"><span id=\"d-blossom\"></span><button class=\"ib\" data-copy=\"blossom\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>names</small><div class=\"v\"><span id=\"d-nip05\"></span></div></div>\n <div class=\"door\"><small>HTTP bridge, NIP-98</small><div class=\"v\"><span>POST /events, /query, /count</span></div></div>\n </div>\n <div class=\"block\">\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 </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\">Point a hostname you control at this relay, such as <span class=\"mono\">relay.example.com</span>. Add it here, then create the record shown at your DNS. The certificate is issued on its own once the record resolves.</p>\n <form id=\"adddomain\" class=\"addrow\"><input class=\"txt mono\" name=\"host\" placeholder=\"relay.example.com\" required><button class=\"btn\">Add</button></form>\n <div id=\"domains\"></div>\n <p class=\"note\" id=\"domainnote\"></p>\n </div>\n <div class=\"block\">\n <h3>Your relay lists</h3>\n <p class=\"note\">Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>\n <div id=\"wire\"></div>\n </div>\n <div class=\"block\">\n <h3>Share</h3>\n <p class=\"note\">A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.</p>\n <div class=\"share\">\n <a href=\"/card.svg\" target=\"_blank\" rel=\"noopener\"><img id=\"cardimg\" src=\"/card.svg\" alt=\"relay card\" width=\"600\" height=\"315\"></a>\n <div class=\"share-side\">\n <img id=\"naddrqr\" class=\"hidden\" alt=\"QR code of the group address\" width=\"150\" height=\"150\">\n <code class=\"mono naddr\" id=\"naddr\"></code>\n <div class=\"row\"><button class=\"btn\" id=\"copynaddr\">Copy naddr</button><button class=\"btn\" id=\"copyembed\">Copy embed</button></div>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"data\">\n <h2>Data</h2>\n <p class=\"lead\">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>\n <div class=\"kbar\" id=\"s-bar\"></div><span class=\"legend slegend\" id=\"s-legend\"></span>\n <div class=\"usage\" id=\"s-totals\"></div>\n <div class=\"block\">\n <h3>By kind</h3>\n <div class=\"scroll\"><table id=\"kinds\" class=\"events\"><thead><tr><th>Kind</th><th class=\"r\">Count</th><th class=\"r\">Size</th><th>Oldest</th><th>Keep for</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Files <span class=\"count\" id=\"blobs-count\"></span></h3>\n <div class=\"scroll\"><table id=\"blobs\" class=\"events\"><thead><tr><th>Time</th><th>File</th><th>Size</th><th>Uploader</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Dumps <span class=\"count\" id=\"dumps-count\"></span></h3>\n <p class=\"note\">Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.</p>\n <form id=\"dumpform\" class=\"addrow\"><select class=\"txt\" name=\"dumps\" style=\"max-width:8rem\"><option value=\"off\">off</option><option value=\"daily\">daily</option><option value=\"weekly\">weekly</option></select><input class=\"txt num\" type=\"number\" name=\"keep\" min=\"1\" max=\"60\" value=\"7\" title=\"how many to keep\" style=\"width:5rem;flex:0 0 auto\"><button class=\"btn\">Save</button><button type=\"button\" id=\"dumpnow\" class=\"btn\">Dump now</button></form>\n <ul id=\"dumps\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Import a file</h3>\n <p class=\"note\">A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.</p>\n <form id=\"importform\" class=\"addrow\"><input class=\"txt\" type=\"file\" name=\"file\" accept=\".jsonl,.json,.ndjson,application/json\" required><button class=\"btn\">Import</button></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"sync\">\n <h2>Sync</h2>\n <p class=\"lead\">Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.</p>\n <div class=\"block\">\n <h3>Jobs</h3>\n <p class=\"note\">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>\n <div class=\"scroll\"><table id=\"jobs\" class=\"events\"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><select class=\"txt\" name=\"every\" style=\"max-width:9rem\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select><button class=\"btn\">Pull</button></form>\n <form id=\"backfillform\" class=\"addrow\"><input class=\"txt mono\" name=\"relays\" placeholder=\"relays, comma separated; blank uses your relay list\"><button class=\"btn\">Fetch my history</button></form>\n <form id=\"pushform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Rebroadcast to</span><input class=\"txt mono\" name=\"targets\" placeholder=\"wss://a.relay, wss://b.relay\" required></label>\n <label><span>Kinds <small>blank for all</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label><span>Newer than <small>days, blank for all</small></span><input class=\"txt num\" type=\"number\" name=\"days\" min=\"0\"></label>\n <label><span>Schedule</span><select class=\"txt\" name=\"every\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Rebroadcast</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Fork this relay</h3>\n <p class=\"note\">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>\n <form id=\"forkform\">\n <div class=\"form\">\n <label><span>Name <small>optional</small></span><input class=\"txt mono\" name=\"name\" placeholder=\"a memorable one if blank\" pattern=\"[a-z0-9-]{3,32}\"></label>\n <label><span>Who claims it <small>npub or hex, you if blank</small></span><input class=\"txt mono\" name=\"holder\"></label>\n <label><span>What to copy</span><select class=\"txt\" name=\"scope\"><option value=\"all\">everything</option><option value=\"mine\">only my events</option><option value=\"kinds\">these kinds</option></select></label>\n <label id=\"forkkinds\" class=\"hidden\"><span>Kinds <small>comma separated</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"people\" checked> Copy the people</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Fork</button></div>\n </form>\n <p class=\"note\" id=\"forknote\"></p>\n <div class=\"row hidden\" id=\"forkresult\"><span class=\"mono\" id=\"forkurl\"></span><button class=\"ib\" id=\"forkcopy\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"views\">\n <h2>Views</h2>\n <p class=\"lead\">Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.</p>\n <div class=\"block\">\n <div id=\"views\"></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"health\">\n <h2>Health</h2>\n <div class=\"counters\">\n <div class=\"counter\"><b class=\"big\" id=\"h-last\">–</b><small>since last event</small><span class=\"sub\" id=\"h-last-sub\"></span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-conns\">–</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">–</b><small id=\"h-fuel-label\">fuel</small><span class=\"sub\" id=\"h-fuel-sub\"></span></div>\n <div class=\"counter\"><div class=\"kbar\" id=\"h-kinds\"></div><small>used for, last 30 days</small><span class=\"sub legend\" id=\"h-kinds-legend\"></span></div>\n </div>\n <div class=\"usage\" id=\"usage\"></div>\n <div class=\"block\">\n <h3>Zaps received</h3>\n <div class=\"scroll\"><table id=\"credits\"><thead><tr><th>When</th><th>From</th><th class=\"r\">Sats</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Notifications</h3>\n <p class=\"note\">The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.</p>\n <form id=\"notify\">\n <div class=\"form\">\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"reports\"> A report arrives</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"fuel\"> Fuel runs low</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"jobs\"> A pull finishes</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"succession\"> The handover clock is running</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"digest\"> A note every week on how the relay is doing</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save notifications</button><button type=\"button\" id=\"notifytest\" class=\"btn\">Send a test message</button></div>\n </form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"owner\">\n <h2>Owner</h2>\n <p class=\"lead\">The relay's key, events, files and fuel stay put through everything here except delete.</p>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.</p>\n <div class=\"row\"><button id=\"exportcfg\" class=\"btn\">Export configuration</button><button id=\"importcfg\" class=\"btn\">Import configuration</button><input type=\"file\" id=\"cfgfile\" accept=\"application/json,.json\" class=\"hidden\"></div>\n </div>\n <div class=\"block danger-zone\">\n <h3>Transfer ownership</h3>\n <p class=\"note\">Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.</p>\n <form id=\"transfer\" class=\"addrow\"><select class=\"txt mono\" name=\"pubkey\" required></select><button class=\"btn danger\">Transfer</button></form>\n </div>\n <div class=\"block danger-zone\">\n <h3>If I lose my key</h3>\n <p class=\"note\">Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.</p>\n <form id=\"succession\">\n <div class=\"form\">\n <label><span>Heir <small>a member</small></span><select class=\"txt mono\" name=\"heir\" required></select></label>\n <label><span>Hand over</span><select class=\"txt\" name=\"afterDays\"><option value=\"90\">after 3 months away</option><option value=\"180\" selected>after 6 months away</option><option value=\"365\">after a year away</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Set heir</button><button type=\"button\" id=\"clearsuccession\" class=\"btn hidden\">Clear</button></div>\n </form>\n <p class=\"note\" id=\"successionnote\"></p>\n </div>\n <div class=\"block danger-zone\">\n <h3>Delete this relay</h3>\n <p class=\"note\">Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.</p>\n <button id=\"deleterelay\" class=\"btn danger\">Delete this relay</button>\n </div>\n </section>\n </div>\n\n <footer class=\"pg\"><p>a bind.ws relay</p><a href=\"https://github.com/FelineStateMachine/bindws\">source</a></footer>\n</main>\n";2+export const CONSOLE_HTML = "<main>\n <header class=\"mast\">\n <div class=\"banner hidden\" id=\"banner\"><img id=\"bannerimg\" alt=\"\"></div>\n <div class=\"wordmark\"><img id=\"iconimg\" class=\"icon hidden\" alt=\"\"><h1 id=\"title\"></h1></div>\n <div class=\"left\"><div class=\"care\" id=\"care\"></div></div>\n <div class=\"right\">\n <span class=\"urlrow\"><code class=\"pill\" id=\"url\"></code><button class=\"ib\" id=\"copy\" title=\"Copy relay URL\" aria-label=\"Copy relay URL\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></span>\n <span class=\"owner\"><i class=\"av hidden\" id=\"owner-av\"></i><span id=\"ownerline\"></span><em id=\"who\"></em>\n <button id=\"signin\" class=\"btn hidden\">Sign in with extension</button>\n <button id=\"signin46\" class=\"btn hidden\">Remote signer</button>\n <button id=\"signout\" class=\"ib hidden\" title=\"Sign out\" aria-label=\"Sign out\"><svg viewBox=\"0 0 24 24\"><path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4M15 8l5 4-5 4M20 12H9\"/></svg></button>\n </span>\n </div>\n </header>\n\n <section id=\"unclaimed\" class=\"hidden\">\n <h2>Nobody owns this relay yet.</h2>\n <p class=\"lead\">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>\n <div class=\"row\"><button id=\"claim\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"claimnote\">Use a remote signer</button><span class=\"note\" id=\"claimnote\"></span></div>\n </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\">\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 </div>\n <div class=\"block\">\n <h3>Blocked words</h3>\n <p class=\"note\">Content containing one is refused. You and your moderators are exempt. An entry written /like this/ is a regular expression.</p>\n <form id=\"wordsform\"><textarea class=\"txt\" name=\"words\" rows=\"4\" placeholder=\"one word, phrase or /pattern/ per line\"></textarea><label class=\"switch\"><input type=\"checkbox\" name=\"inTags\"> Also search tags</label><div class=\"actions\"><button class=\"btn\">Save words</button></div></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"identity\">\n <h2>Identity</h2>\n <form id=\"identity\">\n <h3>Profile</h3>\n <div class=\"form\">\n <label><span>Name</span><input class=\"txt\" name=\"name\" maxlength=\"200\"></label>\n <label><span>Contact</span><input class=\"txt\" name=\"contact\" placeholder=\"mailto:you@example.com\" maxlength=\"500\"></label>\n <label class=\"wide\"><span>Description</span><textarea name=\"description\" maxlength=\"2000\"></textarea></label>\n <label class=\"wide\"><span>Icon URL</span><input class=\"txt\" name=\"icon\" placeholder=\"https://…/icon.png\" maxlength=\"2000\"></label>\n <label class=\"wide\"><span>Banner URL</span><input class=\"txt\" name=\"banner\" placeholder=\"https://…/banner.png\" maxlength=\"2000\"></label>\n </div>\n <h3 class=\"gap\">For directories</h3>\n <div class=\"form\">\n <label><span>Posting policy URL</span><input class=\"txt\" name=\"postingPolicy\" placeholder=\"https://…/posting\" maxlength=\"2000\"></label>\n <label><span>Privacy policy URL</span><input class=\"txt\" name=\"privacyPolicy\" placeholder=\"https://…/privacy\" maxlength=\"2000\"></label>\n <label><span>Tags</span><input class=\"txt\" name=\"tags\" placeholder=\"bitcoin, cats\"></label>\n <label><span>Languages</span><input class=\"txt\" name=\"languageTags\" placeholder=\"en, pt-BR\"></label>\n <label><span>Countries</span><input class=\"txt\" name=\"relayCountries\" placeholder=\"US, DE\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Your own domain</h3>\n <p class=\"note\">Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.</p>\n <form id=\"adddomain\"><div class=\"form\"><label><span>Hostname</span><input class=\"txt mono\" name=\"host\" placeholder=\"relay.example.com\" required></label><label><span>Destination</span><select class=\"txt\" name=\"site\"><option value=\"\">This relay</option></select></label></div><div class=\"actions\"><button class=\"btn\">Add domain</button></div></form>\n <div id=\"domains\"></div>\n <p class=\"note\" id=\"domainnote\"></p>\n </div>\n <div class=\"block\">\n <h3>Your relay lists</h3>\n <p class=\"note\">Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>\n <div id=\"wire\"></div>\n </div>\n <div class=\"block\">\n <h3>Share</h3>\n <p class=\"note\">A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.</p>\n <div class=\"share\">\n <a href=\"/card.svg\" target=\"_blank\" rel=\"noopener\"><img id=\"cardimg\" src=\"/card.svg\" alt=\"relay card\" width=\"600\" height=\"315\"></a>\n <div class=\"share-side\">\n <img id=\"naddrqr\" class=\"hidden\" alt=\"QR code of the group address\" width=\"150\" height=\"150\">\n <code class=\"mono naddr\" id=\"naddr\"></code>\n <div class=\"row\"><button class=\"btn\" id=\"copynaddr\">Copy naddr</button><button class=\"btn\" id=\"copyembed\">Copy embed</button></div>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"data\">\n <h2>Data</h2>\n <p class=\"lead\">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>\n <div class=\"kbar\" id=\"s-bar\"></div><span class=\"legend slegend\" id=\"s-legend\"></span>\n <div class=\"usage\" id=\"s-totals\"></div>\n <div class=\"block\">\n <h3>By kind</h3>\n <div class=\"scroll\"><table id=\"kinds\" class=\"events\"><thead><tr><th>Kind</th><th class=\"r\">Count</th><th class=\"r\">Size</th><th>Oldest</th><th>Keep for</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Files <span class=\"count\" id=\"blobs-count\"></span></h3>\n <div class=\"scroll\"><table id=\"blobs\" class=\"events\"><thead><tr><th>Time</th><th>File</th><th>Size</th><th>Uploader</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Sites <span class=\"count\" id=\"sites-count\"></span></h3>\n <p class=\"note\">Published NIP-5A manifests and the hostnames where they are served.</p>\n <div class=\"scroll\"><table id=\"sites\" class=\"events\"><thead><tr><th>Author</th><th>Name</th><th>URL</th><th class=\"r\">Files</th><th class=\"r\">Size</th><th>Expiry</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Dumps <span class=\"count\" id=\"dumps-count\"></span></h3>\n <p class=\"note\">Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.</p>\n <form id=\"dumpform\" class=\"addrow\"><select class=\"txt\" name=\"dumps\" style=\"max-width:8rem\"><option value=\"off\">off</option><option value=\"daily\">daily</option><option value=\"weekly\">weekly</option></select><input class=\"txt num\" type=\"number\" name=\"keep\" min=\"1\" max=\"60\" value=\"7\" title=\"how many to keep\" style=\"width:5rem;flex:0 0 auto\"><button class=\"btn\">Save</button><button type=\"button\" id=\"dumpnow\" class=\"btn\">Dump now</button></form>\n <ul id=\"dumps\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Import a file</h3>\n <p class=\"note\">A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.</p>\n <form id=\"importform\" class=\"addrow\"><input class=\"txt\" type=\"file\" name=\"file\" accept=\".jsonl,.json,.ndjson,application/json\" required><button class=\"btn\">Import</button></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"sync\">\n <h2>Sync</h2>\n <p class=\"lead\">Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.</p>\n <div class=\"block\">\n <h3>Jobs</h3>\n <p class=\"note\">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>\n <div class=\"scroll\"><table id=\"jobs\" class=\"events\"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><select class=\"txt\" name=\"every\" style=\"max-width:9rem\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select><button class=\"btn\">Pull</button></form>\n <form id=\"backfillform\" class=\"addrow\"><input class=\"txt mono\" name=\"relays\" placeholder=\"relays, comma separated; blank uses your relay list\"><button class=\"btn\">Fetch my history</button></form>\n <form id=\"pushform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Rebroadcast to</span><input class=\"txt mono\" name=\"targets\" placeholder=\"wss://a.relay, wss://b.relay\" required></label>\n <label><span>Kinds <small>blank for all</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label><span>Newer than <small>days, blank for all</small></span><input class=\"txt num\" type=\"number\" name=\"days\" min=\"0\"></label>\n <label><span>Schedule</span><select class=\"txt\" name=\"every\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Rebroadcast</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Fork this relay</h3>\n <p class=\"note\">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>\n <form id=\"forkform\">\n <div class=\"form\">\n <label><span>Name <small>optional</small></span><input class=\"txt mono\" name=\"name\" placeholder=\"a memorable one if blank\" pattern=\"[a-z0-9-]{3,32}\"></label>\n <label><span>Who claims it <small>npub or hex, you if blank</small></span><input class=\"txt mono\" name=\"holder\"></label>\n <label><span>What to copy</span><select class=\"txt\" name=\"scope\"><option value=\"all\">everything</option><option value=\"mine\">only my events</option><option value=\"kinds\">these kinds</option></select></label>\n <label id=\"forkkinds\" class=\"hidden\"><span>Kinds <small>comma separated</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"people\" checked> Copy the people</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Fork</button></div>\n </form>\n <p class=\"note\" id=\"forknote\"></p>\n <div class=\"row hidden\" id=\"forkresult\"><span class=\"mono\" id=\"forkurl\"></span><button class=\"ib\" id=\"forkcopy\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"views\">\n <h2>Views</h2>\n <p class=\"lead\">Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.</p>\n <div class=\"block\">\n <div id=\"views\"></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"health\">\n <h2>Health</h2>\n <div class=\"counters\">\n <div class=\"counter\"><b class=\"big\" id=\"h-last\">–</b><small>since last event</small><span class=\"sub\" id=\"h-last-sub\"></span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-conns\">–</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">–</b><small id=\"h-fuel-label\">fuel</small><span class=\"sub\" id=\"h-fuel-sub\"></span></div>\n <div class=\"counter\"><div class=\"kbar\" id=\"h-kinds\"></div><small>used for, last 30 days</small><span class=\"sub legend\" id=\"h-kinds-legend\"></span></div>\n </div>\n <div class=\"usage\" id=\"usage\"></div>\n <div class=\"block\">\n <h3>Zaps received</h3>\n <div class=\"scroll\"><table id=\"credits\"><thead><tr><th>When</th><th>From</th><th class=\"r\">Sats</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Notifications</h3>\n <p class=\"note\">The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.</p>\n <form id=\"notify\">\n <div class=\"form\">\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"reports\"> A report arrives</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"fuel\"> Fuel runs low</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"jobs\"> A pull finishes</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"succession\"> The handover clock is running</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"digest\"> A note every week on how the relay is doing</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save notifications</button><button type=\"button\" id=\"notifytest\" class=\"btn\">Send a test message</button></div>\n </form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"owner\">\n <h2>Owner</h2>\n <p class=\"lead\">The relay's key, events, files and fuel stay put through everything here except delete.</p>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.</p>\n <div class=\"row\"><button id=\"exportcfg\" class=\"btn\">Export configuration</button><button id=\"importcfg\" class=\"btn\">Import configuration</button><input type=\"file\" id=\"cfgfile\" accept=\"application/json,.json\" class=\"hidden\"></div>\n </div>\n <div class=\"block danger-zone\">\n <h3>Transfer ownership</h3>\n <p class=\"note\">Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.</p>\n <form id=\"transfer\" class=\"addrow\"><select class=\"txt mono\" name=\"pubkey\" required></select><button class=\"btn danger\">Transfer</button></form>\n </div>\n <div class=\"block danger-zone\">\n <h3>If I lose my key</h3>\n <p class=\"note\">Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.</p>\n <form id=\"succession\">\n <div class=\"form\">\n <label><span>Heir <small>a member</small></span><select class=\"txt mono\" name=\"heir\" required></select></label>\n <label><span>Hand over</span><select class=\"txt\" name=\"afterDays\"><option value=\"90\">after 3 months away</option><option value=\"180\" selected>after 6 months away</option><option value=\"365\">after a year away</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Set heir</button><button type=\"button\" id=\"clearsuccession\" class=\"btn hidden\">Clear</button></div>\n </form>\n <p class=\"note\" id=\"successionnote\"></p>\n </div>\n <div class=\"block danger-zone\">\n <h3>Delete this relay</h3>\n <p class=\"note\">Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.</p>\n <button id=\"deleterelay\" class=\"btn danger\">Delete this relay</button>\n </div>\n </section>\n </div>\n\n <footer class=\"pg\"><p>a bind.ws relay</p><a href=\"https://github.com/FelineStateMachine/bindws\">source</a></footer>\n</main>\n";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 }\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 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] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\")]);\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 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 [\"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 = 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\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '<select class=\"txt\" data-view=\"' + esc(v.name) + '\" style=\"width:auto\">' + choices.map((c) => '<option value=\"' + c + '\"' + (c === v.trigger ? \" selected\" : \"\") + \">\" + label(c) + \"</option>\").join(\"\") + \"</select>\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"<span>\" + esc(x) + \"</span>\").join('<span class=\"sep\">|</span>');\n return '<div class=\"wire-row' + (v.on ? \"\" : \" dim\") + '\"><div class=\"wire-main\"><b>' + esc(v.name) + '</b><br><small>' + esc(v.about) + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + pick + (v.on ? '<a class=\"btn\" href=\"' + esc(v.path) + '\" target=\"_blank\" rel=\"noopener\">Open</a>' : \"\") + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '<li><i class=\"ev\">jsonl</i><span><b class=\"mono\">' + esc(d.name) + '</b> <span class=\"dim\">' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"</span></span><span>\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"</span></li>\").join(\"\") : '<li class=\"empty\">no dumps yet</li>';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + '</a> <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites yet</li>';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<i class=\"' + c + '\" style=\"flex:' + b + '\" title=\"' + esc(n) + \" \" + fmtBytes(b) + '\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<span><i class=\"' + c + '\"></i>' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" <small>\" + fmtBytes(st.eventBytes) + \"</small>\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" <small>\" + fmtBytes(st.mediaBytes) + \"</small>\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '<span class=\"kind' + (k.protected ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + k.kind + \"</span> \" + esc(kindName(k.kind));\n const cells = k.protected\n ? '<td class=\"keep sys\" colspan=\"2\" title=\"The relay depends on this kind, so it is never expired or purged.\">required</td>'\n : '<td class=\"keep\"><input class=\"txt num days\" type=\"number\" min=\"0\" max=\"36500\" placeholder=\"' + placeholder + '\" value=\"' + own(k.kind) + '\"> days</td><td class=\"r\">' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"</td>\";\n return '<tr class=\"' + (k.kind === null ? \"any\" : \"\") + '\" data-kind=\"' + key + '\"><td>' + pill + '</td><td class=\"r mono\">' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '</td><td class=\"r mono\">' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '</td><td class=\"dim\">' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"</td>\" + cells + \"</tr>\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '<tr><td class=\"dim\">' + esc(fmtTime(e.created_at)) + '</td><td><span class=\"kind' + (SYS_KINDS.has(e.kind) ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + e.kind + '</span></td><td class=\"mono\" title=\"' + e.pubkey + '\">' + av(e.pubkey) + key(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no events yet</td></tr>';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '<li><i class=\"ev\">' + (t[0] === \"e\" ? \"event\" : \"address\") + '</i><span class=\"mono\">' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"</span><span>\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"</span></li>\").join(\"\") : '<li class=\"muted\">nothing pinned</li>';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"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 === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n return \"<tr><td>\" + what + \"</td><td class=\\\"mono\\\">\" + j.relays.map(esc).join(\"<br>\") + \"</td><td>\" + (f.join(\", \") || \"everything\") + \"</td><td>\" + when + \"</td><td>\" + esc(res) + \"</td><td>\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"</td></tr>\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : '<tr><td colspan=\"6\" class=\"muted\">no jobs yet</td></tr>';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '<button class=\"btn\" data-preset=\"' + esc(p.name) + '\" title=\"' + esc(p.about) + '\">' + esc(p.title) + \"</button>\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '<span class=\"pill ' + cls + '\">' + esc(label) + \"</span>\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-wire=\"' + l.kind + '\" data-do=\"' + act + '\"' + (busy ? \" disabled\" : \"\") + \">\" + label + \"</button>\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"<span>\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"</span>\" : w.state && !busy ? \"<span>no list found</span>\" : \"\";\n const sent = (w.results || []).map((r) => '<span class=\"chip' + (r.ok ? \"\" : \" bad\") + '\" title=\"' + esc(r.msg) + '\">' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"</span>\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '<div class=\"wire-row\"><div class=\"wire-main\"><b>' + l.title + '</b> <small class=\"muted\">' + l.nip + \"</small><br><small>\" + l.about + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + buttons + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let 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>\" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { domains = await rpc(\"listdomains\"); $(\"#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.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '<a class=\"btn\" href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\";\n const app = (label, uri) => '<a class=\"btn\" href=\"' + esc(uri) + '\">' + label + \"</a>\";\n const copy = (label, text) => '<button class=\"btn\" data-copytext=\"' + esc(text) + '\">' + label + \"</button>\";\n const row = (name, where, note, acts) => '<div class=\"app\"><div class=\"app-head\"><b>' + name + \"</b><small>\" + where + \"</small></div><p>\" + note + '</p><div class=\"app-acts\">' + acts.filter(Boolean).join(\"\") + \"</div></div>\";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n el.innerHTML = groups.map(([h, note, rows]) => '<div class=\"appgroup\"><h4>' + h + '</h4><p class=\"note\">' + note + '</p><div class=\"appgrid\">' + rows.join(\"\") + \"</div></div>\").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '<div class=\"door\"><small>' + label + '</small><img src=\"/qr.svg?text=' + enc(text) + '\" alt=\"QR code: ' + esc(label) + '\" width=\"150\" height=\"150\"></div>';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('<a href=\"' + location.origin + '/\"><img src=\"' + location.origin + '/card.svg\" alt=\"' + host + '\" width=\"600\" height=\"315\"></a>'); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n";4+export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '<span class=\"key\" data-short=\"' + short(hex) + '\" title=\"' + hex + '\"><span class=\"full\">' + hex + '</span></span>' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '<i class=\"av\" style=\"--h:' + hue(hex) + '\"></i>';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '<svg viewBox=\"0 0 24 24\"><path d=\"M6 6l12 12M18 6L6 18\"/></svg>',\n undo: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 10h10a5 5 0 0 1 0 10h-3M4 10l4-4M4 10l4 4\"/></svg>',\n check: '<svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg>',\n trash: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 7h16M6 7l1.2 13h9.6L18 7\"/></svg>',\n ban: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg>',\n banuser: '<svg viewBox=\"0 0 24 24\"><circle cx=\"10\" cy=\"8\" r=\"4\"/><path d=\"M3 21a7 7 0 0 1 11-5.7M15 15l6 6M21 15l-6 6\"/></svg>',\n eye: '<svg viewBox=\"0 0 24 24\"><path d=\"M2 12s4-6 10-6 10 6 10 6-4 6-10 6S2 12 2 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>',\n lock: '<svg viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10\" width=\"14\" height=\"10\" rx=\"2\"/><path d=\"M8 10V7a4 4 0 0 1 8 0v3\"/></svg>',\n pen: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 20l4-1 11-11-3-3L5 16l-1 4zM13 8l3 3\"/></svg>',\n people: '<svg viewBox=\"0 0 24 24\"><circle cx=\"9\" cy=\"8\" r=\"3.5\"/><circle cx=\"17\" cy=\"9\" r=\"2.5\"/><path d=\"M3 20a6 6 0 0 1 12 0M15 20a4.5 4.5 0 0 1 7-3.5\"/></svg>',\n person: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/></svg>',\n gauge: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 16a8 8 0 0 1 16 0M12 16l4-5\"/><circle cx=\"12\" cy=\"16\" r=\"1.2\"/></svg>',\n bolt: '<svg viewBox=\"0 0 24 24\"><path d=\"M13 3L5 14h6l-1 7 8-11h-6z\"/></svg>',\n copy: '<svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg>',\n pin: '<svg viewBox=\"0 0 24 24\"><path d=\"M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6zM12 15v5\"/></svg>',\n };\n const ib = (icon, label, act, id, extra) => '<button class=\"ib' + (extra ? \" \" + extra : \"\") + '\" title=\"' + label + '\" aria-label=\"' + label + '\" data-act=\"' + act + '\" data-id=\"' + esc(id) + '\">' + IC[icon] + \"</button>\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('<a href=\"' + esc(u) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(u) + '\">' + esc(label) + \"</a>\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('<span class=\"tag\">' + esc(t) + \"</span>\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"<span>\" + esc(where.join(\", \")) + \"</span>\");\n const link = (label, href) => href ? '<a href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"<span>\" + esc(info.contact) + \"</span>\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('<span class=\"sep\">|</span>'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by <b>' + key(owner) + \"</b>\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"<div><small>\" + k + \"</small><div\" + ([\"Owner\", \"NIPs\", \"Relay key\"].includes(k) ? ' class=\"mono\"' : \"\") + \">\" + (v === undefined || v === \"\" ? '<span class=\"muted\">–</span>' : v instanceof Html ? v.s : esc(v)) + \"</div></div>\").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '<span class=\"g' + (off ? \" off\" : \"\") + '\" title=\"' + label + '\"><i>' + IC[icon] + \"</i><small>\" + label + \"</small></span>\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '<span class=\"who' + (m.pubkey === me ? \" me\" : \"\") + '\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + '<span class=\"mono\">' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"</span>\" + (m.role === \"owner\" ? '<span class=\"role\">owner</span>' : \"\") + \"</span>\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n }\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 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 [\"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 async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '<select class=\"txt\" data-view=\"' + esc(v.name) + '\" style=\"width:auto\">' + choices.map((c) => '<option value=\"' + c + '\"' + (c === v.trigger ? \" selected\" : \"\") + \">\" + label(c) + \"</option>\").join(\"\") + \"</select>\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"<span>\" + esc(x) + \"</span>\").join('<span class=\"sep\">|</span>');\n return '<div class=\"wire-row' + (v.on ? \"\" : \" dim\") + '\"><div class=\"wire-main\"><b>' + esc(v.name) + '</b><br><small>' + esc(v.about) + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + pick + (v.on ? '<a class=\"btn\" href=\"' + esc(v.path) + '\" target=\"_blank\" rel=\"noopener\">Open</a>' : \"\") + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '<li><i class=\"ev\">jsonl</i><span><b class=\"mono\">' + esc(d.name) + '</b> <span class=\"dim\">' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"</span></span><span>\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"</span></li>\").join(\"\") : '<li class=\"empty\">no dumps yet</li>';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + '</a> <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites yet</li>';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<i class=\"' + c + '\" style=\"flex:' + b + '\" title=\"' + esc(n) + \" \" + fmtBytes(b) + '\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<span><i class=\"' + c + '\"></i>' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" <small>\" + fmtBytes(st.eventBytes) + \"</small>\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" <small>\" + fmtBytes(st.mediaBytes) + \"</small>\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '<span class=\"kind' + (k.protected ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + k.kind + \"</span> \" + esc(kindName(k.kind));\n const cells = k.protected\n ? '<td class=\"keep sys\" colspan=\"2\" title=\"The relay depends on this kind, so it is never expired or purged.\">required</td>'\n : '<td class=\"keep\"><input class=\"txt num days\" type=\"number\" min=\"0\" max=\"36500\" placeholder=\"' + placeholder + '\" value=\"' + own(k.kind) + '\"> days</td><td class=\"r\">' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"</td>\";\n return '<tr class=\"' + (k.kind === null ? \"any\" : \"\") + '\" data-kind=\"' + key + '\"><td>' + pill + '</td><td class=\"r mono\">' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '</td><td class=\"r mono\">' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '</td><td class=\"dim\">' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"</td>\" + cells + \"</tr>\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '<tr><td class=\"dim\">' + esc(fmtTime(e.created_at)) + '</td><td><span class=\"kind' + (SYS_KINDS.has(e.kind) ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + e.kind + '</span></td><td class=\"mono\" title=\"' + e.pubkey + '\">' + av(e.pubkey) + key(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no events yet</td></tr>';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '<li><i class=\"ev\">' + (t[0] === \"e\" ? \"event\" : \"address\") + '</i><span class=\"mono\">' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"</span><span>\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"</span></li>\").join(\"\") : '<li class=\"muted\">nothing pinned</li>';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n return \"<tr><td>\" + what + \"</td><td class=\\\"mono\\\">\" + j.relays.map(esc).join(\"<br>\") + \"</td><td>\" + (f.join(\", \") || \"everything\") + \"</td><td>\" + when + \"</td><td>\" + esc(res) + \"</td><td>\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"</td></tr>\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : '<tr><td colspan=\"6\" class=\"muted\">no jobs yet</td></tr>';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '<button class=\"btn\" data-preset=\"' + esc(p.name) + '\" title=\"' + esc(p.about) + '\">' + esc(p.title) + \"</button>\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '<span class=\"pill ' + cls + '\">' + esc(label) + \"</span>\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-wire=\"' + l.kind + '\" data-do=\"' + act + '\"' + (busy ? \" disabled\" : \"\") + \">\" + label + \"</button>\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"<span>\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"</span>\" : w.state && !busy ? \"<span>no list found</span>\" : \"\";\n const sent = (w.results || []).map((r) => '<span class=\"chip' + (r.ok ? \"\" : \" bad\") + '\" title=\"' + esc(r.msg) + '\">' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"</span>\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '<div class=\"wire-row\"><div class=\"wire-main\"><b>' + l.title + '</b> <small class=\"muted\">' + l.nip + \"</small><br><small>\" + l.about + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + buttons + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '<option value=\"' + esc(s.label) + '\"' + (s.label === current ? \" selected\" : \"\") + \">\" + esc(s.title) + \"</option>\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-domain=\"' + esc(d.host) + '\" data-do=\"' + act + '\">' + label + \"</button>\";\n const rows = d.ready ? \"\" : '<div class=\"scroll\"><table class=\"events\"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>' + d.records.map((r) => \"<tr><td>\" + esc(r.type) + '</td><td class=\"mono\">' + esc(r.name) + '</td><td class=\"mono\">' + esc(r.value) + \"</td><td>\" + esc(r.note) + \"</td></tr>\").join(\"\") + \"</tbody></table></div>\";\n return '<div class=\"row\"><b class=\"mono\">' + esc(d.host) + '</b><span class=\"note\">' + esc(state) + \"</span>\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '</div><label class=\"row\"><span>Destination</span><select class=\"txt\" data-domain-site=\"' + esc(d.host) + '\">' + domainOptions(d.site || \"\") + \"</select></label>\" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '<a class=\"btn\" href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\";\n const app = (label, uri) => '<a class=\"btn\" href=\"' + esc(uri) + '\">' + label + \"</a>\";\n const copy = (label, text) => '<button class=\"btn\" data-copytext=\"' + esc(text) + '\">' + label + \"</button>\";\n const row = (name, where, note, acts) => '<div class=\"app\"><div class=\"app-head\"><b>' + name + \"</b><small>\" + where + \"</small></div><p>\" + note + '</p><div class=\"app-acts\">' + acts.filter(Boolean).join(\"\") + \"</div></div>\";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n 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";@@ -246,7 +246,35 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
246246 "names": false,247247 "files": false,248248 "pages": false,249- "signer": false249+ "signer": false,250+ "sites": false251+ }252+ },253+ "kinds": {254+ "allow": [],255+ "block": []256+ },257+ "retention": []258+ }259+ },260+ {261+ "name": "site",262+ "document": {263+ "format": "bind.ws/relay-config/2",264+ "template": {265+ "title": "Sites",266+ "about": "A static website host. Members publish NIP-5A manifests and files; the relay mirrors missing files into its own Blossom store."267+ },268+ "policy": {269+ "writes": "allowlist",270+ "reads": "open",271+ "directoryPublic": true,272+ "features": {273+ "files": true,274+ "sites": {275+ "enabled": true,276+ "mirror": true277+ }250278 }251279 },252280 "kinds": {@@ -7,7 +7,8 @@ import { FAVICON_SVG } from "./ui.ts";
77 import { verifyNIP98 } from "./auth.ts";88 import { leaseNames, leaseDays, validName } from "./names.ts";99 import { now } from "./event.ts";10-import { clientIP, customHost, hostnameKnown, leaseAllowed } from "./edge.ts";10+import { clientIP, customHost, hostnameKnown, leaseAllowed, siteHost } from "./edge.ts";11+import { customTarget } from "./domains.ts";1112 import schema from "../relay-config.schema.json";1213 1314 // The entry module exports the handler and the object only: workerd@@ -67,11 +68,20 @@ export default {
6768 return new Response(null, { status: (await hostnameKnown(env, url.searchParams.get("domain") ?? "")) ? 200 : 404 });6869 }6970 let name: string | null;71+ let site = "";7072 if (host === domain || host === "www." + domain || host === domain + ".localhost") name = null; // apex (<domain>.localhost in wrangler dev)7173 else if (host.endsWith("." + domain)) name = host.slice(0, -(domain.length + 1));7274 else if (host.endsWith(".localhost")) name = host.slice(0, -".localhost".length); // wrangler dev: <name>.localhost73- else name = (await customHost(env, host)) ?? env.DEV_RELAY; // a custom domain, or wrangler dev without a subdomain74-75+ else {76+ const target = customTarget(await customHost(env, host));77+ name = target?.name ?? env.DEV_RELAY;78+ site = target?.site ?? "";79+ }80+ if (name !== null && name.length > 32) {81+ site = name;82+ name = await siteHost(env, site);83+ if (!name) return new Response("Not found", { status: 404 });84+ }7585 if (name === null) {7686 if (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico") return new Response(FAVICON_SVG, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } });7787 // The configuration file's schema, for editors: relay-config.schema.json in the repository.@@ -87,6 +97,8 @@ export default {
8797 }8898 const stub = env.RELAY.getByName(name);8999 const headers = new Headers(req.headers);100+ headers.delete("x-relay-site");101+ if (site) headers.set("x-relay-site", site);90102 headers.set("x-relay-name", name);91103 // The client's address, as the edge saw it. Set here, never read from92104 // the client's own headers, so nothing a caller sends can spoof it.@@ -8,10 +8,11 @@ import { hasTag, isPrivate, now, type Event } from "./event.ts";
88 import type { Filter } from "./filter.ts";99 import { Socket, dial, checkPullURL, runPullRound, type PullFilter, type PullJob, type PullResult } from "./pull.ts";1010 import type { Relay } from "./relay.ts";11+import { runMirrorRound } from "./site-mirror.ts";1112 import { runImportRound } from "./imports.ts";1213 13-export type JobKind = "pull" | "push" | "import";14-export type JobLabel = "pull" | "backfill" | "push" | "replica" | "import";14+export type JobKind = "pull" | "push" | "import" | "mirror";15+export type JobLabel = "pull" | "backfill" | "push" | "replica" | "import" | "mirror";1516 export const EVERY = [0, 1, 6, 24] as const; // hours; 0 runs once1617 1718 export interface JobResult {@@ -48,6 +49,7 @@ export interface Job {
4849 sent: number;4950 refused: number;5051 duplicates?: number; // import: events the relay already had52+ site?: string; // mirror: the manifest event id5153 object?: string; // import: the R2 object's id5254 size?: number; // import: the object's size in bytes5355 carry?: string; // import: the partial last line of the previous round, base64@@ -122,6 +124,7 @@ export function relaysFromList(relay: Relay, pubkey: string): string[] {
122124 // runRound does one round of a job. more: call again for this run.123125 export async function runRound(relay: Relay, job: Job): Promise<{ more: boolean; error: string }> {124126 if (job.kind === "pull") return runPullSourceRound(relay, job);127+ if (job.kind === "mirror") return runMirrorRound(relay, job);125128 if (job.kind === "import") return runImportRound(relay, job);126129 return runPushRound(relay, job);127130 }@@ -53,3 +53,8 @@ export const KIND_GROUP_PINS = 39005;
5353 5454 // NIP-66: the discovery record the relay signs about itself5555 export const KIND_RELAY_DISCOVERY = 30166;56+57+// NIP-5A static websites and immutable manifest snapshots58+export const KIND_SITE = 15128;59+export const KIND_NAMED_SITE = 35128;60+export const KIND_SITE_SNAPSHOT = 5128;@@ -22,8 +22,9 @@ import { DUMP_NAME_RE, deleteDump, dumpBytes, listDumps, writeDump } from "./dum
2222 import { can, type Action, type Role } from "./roles.ts";2323 import { detailOf } from "./audit.ts";2424 import { PRESETS, applyPreset, findPreset } from "./presets.ts";25-import { addDomain, checkDomain, listDomains, removeDomain } from "./domains.ts";25+import { addDomain, checkDomain, listDomains, removeDomain, setDomainSite } from "./domains.ts";2626 import { verifyNIP98 } from "./auth.ts";27+import { SITE_KINDS, checkSite, siteLabel, sitePaths } from "./sites.ts";2728 2829 // A call: the relay and the request, who is calling and as what, the2930 // parameters with their readers, and how to answer.@@ -387,6 +388,32 @@ export const METHODS: Record<string, Method> = {
387388 return reply({ result: rows.map((b) => ({ ...descriptor(host, b), uploader: b.uploader })) });388389 },389390 },391+ listsites: {392+ action: "read", reads: true,393+ run: ({ relay, t, reply }) => {394+ // Site manifests are regular events, so the paths live in their raw395+ // JSON rather than the single-character tag index. Keep this query396+ // bounded like the other console data lists. Expired manifests should not appear even before the index sweep.397+ const rows = relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind IN (${SITE_KINDS.join(",")}) AND (expires=0 OR expires>?) ORDER BY created_at DESC LIMIT 500`, t).toArray();398+ const host = relay.domain;399+ const result = rows.flatMap(({ raw }) => {400+ try {401+ const e = JSON.parse(raw) as Parameters<typeof siteLabel>[0];402+ if (checkSite(e)) return [];403+ const label = siteLabel(e);404+ if (!label) return [];405+ const paths = sitePaths(e);406+ const hashes = [...new Set(paths.map((p) => p[2]).filter((x): x is string => !!x))];407+ const blobs = hashes.length ? relay.sql.exec<{ sha256: string; size: number }>(`SELECT sha256,size FROM blobs WHERE sha256 IN (SELECT value FROM json_each(?))`, JSON.stringify(hashes)).toArray() : [];408+ const present = new Set(blobs.map((b) => b.sha256));409+ const size = blobs.reduce((total, b) => total + b.size, 0);410+ const d = e.kind === 35128 ? e.tags.find((t) => t[0] === "d")?.[1] || "" : "";411+ return [{ id: e.id, kind: e.kind, author: e.pubkey, d, label, url: `https://${label}.${host}`, size, missing: hashes.filter((h) => !present.has(h)).length, expires_at: e.tags.find((t) => t[0] === "expiration")?.[1] ? Number(e.tags.find((t) => t[0] === "expiration")![1]) : 0, paths: paths.length }];412+ } catch { return []; }413+ });414+ return reply({ result });415+ },416+ },390417 deleteblob: {391418 action: "storage",392419 run: async ({ relay, str, hex64, reply }) => {@@ -653,11 +680,18 @@ export const METHODS: Record<string, Method> = {
653680 adddomain: {654681 action: "identity",655682 run: async ({ relay, str, reply }) => {656- const r = await addDomain(relay, str(0));683+ const r = await addDomain(relay, str(0), str(1) || undefined);657684 if (typeof r === "string") return reply({ error: r }, r.startsWith("error:") ? 502 : 400);658685 return reply({ result: r });659686 },660687 },688+ setdomainsite: {689+ action: "identity",690+ run: async ({ relay, str, reply }) => {691+ const r = await setDomainSite(relay, str(0), str(1) || undefined);692+ return typeof r === "string" ? reply({ error: r }, 400) : reply({ result: r });693+ },694+ },661695 checkdomain: {662696 action: "identity", reads: true,663697 run: async ({ relay, str, reply }) => {@@ -5,6 +5,7 @@
55 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";8+import { SITE_KINDS } from "./sites.ts";89 import { nip11Views } from "./views.ts";910 1011 // The less obvious numbers: 43 is added once the relay has an identity; 62@@ -14,7 +15,7 @@ import { nip11Views } from "./views.ts";
1415 // (publishDiscovery); 77 is negentropy sync (handleSync).1516 export const SUPPORTED_NIPS = [1, 5, 9, 11, 13, 17, 29, 40, 42, 45, 46, 50, 56, 59, 62, 66, 67, 70, 77, 86, 98];1617 // The numbers a switched-off feature takes with it (settings.ts, features).17-export const FEATURE_NIPS: Record<Feature, number[]> = { search: [50], sync: [77], count: [45], discovery: [66], names: [5], files: [], pages: [], signer: [46] };18+export const FEATURE_NIPS: Record<Feature, number[]> = { search: [50], sync: [77], count: [45], discovery: [66], names: [5], files: [], pages: [], signer: [46], sites: [] };1819 1920 // supportedNips is the list as it stands with the features that are on.2021 export function supportedNips(relay: Relay): number[] {@@ -65,6 +66,13 @@ export function nip11(relay: Relay, host: string) {
6566 if (p.owner) doc.pubkey = p.owner;6667 if (p.succession && relay.succession.warn) doc.succession_pending = new Date((relay.succession.warn.since + SUCCESSION_WARN_DAYS * 86400) * 1000).toISOString().slice(0, 10);6768 if (relay.settings.isLeased() && p.lease) doc.lease = { expires_at: p.lease.until, holder: p.lease.holder || undefined, claim_url: host ? "https://" + host + "/" : undefined };69+ if (featureOn(p, "sites")) doc.nsites = {70+ host: relay.domain,71+ kinds: SITE_KINDS,72+ root: "https://<npub>." + relay.domain,73+ named: "https://<pubkeyB36><dTag>." + relay.domain,74+ snapshot: "https://v<snapshotIdB36>." + relay.domain,75+ };6876 if (host) doc.self_url = relay.relayURL(host);6977 if (relay.identity.pubkey) {7078 doc.self = relay.identity.pubkey;@@ -1,6 +1,8 @@
11 // The relay: one Durable Object per name, holding its SQLite database and22 // its live websockets (hibernating while idle). Protocol handling mirrors33 // relay.go; policy is per relay and owner-managed (see manage.ts).4+import { queueMirrors } from "./site-mirror.ts";5+import { syncSiteIndex, forgetSites, serveSite } from "./sites.ts";46 import { DurableObject } from "cloudflare:workers";57 import { sha256 } from "@noble/hashes/sha2.js";68 import { difficulty, expiration, hasTag, isPrivate, now, tagValues, validate, canonical, type Event } from "./event.ts";@@ -136,6 +138,7 @@ export class Relay extends DurableObject<Env> {
136138 constructor(ctx: DurableObjectState, env: Env) {137139 super(ctx, env);138140 this.store = new Store(ctx.storage.sql);141+ this.store.onSitesChanged = () => this.queueSites();139142 this.settings = new Settings(ctx.storage.sql);140143 this.fuel = new Fuel(ctx.storage.sql, fuelConfig(env as unknown as Record<string, unknown>));141144 this.identity = new Identity(ctx.storage);@@ -443,6 +446,7 @@ export class Relay extends DurableObject<Env> {
443446 // teardown deletes everything this relay holds and returns the name to444447 // unclaimed. Every socket is closed. Nothing is recoverable afterwards.445448 async teardown() {449+ await forgetSites(this);446450 await forgetDomains(this);447451 for (const ws of this.ctx.getWebSockets()) {448452 try {@@ -465,6 +469,7 @@ export class Relay extends DurableObject<Env> {
465469 this.ipBuckets.clear();466470 this.meter = { bytesIn: 0, bytesOut: 0, rowsRead: 0, rowsWritten: 0, activeMs: 0, accepted: 0, refused: 0, reqs: 0 };467471 this.store = new Store(this.sql);472+ this.store.onSitesChanged = () => this.queueSites();468473 this.settings = new Settings(this.sql);469474 this.fuel = new Fuel(this.sql, this.fuel.cfg);470475 this.identity = new Identity(this.ctx.storage);@@ -477,6 +482,26 @@ export class Relay extends DurableObject<Env> {
477482 await this.ctx.storage.put("slug", this.slug);478483 }479484 485+ private sitesSync: Promise<boolean> | null = null;486+ syncSites(): Promise<boolean> {487+ if (!this.sitesSync) this.sitesSync = syncSiteIndex(this).finally(() => { this.sitesSync = null; });488+ return this.sitesSync;489+ }490+ private sitesQueued = false;491+ private queueSites() {492+ if (this.sitesQueued) return;493+ this.sitesQueued = true;494+ this.ctx.waitUntil(Promise.resolve().then(async () => {495+ this.sitesQueued = false;496+ try {497+ await this.syncSites();498+ } catch (err) {499+ console.log("site index sync failed: " + String(err));500+ }501+ await this.ensureAlarm(now() + 1);502+ }));503+ }504+480505 async deleteBlob(sha: string) {481506 await this.media.delete(`${this.slug}/${sha}`);482507 this.sql.exec(`DELETE FROM blobs WHERE sha256=?`, sha);@@ -740,12 +765,17 @@ export class Relay extends DurableObject<Env> {
740765 const url = new URL(req.url);741766 // Relays claimed before identities existed get theirs on first contact.742767 if (this.settings.policy.owner && !this.identity.pubkey) await this.publishMembership();768+ // The local custom-domain record wins over a cached edge mapping, so769+ // changing a relay domain into a site never exposes the old relay door.770+ const domainSite = this.settings.policy.customHosts?.find((h) => h.host === url.hostname)?.site;771+ const site = domainSite || req.headers.get("x-relay-site");743772 // A blocked address gets no socket and no gated door (routes.ts).744773 const upgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";745- if ((upgrade || isGated(url, req)) && this.settings.isIPBlocked(clientIP(req))) {774+ if ((site || upgrade || isGated(url, req)) && this.settings.isIPBlocked(clientIP(req))) {746775 const msg = "blocked: this address is blocked from this relay";747776 return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "content-type": "application/json", "x-reason": msg, "access-control-allow-origin": "*" } });748777 }778+ if (site) return serveSite(this, req, site);749779 if (upgrade) return this.acceptWebSocket(req);750780 return route(this, req, url);751781 }@@ -1045,6 +1075,8 @@ export class Relay extends DurableObject<Env> {
10451075 await this.teardown();10461076 return;10471077 }1078+ await this.syncSites();1079+ await queueMirrors(this);10481080 if (await this.jobsTick()) {10491081 await this.ctx.storage.setAlarm(Date.now() + 250);10501082 return;@@ -1077,6 +1109,10 @@ export class Relay extends DurableObject<Env> {
10771109 }10781110 // ---- end digest ----10791111 const next = this.store.sweepExpired(t);1112+ if (await this.syncSites()) {1113+ await this.ctx.storage.setAlarm(Date.now() + 250);1114+ return;1115+ }10801116 // ---- dumps: a scheduled JSONL of everything, into R2 (dumps.ts) ----10811117 if (dumpDue(this, t)) {10821118 try {@@ -1087,6 +1123,7 @@ export class Relay extends DurableObject<Env> {
10871123 }10881124 // ---- end dumps ----10891125 let at = t + 86400;1126+ if (this.sql.exec(`SELECT 1 FROM site_mirror_queue LIMIT 1`).toArray().length) at = Math.min(at, t + 60);10901127 if (next > 0 && next < at) at = next;10911128 const lease = this.settings.policy.lease;10921129 if (this.settings.isLeased() && lease && lease.until < at) at = lease.until;@@ -88,7 +88,7 @@ export type ViewSetting = (typeof VIEW_SETTINGS)[number];
8888 // is not one: the console runs on it. What is off leaves supported_nips8989 // (nip11.ts), answers 404 at its door (routes.ts) and is refused at the9090 // socket (gates.ts, relay.ts).91-export const FEATURE_NAMES = ["search", "sync", "count", "discovery", "names", "files", "pages", "signer"] as const;91+export const FEATURE_NAMES = ["search", "sync", "count", "discovery", "names", "files", "pages", "signer", "sites"] as const;9292 export type Feature = (typeof FEATURE_NAMES)[number];9393 export type SearchMode = "full" | "prose" | "off";9494 export interface Features {@@ -99,16 +99,23 @@ export interface Features {
9999 names: boolean; // NIP-05 under the relay's domain100100 files: boolean; // Blossom and NIP-96101101 pages: boolean; // notes and articles as pages, the feed102+ sites: { enabled: boolean; mirror: boolean }; // NIP-5A hosting and automatic blob copies102103 signer: boolean; // NIP-46 traffic carried for anyone103104 }104-export const DEFAULT_FEATURES: Features = { search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true };105-export const featureOn = (p: Policy, f: Feature): boolean => p.features[f] !== false && p.features[f] !== "off";105+export const DEFAULT_FEATURES: Features = { search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true } };106+export const featureOn = (p: Policy, f: Feature): boolean => f === "sites" ? p.features.sites.enabled : p.features[f] !== false && p.features[f] !== "off";106107 107108 export function featureFields(patch: Record<string, unknown>, cur: Features): Partial<Policy> {108109 if (!patch.features || typeof patch.features !== "object") return {};109110 const out: Features = { ...cur };110111 for (const [k, v] of Object.entries(patch.features as Record<string, unknown>)) {111- if (k === "search") {112+ if (k === "sites") {113+ if (typeof v === "boolean") out.sites = { ...cur.sites, enabled: v };114+ else if (v && typeof v === "object") {115+ const patch = v as Record<string, unknown>;116+ out.sites = { enabled: typeof patch.enabled === "boolean" ? patch.enabled : cur.sites.enabled, mirror: typeof patch.mirror === "boolean" ? patch.mirror : cur.sites.mirror };117+ }118+ } else if (k === "search") {112119 if (v === "full" || v === "prose" || v === "off") out.search = v;113120 } else if ((FEATURE_NAMES as readonly string[]).includes(k) && typeof v === "boolean") (out as unknown as Record<string, boolean>)[k] = v;114121 }@@ -0,0 +1,169 @@
1+// Browser sign-in for hosted sites. The site origin is deliberately kept2+// separate from the relay HTTP doors: this module only identifies a visitor3+// and never fetches or publishes site data.4+import { sha256 } from "@noble/hashes/sha2.js";5+import { validate, tagValues, type Event } from "./event.ts";6+import { now } from "./event.ts";7+import { KIND_AUTH } from "./kinds.ts";8+import { page, FONTS } from "./ui.ts";9+import type { Relay } from "./relay.ts";10+11+export const SITE_AUTH_PATH = "/.well-known/nsite/auth";12+const CHALLENGE_TTL = 5 * 60;13+const COOKIE_TTL = 7 * 24 * 60 * 60;14+const MAX_BODY = 32 * 1024;15+const COOKIE = "__Host-nsite";16+17+type AuthRelay = Pick<Relay, "identity" | "settings" | "sql">;18+19+const b64url = (s: string) => btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");20+const unb64 = (s: string) => atob(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - s.length % 4) % 4));21+const text = (req: Request, status: number, message: string) => new Response(req.method === "HEAD" ? null : message, {22+ status, headers: { "cache-control": "private, no-store", "content-type": "text/plain; charset=utf-8" },23+});24+const reject = (req: Request, message = "Authentication required", status = 401) => text(req, status, message);25+26+function exactNIP98(header: string, url: string, method: string, body: string): Event | string {27+ const m = /^Nostr\s+(\S+)$/i.exec(header.trim());28+ if (!m) return "auth-required: missing NIP-98 Authorization header";29+ let e: Event;30+ try { e = JSON.parse(unb64(m[1])); } catch { return "auth-required: malformed NIP-98 token"; }31+ const bad = validate(e);32+ if (bad) return "auth-required: " + bad;33+ if (e.kind !== 27235) return "auth-required: token must be kind 27235";34+ const t = now();35+ if (Math.abs(t - e.created_at) > 60) return "auth-required: token expired";36+ if (tagValues(e, "u").length !== 1 || tagValues(e, "u")[0] !== url) return "auth-required: token was signed for another URL";37+ if (tagValues(e, "method").length !== 1 || (tagValues(e, "method")[0] ?? "").toUpperCase() !== method.toUpperCase()) return "auth-required: token was signed for another method";38+ const payload = tagValues(e, "payload");39+ if (body !== "" && (payload.length !== 1 || payload[0] !== hex(sha256(new TextEncoder().encode(body))))) return "auth-required: token payload hash does not match the body";40+ if (body === "" && (payload.length > 1 || (payload.length === 1 && payload[0] !== hex(sha256(new Uint8Array()))))) return "auth-required: token payload hash does not match the body";41+ return e;42+}43+44+const hex = (bytes: Uint8Array) => [...bytes].map((x) => x.toString(16).padStart(2, "0")).join("");45+function authPage(origin: string, action: string, returnUrl: string, nonce: string, expires: number): Response {46+ const scriptNonce = b64url(crypto.randomUUID());47+ const jsq = (v: string) => JSON.stringify(v).replace(/</g, "\\u003c");48+ const js = `const challenge=${jsq(nonce)}, action=${jsq(action)}, returnUrl=${jsq(returnUrl)};49+async function sign(){50+ if(!window.nostr){document.querySelector('#msg').textContent='Install a NIP-07 signer first';return}51+ const e=await window.nostr.signEvent({kind:${KIND_AUTH},created_at:Math.floor(Date.now()/1000),tags:[['relay',${jsq(origin)}],['challenge',challenge]],content:'bind.ws site sign-in'});52+ const body=JSON.stringify({event:e}); const p=await window.nostr.signEvent({kind:27235,created_at:Math.floor(Date.now()/1000),tags:[['u',action],['method','POST'],['payload',await crypto.subtle.digest('SHA-256',new TextEncoder().encode(body)).then(x=>[...new Uint8Array(x)].map(x=>x.toString(16).padStart(2,'0')).join(''))]],content:''});53+ const r=await fetch(action,{method:'POST',headers:{'content-type':'application/json','authorization':'Nostr '+btoa(JSON.stringify(p))},body}); if(r.ok) location.assign(returnUrl); else document.querySelector('#msg').textContent=await r.text(); }54+document.querySelector('button').onclick=()=>sign().catch(e=>document.querySelector('#msg').textContent=String(e));`;55+ // The shared shell supplies the layout; system fonts keep the sign-in56+ // page independent of external hosts under its narrow content policy.57+ const html = page("Sign in", `<main><h1>Sign in</h1><p class="note" id="msg">Your Nostr signer opens this site under the relay's read rule.</p><button class="btn pri">Sign in</button></main><script nonce="${scriptNonce}">${js}</script>`).replace(FONTS, "");58+ return new Response(html, { status: 401, headers: { "cache-control": "no-store", "content-type": "text/html; charset=utf-8", "content-security-policy": `default-src 'none'; base-uri 'none'; frame-ancestors 'none'; connect-src 'self'; style-src 'unsafe-inline'; script-src 'nonce-${scriptNonce}'`, "x-content-type-options": "nosniff", "x-nsite-challenge-expires": String(expires) } });59+}60+61+function cookieEvent(relay: AuthRelay, label: string, origin: string, pubkey: string, expires: number): Event {62+ return relay.identity.sign(KIND_AUTH, [["site", label], ["origin", origin], ["pubkey", pubkey], ["expiration", String(expires)], ["purpose", "nsite-cookie"]], "bind.ws site cookie");63+}64+65+async function challengePage(relay: AuthRelay, req: Request, label: string): Promise<Response> {66+ const u = new URL(req.url);67+ const origin = u.origin;68+ relay.sql.exec(`CREATE TABLE IF NOT EXISTS nsite_auth_challenges (nonce TEXT PRIMARY KEY, label TEXT NOT NULL, origin TEXT NOT NULL, return_url TEXT NOT NULL, expires INTEGER NOT NULL)`);69+ const t = now();70+ relay.sql.exec(`DELETE FROM nsite_auth_challenges WHERE expires<=?`, t);71+ const outstanding = relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM nsite_auth_challenges WHERE label=? AND origin=?`, label, origin).one().n;72+ const total = relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM nsite_auth_challenges`).one().n;73+ if (outstanding >= 32 || total >= 512) return text(req, 429, "Too many sign-in attempts");74+ const nonce = b64url(crypto.randomUUID() + crypto.randomUUID());75+ const expires = t + CHALLENGE_TTL;76+ const returnUrl = req.method === "GET" && u.pathname === SITE_AUTH_PATH77+ ? (() => { const raw = u.searchParams.get("return"); if (!raw) return origin + "/"; try { const x = new URL(raw, origin); return x.origin === origin ? x.href : origin + "/"; } catch { return origin + "/"; } })()78+ : u.href;79+ relay.sql.exec(`INSERT INTO nsite_auth_challenges(nonce,label,origin,return_url,expires) VALUES(?,?,?,?,?)`, nonce, label, origin, returnUrl, expires);80+ const action = origin + SITE_AUTH_PATH;81+ return authPage(origin, action, returnUrl, nonce, expires);82+}83+84+function readCookie(relay: AuthRelay, value: string, label: string, origin: string): { pubkey: string; expires: number } | null {85+ try {86+ const e = JSON.parse(unb64(value)) as Event;87+ if (validate(e) || e.kind !== KIND_AUTH || e.pubkey !== relay.identity.pubkey) return null;88+ if (tagValues(e, "purpose").length !== 1 || tagValues(e, "purpose")[0] !== "nsite-cookie") return null;89+ if (tagValues(e, "site")[0] !== label || tagValues(e, "origin")[0] !== origin) return null;90+ const pubkey = tagValues(e, "pubkey")[0] ?? "";91+ const expires = Number(tagValues(e, "expiration")[0]);92+ return /^[0-9a-f]{64}$/.test(pubkey) && Number.isSafeInteger(expires) && expires > now() ? { pubkey, expires } : null;93+ } catch { return null; }94+}95+96+// siteIdentity returns the visitor's proved pubkeys, or the sign-in response.97+export async function siteIdentity(relay: AuthRelay, req: Request, label: string): Promise<{ pubkeys: string[] } | Response> {98+ const url = new URL(req.url);99+ const origin = url.origin;100+ const authPath = url.pathname === SITE_AUTH_PATH;101+ const cookie = req.headers.get("cookie")?.split(";").map((x) => x.trim()).find((x) => x.startsWith(COOKIE + "="))?.slice(COOKIE.length + 1);102+ if (cookie) {103+ const c = readCookie(relay, cookie, label, origin);104+ if (c) {105+ const gate = relay.settings.mayRead([c.pubkey]);106+ if (!gate && !authPath) return { pubkeys: [c.pubkey] };107+ if (!authPath) return reject(req, gate, 403);108+ }109+ }110+ if (!authPath) {111+ const supplied = req.headers.get("authorization");112+ if (supplied) {113+ const proof = exactNIP98(supplied, req.url, req.method, "");114+ if (typeof proof === "string") return reject(req, proof);115+ const gate = relay.settings.mayRead([proof.pubkey]);116+ return gate ? reject(req, gate, 403) : { pubkeys: [proof.pubkey] };117+ }118+ const gate = relay.settings.mayRead([]);119+ if (!gate) return { pubkeys: [] };120+ if (req.method === "GET" && /\btext\/html\b/i.test(req.headers.get("accept") ?? "")) return challengePage(relay, req, label);121+ return reject(req, gate);122+ }123+ if (req.method === "GET") {124+ const supplied = req.headers.get("authorization");125+ if (supplied) {126+ const proof = exactNIP98(supplied, req.url, req.method, "");127+ if (typeof proof === "string") return reject(req, proof);128+ const gate = relay.settings.mayRead([proof.pubkey]);129+ if (gate) return reject(req, gate, 403);130+ }131+ if (!/\btext\/html\b/i.test(req.headers.get("accept") ?? "")) return reject(req);132+ if (req.headers.get("origin") && req.headers.get("origin") !== origin) return reject(req);133+ return challengePage(relay, req, label);134+ }135+ if (req.method !== "POST" || req.headers.get("origin") !== origin) return reject(req);136+ const declared = Number(req.headers.get("content-length"));137+ if (Number.isFinite(declared) && declared > MAX_BODY) return text(req, 413, "Request body too large");138+ if (!req.body) return reject(req, "Malformed sign-in request");139+ const reader = req.body.getReader();140+ const chunks: Uint8Array[] = []; let size = 0;141+ for (;;) {142+ const part = await reader.read();143+ if (part.done) break;144+ size += part.value.byteLength;145+ if (size > MAX_BODY) { await reader.cancel(); return text(req, 413, "Request body too large"); }146+ chunks.push(part.value);147+ }148+ // Assemble after the bounded read; this avoids Request.text() accepting an149+ // unbounded upload while retaining the exact bytes used by NIP-98.150+ const bytes = new Uint8Array(size); let at = 0;151+ for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.byteLength; }152+ const rawBody = new TextDecoder().decode(bytes);153+ if (rawBody.length > MAX_BODY) return text(req, 413, "Request body too large");154+ let input: { event?: Event };155+ try { input = JSON.parse(rawBody); } catch { return reject(req, "Malformed sign-in request"); }156+ const proof = exactNIP98(req.headers.get("authorization") ?? "", req.url, req.method, rawBody);157+ if (typeof proof === "string" || !input || !input.event) return reject(req, "Invalid sign-in proof");158+ const e = input.event; const bad = validate(e); const t = now();159+ if (bad || e.kind !== KIND_AUTH || Math.abs(t - e.created_at) > 120 || e.pubkey !== proof.pubkey || tagValues(e, "challenge").length !== 1 || tagValues(e, "relay").length !== 1) return reject(req, "Invalid sign-in proof");160+ const nonce = tagValues(e, "challenge")[0] ?? "";161+ relay.sql.exec(`CREATE TABLE IF NOT EXISTS nsite_auth_challenges (nonce TEXT PRIMARY KEY, label TEXT NOT NULL, origin TEXT NOT NULL, return_url TEXT NOT NULL, expires INTEGER NOT NULL)`);162+ const stored = relay.sql.exec<{ label: string; origin: string; return_url: string; expires: number }>(`DELETE FROM nsite_auth_challenges WHERE nonce=? AND expires>? RETURNING label,origin,return_url,expires`, nonce, t).toArray()[0];163+ if (!stored || stored.label !== label || stored.origin !== origin || tagValues(e, "relay")[0] !== origin) return reject(req, "Invalid or expired challenge");164+ if (relay.settings.mayRead([e.pubkey])) return reject(req, "You are not allowed to read this site", 403);165+ await relay.identity.ensure();166+ const expires = t + COOKIE_TTL;167+ const signed = cookieEvent(relay, label, origin, e.pubkey, expires);168+ return new Response(null, { status: 204, headers: { "set-cookie": `${COOKIE}=${b64url(JSON.stringify(signed))}; Path=/; Max-Age=${COOKIE_TTL}; HttpOnly; Secure; SameSite=Lax`, "cache-control": "no-store" } });169+}@@ -0,0 +1,117 @@
1+// NIP-5A's Blossom lookup, shared by alarm-driven mirroring and a cache2+// miss at the site door. Bytes are checked before storage or delivery.3+import { sha256 } from "@noble/hashes/sha2.js";4+import { blobBlocked, fetchOrigin, readCapped, storeBlob } from "./blossom.ts";5+import { now, tagValues, type Event } from "./event.ts";6+import { bytesToHex } from "./negentropy.ts";7+import { checkSite, manifest, parseSite, siteLabel, sitePaths, siteType } from "./sites.ts";8+import { featureOn } from "./settings.ts";9+import { localName } from "./pull.ts";10+import type { Relay } from "./relay.ts";11+import type { Job } from "./jobs.ts";12+13+// Proxy responses are bounded because verification precedes delivery.14+// Mirroring has the same ceiling; local R2 serving verifies as a stream.15+export const SITE_FETCH_MAX = 32 * 1024 * 1024;16+const MAX_SERVERS = 10; // per list, so author servers always get a turn too17+18+function safeServer(relay: Relay, raw: string): URL | null {19+ try {20+ const u = new URL(raw);21+ const local = localName(u, relay.domain);22+ if (u.username || u.password || u.hash || (u.protocol !== "https:" && !(local && u.protocol === "http:"))) return null;23+ if (local === relay.slug) return null; // our own files were already checked24+ if (!local && (u.port && u.port !== "443" || /^\[|^[\d.]+$/.test(u.hostname) || !u.hostname.includes(".") || /\.(localhost|local|internal)$/.test(u.hostname))) return null;25+ return u;26+ } catch { return null; }27+}28+function servers(relay: Relay, e: Event): string[] {29+ const raw = relay.store.query({ authors: [e.pubkey], kinds: [10063], tags: {} }, { pubkeys: [] }, 1, now()).rows[0];30+ const author = raw ? tagValues(JSON.parse(raw) as Event, "server") : [];31+ return [...new Set([...tagValues(e, "server").slice(0, MAX_SERVERS), ...author.slice(0, MAX_SERVERS)])];32+}33+34+export async function remoteSiteBlob(relay: Relay, e: Event, mapping: string[]): Promise<{ bytes: Uint8Array; type: string; length: string | null } | null> {35+ if (!featureOn(relay.settings.policy, "sites") || relay.fuelStatus().outOfFuel || blobBlocked(relay, mapping[2])) return null;36+ const max = Math.min(SITE_FETCH_MAX, relay.settings.policy.maxBlobMB * 1024 * 1024);37+ for (const server of servers(relay, e)) {38+ const origin = safeServer(relay, server);39+ if (!origin) continue;40+ origin.pathname = origin.pathname.replace(/\/$/, "") + "/" + mapping[2];41+ origin.search = "";42+ let target: URL | null = origin;43+ // Redirect targets go through the same checks. No caller credentials44+ // are forwarded to a server or a sibling relay.45+ for (let redirects = 0; target && redirects <= 3; redirects++) {46+ let response: Response | string;47+ try {48+ response = await fetchOrigin(relay, target.href, { redirect: "manual", headers: { "accept-encoding": "identity" }, signal: AbortSignal.timeout(5000) });49+ if (typeof response === "string") break;50+ if ([301, 302, 303, 307, 308].includes(response.status)) {51+ const location = response.headers.get("location");52+ await response.body?.cancel();53+ target = location ? safeServer(relay, new URL(location, target).href) : null;54+ continue;55+ }56+ if (!response.ok || (response.headers.has("content-encoding") && response.headers.get("content-encoding") !== "identity")) { await response.body?.cancel(); break; }57+ const length = response.headers.get("content-length");58+ if (length !== null && (!/^\d+$/.test(length) || Number(length) > max)) { await response.body?.cancel(); break; }59+ const bytes = await readCapped(response.body, max);60+ if (bytes === "too big") { relay.meterBytes(max, 0); break; }61+ relay.meterBytes(bytes.length, 0);62+ if ((length !== null && Number(length) !== bytes.length) || bytesToHex(sha256(bytes)) !== mapping[2]) break;63+ const type = response.headers.get("content-type") ?? (length === null ? siteType(mapping[1]) : "");64+ return { bytes, type, length };65+ } catch { break; }66+ }67+ }68+ return null;69+}70+71+// queueMirrors drains the SQL queue, which survives full jobs and hibernation.72+// Turning mirroring off discards pending work; proxy reads still work.73+export async function queueMirrors(relay: Relay): Promise<void> {74+ if (!featureOn(relay.settings.policy, "sites") || !relay.settings.policy.features.sites.mirror) {75+ relay.sql.exec(`DELETE FROM site_mirror_queue`);76+ return;77+ }78+ const rows = relay.sql.exec<{ event_id: string }>(`SELECT event_id FROM site_mirror_queue LIMIT 10`).toArray();79+ for (const row of rows) {80+ const raw = relay.store.query({ ids: [row.event_id], tags: {} }, { pubkeys: [] }, 1, now()).rows[0];81+ const e = raw ? JSON.parse(raw) as Event : null;82+ if (e && !checkSite(e)) {83+ const jobs = await relay.jobs();84+ if (!jobs.some((j) => j.kind === "mirror" && j.site === e.id)) {85+ const job = await relay.addChecked({ kind: "mirror", label: "mirror", site: e.id, relays: servers(relay, e), filter: {}, every: 0, running: false, startedAt: 0, rounds: 0, failures: 0, relayIndex: 0, cursor: 0, stored: 0, skipped: 0, blobs: 0, sent: 0, refused: 0, last: null });86+ if (typeof job === "string") return;87+ }88+ }89+ relay.sql.exec(`DELETE FROM site_mirror_queue WHERE event_id=?`, row.event_id);90+ }91+}92+93+export async function runMirrorRound(relay: Relay, job: Job): Promise<{ more: boolean; error: string }> {94+ job.rounds++;95+ if (!featureOn(relay.settings.policy, "sites") || !relay.settings.policy.features.sites.mirror) return { more: false, error: "" };96+ const raw = relay.store.query({ ids: [job.site ?? ""], tags: {} }, { pubkeys: [] }, 1, now()).rows[0];97+ if (!raw) return { more: false, error: "" }; // deleted, replaced, hidden or expired98+ const e = JSON.parse(raw) as Event;99+ if (checkSite(e)) return { more: false, error: "invalid manifest" };100+ const paths = [...new Map(sitePaths(e).map((t) => [t[2], t])).values()];101+ if (job.cursor >= paths.length) return { more: false, error: "" };102+ const mapping = paths[job.cursor];103+ if (relay.sql.exec(`SELECT 1 FROM blobs WHERE sha256=?`, mapping[2]).toArray().length || blobBlocked(relay, mapping[2])) {104+ job.skipped++; job.cursor++;105+ return { more: job.cursor < paths.length, error: "" };106+ }107+ const gate = relay.mayUpload(e.pubkey, `${relay.slug}.${relay.domain}`);108+ if (gate) return { more: false, error: gate };109+ const remote = await remoteSiteBlob(relay, e, mapping);110+ if (!remote) return { more: false, error: "not found: no server returned the verified site file " + mapping[2] };111+ const site = parseSite(siteLabel(e));112+ if (!site || manifest(relay, site)?.id !== e.id || blobBlocked(relay, mapping[2]) || !featureOn(relay.settings.policy, "sites") || !relay.settings.policy.features.sites.mirror || relay.mayUpload(e.pubkey, `${relay.slug}.${relay.domain}`)) return { more: false, error: "" };113+ const { created } = await storeBlob(relay, remote.bytes, remote.type, e.pubkey, now());114+ if (created) job.blobs++; else job.skipped++;115+ job.cursor++;116+ return { more: job.cursor < paths.length, error: "" };117+}@@ -0,0 +1,221 @@
1+// NIP-5A manifests and their single-label addresses. The routing index is2+// an outbox: event mutations and pending KV writes commit together in SQL.3+import { sha256 } from "@noble/hashes/sha2.js";4+import { decode, npubEncode } from "nostr-tools/nip19";5+import { expiration, now, tag, type Event } from "./event.ts";6+import { bytesToHex } from "./negentropy.ts";7+import { KIND_SITE, KIND_NAMED_SITE, KIND_SITE_SNAPSHOT } from "./kinds.ts";8+import type { Filter } from "./filter.ts";9+import type { Relay } from "./relay.ts";10+import { SITE_AUTH_PATH, siteIdentity } from "./site-auth.ts";11+import { denyStatus } from "./auth.ts";12+import { featureOn } from "./settings.ts";13+import { remoteSiteBlob } from "./site-mirror.ts";14+import { blobBlocked, type Blob } from "./blossom.ts";15+16+export const SITE_KINDS = [KIND_SITE, KIND_NAMED_SITE, KIND_SITE_SNAPSHOT];17+const HEX = /^[0-9a-f]{64}$/;18+export const SITE_NAME = /^[a-z0-9-]{1,13}(?<!-)$/;19+export type Site = { kind: typeof KIND_SITE; pubkey: string } | { kind: typeof KIND_NAMED_SITE; pubkey: string; d: string } | { kind: typeof KIND_SITE_SNAPSHOT; id: string };20+21+// base36 preserves all 32 bytes at a fixed width, including leading zeros. The22+// draft's "no padding" and "always exactly 50" conflict for small integers;23+// the parser and DNS boundary require the latter (see docs/20).24+export function base36(hex: string): string {25+ if (!HEX.test(hex)) throw new Error("expected 32 bytes in lowercase hex");26+ return BigInt("0x" + hex).toString(36).padStart(50, "0");27+}28+export function unbase36(s: string): string | null {29+ if (!/^[0-9a-z]{50}$/.test(s)) return null;30+ let n = 0n;31+ for (const c of s) n = n * 36n + BigInt(parseInt(c, 36));32+ return n < 1n << 256n ? n.toString(16).padStart(64, "0") : null;33+}34+export function parseSite(label: string): Site | null {35+ if (label.startsWith("npub1")) {36+ try {37+ const p = decode(label);38+ if (p.type === "npub" && npubEncode(p.data) === label) return { kind: KIND_SITE, pubkey: p.data };39+ } catch { /* continue in the draft's specified order */ }40+ }41+ if (/^v[0-9a-z]{50}$/.test(label)) {42+ const id = unbase36(label.slice(1));43+ return id ? { kind: KIND_SITE_SNAPSHOT, id } : null;44+ }45+ if (!/^[0-9a-z]{50}[a-z0-9-]{1,13}$/.test(label) || label.endsWith("-")) return null;46+ const pubkey = unbase36(label.slice(0, 50));47+ return pubkey ? { kind: KIND_NAMED_SITE, pubkey, d: label.slice(50) } : null;48+}49+export function siteLabel(e: Event): string {50+ if (e.kind === KIND_SITE) return npubEncode(e.pubkey);51+ if (e.kind === KIND_NAMED_SITE) return base36(e.pubkey) + tag(e, "d");52+ if (e.kind === KIND_SITE_SNAPSHOT) return "v" + base36(e.id);53+ return "";54+}55+export const siteKey = (label: string) => "nsite:" + label;56+export const sitePaths = (e: Event) => e.tags.filter((t) => t[0] === "path");57+export function aggregate(e: Pick<Event, "tags">): string {58+ const lines = e.tags.filter((t) => t[0] === "path").map((t) => `${t[2]} ${t[1]}\n`).sort().join("");59+ return bytesToHex(sha256(new TextEncoder().encode(lines)));60+}61+function reference(s: string): boolean {62+ const [k, pubkey, d, extra] = s.split(":");63+ return extra === undefined && HEX.test(pubkey ?? "") && (k === String(KIND_SITE) ? d === "" : k === String(KIND_NAMED_SITE) && SITE_NAME.test(d ?? ""));64+}65+export function checkSite(e: Event): string {66+ if (!SITE_KINDS.includes(e.kind)) return "";67+ const ds = e.tags.filter((t) => t[0] === "d");68+ if (e.kind === KIND_NAMED_SITE ? ds.length !== 1 || !SITE_NAME.test(ds[0][1] ?? "") : ds.length !== 0) return "invalid: site d tag must be a 1-13 character named-site identifier; root sites and snapshots have none";69+ const paths = sitePaths(e);70+ if (!paths.length) return "invalid: a site needs at least one path tag";71+ const seen = new Set<string>();72+ for (const t of paths) {73+ const p = t[1] ?? "";74+ if (t.length !== 3 || !/^\/(?:[^/]+\/)*[^/]+\.[^/.]+$/.test(p) || /[\\?#\x00-\x1f\x7f]/.test(p) || p.split("/").some((s) => s === "." || s === "..") || !HEX.test(t[2])) return "invalid: site paths must map absolute filenames with extensions to lowercase sha256 hashes";75+ if (seen.has(p)) return "invalid: duplicate site path";76+ seen.add(p);77+ }78+ const xs = e.tags.filter((t) => t[0] === "x");79+ if (xs.length > 1 || (e.kind === KIND_SITE_SNAPSHOT && xs.length !== 1) || xs.some((t) => t.length !== 3 || t[2] !== "aggregate" || t[1] !== aggregate(e))) return "invalid: site x tag must match the aggregate hash";80+ const a = e.tags.filter((t) => t[0] === "a");81+ const A = e.tags.filter((t) => t[0] === "A");82+ if (a.length > 1 || A.length > 1 || [...a, ...A].some((t) => !reference(t[1] ?? ""))) return "invalid: site lineage must reference a root or named site";83+ if (e.kind === KIND_SITE_SNAPSHOT ? a.length !== 1 : a.length !== A.length) return "invalid: snapshot needs a source a tag; copies need a and A tags";84+ return "";85+}86+87+export function manifest(relay: Relay, site: Site): Event | null {88+ const f: Filter = site.kind === KIND_SITE_SNAPSHOT ? { ids: [site.id], kinds: [site.kind], tags: {} } : { authors: [site.pubkey], kinds: [site.kind], tags: site.kind === KIND_NAMED_SITE ? { d: [site.d] } : {} };89+ const raw = relay.store.query(f, { pubkeys: [] }, 1, now()).rows[0];90+ if (!raw) return null;91+ const e = JSON.parse(raw) as Event;92+ return checkSite(e) ? null : e;93+}94+95+export const SITE_SCHEMA = `96+CREATE TABLE IF NOT EXISTS site_mirror_queue (event_id TEXT PRIMARY KEY);97+CREATE TABLE IF NOT EXISTS site_outbox (seq INTEGER PRIMARY KEY AUTOINCREMENT, raw TEXT NOT NULL, removed INTEGER NOT NULL);98+CREATE TRIGGER IF NOT EXISTS site_added AFTER INSERT ON events WHEN new.kind IN (${SITE_KINDS.join(",")}) BEGIN99+ INSERT INTO site_outbox(raw,removed) VALUES(new.raw,0);100+ INSERT OR IGNORE INTO site_mirror_queue(event_id) VALUES(new.id);101+END;102+CREATE TRIGGER IF NOT EXISTS site_removed AFTER DELETE ON events WHEN old.kind IN (${SITE_KINDS.join(",")}) BEGIN103+ INSERT INTO site_outbox(raw,removed) VALUES(old.raw,1);104+ DELETE FROM site_mirror_queue WHERE event_id=old.id;105+END;106+`;107+108+// syncSiteIndex never clears another relay's or publication's mapping.109+// A failed KV write leaves the SQL entry intact for the next alarm.110+export async function syncSiteIndex(relay: Relay): Promise<boolean> {111+ if (!relay.hosts || !relay.slug) return false;112+ const rows = relay.sql.exec<{ seq: number; raw: string; removed: number }>(`SELECT * FROM site_outbox ORDER BY seq LIMIT 100`).toArray();113+ for (const row of rows) {114+ const e = JSON.parse(row.raw) as Event;115+ if (!checkSite(e)) {116+ const key = siteKey(siteLabel(e));117+ if (row.removed || (expiration(e) > 0 && expiration(e) <= now())) {118+ const existing = await relay.hosts.get(key);119+ if (existing === JSON.stringify({ name: relay.slug, event: e.id })) await relay.hosts.delete(key);120+ } else {121+ await relay.hosts.put(key, JSON.stringify({ name: relay.slug, event: e.id }));122+ }123+ }124+ relay.sql.exec(`DELETE FROM site_outbox WHERE seq=?`, row.seq);125+ }126+ return relay.sql.exec(`SELECT 1 FROM site_outbox LIMIT 1`).toArray().length > 0;127+}128+129+export async function forgetSites(relay: Relay): Promise<void> {130+ if (!relay.hosts) return;131+ relay.sql.exec(`INSERT INTO site_outbox(raw,removed) SELECT raw,1 FROM events WHERE kind IN (${SITE_KINDS.join(",")})`);132+ do { await relay.syncSites(); } while (relay.sql.exec(`SELECT 1 FROM site_outbox LIMIT 1`).toArray().length);133+}134+135+const SITE_TYPES: Record<string, string> = {136+ html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8",137+ json: "application/json", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",138+ txt: "text/plain; charset=utf-8", xml: "application/xml", wasm: "application/wasm", woff: "font/woff", woff2: "font/woff2", pdf: "application/pdf",139+};140+export const siteType = (path: string) => SITE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";141+const siteError = (req: Request, status: number, message: string) => new Response(req.method === "HEAD" ? null : message, { status, headers: { "cache-control": "private, no-store", "content-type": "text/plain; charset=utf-8" } });142+143+// serveSite answers the site origin, under the read rule. Relay handlers144+// and content negotiation never run here, even on paths the relay uses.145+export async function serveSite(relay: Relay, req: Request, label: string): Promise<Response> {146+ const site = parseSite(label);147+ const host = new URL(req.url).hostname;148+ const canonical = host === `${label}.${relay.domain.toLowerCase()}` || host === `${label}.localhost`;149+ if (!canonical && relay.settings.policy.customHosts?.find((h) => h.host === host)?.site !== label) return siteError(req, 404, "Not found");150+ if (!site || !featureOn(relay.settings.policy, "sites") || relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return siteError(req, 404, "Not found");151+ if (req.headers.get("upgrade") || (!["GET", "HEAD"].includes(req.method) && !(new URL(req.url).pathname === SITE_AUTH_PATH && req.method === "POST"))) return siteError(req, 405, "Method not allowed");152+ const who = await siteIdentity(relay, req, label);153+ if (who instanceof Response) return who;154+ const gate = relay.settings.mayRead(who.pubkeys);155+ if (gate) return siteError(req, denyStatus(gate), gate);156+ const e = manifest(relay, site);157+ if (!e) return siteError(req, 404, "Not found");158+ const url = new URL(req.url);159+ let path: string;160+ try { path = decodeURIComponent(url.pathname); } catch { return siteError(req, 404, "Not found"); }161+ const directory = !/[^/]+\.[^/.]+$/.test(path);162+ if (directory && !path.endsWith("/")) {163+ url.pathname += "/";164+ return new Response(null, { status: 308, headers: { location: url.href, "cache-control": "private, no-store" } });165+ }166+ if (directory) path += "index.html";167+ const paths = sitePaths(e);168+ let mapping = paths.find((t) => t[1] === url.pathname) ?? paths.find((t) => t[1] === path);169+ let status = 200;170+ if (!mapping) { mapping = paths.find((t) => t[1] === "/404.html"); status = 404; }171+ if (!mapping || blobBlocked(relay, mapping[2])) return siteError(req, 404, "Not found");172+ const sha = mapping[2];173+ const blob = relay.sql.exec<Blob>(`SELECT * FROM blobs WHERE sha256=?`, sha).toArray()[0];174+ const obj = blob ? await relay.media.get(`${relay.slug}/${sha}`) : null;175+ let remote: Awaited<ReturnType<typeof remoteSiteBlob>> = null;176+ if (obj) {177+ // Verify in a streaming pass, then serve the same immutable R2 version.178+ const hash = sha256.create();179+ const reader = obj.body.getReader();180+ for (;;) {181+ const { value, done } = await reader.read();182+ if (done) break;183+ hash.update(value);184+ }185+ if (bytesToHex(hash.digest()) !== sha) return siteError(req, 404, "Not found");186+ } else {187+ remote = await remoteSiteBlob(relay, e, mapping);188+ if (!remote) return siteError(req, 404, "Not found");189+ }190+ // Every asynchronous fetch is followed by the live policy and manifest191+ // check, including HEAD and conditional responses.192+ if (!featureOn(relay.settings.policy, "sites") || blobBlocked(relay, sha) || manifest(relay, site)?.id !== e.id || relay.settings.mayRead(who.pubkeys)) return siteError(req, 404, "Not found");193+ const headers = new Headers({194+ etag: `"${sha}"`,195+ // Revalidate every request so policy changes and expiry cannot be196+ // bypassed by a fresh cache entry, including on snapshot URLs.197+ "cache-control": relay.settings.policy.reads === "open" ? "public, no-cache, must-revalidate" : "private, no-store",198+ "x-content-type-options": "nosniff", "referrer-policy": "same-origin",199+ });200+ if (obj && blob) {201+ headers.set("content-type", blob.type || siteType(mapping[1]));202+ headers.set("content-length", String(obj.size));203+ } else if (remote) {204+ if (remote.type) headers.set("content-type", remote.type);205+ if (remote.length !== null) headers.set("content-length", remote.length);206+ }207+ if (status === 200 && req.headers.get("if-none-match")?.split(",").some((v) => v.trim().replace(/^W\//, "") === headers.get("etag") || v.trim() === "*")) {208+ headers.delete("content-length");209+ return new Response(null, { status: 304, headers });210+ }211+ if (req.method === "HEAD") return new Response(null, { status, headers });212+ if (remote) {213+ relay.meterBytes(0, remote.bytes.length);214+ return new Response(remote.bytes, { status, headers });215+ }216+ if (!obj) return siteError(req, 404, "Not found");217+ const verified = await relay.media.get(`${relay.slug}/${sha}`, { onlyIf: { etagMatches: obj.etag } });218+ if (!verified || !("body" in verified) || !featureOn(relay.settings.policy, "sites") || relay.settings.leaseExpired(now()) || blobBlocked(relay, sha) || manifest(relay, site)?.id !== e.id || relay.settings.mayRead(who.pubkeys)) return siteError(req, 404, "Not found");219+ relay.meterBytes(0, verified.size);220+ return new Response(verified.body, { status, headers });221+}@@ -2,6 +2,7 @@
22 // synchronous, so each call is one atomic write batch on the DO.33 import { canonical, expiration, isAddressable, isEphemeral, isPrivate, isReplaceable, tag, tagValues, type Event } from "./event.ts";44 import { ftsQuery, searchTerms, type Filter } from "./filter.ts";5+import { SITE_SCHEMA, SITE_KINDS } from "./sites.ts";56 import { HLL } from "./hll.ts";67 import { hexToBytes, type SyncItem } from "./negentropy.ts";78 @@ -71,10 +72,13 @@ export class Store {
7172 // affects events from then on; the index is not rebuilt.7273 searchMode: () => "full" | "prose" | "off" = () => "prose";7374 75+ onSitesChanged: () => void = () => {};76+7477 constructor(private sql: SqlStorage) {}7578 7679 init() {7780 this.sql.exec(SCHEMA);81+ this.sql.exec(SITE_SCHEMA);7882 }7983 8084 // x runs a statement and remembers its cursor so drain() can report rows@@ -82,6 +86,7 @@ export class Store {
8286 private x<T extends Record<string, SqlStorageValue>>(q: string, ...args: unknown[]): SqlStorageCursor<T> {8387 const c = this.sql.exec<T>(q, ...args);8488 this.pending.push(c as SqlStorageCursor<Record<string, SqlStorageValue>>);89+ if (q.startsWith("DELETE FROM events") && c.rowsWritten && this.sql.exec(`SELECT 1 FROM site_outbox LIMIT 1`).toArray().length) this.onSitesChanged();8590 return c;8691 }8792 @@ -151,6 +156,7 @@ export class Store {
151156 for (const t of e.tags) {152157 if (t.length >= 2 && t[0].length === 1) this.x(`INSERT INTO tags(event_id,name,value) VALUES(?,?,?)`, e.id, t[0], t[1]);153158 }159+ if (SITE_KINDS.includes(e.kind)) this.onSitesChanged();154160 const mode = this.searchMode();155161 const indexed = mode === "full" ? !isPrivate(e.kind) : mode === "prose" && searchable(e.kind);156162 if (e.content !== "" && indexed) this.x(`INSERT INTO search(rowid,content) VALUES(?,?)`, seq, e.content);@@ -0,0 +1,130 @@
1+// NIP-5A from outside the relay: discover the host forms from NIP-11, upload2+// the files through Blossom, publish signed manifests, and fetch each site3+// through its canonical single-label hostname.4+import { describe, it, expect } from "vitest";5+import { sha256 } from "@noble/hashes/sha2.js";6+import { newEvent, newKey, pub, hex, HTTP_URL, RELAY_URL, now, sleep, Client } from "./helpers.ts";7+import { npubEncode } from "nostr-tools/nip19";8+9+function blossomToken(sk: Uint8Array, action: string, hash?: string): string {10+ const tags = [["t", action], ...(hash ? [["x", hash]] : []), ["expiration", String(now() + 300)]];11+ return "Nostr " + Buffer.from(JSON.stringify(newEvent(sk, 24242, action, tags))).toString("base64");12+}13+14+async function upload(sk: Uint8Array, body: string, type: string): Promise<string> {15+ const bytes = new TextEncoder().encode(body);16+ const hash = hex(sha256(bytes));17+ const response = await fetch(`${HTTP_URL}/upload`, {18+ method: "PUT",19+ headers: { authorization: blossomToken(sk, "upload", hash), "content-type": type },20+ body: bytes,21+ });22+ expect(response.status, await response.clone().text()).toBe(200);23+ const descriptor: any = await response.json();24+ expect(descriptor.sha256).toBe(hash);25+ return hash;26+}27+28+function siteHash(paths: string[][]): string {29+ const lines = paths.map((t) => `${t[2]} ${t[1]}\n`).sort().join("");30+ return hex(sha256(new TextEncoder().encode(lines)));31+}32+33+function discoveredHost(template: string, replacements: Record<string, string>): string {34+ let value = template;35+ for (const [key, replacement] of Object.entries(replacements)) value = value.replace(`<${key}>`, replacement);36+ return new URL(value).hostname;37+}38+39+// siteFetch keeps Wrangler runs local while using the canonical hostname40+// when RELAY_URL points at a public relay.41+function siteFetch(host: string, path: string, init: RequestInit = {}) {42+ const endpoint = new URL(HTTP_URL);43+ const local = endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1" || endpoint.hostname === "[::1]" || endpoint.hostname === "::1";44+ endpoint.hostname = local ? `${host.split(".")[0]}.localhost` : host;45+ endpoint.pathname = path;46+ return fetch(endpoint, init);47+}48+49+async function waitForSite(host: string): Promise<void> {50+ for (let attempt = 0; attempt < 20; attempt++) {51+ const response = await siteFetch(host, "/");52+ if (response.status === 200) { await response.arrayBuffer(); return; }53+ await response.arrayBuffer();54+ await sleep(100);55+ }56+ throw new Error(`site index did not become visible at ${host}`);57+}58+59+describe("NIP-5A static websites", () => {60+ it("discovers canonical labels, serves root/named/snapshot sites, and applies fallbacks", async () => {61+ const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } });62+ expect(infoResponse.status).toBe(200);63+ const doc: any = await infoResponse.json();64+ expect(doc.nsites).toMatchObject({ kinds: [15128, 35128, 5128] });65+ expect(typeof doc.nsites.root).toBe("string");66+ expect(typeof doc.nsites.named).toBe("string");67+ expect(typeof doc.nsites.snapshot).toBe("string");68+69+ const sk = newKey();70+ const index = "<!doctype html><title>root</title>";71+ const blogIndex = "<!doctype html><title>blog</title>";72+ const notFound = "<!doctype html><title>missing</title>";73+ const indexHash = await upload(sk, index, "text/html; charset=utf-8");74+ const blogHash = await upload(sk, blogIndex, "text/html; charset=utf-8");75+ const notFoundHash = await upload(sk, notFound, "text/html; charset=utf-8");76+ const paths = [["path", "/index.html", indexHash], ["path", "/blog/index.html", blogHash], ["path", "/404.html", notFoundHash]];77+ const aggregate = siteHash(paths);78+ const c = await Client.connect(RELAY_URL);79+ try {80+ const root = newEvent(sk, 15128, "", paths);81+ await c.publish(root);82+ const named = newEvent(sk, 35128, "", [...paths, ["d", "demo-site"]]);83+ await c.publish(named);84+ const snapshot = newEvent(sk, 5128, "", [...paths, ["x", aggregate, "aggregate"], ["a", `15128:${pub(sk)}:`]]);85+ await c.publish(snapshot);86+87+ const replacements = { npub: npubEncode(pub(sk)) };88+ const rootHost = discoveredHost(doc.nsites.root, replacements);89+ const namedHost = discoveredHost(doc.nsites.named, { pubkeyB36: base36(pub(sk)), dTag: "demo-site" });90+ const snapshotHost = discoveredHost(doc.nsites.snapshot, { snapshotIdB36: base36(snapshot.id) });91+ // The event acknowledgement and KV index update are separate async92+ // tasks on some deployments, so wait for each discovered label rather93+ // than relying on a fixed delay.94+ for (const host of [rootHost, namedHost, snapshotHost]) await waitForSite(host);95+96+ for (const host of [rootHost, namedHost, snapshotHost]) {97+ const response = await siteFetch(host, "/");98+ expect(response.status, host).toBe(200);99+ expect(await response.text(), host).toBe(index);100+ expect(response.headers.get("content-type")).toContain("text/html");101+ expect(response.headers.get("etag")).toBe(`"${indexHash}"`);102+ expect(response.headers.get("cache-control")).toContain("public");103+ const head = await siteFetch(host, "/index.html", { method: "HEAD" });104+ expect(head.status).toBe(200);105+ expect(head.headers.get("content-length")).toBe(String(new TextEncoder().encode(index).length));106+107+ const blog = await siteFetch(host, "/blog/");108+ expect(blog.status).toBe(200);109+ expect(await blog.text()).toBe(blogIndex);110+ const fallback = await siteFetch(host, "/does-not-exist.txt");111+ expect(fallback.status).toBe(404);112+ expect(await fallback.text()).toBe(notFound);113+ const redirect = await siteFetch(host, "/blog", { redirect: "manual" });114+ expect(redirect.status).toBe(308);115+ expect(new URL(redirect.headers.get("location")!).pathname).toBe("/blog/");116+ }117+118+ const notFoundSite = await siteFetch(rootHost, "/no-extension", { redirect: "manual" });119+ expect(notFoundSite.status).toBe(308);120+ const missingManifest = await siteFetch(discoveredHost(doc.nsites.root, { npub: npubEncode(pub(newKey())) }), "/");121+ expect(missingManifest.status).toBe(404);122+ } finally {123+ c.close();124+ }125+ });126+});127+128+function base36(hexKey: string): string {129+ return BigInt("0x" + hexKey).toString(36).padStart(50, "0");130+}@@ -1,12 +1,15 @@
11 // Custom domains: a hostname the owner controls, mapped to the relay through22 // KV and registered with Cloudflare for SaaS. The Cloudflare API is a fake33 // fetch here; routing goes through the real worker and the KV binding.4-import { env, runInDurableObject } from "cloudflare:test";4+import { SELF, env, runInDurableObject } from "cloudflare:test";55 import { describe, it, expect } from "vitest";66 import { generateSecretKey } from "nostr-tools/pure";77 import type { Relay } from "../../src/relay.ts";88 import { Hostnames, checkHostname, MAX_CUSTOM_HOSTS } from "../../src/domains.ts";9-import { rpc, info } from "../helpers/relay.ts";9+import { siteLabel } from "../../src/sites.ts";10+import { upload } from "../helpers/media.ts";11+import { WS } from "../helpers/ws.ts";12+import { rpc, info, ev, nip98 } from "../helpers/relay.ts";1013 1114 // fakeCloudflare answers the custom hostnames API and records every call.1215 function fakeCloudflare(opts: { active?: boolean; refuse?: boolean } = {}) {@@ -153,4 +156,48 @@ describe("custom domains", () => {
153156 expect(fake.calls.some((c) => c.method === "DELETE" && c.url.endsWith("/ch-1"))).toBe(true);154157 expect(await env.HOSTS.get("relay.gone.test")).toBeNull();155158 });159+ it("routes a custom site domain only to site content under the relay read rule", async () => {160+ const name = "domain-sites", host = name + ".bind.ws", sk = generateSecretKey();161+ await rpc(host, sk, "claim"); await enable(name, fakeCloudflare());162+ const file = await upload(host, sk, "custom site");163+ const e = ev(sk, 15128, "", [["path", "/index.html", file.sha]]);164+ const c = await WS.connect(host); expect((await c.ok(e)).ok).toBe(true); c.ws.close();165+ const label = siteLabel(e), custom = "www.nsite-example.test";166+ const added = await rpc(host, sk, "adddomain", custom, label);167+ expect(added.status).toBe(200); expect(added.result.site).toBe(label);168+ expect(JSON.parse((await env.HOSTS.get(custom))!)).toEqual({ name, site: label });169+ const url = "https://" + custom + "/";170+ const res = await SELF.fetch(url, { headers: { accept: "application/nostr+json" } });171+ expect(res.status).toBe(200); expect(await res.text()).toBe("custom site");172+ expect((await SELF.fetch(url, { headers: { upgrade: "websocket" } })).status).toBe(405);173+ expect((await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc" }, body: '{"method":"getpolicy"}' })).status).toBe(405);174+ await rpc(host, sk, "setpolicy", { reads: "members" });175+ expect((await SELF.fetch(url)).status).toBe(401);176+ expect((await SELF.fetch(url, { headers: { authorization: await nip98(sk, url) } })).status).toBe(200);177+ expect((await rpc(host, sk, "removedomain", custom)).status).toBe(200);178+ expect(await env.HOSTS.get(custom)).toBeNull();179+ // A cached edge mapping cannot keep a removed custom site alive.180+ expect((await SELF.fetch(url, { headers: { authorization: await nip98(sk, url) } })).status).toBe(404);181+ });182+183+ it("changes a relay domain into a site without exposing the cached relay door", async () => {184+ const name = "domain-target", host = name + ".bind.ws", sk = generateSecretKey();185+ await rpc(host, sk, "claim"); await enable(name, fakeCloudflare());186+ const file = await upload(host, sk, "retargeted");187+ const e = ev(sk, 35128, "", [["d", "docs"], ["path", "/index.html", file.sha]]);188+ const c = await WS.connect(host); expect((await c.ok(e)).ok).toBe(true); c.ws.close();189+ const custom = "target.nsite-example.test";190+ expect((await rpc(host, sk, "adddomain", custom)).status).toBe(200);191+ expect((await info(custom)).pubkey).toBe((await info(host)).pubkey);192+ expect((await rpc(host, sk, "setdomainsite", custom, siteLabel(e))).status).toBe(200);193+ const res = await SELF.fetch("https://" + custom + "/", { headers: { accept: "application/nostr+json" } });194+ expect(res.status).toBe(200); expect(await res.text()).toBe("retargeted");195+ expect((await SELF.fetch("https://" + custom + "/", { headers: { upgrade: "websocket" } })).status).toBe(405);196+ expect((await rpc(host, sk, "setdomainsite", custom, "invalid")).status).toBe(400);197+ expect((await rpc(host, generateSecretKey(), "setdomainsite", custom, "")).status).toBe(403);198+ expect((await rpc(host, sk, "setdomainsite", custom, "")).status).toBe(200);199+ expect(await env.HOSTS.get(custom)).toBe(name);200+ expect((await info(custom)).pubkey).toBe((await info(host)).pubkey);201+ });202+156203 });@@ -4,12 +4,13 @@
44 // socket, and nothing that names her, her note or her file may come back.55 // Then she asks, and it all does. This is the test that keeps the next66 // door honest: add a path here when you add one to the relay.7-import { SELF } from "cloudflare:test";7+import { SELF, env, runInDurableObject } from "cloudflare:test";88 import { describe, it, expect } from "vitest";99 import { generateSecretKey } from "nostr-tools/pure";1010 import { npubEncode } from "nostr-tools/nip19";1111 import { sha256 } from "@noble/hashes/sha2.js";1212 import { bytesToHex } from "../../src/negentropy.ts";13+import { siteLabel } from "../../src/sites.ts";1314 import { VIEWS } from "../../src/views.ts";1415 import { ev, pk, nip98, rpc } from "../helpers/relay.ts";1516 import { WS } from "../helpers/ws.ts";@@ -17,6 +18,7 @@ import { blossomToken } from "../helpers/media.ts";
1718 1819 interface Fixture {1920 host: string;21+ siteHost: string;2022 owner: Uint8Array;2123 eve: Uint8Array;2224 stranger: Uint8Array;@@ -38,14 +40,20 @@ async function seed(host: string): Promise<Fixture> {
3840 const c = await WS.connect(host);3941 const note = ev(eve, 1, "the numbers are in");4042 expect((await c.ok(note)).ok).toBe(true);43+ const site = ev(eve, 15128, "", [["path", "/index.html", sha]]);44+ expect((await c.ok(site)).ok).toBe(true);45+ await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), (r) => r.syncSites());4146 c.ws.close();4247 await rpc(host, owner, "setpolicy", { reads: "members", writes: "allowlist", directoryPublic: false });43- return { host, owner, eve, stranger, sha, noteId: note.id, secrets: [pk(eve), npubEncode(pk(eve)), note.id, sha, "evelynq7"] };48+ return { host, siteHost: siteLabel(site) + ".bind.ws", owner, eve, stranger, sha, noteId: note.id, secrets: [pk(eve), npubEncode(pk(eve)), note.id, sha, "evelynq7"] };4449 }4550 4651 // Every HTTP path a relay answers that could carry something of a member's.47-function doors(f: Fixture): { path: string; method?: string; gated: boolean; blossom?: string }[] {52+function doors(f: Fixture): { path: string; host?: string; method?: string; gated: boolean; blossom?: string }[] {4853 return [54+ { host: f.siteHost, path: "/", gated: true },55+ { host: f.siteHost, path: "/index.html", method: "HEAD", gated: true },56+ { host: f.siteHost, path: "/.well-known/nsite/auth", gated: true },4957 { path: "/", gated: false },5058 { path: "/people", gated: false },5159 { path: "/.well-known/nostr.json", gated: false },@@ -75,7 +83,7 @@ describe("the read rule at every door", () => {
7583 it("shows a stranger nothing of a member, her note or her file", async () => {7684 const f = await seed("exposure-stranger.bind.ws");7785 for (const d of doors(f)) {78- const resp = await SELF.fetch(`http://${f.host}${d.path}`, { method: d.method ?? "GET" });86+ const resp = await SELF.fetch(`http://${d.host ?? f.host}${d.path}`, { method: d.method ?? "GET" });7987 const text = await resp.text();8088 expect(leaks(text, f.secrets), `${d.method ?? "GET"} ${d.path} -> ${resp.status}`).toEqual([]);8189 if (d.gated) expect(resp.status, d.path).toBe(401);@@ -120,7 +128,7 @@ describe("the read rule at every door", () => {
120128 it("shows a signed-in non-member the same nothing, with 403 where the stranger got 401", async () => {121129 const f = await seed("exposure-outsider.bind.ws");122130 for (const d of doors(f)) {123- const url = `http://${f.host}${d.path}`;131+ const url = `http://${d.host ?? f.host}${d.path}`;124132 const method = d.method ?? "GET";125133 const headers = { authorization: await nip98(f.stranger, url, method) };126134 const resp = await SELF.fetch(url, { method, headers });@@ -154,6 +162,10 @@ describe("the read rule at every door", () => {
154162 return { status: resp.status, text: await resp.text() };155163 };156164 const url = (path: string) => `http://${f.host}${path}`;165+ const siteURL = `http://${f.siteHost}/`;166+ const site = await SELF.fetch(siteURL, { headers: { authorization: await nip98(f.eve, siteURL) } });167+ expect(site.status).toBe(200);168+ expect(await site.text()).toBe("eve's quarterly numbers");157169 // Her file, with a Blossom get token and with NIP-98.158170 let r = await get(`/${f.sha}`, blossomToken(f.eve, "get", f.sha));159171 expect(r.status).toBe(200);@@ -177,7 +189,7 @@ describe("the read rule at every door", () => {
177189 expect(JSON.parse(r.text).people.map((m: any) => m.pubkey)).toContain(pk(f.eve));178190 const c = await WS.connect(f.host);179191 await c.auth(f.eve, f.host);180- expect((await c.open("r", { authors: [pk(f.eve)] })).events.map((e) => e.id)).toEqual([f.noteId]);192+ expect((await c.open("r", { authors: [pk(f.eve)], kinds: [1] })).events.map((e) => e.id)).toEqual([f.noteId]);181193 });182194 183195 it("closes the subscriptions a tightened read rule no longer admits", async () => {@@ -15,7 +15,7 @@ describe("features", () => {
1515 const owner = generateSecretKey();1616 await rpc(host, owner, "claim");1717 const p = (await rpc(host, owner, "getpolicy")).result;18- expect(p.features).toEqual({ search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true });18+ expect(p.features).toEqual({ search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true } });1919 for (const n of [5, 45, 46, 50, 66, 77]) expect((await info(host)).supported_nips).toContain(n);2020 const r = await rpc(host, owner, "setpolicy", { features: { search: "full", sync: false, bogus: true, count: "no" } });2121 expect(r.result.features).toMatchObject({ search: "full", sync: false, count: true });@@ -11,7 +11,7 @@ describe("presets", () => {
1111 const owner = generateSecretKey();1212 await rpc(host, owner, "claim");1313 const list = (await rpc(host, owner, "listpresets")).result;14- expect(list.map((p: any) => p.name)).toEqual(["default", "outbox", "inbox", "private", "chat", "media", "search", "articles", "dm", "quiet"]);14+ expect(list.map((p: any) => p.name)).toEqual(["default", "outbox", "inbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site"]);1515 for (const p of list) expect(p.about.length).toBeGreaterThan(10);1616 expect(list.find((p: any) => p.name === "search").source).toBe("required");1717 expect(list.find((p: any) => p.name === "articles").source).toBe("optional");@@ -0,0 +1,131 @@
1+import { SELF, runInDurableObject, env } from "cloudflare:test";2+import { describe, expect, it } from "vitest";3+import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";4+import { sha256 } from "@noble/hashes/sha2.js";5+import { ev, rpc, nip98, pk, now } from "../helpers/relay.ts";6+import { upload } from "../helpers/media.ts";7+import { WS } from "../helpers/ws.ts";8+import { siteLabel } from "../../src/sites.ts";9+import { siteIdentity, SITE_AUTH_PATH } from "../../src/site-auth.ts";10+import type { Relay } from "../../src/relay.ts";11+12+const hex = (b: Uint8Array) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");13+const token = (sk: Uint8Array, url: string, body: string) => {14+ const e = finalizeEvent({ kind: 27235, content: "", tags: [["u", url], ["method", "POST"], ["payload", hex(sha256(new TextEncoder().encode(body)))]], created_at: Math.floor(Date.now() / 1000) }, sk);15+ return "Nostr " + btoa(JSON.stringify(e));16+};17+18+describe("hosted site sign-in", () => {19+ it("issues a relay-signed cookie, consumes challenges once, and checks the cookie", async () => {20+ const stub = env.RELAY.getByName("site-auth-direct");21+ const user = generateSecretKey();22+ const url = "https://site-auth.bind.ws/private/index.html";23+ const response = await runInDurableObject(stub, async (relay: Relay) => {24+ relay.settings.update({ reads: "auth" });25+ const page = await siteIdentity(relay, new Request(url, { headers: { accept: "text/html" } }), "site");26+ expect(page).toBeInstanceOf(Response);27+ const html = await (page as Response).text();28+ const nonce = /const challenge="([^"]+)"/.exec(html)?.[1];29+ expect(nonce).toBeTruthy();30+ const challenge = finalizeEvent({ kind: 22242, content: "bind.ws site sign-in", tags: [["relay", "https://site-auth.bind.ws"], ["challenge", nonce!]], created_at: Math.floor(Date.now() / 1000) }, user);31+ const body = JSON.stringify({ event: challenge });32+ return siteIdentity(relay, new Request("https://site-auth.bind.ws/.well-known/nsite/auth", { method: "POST", headers: { origin: "https://site-auth.bind.ws", authorization: token(user, "https://site-auth.bind.ws/.well-known/nsite/auth", body), "content-type": "application/json" }, body }), "site");33+ });34+ expect(response).toBeInstanceOf(Response);35+ expect((response as Response).status).toBe(204);36+ const setCookie = (response as Response).headers.get("set-cookie")!;37+ expect(setCookie).toMatch(/^__Host-nsite=/);38+ expect(setCookie).toMatch(/HttpOnly/);39+ expect(setCookie).toMatch(/Secure/);40+ expect(setCookie).not.toMatch(/Domain=/i);41+ await runInDurableObject(stub, async (relay: Relay) => {42+ const value = setCookie.split(";", 1)[0];43+ const who = await siteIdentity(relay, new Request(url, { headers: { cookie: value } }), "site");44+ expect(who).toEqual({ pubkeys: [getPublicKey(user)] });45+ });46+ });47+});48+49+50+async function protectedSite(name: string) {51+ const host = name + ".bind.ws", owner = generateSecretKey(), member = generateSecretKey();52+ await rpc(host, owner, "claim");53+ await rpc(host, owner, "allowpubkey", pk(member));54+ const file = await upload(host, owner, "private site bytes");55+ const e = ev(owner, 15128, "", [["path", "/index.html", file.sha]]);56+ const c = await WS.connect(host); expect((await c.ok(e)).ok).toBe(true); c.ws.close();57+ await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());58+ await rpc(host, owner, "setpolicy", { reads: "members" });59+ return { name, host, owner, member, origin: "https://" + siteLabel(e) + ".bind.ws", label: siteLabel(e) };60+}61+62+async function signInRequest(origin: string, member: Uint8Array, path = "/") {63+ const page = await SELF.fetch(origin + path, { headers: { accept: "text/html" } });64+ expect(page.status).toBe(401);65+ expect(page.headers.get("content-security-policy")).toContain("connect-src 'self'");66+ const html = await page.text();67+ expect(html).not.toContain("private site bytes");68+ const nonce = /const challenge="([^"]+)"/.exec(html)?.[1];69+ expect(nonce).toBeTruthy();70+ const challenge = ev(member, 22242, "bind.ws site sign-in", [["relay", origin], ["challenge", nonce!]]);71+ const body = JSON.stringify({ event: challenge });72+ return { nonce, html, body, request: new Request(origin + SITE_AUTH_PATH, { method: "POST", headers: { origin, authorization: token(member, origin + SITE_AUTH_PATH, body), "content-type": "application/json" }, body }) };73+}74+75+describe("site sign-in at the public door", () => {76+ it("opens a member's site with a host-only cookie and consumes a challenge only once", async () => {77+ const f = await protectedSite("auth-public");78+ const proof = await signInRequest(f.origin, f.member);79+ const replies = await Promise.all([SELF.fetch(proof.request.clone()), SELF.fetch(proof.request.clone())]);80+ expect(replies.map((r) => r.status).sort()).toEqual([204, 401]);81+ const cookie = replies.find((r) => r.status === 204)!.headers.get("set-cookie")!;82+ expect(cookie).toContain("Secure"); expect(cookie).toContain("HttpOnly"); expect(cookie).toContain("SameSite=Lax"); expect(cookie).not.toContain("Domain=");83+ const value = cookie.split(";")[0];84+ const page = await SELF.fetch(f.origin + "/", { headers: { cookie: value } });85+ expect(page.status).toBe(200); expect(await page.text()).toBe("private site bytes");86+ expect(page.headers.get("cache-control")).toContain("no-store");87+ const altered = value.slice(0, -5) + "zzzzz";88+ expect((await SELF.fetch(f.origin + "/", { headers: { cookie: altered } })).status).toBe(401);89+ // A cookie for this site does not authenticate at the relay door.90+ expect((await SELF.fetch("https://" + f.host + "/query", { method: "POST", headers: { cookie: value }, body: "[]" })).status).toBe(401);91+ await rpc(f.host, f.owner, "removemember", pk(f.member));92+ expect((await SELF.fetch(f.origin + "/", { headers: { cookie: value } })).status).toBe(403);93+ });94+95+ it("rejects cross-origin proofs, expired challenges and malformed exchanges", async () => {96+ const f = await protectedSite("auth-reject");97+ const proof = await signInRequest(f.origin, f.member, "/index.html?x=1");98+ expect(proof.html).toContain('returnUrl="' + f.origin + '/index.html?x=1"');99+ const wrongHeaders = new Headers(proof.request.headers);100+ wrongHeaders.set("origin", "https://other.test");101+ const wrong = new Request(proof.request.url, { method: "POST", headers: wrongHeaders, body: proof.body });102+ expect((await SELF.fetch(wrong)).status).toBe(401);103+ await runInDurableObject(env.RELAY.getByName(f.name), (r) => { r.sql.exec(`UPDATE nsite_auth_challenges SET expires=? WHERE nonce=?`, now() - 1, proof.nonce); });104+ expect((await SELF.fetch(proof.request)).status).toBe(401);105+ const nullBody = "null";106+ expect((await SELF.fetch(f.origin + SITE_AUTH_PATH, { method: "POST", headers: { origin: f.origin, authorization: token(f.member, f.origin + SITE_AUTH_PATH, nullBody) }, body: nullBody })).status).toBe(401);107+ const head = await SELF.fetch(f.origin + "/", { method: "HEAD", headers: { accept: "text/html" } });108+ expect(head.status).toBe(401); expect(await head.text()).toBe("");109+ const huge = await SELF.fetch(f.origin + SITE_AUTH_PATH, { method: "POST", headers: { origin: f.origin }, body: "x".repeat(33 * 1024) });110+ expect(huge.status).toBe(413);111+ const outsider = await signInRequest(f.origin, generateSecretKey());112+ expect((await SELF.fetch(outsider.request)).status).toBe(403);113+ });114+115+ it("requires exact NIP-98 URLs and binds signed cookies to origin, site and lifetime", async () => {116+ const f = await protectedSite("auth-binding");117+ const url = f.origin + "/";118+ const good = await nip98(f.member, url);119+ expect((await SELF.fetch(url, { headers: { authorization: good } })).status).toBe(200);120+ expect((await SELF.fetch(url + "?different=1", { headers: { authorization: good } })).status).toBe(401);121+ expect((await SELF.fetch(url.replace("https:", "http:"), { headers: { authorization: good } })).status).toBe(401);122+ const cookies = await runInDurableObject(env.RELAY.getByName(f.name), (r) => {123+ const make = (label: string, origin: string, expires: number) => {124+ const signed = r.identity.sign(22242, [["site", label], ["origin", origin], ["pubkey", pk(f.member)], ["expiration", String(expires)], ["purpose", "nsite-cookie"]]);125+ return "__Host-nsite=" + btoa(JSON.stringify(signed)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");126+ };127+ return [make("wrong-site", f.origin, now() + 60), make(f.label, "https://elsewhere.test", now() + 60), make(f.label, f.origin, now() - 1)];128+ });129+ for (const cookie of cookies) expect((await SELF.fetch(url, { headers: { cookie } })).status).toBe(401);130+ });131+});@@ -0,0 +1,97 @@
1+import { SELF, env, runInDurableObject } from "cloudflare:test";2+import { describe, it, expect } from "vitest";3+import { generateSecretKey } from "nostr-tools/pure";4+import { sha256 } from "@noble/hashes/sha2.js";5+import { bytesToHex } from "../../src/negentropy.ts";6+import { siteLabel } from "../../src/sites.ts";7+import { queueMirrors, remoteSiteBlob } from "../../src/site-mirror.ts";8+import { ev, pk, rpc, now, drive } from "../helpers/relay.ts";9+import { upload } from "../helpers/media.ts";10+import { WS } from "../helpers/ws.ts";11+12+const text = "<!doctype html><h1>from Blossom</h1>";13+const hash = bytesToHex(sha256(new TextEncoder().encode(text)));14+15+describe("site mirrors", () => {16+ it("proxies missing files using manifest servers then BUD-03, verifying hashes and forwarding metadata", async () => {17+ const name = "site-proxy", host = name + ".bind.ws", sk = generateSecretKey();18+ await rpc(host, sk, "claim");19+ await rpc(host, sk, "setpolicy", { features: { sites: { mirror: false } } });20+ const calls: string[] = [];21+ await runInDurableObject(env.RELAY.getByName(name), (r) => {22+ r.fetcher = async (url) => {23+ calls.push(url);24+ return url.startsWith("https://bad.test") ? new Response("wrong bytes") : new Response(text, { headers: { "content-type": "text/html; charset=utf-8", "content-length": String(text.length) } });25+ };26+ });27+ const e = ev(sk, 15128, "", [["path", "/index.html", hash], ["server", "https://bad.test"]]);28+ const c = await WS.connect(host);29+ expect((await c.ok(ev(sk, 10063, "", [["server", "https://good.test"]]))).ok).toBe(true);30+ expect((await c.ok(e)).ok).toBe(true);31+ await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());32+ const url = "http://" + siteLabel(e) + ".bind.ws/";33+ const response = await SELF.fetch(url);34+ expect(response.status).toBe(200);35+ expect(await response.text()).toBe(text);36+ expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");37+ expect(response.headers.get("content-length")).toBe(String(text.length));38+ expect(calls.slice(-2)).toEqual(["https://bad.test/" + hash, "https://good.test/" + hash]);39+ expect((await rpc(host, sk, "listblobs")).result).toEqual([]);40+ await drive(host, sk);41+ expect((await rpc(host, sk, "listjobs")).result).toEqual([]);42+ c.ws.close();43+ });44+45+ it("mirrors each unique file once as the manifest author, including sibling relays", async () => {46+ const name = "site-mirror", host = name + ".bind.ws", sk = generateSecretKey();47+ const origin = "site-origin.bind.ws";48+ await rpc(host, sk, "claim"); await rpc(origin, sk, "claim");49+ const file = await upload(origin, sk, text);50+ const e = ev(sk, 35128, "", [["d", "blog"], ["path", "/index.html", file.sha], ["path", "/alias.html", file.sha], ["server", "https://" + origin]]);51+ const c = await WS.connect(host); expect((await c.ok(e)).ok).toBe(true);52+ const jobs = await drive(host, sk);53+ expect(jobs.find((j) => j.kind === "mirror")?.last).toMatchObject({ blobs: 1, error: "" });54+ const blobs = (await rpc(host, sk, "listblobs")).result;55+ expect(blobs).toHaveLength(1); expect(blobs[0]).toMatchObject({ sha256: file.sha, uploader: pk(sk), size: text.length });56+ await rpc(origin, sk, "setpolicy", { reads: "members" });57+ const response = await SELF.fetch("http://" + siteLabel(e) + ".bind.ws/");58+ expect(response.status).toBe(200); expect(await response.text()).toBe(text);59+ c.ws.close();60+ });61+62+ it("does not resurrect a deleted manifest or a moderator-blocked blob", async () => {63+ const name = "site-deleted", host = name + ".bind.ws", sk = generateSecretKey();64+ await rpc(host, sk, "claim");65+ const e = ev(sk, 15128, "", [["path", "/index.html", hash], ["server", "https://good.test"]]);66+ await runInDurableObject(env.RELAY.getByName(name), async (r) => {67+ r.accept(e, null);68+ await queueMirrors(r);69+ r.store.deleteEvent(e.id);70+ r.fetcher = async () => { throw new Error("must not fetch deleted site"); };71+ });72+ const jobs = await drive(host, sk);73+ expect(jobs.find((j) => j.kind === "mirror")?.last?.blobs).toBe(0);74+ await runInDurableObject(env.RELAY.getByName(name), async (r) => {75+ r.settings.setEvent(hash, "ban", "removed", now());76+ expect(await remoteSiteBlob(r, e, e.tags[0])).toBeNull();77+ });78+ expect((await rpc(host, sk, "listblobs")).result).toEqual([]);79+ });80+81+ it("rejects private-address redirects, oversize bodies and hash mismatches, and infers type only without both headers", async () => {82+ const name = "site-origins", host = name + ".bind.ws", sk = generateSecretKey();83+ await rpc(host, sk, "claim");84+ const e = ev(sk, 15128, "", [["path", "/index.html", hash], ["server", "https://good.test"]]);85+ await runInDurableObject(env.RELAY.getByName(name), async (r) => {86+ let calls = 0;87+ r.fetcher = async () => { calls++; return new Response(null, { status: 302, headers: { location: "https://127.0.0.1/secret" } }); };88+ expect(await remoteSiteBlob(r, e, e.tags[0])).toBeNull(); expect(calls).toBe(1);89+ r.fetcher = async () => new Response(text, { headers: { "content-length": String(100 * 1024 * 1024) } });90+ expect(await remoteSiteBlob(r, e, e.tags[0])).toBeNull();91+ r.fetcher = async () => new Response("corrupted");92+ expect(await remoteSiteBlob(r, e, e.tags[0])).toBeNull();93+ r.fetcher = async () => new Response(new TextEncoder().encode(text));94+ expect((await remoteSiteBlob(r, e, e.tags[0]))?.type).toBe("text/html; charset=utf-8");95+ });96+ });97+});@@ -0,0 +1,240 @@
1+import { SELF, env, runInDurableObject } from "cloudflare:test";2+import { describe, it, expect } from "vitest";3+import { generateSecretKey } from "nostr-tools/pure";4+import { aggregate, base36, checkSite, parseSite, siteKey, siteLabel, unbase36 } from "../../src/sites.ts";5+import { upload } from "../helpers/media.ts";6+import { ev, pk, rpc, now, nip98, info } from "../helpers/relay.ts";7+import { WS } from "../helpers/ws.ts";8+9+const paths = [["path", "/index.html", "ab".repeat(32)]];10+describe("sites", () => {11+ it("validates manifests, names, aggregates and snapshot references", () => {12+ const sk = generateSecretKey();13+ const root = ev(sk, 15128, "", paths);14+ expect(checkSite(root)).toBe("");15+ expect(checkSite(ev(sk, 15128, "", [...paths, ["d", "no"]]))).toMatch(/^invalid:/);16+ for (const d of ["", "a-", "UPPER", "a".repeat(14)]) expect(checkSite(ev(sk, 35128, "", [...paths, ["d", d]]))).toMatch(/^invalid:/);17+ for (const path of ["index.html", "/dir/", "/../a.html", "//a.html", "/a.html?x", "/a\nb.html"]) expect(checkSite(ev(sk, 15128, "", [["path", path, paths[0][2]]]))).toMatch(/^invalid:/);18+ expect(checkSite(ev(sk, 15128, "", [...paths, ...paths]))).toMatch(/duplicate/);19+ const hash = aggregate(root);20+ expect(aggregate({ tags: [["title", "ignored"], ...paths].reverse() })).toBe(hash);21+ expect(checkSite(ev(sk, 5128, "", [...paths, ["x", hash, "aggregate"], ["a", `15128:${pk(sk)}:`]]))).toBe("");22+ expect(checkSite(ev(sk, 5128, "", paths))).toMatch(/aggregate/);23+ expect(checkSite(ev(sk, 15128, "", [...paths, ["x", "00".repeat(32), "aggregate"]]))).toMatch(/aggregate/);24+ });25+26+ it("round trips all label forms, leading zeros and the full 256-bit boundary", () => {27+ const sk = generateSecretKey();28+ for (const e of [ev(sk, 15128, "", paths), ev(sk, 35128, "", [...paths, ["d", "blog"]]), ev(sk, 5128, "", paths)]) {29+ expect(parseSite(siteLabel(e))).toMatchObject(e.kind === 5128 ? { kind: e.kind, id: e.id } : { kind: e.kind, pubkey: e.pubkey });30+ }31+ for (const hex of ["00".repeat(32), "00".repeat(31) + "01", "ff".repeat(32)]) expect(unbase36(base36(hex))).toBe(hex);32+ expect(unbase36("z".repeat(50))).toBeNull();33+ for (const label of ["bad", "v" + "z".repeat(50), base36(pk(sk)) + "blog-", "npub1" + "q".repeat(58)]) expect(parseSite(label)).toBeNull();34+ });35+36+ it("indexes accepted versions and clears routes after deletion and expiry", async () => {37+ const name = "sites-index";38+ const host = name + ".bind.ws";39+ const sk = generateSecretKey();40+ await rpc(host, sk, "claim");41+ const c = await WS.connect(host);42+ const first = ev(sk, 15128, "", paths, now() - 1);43+ expect((await c.ok(first)).ok).toBe(true);44+ await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());45+ const key = siteKey(siteLabel(first));46+ expect(JSON.parse((await env.HOSTS.get(key))!)).toEqual({ name, event: first.id });47+ const next = ev(sk, 15128, "new", paths);48+ expect((await c.ok(next)).ok).toBe(true);49+ await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());50+ expect(JSON.parse((await env.HOSTS.get(key))!).event).toBe(next.id);51+ expect((await c.ok(ev(sk, 5, "", [["e", next.id]]))).ok).toBe(true);52+ await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());53+ expect(await env.HOSTS.get(key)).toBeNull();54+ const expires = ev(sk, 35128, "", [...paths, ["d", "temp"], ["expiration", String(now() + 60)]]);55+ expect((await c.ok(expires)).ok).toBe(true);56+ await runInDurableObject(env.RELAY.getByName(name), async (r) => {57+ await r.syncSites();58+ r.store.sweepExpired(now() + 61);59+ await r.syncSites();60+ });61+ expect(await env.HOSTS.get(siteKey(siteLabel(expires)))).toBeNull();62+ c.ws.close();63+ });64+ it("lists manifests with deduplicated sizes and pending mirror files", async () => {65+ const name = "sites-list", host = name + ".bind.ws", sk = generateSecretKey();66+ await rpc(host, sk, "claim");67+ const file = await upload(host, sk, "listed site");68+ const missing = "cd".repeat(32);69+ const tags = [["path", "/index.html", file.sha], ["path", "/copy.html", file.sha], ["path", "/missing.js", missing]];70+ const c = await WS.connect(host);71+ const root = ev(sk, 15128, "", tags), named = ev(sk, 35128, "", [...tags, ["d", "docs"]]);72+ const snapTags = [...tags, ["a", `15128:${pk(sk)}:`], ["x", aggregate({ tags }), "aggregate"]];73+ const snapshot = ev(sk, 5128, "", snapTags);74+ for (const e of [root, named, snapshot]) expect((await c.ok(e)).ok).toBe(true);75+ const listed = (await rpc(host, sk, "listsites")).result;76+ expect(listed).toHaveLength(3);77+ expect(listed.find((s: any) => s.id === root.id)).toMatchObject({ d: "", paths: 3, size: "listed site".length, missing: 1, url: "https://" + siteLabel(root) + ".bind.ws" });78+ expect(listed.find((s: any) => s.id === named.id)).toMatchObject({ d: "docs", size: "listed site".length });79+ expect(listed.find((s: any) => s.id === snapshot.id)).toMatchObject({ d: "", kind: 5128 });80+ expect((await rpc(host, generateSecretKey(), "listsites")).status).toBe(403);81+ const many = ev(sk, 35128, "", [["d", "large"], ...Array.from({ length: 120 }, (_, i) => ["path", `/file${i}.txt`, i.toString(16).padStart(64, "0")])]);82+ expect((await c.ok(many)).ok).toBe(true);83+ expect((await rpc(host, sk, "listsites")).result.find((s: any) => s.id === many.id)).toMatchObject({ paths: 120, size: 0, missing: 120 });84+ c.ws.close();85+ });86+ it("serves all hostname forms, directories, fallback pages and verified metadata", async () => {87+ const name = "sites-serve", host = name + ".bind.ws", sk = generateSecretKey();88+ await rpc(host, sk, "claim");89+ const file = await upload(host, sk, "hello site"), missing = await upload(host, sk, "custom missing");90+ const tags = [["path", "/index.html", file.sha], ["path", "/blog/index.html", file.sha], ["path", "/404.html", missing.sha]];91+ const c = await WS.connect(host);92+ const events = [ev(sk, 15128, "", tags), ev(sk, 35128, "", [...tags, ["d", "blog"]]), ev(sk, 5128, "", [...tags, ["a", `15128:${pk(sk)}:`], ["x", aggregate({ tags }), "aggregate"]])];93+ for (const e of events) expect((await c.ok(e)).ok).toBe(true);94+ await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());95+ for (const e of events) {96+ const url = "http://" + siteLabel(e) + ".bind.ws";97+ const res = await SELF.fetch(url + "/");98+ expect(res.status).toBe(200);99+ expect(await res.text()).toBe("hello site");100+ expect(res.headers.get("content-type")).toBe("text/plain");101+ expect(res.headers.get("content-length")).toBe("10");102+ expect(res.headers.get("etag")).toBe('"' + file.sha + '"');103+ const head = await SELF.fetch(url + "/", { method: "HEAD" });104+ expect(head.status).toBe(200); expect(await head.text()).toBe("");105+ expect((await SELF.fetch(url + "/", { headers: { "if-none-match": res.headers.get("etag")! } })).status).toBe(304);106+ expect((await SELF.fetch(url + "/blog", { redirect: "manual" })).headers.get("location")).toBe(url + "/blog/");107+ expect(await (await SELF.fetch(url + "/blog/")).text()).toBe("hello site");108+ const no = await SELF.fetch(url + "/absent.html"); expect(no.status).toBe(404); expect(await no.text()).toBe("custom missing");109+ // Content negotiation and relay paths cannot escape the site door.110+ expect(await (await SELF.fetch(url + "/", { headers: { accept: "application/nostr+json" } })).text()).toBe("hello site");111+ expect((await SELF.fetch(url + "/", { method: "POST", headers: { "content-type": "application/nostr+json+rpc" }, body: '{"method":"claim"}' })).status).toBe(405);112+ expect((await SELF.fetch(url + "/", { headers: { upgrade: "websocket" } })).status).toBe(405);113+ }114+ const url = "http://" + siteLabel(events[0]) + ".bind.ws/";115+ await rpc(host, sk, "setpolicy", { reads: "members" });116+ expect((await SELF.fetch(url)).status).toBe(401);117+ expect((await SELF.fetch(url, { headers: { authorization: await nip98(generateSecretKey(), url) } })).status).toBe(403);118+ const member = await SELF.fetch(url, { headers: { authorization: await nip98(sk, url) } });119+ expect(member.status).toBe(200); expect(member.headers.get("cache-control")).toContain("no-store");120+ await rpc(host, sk, "setpolicy", { reads: "open", features: { sites: false } });121+ expect((await SELF.fetch(url)).status).toBe(404); expect((await info(host)).nsites).toBeUndefined();122+ await rpc(host, sk, "setpolicy", { features: { sites: true } });123+ expect((await info(host)).nsites.host).toBe("bind.ws");124+ await env.MEDIA.put(name + "/" + file.sha, "corrupt");125+ expect((await SELF.fetch(url)).status).toBe(404);126+ c.ws.close();127+ });128+129+ it("rejects invalid and unknown site labels and ignores a forged routing header", async () => {130+ expect((await SELF.fetch("http://" + "z".repeat(55) + ".bind.ws/", { redirect: "manual" })).status).toBe(404);131+ const unknown = siteLabel(ev(generateSecretKey(), 15128, "", paths));132+ expect((await SELF.fetch("http://" + unknown + ".bind.ws/", { redirect: "manual" })).status).toBe(404);133+ const res = await SELF.fetch("http://sites-normal.bind.ws/", { headers: { "x-relay-site": unknown, accept: "application/nostr+json" } });134+ expect((await res.json<any>()).name).toBe("sites-normal");135+ });136+137+});138+139+async function publishForIndex(host: string, sk: Uint8Array, ...events: ReturnType<typeof ev>[]) {140+ const c = await WS.connect(host);141+ for (const e of events) expect((await c.ok(e)).ok).toBe(true);142+ c.ws.close();143+}144+145+async function syncIndex(name: string) {146+ await runInDurableObject(env.RELAY.getByName(name), (r) => r.syncSites());147+}148+149+describe("site hostname index cleanup", () => {150+ it("clears a mapping when management deletes its manifest", async () => {151+ const name = "idx-manage";152+ const sk = generateSecretKey();153+ await rpc(name + ".bind.ws", sk, "claim");154+ const e = ev(sk, 15128, "", paths);155+ await publishForIndex(name + ".bind.ws", sk, e);156+ await syncIndex(name);157+ const key = siteKey(siteLabel(e));158+ expect(await env.HOSTS.get(key)).toContain(name);159+ expect((await rpc(name + ".bind.ws", sk, "deleteevent", e.id)).result).toBe(true);160+ await syncIndex(name);161+ expect(await env.HOSTS.get(key)).toBeNull();162+ });163+164+ it("clears root and named mappings through NIP-09 a tags", async () => {165+ const name = "idx-nip09";166+ const sk = generateSecretKey();167+ await rpc(name + ".bind.ws", sk, "claim");168+ const root = ev(sk, 15128, "", paths);169+ const named = ev(sk, 35128, "", [...paths, ["d", "docs"]]);170+ await publishForIndex(name + ".bind.ws", sk, root, named);171+ await syncIndex(name);172+ const rootKey = siteKey(siteLabel(root));173+ const namedKey = siteKey(siteLabel(named));174+ expect(await env.HOSTS.get(rootKey)).toContain(name);175+ expect(await env.HOSTS.get(namedKey)).toContain(name);176+ const deletion = ev(sk, 5, "", [["a", `15128:${pk(sk)}:`], ["a", `35128:${pk(sk)}:docs`]]);177+ await publishForIndex(name + ".bind.ws", sk, deletion);178+ await syncIndex(name);179+ expect(await env.HOSTS.get(rootKey)).toBeNull();180+ expect(await env.HOSTS.get(namedKey)).toBeNull();181+ });182+183+ it("clears mappings when retention and vanish delete manifests", async () => {184+ const retentionName = "idx-retain";185+ const retentionKey = generateSecretKey();186+ await rpc(retentionName + ".bind.ws", retentionKey, "claim");187+ const old = ev(retentionKey, 15128, "", paths, now() - 3 * 86400);188+ await publishForIndex(retentionName + ".bind.ws", retentionKey, old);189+ await syncIndex(retentionName);190+ const oldKey = siteKey(siteLabel(old));191+ expect(await env.HOSTS.get(oldKey)).toContain(retentionName);192+ await rpc(retentionName + ".bind.ws", retentionKey, "setretention", 15128, 1);193+ await runInDurableObject(env.RELAY.getByName(retentionName), (r) => r.sweepRetention(now()));194+ await syncIndex(retentionName);195+ expect(await env.HOSTS.get(oldKey)).toBeNull();196+197+ const vanishName = "idx-vanish";198+ const vanishKey = generateSecretKey();199+ await rpc(vanishName + ".bind.ws", vanishKey, "claim");200+ const e = ev(vanishKey, 15128, "", paths);201+ await publishForIndex(vanishName + ".bind.ws", vanishKey, e);202+ await syncIndex(vanishName);203+ const key = siteKey(siteLabel(e));204+ expect(await env.HOSTS.get(key)).toContain(vanishName);205+ await publishForIndex(vanishName + ".bind.ws", vanishKey, ev(vanishKey, 62, "", [["relay", "ALL_RELAYS"]]));206+ await syncIndex(vanishName);207+ expect(await env.HOSTS.get(key)).toBeNull();208+ });209+210+ it("clears only its own mapping when another relay has the same site", async () => {211+ const sk = generateSecretKey();212+ const first = "idx-copy-a";213+ const second = "idx-copy-b";214+ await rpc(first + ".bind.ws", sk, "claim");215+ await rpc(second + ".bind.ws", sk, "claim");216+ const e = ev(sk, 15128, "", paths);217+ await publishForIndex(first + ".bind.ws", sk, e);218+ await syncIndex(first);219+ await publishForIndex(second + ".bind.ws", sk, e);220+ await syncIndex(second);221+ const key = siteKey(siteLabel(e));222+ expect(JSON.parse((await env.HOSTS.get(key))!).name).toBe(second);223+ expect((await rpc(first + ".bind.ws", sk, "deleteevent", e.id)).result).toBe(true);224+ await syncIndex(first);225+ expect(JSON.parse((await env.HOSTS.get(key))!).name).toBe(second);226+ });227+228+ it("clears site mappings during relay teardown", async () => {229+ const name = "idx-teardown";230+ const sk = generateSecretKey();231+ await rpc(name + ".bind.ws", sk, "claim");232+ const e = ev(sk, 15128, "", paths);233+ await publishForIndex(name + ".bind.ws", sk, e);234+ await syncIndex(name);235+ const key = siteKey(siteLabel(e));236+ expect(await env.HOSTS.get(key)).toContain(name);237+ expect((await rpc(name + ".bind.ws", sk, "deleterelay", name)).result).toEqual({ deleted: true, name });238+ expect(await env.HOSTS.get(key)).toBeNull();239+ });240+});