Add owner-configured app shortcuts to the Connect fold, backed by a connection template library with placeholders, visibility controls, QR codes, and preset support. Compact the console layout, validate repository names before generating clone commands, and clarify that shortcut visibility does not make stored media private. Validation: PR CI passed, with the full typecheck and 30 focused connection tests also passing locally during review.
- commit
- 2e6533113689ff75f77fbaa53b00d150d0ded92d
- parent
- 6c2cfc06155b274058a386cfd63ce41867f73942
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -21,8 +21,8 @@ That answers with a relay at a memorable name, open to anyone for 14 days. Claim
2121 - **Encrypted group transport.** Opt-in Marmot KeyPackages and opaque MLS group messages, with account admission and storage caps for ephemeral authors. Clients manage encryption and group membership.2222 - **A Git host.** Opt-in GRASP-01 Smart HTTP, backed by [ntig](https://github.com/FelineStateMachine/ntig). Signed NIP-34 state authorizes branch and tag changes; events wait for their Git objects before publication. Hosting has [explicit storage limits](docs/22-grasp-01-git-hosting.md#git-http), and the owner can request a bounded read-only inventory without authorizing deletion.2323 - **A file host.** Blossom and NIP-96 on the same bucket, with mirroring, pre-flight checks and file reports.24-- **A console.** Nine tabs on the relay's own page, signed by a browser extension or a remote signer app on a phone. Presets set up an outbox, an inbox, a private relay, a chat, a search replica, a media host or a quiet relay with every costly feature off, in one click. The template collection also includes static sites, public or member-published Marmot transport and Git repositories.25-- **A file.** Everything the console sets, as one document with a schema: check it offline, see what applying would change, apply it, pull it back. Keep your relay in your repository. The presets are the same files, in `relay-templates/`.24+- **A console.** Ten tabs on the relay's own page, signed by a browser extension or a remote signer app on a phone. Presets set up an outbox, an inbox, a private relay, a chat, a search replica, a media host, a quiet relay with every costly feature off or a home with sites, files and Git on, in one click, with the app shortcuts to match. The template collection also includes static sites, public or member-published Marmot transport and Git repositories. The Connect fold shows those shortcuts to anyone who opens the page: the right app, the relay already attached, a QR for a phone.25+- **A file.** Everything the console sets, the app shortcuts included, as one document with a schema: check it offline, see what applying would change, apply it, pull it back. Keep your relay in your repository. The presets are the same files, in `relay-templates/`, and the shortcuts they name are files in `connection-templates/`.2626 - **Jobs.** Pull another relay in, keep a standing mirror, fetch your own history from your relay list, rebroadcast to other relays, and dump everything to a file on a schedule.2727 - **Your own domain.** `wss://relay.example.com` onto your relay with one CNAME, or a custom hostname targeting one of your static sites.2828 - **Succession.** Name an heir. If your key goes silent, the relay warns you for a month and then hands itself over.@@ -55,6 +55,7 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena
5555 - [Hosting bind.ws](docs/11-hosting-bindws.md): run your own on your own domain, and watch it through logs, traces and metrics.5656 - [Hosting without Cloudflare](docs/16-hosting-without-cloudflare.md): the same Worker on celld, with your own bucket and proxy, and what "supported" promises.5757 - [Develop and extend](docs/12-develop-extend.md): layout, tests, adding methods and NIPs, the console, the templates.58+- [Connection templates](docs/17-connection-templates.md): the app shortcuts on the Connect fold, the template file, its placeholders, the owner's list, `/connect.json`, and how to add one.5859 - [Costs and margins](docs/15-costs-and-margins.md): application budgets, retained storage, inventory interpretation and deployment margins.5960 6061 ### Draft NIPs@@ -40,7 +40,8 @@ docs: main takes commits, not pull requests
4040 4141 - **The area** is the module, folder or concern the change belongs to:4242 `views`, `kinds`, `config`, `routes`, `console`, `docs`, `test`, `ci`,43- `host`, `rules`, `relay-templates`, `scripts`, `tidy`.43+ `host`, `rules`, `relay-templates`, `connection-templates`, `connections`,44+ `scripts`, `tidy`.4445 - **No imperative mood and no Conventional Commits.** There is no `feat:`,4546 `fix:`, `chore:`, and no "add", "update" or "refactor" opening a subject.4647 - **Length is not a constraint.** The median subject is 75 characters and the@@ -55,7 +56,7 @@ Each commit typechecks on its own.
5556 5657 ## Documentation5758 58-- `docs/` is numbered with two digits: `00` to `05` for users, `10` to `16`59+- `docs/` is numbered with two digits: `00` to `05` for users, `10` to `17`5960 for developers, `20` and up for draft NIPs the repository implements ahead6061 of upstream.6162 - Each file opens with frontmatter naming its `title` and its `audience`@@ -164,6 +165,7 @@ file means adding its row.
164165 | Catalog | Where | Enforced |165166 |---|---|---|166167 | one row per template | `relay-templates/README.md` | yes, the build fails without it |168+| one row per connection template | `connection-templates/README.md` | yes, the build fails without it |167169 | one link per doc | `README.md` | by review |168170 | one entry per generated file | `src/gen/` staleness check | yes, in `npm run typecheck` |169171 | every door | `test/object/exposure.test.ts` | by review |@@ -0,0 +1,53 @@
1+{2+ "$schema": "http://json-schema.org/draft-07/schema#",3+ "$id": "https://bind.ws/connection-template.schema.json",4+ "title": "bind.ws connection template",5+ "description": "One app shortcut for a relay's Connect fold: the app, what it does with this relay, and the links that hand a viewer over to it. Links carry {relay:*}, {owner:*}, {user:*} and {input:*} placeholders the relay fills in. The relay's parser (src/connections.ts) is the last word; this schema is for the editor.",6+ "type": "object",7+ "required": ["format", "title", "about", "app", "links"],8+ "additionalProperties": false,9+ "properties": {10+ "$schema": { "type": "string" },11+ "format": { "enum": ["bind.ws/connection-template/1"] },12+ "title": { "type": "string", "minLength": 1, "maxLength": 40, "description": "what the shortcut is for, in the owner's words: Repos, Notes, Photo library" },13+ "about": { "type": "string", "minLength": 1, "maxLength": 200, "description": "one sentence on what opens and what it does with this relay" },14+ "app": { "type": "string", "minLength": 1, "maxLength": 40, "description": "the app the shortcut hands over to" },15+ "where": { "type": "string", "maxLength": 40, "description": "where the app runs: web, phone, iPhone, Android, desktop, terminal" },16+ "icon": { "enum": ["notes", "blog", "bookmark", "photo", "site", "git", "chat", "person", "files", "key", "search", "feed", "lock", "app"], "description": "one of the console's icons" },17+ "feature": { "enum": ["search", "sync", "count", "discovery", "names", "files", "pages", "signer", "sites", "marmot", "grasp", "push"], "description": "the shortcut is shown only while this feature is on" },18+ "visibility": { "enum": ["public", "auth", "members", "owner"], "description": "who sees the shortcut when the owner adds it without saying: anyone, anyone signed in, members, the owner" },19+ "links": {20+ "type": "array",21+ "minItems": 1,22+ "maxItems": 6,23+ "items": {24+ "type": "object",25+ "required": ["label"],26+ "additionalProperties": false,27+ "properties": {28+ "label": { "type": "string", "minLength": 1, "maxLength": 40 },29+ "href": { "type": "string", "minLength": 1, "maxLength": 2000, "description": "a link to open: https://, or nostr: for the viewer's own app" },30+ "copy": { "type": "string", "minLength": 1, "maxLength": 2000, "description": "text to put on the clipboard instead of a link" }31+ },32+ "oneOf": [{ "required": ["href"] }, { "required": ["copy"] }]33+ }34+ },35+ "qr": { "type": "string", "minLength": 1, "maxLength": 2000, "description": "what the QR code carries; the first link's href when left out" },36+ "inputs": {37+ "type": "array",38+ "maxItems": 4,39+ "items": {40+ "type": "object",41+ "required": ["name", "label"],42+ "additionalProperties": false,43+ "properties": {44+ "name": { "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,23}$", "description": "the {input:name} placeholder it fills" },45+ "label": { "type": "string", "minLength": 1, "maxLength": 60 },46+ "placeholder": { "type": "string", "maxLength": 200 },47+ "default": { "type": "string", "maxLength": 500, "description": "the value when the owner leaves it blank" },48+ "pattern": { "type": "string", "maxLength": 200, "description": "a regular expression the owner's value must match; a value that lands in a URL path or a shell command names one" }49+ }50+ }51+ }52+ }53+}@@ -0,0 +1,15 @@
1+// The relay as a feed: everything posted here, in Jumble, which opens a2+// relay by URL. The QR carries the same link for a phone.3+{4+ "$schema": "https://bind.ws/connection-template.schema.json",5+ "format": "bind.ws/connection-template/1",6+ "title": "Notes",7+ "about": "Everything posted on this relay as one feed.",8+ "app": "Jumble",9+ "where": "web",10+ "icon": "notes",11+ "visibility": "public",12+ "links": [13+ { "label": "Open", "href": "https://jumble.social/?r={relay:url|enc}" }14+ ]15+}@@ -0,0 +1,19 @@
1+// Feed apps have no relay pages. They meet this relay through the owner's2+// profile with the relay attached as the hint, and keep it once it is in3+// the app's relay settings. The nostr: link opens whatever app the viewer4+// has set for it; the web link is Primal.5+{6+ "$schema": "https://bind.ws/connection-template.schema.json",7+ "format": "bind.ws/connection-template/1",8+ "title": "Find me here",9+ "about": "The owner's profile with this relay attached, for feed apps.",10+ "app": "Primal",11+ "where": "web, phone",12+ "icon": "person",13+ "visibility": "public",14+ "links": [15+ { "label": "Open", "href": "https://primal.net/p/{owner:nprofile}" },16+ { "label": "Open in app", "href": "nostr:{owner:nprofile}" }17+ ],18+ "qr": "nostr:{owner:nprofile}"19+}@@ -0,0 +1,19 @@
1+// The relay's NIP-29 group as a room: Flotilla opens the relay as a space2+// on the web and on a phone, and any group-aware app takes the naddr. The3+// QR carries the group address for a phone.4+{5+ "$schema": "https://bind.ws/connection-template.schema.json",6+ "format": "bind.ws/connection-template/1",7+ "title": "Group",8+ "about": "The relay as a space, with its group as a room.",9+ "app": "Flotilla",10+ "where": "web, phone",11+ "icon": "chat",12+ "visibility": "public",13+ "links": [14+ { "label": "Open", "href": "https://app.flotilla.social/spaces/{relay:host|enc}" },15+ { "label": "Open group in app", "href": "nostr:{relay:naddr}" },16+ { "label": "Copy naddr", "copy": "{relay:naddr}" }17+ ],18+ "qr": "nostr:{relay:naddr}"19+}@@ -0,0 +1,20 @@
1+// The owner's long-form articles (kind 30023), in YakiHonne, which reads2+// and writes them and opens a profile at /profile/<nprofile>, the pattern3+// the console already uses. Habla has a /p/<nprofile> route in its source4+// (verbiricha/habla.news src/pages/p/[nprofile].tsx) but habla.news5+// answered 404 on 2026-09-05, so the second link is the nostr: handoff.6+{7+ "$schema": "https://bind.ws/connection-template.schema.json",8+ "format": "bind.ws/connection-template/1",9+ "title": "Blog",10+ "about": "The owner's articles, with this relay attached so a reader finds the rest of them.",11+ "app": "YakiHonne",12+ "where": "web, phone",13+ "icon": "blog",14+ "visibility": "public",15+ "links": [16+ { "label": "Open", "href": "https://yakihonne.com/profile/{owner:nprofile}" },17+ { "label": "Open in app", "href": "nostr:{owner:nprofile}" }18+ ],19+ "qr": "nostr:{owner:nprofile}"20+}@@ -0,0 +1,25 @@
1+// The relay's Git repositories: GitWorkshop has a relay page by host, the2+// pattern the console already uses. The clone command names the signed-in3+// viewer, since a repository here lives at <relay>/<npub>/<repo>.git4+// (docs/22-grasp-01-git-hosting.md), so a visitor sees that link left out.5+// The owner fills in the repository name when adding the shortcut; the6+// input's pattern keeps it to letters, digits, dot, dash and underscore,7+// since the value lands inside a quoted shell command and a URL path.8+{9+ "$schema": "https://bind.ws/connection-template.schema.json",10+ "format": "bind.ws/connection-template/1",11+ "title": "Repos",12+ "about": "This relay's Git repositories, and the clone command for yours.",13+ "app": "GitWorkshop",14+ "where": "web, terminal",15+ "icon": "git",16+ "feature": "grasp",17+ "visibility": "public",18+ "inputs": [19+ { "name": "repo", "label": "Repository name", "placeholder": "my-project", "default": "<repo>", "pattern": "^[A-Za-z0-9][A-Za-z0-9._\\-]{0,63}$" }20+ ],21+ "links": [22+ { "label": "Open", "href": "https://gitworkshop.dev/relay/{relay:host|enc}" },23+ { "label": "Copy clone command", "copy": "git clone '{relay:web}/{user:npub}/{input:repo}.git'" }24+ ]25+}@@ -0,0 +1,21 @@
1+// NIP-5A sites: a root site lives at https://<npub>.<domain>, so the2+// owner's and the viewer's own are plain links (docs/20-nip-5a-static-websites.md).3+// The publish command is nsyte's, with this relay as both the relay and4+// the Blossom server; the -r/--relays and -s/--servers flags are read from5+// sandwichfarm/nsyte src/commands/deploy.ts on 2026-09-05.6+{7+ "$schema": "https://bind.ws/connection-template.schema.json",8+ "format": "bind.ws/connection-template/1",9+ "title": "Sites",10+ "about": "Static sites this relay hosts, and the command that publishes one here.",11+ "app": "nsyte",12+ "where": "web, terminal",13+ "icon": "site",14+ "feature": "sites",15+ "visibility": "public",16+ "links": [17+ { "label": "Open the owner's site", "href": "https://{owner:npub}.{relay:domain}" },18+ { "label": "Open your site", "href": "https://{user:npub}.{relay:domain}" },19+ { "label": "Copy publish command", "copy": "nsyte deploy ./dist --relays {relay:url} --servers {relay:web}" }20+ ]21+}@@ -0,0 +1,19 @@
1+// The viewer's own bookmarks and lists (kinds 10003 and 30003), in Listr,2+// whose user page takes an npub (erskingardner/listr src/params/user.ts and3+// src/routes/(app)/[userId=user]/+page.svelte; both kinds are in4+// src/lib/utils/lists.ts SUPPORTED_LIST_KINDS). noStrudel takes an nprofile5+// at /bookmarks/<pointer> (hzrd149/nostrudel src/views/bookmarks/routes.tsx).6+{7+ "$schema": "https://bind.ws/connection-template.schema.json",8+ "format": "bind.ws/connection-template/1",9+ "title": "Bookmarks",10+ "about": "Your bookmarks and lists in a list manager; add this relay in its settings so they land here.",11+ "app": "Listr",12+ "where": "web",13+ "icon": "bookmark",14+ "visibility": "auth",15+ "links": [16+ { "label": "Open", "href": "https://listr.lol/{user:npub}" },17+ { "label": "Open in noStrudel", "href": "https://nostrudel.ninja/bookmarks/{user:nprofile}" }18+ ]19+}@@ -0,0 +1,21 @@
1+// The owner's photos on this relay's Blossom store, in bouquet, a media2+// manager for Blossom and NIP-96 servers (flox1an/bouquet README). No3+// client here encrypts blobs, and the owner-only start hides only the4+// shortcut: who may read the files is the relay's read rule, which a blob5+// follows like an event. bouquet has no server URL parameter6+// (src/main.tsx), so the store's address is a copy link.7+{8+ "$schema": "https://bind.ws/connection-template.schema.json",9+ "format": "bind.ws/connection-template/1",10+ "title": "Photo library",11+ "about": "The owner's photos on this relay's file store, in a media manager that takes the store as a server; the read rule says who may see the files.",12+ "app": "bouquet",13+ "where": "web",14+ "icon": "photo",15+ "feature": "files",16+ "visibility": "owner",17+ "links": [18+ { "label": "Open", "href": "https://bouquet.slidestr.net/" },19+ { "label": "Copy Blossom server URL", "copy": "{relay:web}" }20+ ]21+}@@ -0,0 +1,21 @@
1+// The relay's Blossom store for members: bouquet browses, uploads and2+// syncs across Blossom and NIP-96 servers (flox1an/bouquet README) once3+// this relay is in its server list, which is why the server URL and the4+// NIP-96 discovery document are copy links. The discovery path is the5+// relay's own (src/nip96.ts).6+{7+ "$schema": "https://bind.ws/connection-template.schema.json",8+ "format": "bind.ws/connection-template/1",9+ "title": "Files",10+ "about": "This relay's file store, for members, in a media manager that takes it as a server.",11+ "app": "bouquet",12+ "where": "web",13+ "icon": "files",14+ "feature": "files",15+ "visibility": "members",16+ "links": [17+ { "label": "Open", "href": "https://bouquet.slidestr.net/" },18+ { "label": "Copy Blossom server URL", "copy": "{relay:web}" },19+ { "label": "Copy NIP-96 URL", "copy": "{relay:web}/.well-known/nostr/nip96.json" }20+ ]21+}@@ -0,0 +1,18 @@
1+// Private messages (NIP-17): the owner in a DM app by the nostr: handoff,2+// since DM apps open a profile and not a relay. 0xchat is the reference,3+// on phone and desktop with NIP-17 (0xchat-app/0xchat-core README). The4+// relay URL goes in the app's DM inbox list (kind 10050) so messages land here.5+{6+ "$schema": "https://bind.ws/connection-template.schema.json",7+ "format": "bind.ws/connection-template/1",8+ "title": "Messages",9+ "about": "Message the owner privately; put this relay in your DM inbox list (kind 10050) so replies land here.",10+ "app": "0xchat",11+ "where": "phone, desktop",12+ "icon": "chat",13+ "visibility": "public",14+ "links": [15+ { "label": "Open in app", "href": "nostr:{owner:nprofile}" }16+ ],17+ "qr": "nostr:{owner:nprofile}"18+}@@ -0,0 +1,19 @@
1+// Marmot (MLS over Nostr) group chat: White Noise is the reference client,2+// on iOS by TestFlight and Android by Zapstore or GitHub (whitenoise.chat3+// and its /download page, checked 2026-09-05). The app has no relay URL4+// form, so the links are its site and the relay URL for its settings.5+{6+ "$schema": "https://bind.ws/connection-template.schema.json",7+ "format": "bind.ws/connection-template/1",8+ "title": "Encrypted groups",9+ "about": "End-to-end encrypted group chat over this relay, in White Noise.",10+ "app": "White Noise",11+ "where": "phone",12+ "icon": "lock",13+ "feature": "marmot",14+ "visibility": "public",15+ "links": [16+ { "label": "Open", "href": "https://whitenoise.chat/" },17+ { "label": "Get the app", "href": "https://whitenoise.chat/download" }18+ ]19+}@@ -0,0 +1,16 @@
1+// The relay's own page in Coracle: its feed and its people, by host, the2+// pattern the console already uses. The host is encoded as the console3+// does, which a plain hostname passes through unchanged.4+{5+ "$schema": "https://bind.ws/connection-template.schema.json",6+ "format": "bind.ws/connection-template/1",7+ "title": "Relay page",8+ "about": "This relay's page in Coracle: its feed and its people.",9+ "app": "Coracle",10+ "where": "web",11+ "icon": "feed",12+ "visibility": "public",13+ "links": [14+ { "label": "Open", "href": "https://coracle.social/relays/{relay:host|enc}" }15+ ]16+}@@ -0,0 +1,56 @@
1+# Connection templates2+3+An app shortcut on a relay's Connect fold is one of these files, chosen by4+the owner. Each says which app opens, what it does with this relay, and the5+links that hand a viewer over to it, written once with placeholders the6+relay fills in for whoever is looking. One good template works for anyone.7+8+| Template | Choose it when |9+|---|---|10+| [notes](01-notes.jsonc) | the relay is a feed and Jumble should open it as one. |11+| [find-me](02-find-me.jsonc) | feed apps should meet this relay through the owner's profile with the relay attached. |12+| [group](03-group.jsonc) | the relay's group is a room, in Flotilla or any group-aware app. |13+| [blog](04-blog.jsonc) | the owner writes articles and YakiHonne should open them with this relay attached. |14+| [repos](05-repos.jsonc) | the relay hosts Git repositories; GitWorkshop opens them and the clone command names yours. |15+| [sites](06-sites.jsonc) | the relay hosts NIP-5A sites: the owner's, the viewer's own, and the nsyte command that publishes one. |16+| [bookmarks](07-bookmarks.jsonc) | signed-in viewers keep bookmarks and lists here and Listr should manage them. |17+| [photos](08-photos.jsonc) | the owner's photos live on this relay's file store and only the owner should see the shortcut; the read rule, not the shortcut, says who may see the files. |18+| [files](09-files.jsonc) | members keep files on this relay's Blossom store and bouquet should manage them. |19+| [dm](10-dm.jsonc) | the relay is a DM inbox and people should message the owner privately from here. |20+| [marmot](11-marmot.jsonc) | the relay carries Marmot groups and White Noise is the app. |21+| [relay-page](12-relay-page.jsonc) | Coracle should open the relay's page: its feed and its people. |22+23+## The files24+25+A template is a JSON document (`$schema` names the copy served at26+`https://bind.ws/connection-template.schema.json`, the same file as27+`../connection-template.schema.json`) with a title, one sentence about it,28+the app, where the app runs, an icon, the links, and optionally the feature29+the shortcut needs, the visibility it starts with, what its QR carries and30+the inputs the owner fills in when adding it. An input whose value lands in31+a URL path or a shell command names a `pattern` the value must match, so32+what the owner types cannot change the link or the command around it. The33+fold has one relay URL copy control of its own, so a template does not34+carry one.35+36+Links and the QR text carry placeholders:37+38+| Placeholder | Fills in |39+|---|---|40+| `{relay:url}` `{relay:host}` `{relay:web}` `{relay:name}` `{relay:domain}` | `wss://name.bind.ws`, `name.bind.ws`, `https://name.bind.ws`, `name`, `bind.ws` (where NIP-5A sites live) |41+| `{relay:hex}` `{relay:npub}` `{relay:nprofile}` `{relay:naddr}` | the relay's own key, and the group address |42+| `{owner:hex}` `{owner:npub}` `{owner:nprofile}` | the owner, the nprofile with this relay as the hint |43+| `{user:hex}` `{user:npub}` `{user:nprofile}` | whoever is signed in and looking; a link that needs one is not shown to a visitor |44+| `{input:<name>}` | what the owner typed into the template's input of that name |45+46+A `|enc` suffix, as in `{relay:url|enc}`, percent-encodes the value for a47+query string. The number in the file name is the order the library shows48+them in.49+50+`npm run build:connections` folds the folder into `src/gen/connections.ts`,51+which the relay serves as its library; `npm run typecheck` checks every file52+here against the schema and the placeholder vocabulary, fails when the53+generated module is stale, and fails when a file is missing from the table54+above. A relay template in `../relay-templates/` names these in its55+`connections` section, so one preset sets the rules and the shortcuts56+together ([Connection templates](../docs/17-connection-templates.md)).@@ -47,7 +47,7 @@ wss://<name>.bind.ws
4747 4848 That is all. The relay speaks the standard protocol, so Damus, Amethyst, noStrudel, Coracle and the rest need no special setup. Your notes, reactions and profile land on your relay the moment you publish.4949 50-The relay's page has a shortcut for each app: open the **Connect** fold, under **Open it in an app**. Relay apps such as Jumble, Coracle and Flotilla open the relay as a place, and 0xchat opens its group. Feed apps such as Primal, Damus, Amethyst, Nostur and YakiHonne have no relay pages, so their button opens the owner's profile with this relay attached as the hint; add the relay in the app's settings from there. Two QR codes carry the same links to a phone.50+The relay's page has shortcuts too: open the **Connect** fold. The relay URL with its copy button comes first, then one tile per shortcut the owner chose: an app, what it does with this relay, an **Open** button, a copy button for a command or an address when the app takes one, a QR button that puts the handoff link on a phone, and **More** for the app's other links. Relay apps such as Jumble and Flotilla open the relay as a place. Feed apps such as Primal have no relay pages, so **Find me here** opens the owner's profile with this relay attached as the hint; add the relay in the app's settings from there. A visitor sees the public shortcuts; sign in to see the rest the owner shares with you, with the links that need your own key filled in. The Blossom, names and bridge addresses and the Git clone example wait under **Connection details** at the bottom of the fold. As the owner, you pick the shortcuts on the Connect tab ([Relay configuration](01-relay-configuration.md#connect)).5151 5252 ## Your relay lists5353 @@ -5,7 +5,7 @@ audience: user
55 66 # Relay configuration77 8-Your relay's page at `https://<name>.bind.ws/` is the console. Sign in with your extension to see the owner tabs: People, Moderation, Rules, Identity, Data, Sync, Views, Health and Owner.8+Your relay's page at `https://<name>.bind.ws/` is the console. Sign in with your extension to see the ten owner tabs: People, Moderation, Rules, Identity, Connect, Data, Sync, Views, Health and Owner.99 1010 Everything on these tabs is also available to scripts as signed JSON-RPC calls (NIP-86). The page is a client of that same API.1111 @@ -73,12 +73,14 @@ Three features add bounded services beside the relay feed. **Sites** is on by de
7373 7474 ### Presets7575 76-Above the rules form, one button per preset applies a template from the repository's `relay-templates/` folder: writes, reads, the directory, the features, the kind rules and the keep-for rules together. Limits, identity, people and bans stay. **Default** restores open writes and reads, a public directory, every kind and indefinite retention. It leaves feature settings alone, so applying it after **Quiet** keeps the features switched off. **Outbox** is only you writing, anyone reading, private kinds refused. **Inbox** takes notes, replies, reactions, comments, reports and zaps from anyone and keeps them 90 days. **Private** is only you writing and only members reading, every kind, forever. **Chat** is the members-only group: private messages, chat and threads, directory hidden. Your own profile and lists always land, whatever the kind rules say. Names are cheap, so one name per role is the way to run all four.76+Above the rules form, one button per preset applies a template from the repository's `relay-templates/` folder: writes, reads, the directory, the features, the kind rules and the keep-for rules together. Limits, identity, people and bans stay. **Default** restores open writes and reads, a public directory, every kind and indefinite retention. It leaves feature settings alone, so applying it after **Quiet** keeps the features switched off. A preset with shortcuts sets the Connect fold's shortcuts too (Connect, below); **Default** leaves them, as it leaves the features. **Outbox** is only you writing, anyone reading, private kinds refused. **Inbox** takes notes, replies, reactions, comments, reports and zaps from anyone and keeps them 90 days. **Private** is only you writing and only members reading, every kind, forever. **Chat** is the members-only group: private messages, chat and threads, directory hidden. Your own profile and lists always land, whatever the kind rules say. Names are cheap, so one name per role is the way to run all four.7777 7878 Four more presets make single-purpose names: **Media** (members upload files; the only events accepted are profiles and Blossom server lists), **Search replica** (a read-only copy of another relay's searchable kinds, refreshed every six hours), **Articles** (long-form and profiles only, mirrored daily from a source if one is given) and **DM inbox** (anyone drops gift wraps, only members read). The replica presets take a source relay URL in the field next to the buttons; applying a preset again replaces the standing pull rather than adding one, and applying a preset with no source removes it. The pull appears in the Jobs table on the Data tab.7979 8080 **Quiet** is a small private relay with every feature that costs or announces switched off: members write and read, and nothing else is on until you switch it on.8181 82+**Home** is one name as your home on nostr: members write, anyone reads, sites, files and Git hosting on, with notes, blog, bookmarks, sites, repos and a photo library shortcut only you see on the Connect fold. Only the shortcut is yours alone: anyone reads a Home relay, files included, and the members read rule is what closes the store.83+8284 ## Identity8385 8486 Name, description, icon and contact appear in your relay's public information document, which clients read. The relay also keeps a profile of its own, signed with its own key, made from the name, description and icon, so it shows up like a person in clients that look one up.@@ -93,6 +95,17 @@ The relay also publishes a discovery record for relay directories (NIP-66, kind
9395 9496 One row per list that names relays: relay list, DM inbox, search relays and Blossom servers. **Check** finds your newest copy, **Add this relay** publishes a merged list with this relay first and **Remove this relay** publishes it without. See [Getting started](00-getting-started.md#tell-your-clients).9597 98+## Connect99+100+The app shortcuts on your relay's page. Anyone who opens the **Connect** fold there sees the relay URL with its copy button, then each shortcut as a tile: an icon, a title, the app and where it runs, one sentence, an **Open** button, a copy button when the app takes a command or an address, a QR button that shows the handoff link as a QR code for a phone, and **More** for the app's other links. The Blossom, names and bridge addresses and the Git clone example sit under **Connection details** below the tiles. A visitor sees the public shortcuts; someone who signs in sees the rest you let them see, with the links that need their own key filled in.101+102+- **Shortcuts** is your list, in the order the fold shows it: one row per shortcut with who sees it (anyone, anyone signed in, members, only you), **Edit** for a title of your own and the template's inputs, move up and down, and remove. **Save**, at the top of the list, lights up while there is something to save and writes the whole list. Who sees it hides the shortcut and nothing else: the read rule still says who may read what the shortcut leads to. Until you save a list, the fold shows notes, find me here and the group; a saved empty list shows nothing.103+- **Library** is one card per template with **Add**. A template that needs a feature you switched off says so, and its shortcut stays off the fold until the feature is on.104+105+106+107+The shortcuts travel with the exported configuration as its `connections` section, and a preset may set them (Presets, above). The templates are files in the repository's `connection-templates/` folder, and its README is the catalog; [Connection templates](17-connection-templates.md) says how one is written and how to add one.108+96109 ## Data97110 98111 What is here and what leaves.@@ -73,8 +73,9 @@ Names are cheap, so a relay does not have to do everything. On the Rules tab, **
7373 | marmot | anyone | anyone | KeyPackages, encrypted group envelopes, private welcomes and relay lists; Marmot enabled |7474 | grasp | members publish events | anyone | every kind, forever; GRASP Git hosting enabled |7575 | marmot-members | members; group writes authenticate | anyone | KeyPackages, encrypted group envelopes, private welcomes and relay lists; Marmot enabled |76+| home | members | anyone | every kind, forever; sites, files and Git hosting on, with notes, blog, bookmarks, sites, repos and a photo library shortcut only the owner sees on the Connect fold |7677 77-Each preset is a file in the repository's `relay-templates/` folder: a relay configuration with the rules sections only, which is also what a file of your own looks like ([Scripts and agents](13-scripts-and-agents.md#the-configuration-file)). A preset that names features sets them too.78+Each preset is a file in the repository's `relay-templates/` folder: a relay configuration with the rules sections only, which is also what a file of your own looks like ([Scripts and agents](13-scripts-and-agents.md#the-configuration-file)). A preset that names features sets them too. A preset may also carry the Connect fold's shortcuts ([Relay configuration](01-relay-configuration.md#connect)): one that does sets them, and one that does not leaves them alone.7879 7980 The search and articles presets take a source relay URL in the field next to the buttons. Applying one adds a standing pull of its kinds from that source. Applying any preset again replaces that pull rather than adding another, and applying one with no source removes it. The pull appears in the Jobs table.8081 @@ -27,6 +27,7 @@ src/
2727 audit.ts the moderation log: one row per change from manage.ts or groups.ts2828 presets.ts the templates as one-click presets, some with a standing pull2929 config.ts the configuration document: parse, plan, apply, export30+ connections.ts the Connect fold's app shortcuts: the library's parser, the owner's list, /connect.json3031 manage.ts NIP-86 methods over NIP-983132 domains.ts custom hostnames: the Cloudflare client and the KV mapping3233 dumps.ts scheduled JSONL dumps to R2@@ -59,6 +60,7 @@ src/
5960 console/ console.html, console.css, console.js: the console as written6061 gen/signer.ts generated: the NIP-46 client library the console loads from /signer.js6162 gen/templates.ts generated from relay-templates/63+ gen/connections.ts generated from connection-templates/6264 landing.ts the apex, rendered from config6365 ui.ts the shared look6466 test/@@ -68,11 +70,14 @@ test/
6870 conformance/ black-box suite for any relay URL, one file per NIP, files included6971 relay-templates/ one relay configuration per template, the presets the console offers7072 relay-config.schema.json the configuration file's schema, served at the apex73+connection-templates/ one app shortcut per template, the library the Connect tab offers74+connection-template.schema.json a connection template's schema, served at the apex7175 scripts/7276 build/ build-signer.mjs signer-entry.js bundle nostr-tools for the console's remote signing7377 build-console.mjs fold src/console into src/gen/console.ts7478 build-templates.mjs fold relay-templates/ into src/gen/templates.ts75- check/ check-console.mjs check-celld.mjs check-config.mjs run by npm run typecheck; check-config also checks any file79+ build-connections.mjs fold connection-templates/ into src/gen/connections.ts80+ check/ check-console.mjs check-celld.mjs check-connections.mjs check-config.mjs run by npm run typecheck; check-config also checks any file7681 dev/ dev-signer.mjs seed.mjs stage.mjs junk.mjs shot.mjs zaptest.mjs npm run dev:signer, dev:seed, dev:stage, dev:junk, dev:shot, dev:zaptest7782 ops/ margin.mjs network.mjs relay.mjs npm run margin; npm run test:network; npm run relay check|plan|push|pull7883 @@ -157,9 +162,11 @@ To look at what you changed: `npm run dev`, then `npm run dev:signer`, open `htt
157162 158163 Rules that hold across pages: no middle dots, no purposeless subtext, tables scroll inside their card on narrow screens, decorative elements are not selectable, a multi-field form uses the labelled grid rather than one row of inputs, and the copy above a block is one sentence.159164 165+The Connect fold on the relay's page and the Connect tab in the owner's console are clients of `/connect.json` and the `listconnectiontemplates`, `listconnections` and `setconnections` methods. What they show is a library of files in `connection-templates/`, folded into `src/gen/connections.ts` by `npm run build:connections` the way the presets are; the file format, the placeholders, the owner's list and how to add a template are in [Connection templates](17-connection-templates.md).166+160167 ## Screenshots161168 162-`npm run dev:shot <url> <out.png> [width] [height]` renders a page with phone or desktop emulation over the Chrome debugging protocol and prints the layout's scroll width, which should equal the viewport width.169+`npm run dev:shot <url> <out.png> [width] [height] [--nostr] [--eval <js>]` renders a page with phone or desktop emulation over the Chrome debugging protocol and prints the layout's scroll width, which should equal the viewport width. `--nostr` signs in as the dev signer's key (`npm run dev:signer` must be running), so the owner's console is in the picture; `--eval` runs a snippet after the page has loaded, such as opening a fold or a tab, before the shot. The pictures in `docs/img/` come from a relay staged with `npm run dev:stage`.163170 164171 ## Pull requests165172 @@ -144,7 +144,9 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted,
144144 145145 - `stats`: counts, connections, name, owner and fuel.146146 - `getpolicy`: the whole policy.147-- `listpresets`: preset names, descriptions and whether one needs a source.147+- `listpresets`: preset names, descriptions, whether one needs a source, and the Connect fold's shortcuts a preset sets when it has them.148+- `listconnectiontemplates`: the Connect fold's library, each template with `available`, whether the feature it needs is on.149+- `listconnections`: the owner's shortcut list, or the defaults while there is none.148150 - `listmembers`, `listpeople`, `listallowedpubkeys`: the member list in three shapes.149151 - `listbannedpubkeys`, `listbannedevents`, `listblockedips`: the bans.150152 - `listinvites`: detailed invite records, including expired and exhausted entries; a member under the invite rule sees only their own.@@ -191,6 +193,7 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted,
191193 - `setretention kind|null days`, `purgekind kind|null days`.192194 - `resetrules`: the defaults, kind rules and keep-for cleared.193195 - `applypreset name {source?}`: a bundle; `search` needs a source, `articles` takes one.196+- `setconnections [list]`: the Connect fold's shortcuts, the whole list in the order shown, each `{template, visibility?, title?, about?, inputs?}`, up to 24; an entry that does not fit fails the call with `invalid:` and the reason. See [Connection templates](17-connection-templates.md).194197 195198 **Identity**196199 @@ -210,7 +213,7 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted,
210213 211214 **Config**212215 213-- `exportconfig`, `importconfig document [{dryRun: true}]`: rules, identity, members, bans, address blocks, kind rules and retention as one document (format `bind.ws/relay-config/2`). A section the document leaves out is left alone. With `dryRun`, the answer is `{ changes, warnings }`: what applying would change, section by section with a `summary` line each, and the entries the relay would not take; nothing is applied or logged.216+- `exportconfig`, `importconfig document [{dryRun: true}]`: rules, identity, members, bans, address blocks, kind rules, retention and the Connect fold's shortcuts as one document (format `bind.ws/relay-config/2`). A section the document leaves out is left alone. With `dryRun`, the answer is `{ changes, warnings }`: what applying would change, section by section with a `summary` line each, and the entries the relay would not take; nothing is applied or logged.214217 215218 **Jobs**216219 @@ -239,7 +242,7 @@ per live Durable Object instance, so eviction or restart resets it.
239242 240243 ## The configuration file241244 242-`exportconfig` is a document, and a document can live in a repository. The format is `bind.ws/relay-config/2`: `policy`, `members`, `bans`, `addresses`, `banned_events`, `kinds` and `retention`, each a section, and a section the file leaves out is left alone when it is applied, so a file may carry the rules and nothing about people. The schema is at `https://bind.ws/relay-config.schema.json`; put it in `$schema` and the editor checks the file as you type. JSONC is fine: comments and trailing commas.245+`exportconfig` is a document, and a document can live in a repository. The format is `bind.ws/relay-config/2`: `policy`, `members`, `bans`, `addresses`, `banned_events`, `kinds`, `retention` and `connections`, each a section, and a section the file leaves out is left alone when it is applied, so a file may carry the rules and nothing about people. `connections` is the Connect fold's shortcuts, in order, each naming a template from the repository's `connection-templates/` folder with who sees it and the template's inputs ([Connection templates](17-connection-templates.md)). The schema is at `https://bind.ws/relay-config.schema.json`; put it in `$schema` and the editor checks the file as you type. JSONC is fine: comments and trailing commas.243246 244247 Four verbs, run from a checkout of the repository with `RELAY_SK` holding the owner's key as hex or nsec:245248 @@ -21,6 +21,7 @@ management stay open.
2121 | `/lease` | POST | NIP-98 optional | `{ name, url, console, expires_at, days, holder?, claim }`; a signature reserves the claim for that key | 201; 401 bad signature; 429 rate limited, five a minute per address, 60 overall; 503 no free name |2222 | `/favicon.svg` | GET | none | the icon | 200 |2323 | `/relay-config.schema.json` | GET | none | the JSON Schema of a relay configuration file, for `$schema` ([Scripts and agents](13-scripts-and-agents.md#the-configuration-file)) | 200 |24+| `/connection-template.schema.json` | GET | none | the JSON Schema of a connection template, for `$schema` ([Connection templates](17-connection-templates.md)) | 200 |2425 | `/.well-known/bindws/hostname?domain=<host>` | GET, on any host | none | whether the hostname is one of ours: the apex, a valid name under it, or a mapped custom hostname; no body. For a proxy issuing certificates on demand ([Hosting without Cloudflare](16-hosting-without-cloudflare.md)) | 200 ours; 404 not |2526 2627 ## Relay page and information@@ -193,6 +194,12 @@ Only while reads are open. Otherwise every path here answers 404, and an unclaim
193194 | `/card.svg` | GET | a 600 by 315 picture with the naddr as a QR | 200 |194195 | `/qr.svg?text=` | GET | any text up to 512 bytes as a QR | 200; 400 empty; 413 too long |195196 197+## Connect198+199+| Path | Method | Auth | Answers | Status |200+|---|---|---|---|---|201+| `/connect.json` | GET | NIP-98 optional | `{ relay: { url, host, web, name }, viewer, connections }`: the Connect fold's shortcuts the asker may see, links filled in for them, each `{ template, title, about, app, where, icon, visibility, links, qr, needsUser }`. Unsigned, the public ones, with the links that need a viewer left out and `needsUser` set, cached a minute; signed, that key's view, `no-store` ([Connection templates](17-connection-templates.md)) | 200; 401 bad signature |202+196203 ## People, invites and names197204 198205 The NIP-86 `createclaim`, `listclaims` and `deleteclaim` methods use `POST /` to manage the same codes redeemed below. Their parameters, results and limits are in [NIP-86 membership claims](24-nip86-claims.md); the bind.ws `claim` method still concerns relay ownership.@@ -0,0 +1,204 @@
1+---2+title: Connection templates3+audience: developer4+---5+6+# Connection templates7+8+A connection template is one app shortcut for a relay's Connect fold: which app opens, what it does with this relay, and the links that hand a viewer over to it. The links are written once, with placeholders, and the relay fills them in for whoever is looking, so one good template works on any relay and every client sees the same handoff. The library is `connection-templates/`, one file per shortcut, folded into `src/gen/connections.ts`; `src/connections.ts` parses, resolves and serves it. The owner picks from the library on the console's Connect tab: which shortcuts show, in what order, and who sees each. A relay template in `relay-templates/` can name shortcuts too, so one preset sets the rules and the handoffs that go with them.9+10+The catalog, one row per template on when to choose it, is the folder's README, [Connection templates](../connection-templates/README.md). The seed library is `notes` (the relay as a feed in Jumble), `find-me` (the owner's profile with this relay attached, in Primal or the viewer's own app through a `nostr:` link), `group` (the relay's group in Flotilla), `blog` (the owner's articles in YakiHonne), `repos` (GitWorkshop, needs GRASP, with one input, the repository name, for a clone command that names the viewer), `sites` (needs sites: the owner's root site and the viewer's own at `https://<npub>.<domain>`, and an nsyte publish command), `bookmarks` (Listr, for signed-in viewers), `photos` (bouquet, a shortcut the owner alone sees by default, needs files; the files themselves follow the relay's read rule), `files` (bouquet, members, needs files), `dm` (the owner in 0xchat), `marmot` (White Noise, needs Marmot) and `relay-page` (Coracle).11+12+## The file13+14+A template is a JSON document, comments and trailing commas allowed, whose `$schema` names `https://bind.ws/connection-template.schema.json`, the same file as `connection-template.schema.json` at the repository root and served at the apex. The schema is for the editor; `parseConnectionTemplate` in `src/connections.ts` is the last word, and the build runs both. The file's name is a number and the template's name: `03-group.jsonc` is `group` in an owner's list, and the number is the order the library shows them in.15+16+| Field | Required | Value |17+|---|---|---|18+| `format` | yes | `bind.ws/connection-template/1` |19+| `title` | yes | what the shortcut is for, in the owner's words, up to 40 characters: Notes, Repos, Photo library |20+| `about` | yes | one sentence on what opens and what it does with this relay, up to 200 characters |21+| `app` | yes | the app the shortcut hands over to, up to 40 characters |22+| `where` | no | where the app runs, up to 40 characters: web, phone, iPhone, Android, desktop, terminal |23+| `icon` | no | one of the console's icons: `notes`, `blog`, `bookmark`, `photo`, `site`, `git`, `chat`, `person`, `files`, `key`, `search`, `feed`, `lock`, `app`; `app` when left out |24+| `feature` | no | one of `search`, `sync`, `count`, `discovery`, `names`, `files`, `pages`, `signer`, `sites`, `marmot`, `grasp`, `push`; the shortcut is shown only while that feature is on |25+| `visibility` | no | `public`, `auth`, `members` or `owner`: who sees the shortcut when the owner adds it without saying; `public` when left out |26+| `links` | yes | 1 to 6, each a `label` (up to 40 characters) with either an `href` to open (`https://` or `nostr:`) or a `copy` text for the clipboard, up to 2,000 characters |27+| `qr` | no | what the QR code carries, up to 2,000 characters; when left out, the first link's `href`, or the first link's `copy` text when no link has one |28+| `inputs` | no | up to 4, each a `name` (a lowercase letter, then up to 23 lowercase letters, digits, dashes or underscores), a `label` (up to 60 characters), an optional `placeholder` (200), an optional `default` (500) and an optional `pattern` (a regular expression, up to 200 characters, that the owner's value must match); the owner fills them when adding the shortcut and a link names one as `{input:name}`. A value that lands in a URL path or a shell command names a pattern, so what the owner types cannot change the link or the command around it: `repos` keeps its repository name to letters, digits, dot, dash and underscore |29+30+The group template, as it is in the repository:31+32+```jsonc33+{34+ "$schema": "https://bind.ws/connection-template.schema.json",35+ "format": "bind.ws/connection-template/1",36+ "title": "Group",37+ "about": "The relay as a space, with its group as a room.",38+ "app": "Flotilla",39+ "where": "web, phone",40+ "icon": "chat",41+ "visibility": "public",42+ "links": [43+ { "label": "Open", "href": "https://app.flotilla.social/spaces/{relay:host|enc}" },44+ { "label": "Open group in app", "href": "nostr:{relay:naddr}" },45+ { "label": "Copy naddr", "copy": "{relay:naddr}" }46+ ],47+ "qr": "nostr:{relay:naddr}"48+}49+```50+51+## Placeholders52+53+Links, copy texts and the QR text carry placeholders of the form `{source:field}`. `PLACEHOLDER_FIELDS` in `src/connections.ts` is the vocabulary.54+55+| Placeholder | Fills in |56+|---|---|57+| `{relay:url}` | the relay's websocket address, `wss://<name>.bind.ws`, from the host the request came in on, so a custom domain fills in its own |58+| `{relay:host}` | that host, `<name>.bind.ws` |59+| `{relay:web}` | the same address for a browser, `https://<name>.bind.ws` |60+| `{relay:name}` | the relay's name, `<name>` |61+| `{relay:domain}` | the domain the relay runs under, `bind.ws`, where NIP-5A sites live |62+| `{relay:hex}`, `{relay:npub}`, `{relay:nprofile}` | the relay's own key, the nprofile with this relay as the hint |63+| `{relay:naddr}` | the group address: kind 39000, the relay's key, the relay's name, this relay as the hint |64+| `{owner:hex}`, `{owner:npub}`, `{owner:nprofile}` | the owner, the nprofile with this relay as the hint |65+| `{user:hex}`, `{user:npub}`, `{user:nprofile}` | whoever is signed in and looking |66+| `{input:<name>}` | what the owner typed into the template's input of that name, or the input's default |67+68+A `|enc` suffix, as in `{relay:url|enc}`, percent-encodes the value for a query string.69+70+A link that names a `{user:*}` value is left out for a visitor, and the shortcut says `needsUser` so the fold can ask for a sign-in. A value the relay does not have yet, the owner of an unclaimed relay or the relay's own key before it has an identity, drops the link rather than showing it broken. A template that names a field outside this table, or an `{input:*}` it does not declare, fails the build. An owner's input cannot smuggle another scheme in: a resolved link is shown only while it is still a web or `nostr:` link. A template's `href` must start with `https://` or `nostr:`; plain `http://` is admitted after resolution only, since `{relay:web}` resolves to it on a local dev host.71+72+## Visibility73+74+| Visibility | Who sees the shortcut |75+|---|---|76+| `public` | anyone |77+| `auth` | anyone signed in |78+| `members` | members and the owner |79+| `owner` | the owner |80+81+Visibility is judged from the key the asker proved with a NIP-98 signature (`visibleTo` in `src/connections.ts`). A template sets the visibility a shortcut starts with; the owner changes it per shortcut. It hides the shortcut and nothing else: who may read the events and files a shortcut leads to is the relay's read rule, so an owner-only photo library on a relay anyone reads is a shortcut only the owner sees to files anyone may fetch.82+83+## The owner's list84+85+The list is kept in settings as the ordered list the fold shows, and is the `connections` section of the configuration document, at the top level beside `kinds` and `retention`. Each entry names a template and may say who sees it, replace its words and fill its inputs. At most 24 entries, in the order shown.86+87+| Field | Value |88+|---|---|89+| `template` | a file in `connection-templates/`, by name: `notes` for `01-notes.jsonc` |90+| `visibility` | `public`, `auth`, `members` or `owner`; the template's own when left out |91+| `title` | the owner's title instead of the template's, up to 40 characters |92+| `about` | the owner's sentence instead of the template's, up to 200 characters |93+| `inputs` | the template's inputs by name, each a string up to 500 characters; a blank one takes the template's default |94+95+```jsonc96+"connections": [97+ { "template": "notes" },98+ { "template": "repos", "inputs": { "repo": "bindws" } },99+ { "template": "photos", "visibility": "owner", "title": "Photo library" }100+]101+```102+103+Until the owner saves a list, the relay shows the defaults (`DEFAULT_CONNECTIONS` in `src/settings.ts`): `notes`, `find-me` and `group`, all public. A saved list replaces them, and a saved empty list shows nothing. `parseConnections` checks entries against the library when they are written, and `resolveConnections` checks again when the fold asks, so a template that left the library is skipped rather than shown broken.104+105+## How the fold shows them106+107+`resolveConnections` in `src/connections.ts` is the owner's list as one viewer sees it. For each entry, in order:108+109+1. The template is in the library, or the entry is skipped.110+2. The template's `feature`, when it names one, is on, or the shortcut is skipped.111+3. The shortcut's visibility admits the viewer.112+4. Each input takes the owner's value, or the template's default.113+5. Each link is filled in. A link with a value the relay cannot fill is left out; when the missing value is a `{user:*}` one, the shortcut says `needsUser`.114+6. A shortcut with no links left and nothing waiting on a sign-in is not shown.115+7. The QR text is filled in; it is `""` when a value is missing or the text is over 512 bytes, the QR encoder's limit.116+8. The title and the sentence are the owner's when the entry set them, else the template's.117+118+`GET /connect.json` is that list for whoever asks (`connectDoor`). The NIP-98 signature is optional: without one the asker is a visitor, with one the door answers for that key. A signature that does not verify answers 401 with the reason rather than a visitor's view, so a client that tried to sign learns what went wrong. The unsigned answer carries `cache-control: public, max-age=60`, the signed one `no-store`, and both allow any origin.119+120+```json121+{122+ "relay": { "url": "wss://name.bind.ws", "host": "name.bind.ws", "web": "https://name.bind.ws", "name": "name" },123+ "viewer": "<the signer's hex pubkey, or null>",124+ "connections": [125+ {126+ "template": "group", "title": "Group", "about": "The relay as a space, with its group as a room.",127+ "app": "Flotilla", "where": "web, phone", "icon": "chat", "visibility": "public",128+ "links": [{ "label": "Open", "href": "https://app.flotilla.social/spaces/name.bind.ws" }, { "label": "Open group in app", "href": "nostr:naddr1..." }, { "label": "Copy naddr", "copy": "naddr1..." }],129+ "qr": "nostr:naddr1...", "needsUser": false130+ }131+ ]132+}133+```134+135+The Connect fold on the relay's page is a client of this door. The relay URL with its copy button comes first, then the shortcuts as tiles: the icon, the title, the app and where it runs, the sentence, the first `href` as the one Open button, the first `copy` text as a compact copy button beside it, a QR button that reveals a QR code of the handoff link for a phone, and the remaining links behind a More disclosure. So a template's first link is the one that leads, and its first copy text is the one command or address the app takes; the fold has one relay URL copy control of its own, which is why no template carries one. The Blossom, names and bridge doors and the Git clone example are folded under Connection details beneath the tiles. A visitor sees the public shortcuts; signing in reveals the rest the viewer may see and fills the links that need the viewer's own key.136+137+The owner's console has a Connect tab, between Identity and Data. **Shortcuts** is the current list as compact rows: the icon and title, the app, a visibility selector, and Edit, move up, move down and remove; Edit opens one row's title override and inputs below it, and Save sits at the top of the list, lit while something is unsaved, and sends the whole list. **Library** is one card per template with Add; a template whose feature is off says so.138+139+The fold as the owner sees it on a relay with the `home` preset applied, at a desktop width and at a phone width:140+141+142+143+144+145+## Management methods146+147+Three NIP-86 methods in `METHODS` (`src/manage.ts`), called like every other ([Scripts and agents](13-scripts-and-agents.md#management-calls-nip-86)):148+149+| Method | Action | Answers |150+|---|---|---|151+| `listconnectiontemplates` | read | the library, each template with its fields and `available`: whether the feature it needs is on, `true` when it needs none |152+| `listconnections` | read | the owner's list as saved, or the defaults |153+| `setconnections list` | rules, the owner's | replaces the whole list in the order given and answers it; 400 with the reason when an entry names no template, a visibility that is not one of the four, an input the template lacks or one that does not match its pattern, or the list runs past 24. The console builds the list from the library, so a bad entry is a mistake to report, not to drop |154+155+`listpresets` entries carry `connections` when the template has them, so the console can say what a preset sets.156+157+## The configuration document158+159+`connections` is a section like `kinds` and `retention` ([Scripts and agents](13-scripts-and-agents.md#the-configuration-file)): `exportconfig` includes it, `importconfig` applies it, and a document that leaves it out leaves the shortcuts alone. `parseConfig` drops an entry no relay would take, one warning each: `connections[2]: no connection template named blog`, `connections[0].inputs.repo: notes has no such input`, `connections[1].inputs.repo: must match ^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$`, `connections[24]: at most 24 connections`. `npm run relay check` prints the section as `connections: notes, photos (owner)`.160+161+A dry run summarizes the section in one line. A shortcut is the whole of its entry, so the same template with another visibility or input is one removed and one added: `connections: +repos, -sites (members)`. A list with the same entries in another order says `connections: reordered`.162+163+## Relay templates164+165+A relay template in `relay-templates/` may carry a `connections` section in the same shape. Applying the preset then sets the shortcuts with the rules; a template without the section leaves the shortcuts as they are, the way Default leaves the feature settings alone. The seed templates:166+167+| Template | Shortcuts |168+|---|---|169+| default | left alone |170+| outbox | notes, find-me, blog |171+| inbox | find-me, notes |172+| chat | group |173+| media | files, photos |174+| search | notes |175+| articles | blog, notes |176+| dm | dm |177+| quiet | none: an empty list |178+| site | sites, notes |179+| marmot, marmot-members | marmot |180+| grasp | repos, notes |181+| home | notes, blog, bookmarks, sites, repos, photos (the owner alone sees the shortcut), find-me and group |182+183+`home` (`15-home.jsonc`) is the one-name relay: members write, anyone reads, sites, files and Git on, with a shortcut for each.184+185+## Build and checks186+187+`npm run build:connections` folds `connection-templates/*.jsonc` into `src/gen/connections.ts`, in file order, with each file's `$schema` dropped; the generated file is committed. `npm run typecheck` runs three checks on the folder:188+189+| Check | Script | Fails when |190+|---|---|---|191+| the generated module | `scripts/build/build-connections.mjs --check` | `src/gen/connections.ts` is stale |192+| the catalog | the same script, in either mode | `connection-templates/README.md` has no row linking the file |193+| every file | `scripts/check/check-connections.mjs` | a file fails the schema, or the relay's own parser: a placeholder outside the vocabulary, an `{input:*}` the file does not declare, a link with both an `href` and a `copy` or neither, a scheme other than `https://` or `nostr:` |194+195+The parser check bundles `src/connections.ts` with esbuild and runs `parseConnectionTemplate` itself, so the file that fails here is the file that would have failed the fold.196+197+## Add a connection template198+199+1. Write `connection-templates/NN-name.jsonc`, the next number and the name the owner's list will use. Put `$schema` first, so the editor checks it, and a comment above the document saying what the template is for and why its links are what they are. A link that only makes sense for a signed-in viewer names `{user:*}`; a shortcut that only makes sense with a feature on names it in `feature`; something the owner has to supply, such as a repository name, is an `input`.200+2. Add a row to the table in `connection-templates/README.md`, linking the file and saying when to choose it.201+3. `npm run build:connections`.202+4. `npm run typecheck`.203+5. Try it: `npm run dev`, then `npm run dev:signer`, open `http://<name>.localhost:8787/`, sign in as the owner, and on the Connect tab add the template from the Library and Save. The Connect fold on the relay's page shows the tile, and `curl http://<name>.localhost:8787/connect.json` shows the links as a visitor gets them.204+6. Commit the file, the README row and `src/gen/connections.ts` together, so the commit typechecks on its own.Binary file.
Binary file.
Binary file.
@@ -8,12 +8,13 @@
88 "deploy": "wrangler deploy",99 "test": "vitest run",1010 "test:conformance": "vitest run --config vitest.conformance.config.ts",11- "typecheck": "tsc --noEmit && node scripts/build/build-signer.mjs --check && node scripts/build/build-console.mjs --check && node scripts/build/build-templates.mjs --check && node scripts/check/check-console.mjs && node scripts/check/check-celld.mjs && node scripts/check/check-config.mjs relay-templates/*.jsonc",11+ "typecheck": "tsc --noEmit && node scripts/build/build-signer.mjs --check && node scripts/build/build-console.mjs --check && node scripts/build/build-templates.mjs --check && node scripts/build/build-connections.mjs --check && node scripts/check/check-console.mjs && node scripts/check/check-celld.mjs && node scripts/check/check-connections.mjs && node scripts/check/check-config.mjs relay-templates/*.jsonc",1212 "relay": "node scripts/ops/relay.mjs",1313 "margin": "node scripts/ops/margin.mjs",1414 "build:signer": "node scripts/build/build-signer.mjs",1515 "build:console": "node scripts/build/build-console.mjs",1616 "build:templates": "node scripts/build/build-templates.mjs",17+ "build:connections": "node scripts/build/build-connections.mjs",1718 "check:celld": "node scripts/check/check-celld.mjs",1819 "dev:signer": "node scripts/dev/dev-signer.mjs",1920 "dev:seed": "node scripts/dev/seed.mjs",@@ -2,7 +2,7 @@
22 "$schema": "http://json-schema.org/draft-07/schema#",33 "$id": "https://bind.ws/relay-config.schema.json",44 "title": "bind.ws relay configuration",5- "description": "A relay's rules, identity, people and lists as one document: what exportconfig writes and importconfig reads. A section left out is left alone. The relay's parser (src/config.ts) is the last word; this schema is for the editor.",5+ "description": "A relay's rules, identity, people, lists and app shortcuts as one document: what exportconfig writes and importconfig reads. A section left out is left alone. The relay's parser (src/config.ts) is the last word; this schema is for the editor.",66 "type": "object",77 "required": ["format"],88 "additionalProperties": false,@@ -149,6 +149,23 @@
149149 "retention": {150150 "type": "array",151151 "items": { "type": "object", "required": ["kind", "days"], "additionalProperties": false, "properties": { "kind": { "oneOf": [{ "$ref": "#/definitions/kind" }, { "type": "null" }] }, "days": { "type": "integer", "minimum": 1, "maximum": 36500 } } }152+ },153+ "connections": {154+ "type": "array",155+ "maxItems": 24,156+ "description": "the Connect fold's app shortcuts, in order: each names a connection template from connection-templates/",157+ "items": {158+ "type": "object",159+ "required": ["template"],160+ "additionalProperties": false,161+ "properties": {162+ "template": { "type": "string", "pattern": "^[a-z0-9-]{1,40}$", "description": "a file in connection-templates/, by name" },163+ "visibility": { "enum": ["public", "auth", "members", "owner"], "description": "who sees it: anyone, anyone signed in, members, the owner; the template's own when left out" },164+ "title": { "type": "string", "maxLength": 40, "description": "the owner's title instead of the template's" },165+ "about": { "type": "string", "maxLength": 200, "description": "the owner's sentence instead of the template's" },166+ "inputs": { "type": "object", "additionalProperties": { "type": "string", "maxLength": 500 }, "description": "the template's inputs, by name" }167+ }168+ }152169 }153170 },154171 "definitions": {@@ -0,0 +1,41 @@
1+// A home on nostr, one name for all of it: notes, articles, bookmarks,2+// static sites, Git repositories and a photo library, each one click away3+// on the Connect fold. Members write, anyone reads, so family or a team4+// can publish here too; sites, files and Git hosting are on because the5+// shortcuts need them. The photo library shortcut is the owner's alone,6+// which hides the shortcut and nothing else: anyone reads here, files7+// included, and a members-only read rule is the way to close the store.8+// Search stays on prose and every kind is kept forever.9+{10+ "$schema": "https://bind.ws/relay-config.schema.json",11+ "format": "bind.ws/relay-config/2",12+ "template": {13+ "title": "Home",14+ "about": "Your relay as your home on nostr. Members write, anyone reads; sites, files and Git hosting on, with notes, blog, bookmarks, sites, repos and a photo library shortcut only you see."15+ },16+ "policy": {17+ "writes": "allowlist",18+ "reads": "open",19+ "directoryPublic": true,20+ "features": {21+ "files": true,22+ "sites": { "enabled": true, "mirror": true },23+ "grasp": true24+ }25+ },26+ "kinds": {27+ "allow": [],28+ "block": []29+ },30+ "retention": [],31+ "connections": [32+ {"template": "notes", "visibility": "public"},33+ {"template": "blog", "visibility": "public"},34+ {"template": "bookmarks", "visibility": "auth"},35+ {"template": "sites", "visibility": "public"},36+ {"template": "repos", "visibility": "public"},37+ {"template": "photos", "visibility": "owner"},38+ {"template": "find-me", "visibility": "public"},39+ {"template": "group", "visibility": "public"}40+ ]41+}@@ -21,6 +21,7 @@ copy as the start of your own.
2121 | [marmot](12-marmot.jsonc) | anyone can publish Marmot KeyPackages and opaque group messages; gift wraps keep their recipient checks. |2222 | [grasp](13-grasp.jsonc) | it hosts GRASP Git repositories: admitted members publish repository state and related events, with reads open. |2323 | [marmot-members](14-marmot-members.jsonc) | members publish Marmot transport, with authenticated group writes and member limits; encrypted messages stay publicly readable. |24+| [home](15-home.jsonc) | one name is your home on nostr: members write, anyone reads, sites, files and Git on, with notes, blog, bookmarks, sites, repos and a private photo library one click away. |2425 2526 Each file says more in its comment: what the template does, which Haven relay2627 it mirrors, and why its kinds are what they are.@@ -30,11 +31,14 @@ it mirrors, and why its kinds are what they are.
3031 A template is a relay configuration (`$schema` names the copy served at3132 `https://bind.ws/relay-config.schema.json`, the same file as `../relay-config.schema.json`) with a3233 `template` block for the title and the blurb, and the rules sections only:33-policy, kinds and retention. People, bans and addresses are never in a34-template, and a section a template leaves out is left alone when it is35-applied. Default resets the write, read, directory, kind and retention rules;36-it leaves feature settings alone. Applying Default after Quiet keeps the37-features switched off. It is a rules reset, not a full relay reset.34+policy, kinds, retention, and connections, the app shortcuts the Connect35+fold shows, each naming a file in `../connection-templates/` with who may36+see it ([Connection templates](../docs/17-connection-templates.md)). People,37+bans and addresses are never in a template, and a section a template leaves38+out is left alone when it is applied. Default resets the write, read,39+directory, kind and retention rules; it leaves feature settings and the40+shortcuts alone. Applying Default after Quiet keeps the features switched41+off. It is a rules reset, not a full relay reset.3842 3943 The number in the file name is the order the console shows them in.4044 @@ -0,0 +1,40 @@
1+// Folds connection-templates/*.jsonc into src/gen/connections.ts, the app2+// shortcut library the relay offers, in file order. `--check` rebuilds in3+// memory and fails when the committed file is stale, so typecheck catches a4+// forgotten rebuild.5+import { readdirSync, readFileSync, writeFileSync } from "node:fs";6+import { stripComments } from "../check/jsonc.mjs";7+8+const DIR = new URL("../../connection-templates/", import.meta.url);9+const OUT = new URL("../../src/gen/connections.ts", import.meta.url);10+const files = readdirSync(DIR).filter((f) => f.endsWith(".jsonc")).sort();11+// The folder's README is the catalog: every template has a row there.12+const readme = readFileSync(new URL("README.md", DIR), "utf8");13+const missing = files.filter((f) => !readme.includes(`](${f})`));14+if (missing.length) {15+ console.error(`connection-templates/README.md has no row for: ${missing.join(", ")}`);16+ process.exit(1);17+}18+const templates = files.map((f) => {19+ const doc = JSON.parse(stripComments(readFileSync(new URL(f, DIR), "utf8")));20+ delete doc.$schema;21+ return { name: f.replace(/^\d+-/, "").replace(/\.jsonc$/, ""), document: doc };22+});23+const file = `// Generated by scripts/build/build-connections.mjs from connection-templates/. Do not edit; run npm run build:connections.24+export const CONNECTION_LIBRARY: { name: string; document: unknown }[] = ${JSON.stringify(templates, null, 2)};25+`;26+if (process.argv.includes("--check")) {27+ let cur = "";28+ try {29+ cur = readFileSync(OUT, "utf8");30+ } catch {31+ /* missing */32+ }33+ if (cur !== file) {34+ console.error("src/gen/connections.ts is stale: run npm run build:connections");35+ process.exit(1);36+ }37+} else {38+ writeFileSync(OUT, file);39+ console.log(`src/gen/connections.ts: ${templates.length} connection templates`);40+}@@ -0,0 +1,39 @@
1+// Checks every connection template in connection-templates/ two ways: against2+// the schema, for the editor's sake, and with the relay's own parser3+// (src/connections.ts), which is the last word on placeholders. A template4+// whose link names a placeholder the relay cannot fill, or an input it does5+// not declare, would show a visitor a broken link, so it fails here instead.6+import { readdirSync, readFileSync } from "node:fs";7+import { buildSync } from "esbuild";8+import Ajv from "ajv";9+import { stripComments } from "./jsonc.mjs";10+11+const ROOT = new URL("../../", import.meta.url);12+const DIR = new URL("connection-templates/", ROOT);13+const schema = JSON.parse(readFileSync(new URL("connection-template.schema.json", ROOT), "utf8"));14+const validate = new Ajv({ allErrors: true }).compile(schema);15+const js = buildSync({ entryPoints: [new URL("src/connections.ts", ROOT).pathname], bundle: true, format: "esm", platform: "neutral", write: false, logLevel: "silent", external: ["cloudflare:*"] }).outputFiles[0].text;16+const { parseConnectionTemplate } = await import("data:text/javascript;base64," + Buffer.from(js).toString("base64"));17+18+const files = readdirSync(DIR).filter((f) => f.endsWith(".jsonc")).sort();19+let failed = false;20+for (const f of files) {21+ const label = `connection-templates/${f}`;22+ const problems = [];23+ let doc;24+ try {25+ doc = JSON.parse(stripComments(readFileSync(new URL(f, DIR), "utf8")));26+ } catch (e) {27+ problems.push(`${label}: ${e.message}`);28+ }29+ if (doc) {30+ if (!validate(doc)) for (const e of validate.errors) problems.push(`${label}: ${e.instancePath || "/"} ${e.message}`);31+ const parsed = parseConnectionTemplate(f.replace(/^\d+-/, "").replace(/\.jsonc$/, ""), doc);32+ if (typeof parsed === "string") problems.push(`${label}: ${parsed}`);33+ }34+ if (problems.length) {35+ failed = true;36+ console.error(problems.join("\n"));37+ } else console.log(`${label}: ok`);38+}39+process.exit(failed ? 1 : 0);@@ -46,5 +46,6 @@ export function describe(cfg) {
4646 if (cfg.sections.includes("banned_events")) lines.push(`banned events: ${cfg.banned_events.length}`);4747 if (cfg.sections.includes("kinds")) lines.push(`kinds: allow ${cfg.kinds.allow.length ? cfg.kinds.allow.join(", ") : "every kind"}; block ${cfg.kinds.block.join(", ") || "none"}`);4848 if (cfg.sections.includes("retention")) lines.push(`retention: ${cfg.retention.map((r) => `${r.kind === null ? "everything" : "kind " + r.kind} ${r.days} days`).join(", ") || "none"}`);49+ if (cfg.sections.includes("connections")) lines.push(`connections: ${cfg.connections.map((c) => c.template + (c.visibility === "public" ? "" : " (" + c.visibility + ")")).join(", ") || "none"}`);4950 return lines;5051 }@@ -1,8 +1,21 @@
1-// Screenshot a page with phone emulation over CDP. Usage: npm run dev:shot <url> <out.png> [width] [height]1+// Screenshot a page with phone emulation over CDP.2+//3+// npm run dev:shot <url> <out.png> [width] [height] [--nostr] [--eval <js>]4+//5+// --nostr signs in as the dev signer's key (scripts/dev/dev-signer.mjs must be6+// running), so the owner's console is in the picture; --eval runs a snippet7+// after the page has loaded, such as opening a fold or a tab, before the shot.28 import { spawn } from "node:child_process";39 import { writeFileSync } from "node:fs";410 import WebSocket from "ws";5-const [url, out, w = "390", h = "1600"] = process.argv.slice(2);11+const args = process.argv.slice(2);12+const flag = (name) => { const i = args.indexOf(name); if (i < 0) return null; const [, v] = args.splice(i, name === "--nostr" ? 1 : 2); return v ?? true; };13+const nostr = flag("--nostr");14+const evalJS = flag("--eval");15+const [url, out, w = "390", h = "1600"] = args;16+// The dev signer's key, never real: new Uint8Array(32).fill(7).17+const DEV_PUBKEY = "989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f";18+const NOSTR = `localStorage.setItem("me", "${DEV_PUBKEY}"); window.nostr = { getPublicKey: async () => (await (await fetch("http://127.0.0.1:9999/pk")).text()), signEvent: async (e) => (await fetch("http://127.0.0.1:9999/sign", { method: "POST", body: JSON.stringify(e) })).json() };`;619 const bin = "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser";720 const port = 9333;821 const br = spawn(bin, ["--headless=new", "--disable-gpu", "--no-first-run", `--remote-debugging-port=${port}`, "--user-data-dir=/tmp/brave-cdp", "about:blank"], { stdio: "ignore" });@@ -17,8 +30,10 @@ ws.on("message", (m) => { const j = JSON.parse(m); if (j.id && pending.has(j.id)
1730 const call = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });1831 await call("Emulation.setDeviceMetricsOverride", { width: +w, height: +h, deviceScaleFactor: 2, mobile: true });1932 await call("Page.enable");33+if (nostr) await call("Page.addScriptToEvaluateOnNewDocument", { source: NOSTR });2034 await call("Page.navigate", { url });2135 await sleep(3500);36+if (evalJS) { await call("Runtime.evaluate", { expression: evalJS, awaitPromise: true }); await sleep(1500); }2237 const { result } = await call("Runtime.evaluate", { expression: "JSON.stringify({sw:document.documentElement.scrollWidth, cw:document.documentElement.clientWidth, sh:document.documentElement.scrollHeight})" });2338 console.log("layout", result.result.value);2439 const shot = await call("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });@@ -3,12 +3,15 @@
33 // an operator's repository holds. parseConfig checks the shape and says44 // what it dropped; planConfig says what applying it would change; applyConfig55 // does. A section that is absent is left alone, so a template carries the6-// rules and nothing about people.7-import { DEFAULT_POLICY, policyPatch, validIP, type Policy, type Settings } from "./settings.ts";6+// rules and nothing about people. The connections section is the Connect7+// fold's app shortcuts, so a template can set the rules and the shortcuts8+// that go with them in one click.9+import { DEFAULT_POLICY, policyPatch, validIP, type Connection, type Policy, type Settings } from "./settings.ts";10+import { parseConnections } from "./connections.ts";811 912 export const FORMAT = "bind.ws/relay-config/2";1013 const FORMATS = new Set(["bind.ws/relay-config/1", FORMAT]);11-export const SECTIONS = ["policy", "members", "bans", "addresses", "banned_events", "kinds", "retention"] as const;14+export const SECTIONS = ["policy", "members", "bans", "addresses", "banned_events", "kinds", "retention", "connections"] as const;1215 export type Section = (typeof SECTIONS)[number];1316 // What an export leaves out: the owner's own, and the platform's.1417 const NOT_IN_A_FILE = new Set(["owner", "lease", "succession", "customHosts"]);@@ -39,6 +42,8 @@ export interface Config {
3942 banned_events: { id: string; reason: string }[];4043 kinds: { allow: number[]; block: number[] };4144 retention: { kind: number | null; days: number }[];45+ // The Connect fold's app shortcuts (connections.ts), in order.46+ connections: Connection[];4247 // Entries the document had that no relay would take, one line each.4348 warnings: string[];4449 }@@ -55,7 +60,7 @@ export function parseConfig(raw: unknown, cur: Policy = DEFAULT_POLICY): Config
5560 if (!c || typeof c !== "object" || Array.isArray(c)) return "invalid: not a bind.ws relay configuration";5661 if (!FORMATS.has(c.format as string)) return "invalid: format must be " + FORMAT;5762 const warnings: string[] = [];58- const out: Config = { name: str(c.name, 64), sections: [], policy: {}, members: [], bans: [], addresses: [], banned_events: [], kinds: { allow: [], block: [] }, retention: [], warnings };63+ const out: Config = { name: str(c.name, 64), sections: [], policy: {}, members: [], bans: [], addresses: [], banned_events: [], kinds: { allow: [], block: [] }, retention: [], connections: [], warnings };5964 if (c.template && typeof c.template === "object") {6065 const t = c.template as Record<string, unknown>;6166 const template: Template = { title: str(t.title, 80), about: str(t.about, 400) };@@ -128,6 +133,13 @@ export function parseConfig(raw: unknown, cur: Policy = DEFAULT_POLICY): Config
128133 if ((kind === null || kindOK(kind)) && Number.isInteger(x.days) && (x.days as number) > 0 && (x.days as number) <= 36500) out.retention.push({ kind: kind as number | null, days: x.days as number });129134 else warnings.push(`retention[${i}]: needs a kind (or null) and days from 1 to 36500`);130135 }136+ if (c.connections !== undefined) {137+ const parsed = parseConnections(c.connections);138+ if (typeof parsed === "string") return parsed;139+ out.connections = parsed.list;140+ warnings.push(...parsed.warnings);141+ out.sections.push("connections");142+ }131143 return out;132144 }133145 @@ -139,6 +151,7 @@ export interface Changes {
139151 banned_events: { add: string[]; remove: string[] };140152 kinds: { allow: { add: number[]; remove: number[] }; block: { add: number[]; remove: number[] } };141153 retention: { add: { kind: number | null; days: number }[]; remove: { kind: number | null; days: number }[] };154+ connections: { add: Connection[]; remove: Connection[]; reordered: boolean };142155 // One line per change, for a person; empty when applying would change nothing.143156 summary: string[];144157 }@@ -161,7 +174,7 @@ const show = (v: unknown) => {
161174 // sections the document has are compared.162175 export function planConfig(s: Settings, cfg: Config): Changes {163176 const has = (k: Section) => cfg.sections.includes(k);164- const ch: Changes = { policy: [], members: { add: [], remove: [], change: [] }, bans: { add: [], remove: [] }, addresses: { add: [], remove: [] }, banned_events: { add: [], remove: [] }, kinds: { allow: { add: [], remove: [] }, block: { add: [], remove: [] } }, retention: { add: [], remove: [] }, summary: [] };177+ const ch: Changes = { policy: [], members: { add: [], remove: [], change: [] }, bans: { add: [], remove: [] }, addresses: { add: [], remove: [] }, banned_events: { add: [], remove: [] }, kinds: { allow: { add: [], remove: [] }, block: { add: [], remove: [] } }, retention: { add: [], remove: [] }, connections: { add: [], remove: [], reordered: false }, summary: [] };165178 if (has("policy")) {166179 for (const [k, to] of Object.entries(cfg.policy)) {167180 const from = (s.policy as unknown as Record<string, unknown>)[k];@@ -206,6 +219,16 @@ export function planConfig(s: Settings, cfg: Config): Changes {
206219 const say = (r: { kind: number | null; days: number }) => `${r.kind === null ? "everything" : "kind " + r.kind} ${r.days} days`;207220 if (d.add.length || d.remove.length) ch.summary.push(`retention: ${[...d.add.map((r) => "+" + say(r)), ...d.remove.map((r) => "-" + say(r))].join(", ")}`);208221 }222+ if (has("connections")) {223+ // A shortcut is the whole of its entry: the same template with another224+ // visibility or input is one removed and one added.225+ const cur = s.connections();226+ const d = diff(cur, cfg.connections, (c) => JSON.stringify(c));227+ ch.connections = { ...d, reordered: d.add.length === 0 && d.remove.length === 0 && JSON.stringify(cur) !== JSON.stringify(cfg.connections) };228+ const say = (c: Connection) => c.template + (c.visibility === "public" ? "" : " (" + c.visibility + ")");229+ if (d.add.length || d.remove.length) ch.summary.push(`connections: ${[...d.add.map((c) => "+" + say(c)), ...d.remove.map((c) => "-" + say(c))].join(", ")}`);230+ else if (ch.connections.reordered) ch.summary.push("connections: reordered");231+ }209232 return ch;210233 }211234 @@ -241,6 +264,7 @@ export function applyConfig(s: Settings, cfg: Config, now: number) {
241264 for (const r of s.listRetention()) s.setRetention(r.kind, 0);242265 for (const r of cfg.retention) s.setRetention(r.kind, r.days);243266 }267+ if (has("connections")) s.setConnections(cfg.connections);244268 }245269 246270 // exportConfig writes the relay as a document, every section, lists in a@@ -258,5 +282,6 @@ export function exportConfig(s: Settings, name: string) {
258282 addresses: byKey(s.listIPBlocks(), (a) => a.ip),259283 kinds: { allow: [...s.listKinds("allow")].sort((a, b) => a - b), block: [...s.listKinds("block")].sort((a, b) => a - b) },260284 retention: s.listRetention(),285+ connections: s.connections(),261286 };262287 }@@ -0,0 +1,328 @@
1+// Connection shortcuts: the owner's list of apps on the Connect fold. Each2+// is a connection template from connection-templates/ (the library, folded3+// into gen/connections.ts) with a visibility and the template's inputs4+// filled in, kept in settings as the ordered list the fold shows.5+//6+// GET /connect.json the shortcuts the asker may see, links resolved7+//8+// A template writes its links once, with placeholders: {relay:url},9+// {owner:nprofile}, {user:npub}, {input:repo} and so on. The relay fills10+// them in here, for whoever is looking, so one good template works on any11+// relay and every client sees the same handoff. The door takes an optional12+// NIP-98 signature: it decides which shortcuts the asker sees (public,13+// signed in, members, owner) and fills the {user:*} placeholders. A link14+// that needs a user is left out for a visitor, and the shortcut says so.15+import { naddrEncode, npubEncode, nprofileEncode } from "nostr-tools/nip19";16+import { CONNECTION_LIBRARY } from "./gen/connections.ts";17+import { KIND_GROUP_METADATA } from "./kinds.ts";18+import { FEATURE_NAMES, VISIBILITIES, featureOn, type Connection, type Feature, type Settings, type Visibility } from "./settings.ts";19+import { whoAsks } from "./auth.ts";20+import { QR_MAX_BYTES } from "./card.ts";21+import type { Relay } from "./relay.ts";22+23+export const FORMAT = "bind.ws/connection-template/1";24+// The console draws these; a template names one (console.js, IC).25+export const ICONS = ["notes", "blog", "bookmark", "photo", "site", "git", "chat", "person", "files", "key", "search", "feed", "lock", "app"] as const;26+export type Icon = (typeof ICONS)[number];27+export const MAX_CONNECTIONS = 24;28+const MAX_LINKS = 6;29+const MAX_INPUTS = 4;30+const MAX_TEXT = 2000;31+32+export interface ConnectionLink {33+ label: string;34+ href?: string; // a link to open: https://, or nostr: for the viewer's own app35+ copy?: string; // text for the clipboard instead36+}37+export interface ConnectionInput {38+ name: string; // fills {input:name}39+ label: string;40+ placeholder: string;41+ default: string; // the value when the owner leaves it blank42+ // A regular expression the owner's value must match, or "" for any text.43+ // A value that lands in a URL path or a shell command names one, so what44+ // the owner types cannot change the link or the command around it.45+ pattern: string;46+}47+export interface ConnectionTemplate {48+ name: string;49+ title: string;50+ about: string;51+ app: string;52+ where: string;53+ icon: Icon;54+ feature?: Feature; // shown only while this feature is on55+ visibility: Visibility; // what a shortcut starts with56+ links: ConnectionLink[];57+ qr: string; // what the QR carries; "" for none58+ inputs: ConnectionInput[];59+}60+61+// What a placeholder may name, by source. {input:*} names one of the62+// template's own inputs instead.63+export const PLACEHOLDER_FIELDS: Record<"relay" | "owner" | "user", string[]> = {64+ relay: ["url", "host", "web", "name", "domain", "hex", "npub", "nprofile", "naddr"],65+ owner: ["hex", "npub", "nprofile"],66+ user: ["hex", "npub", "nprofile"],67+};68+const PLACEHOLDER = /\{(relay|owner|user|input):([a-z][a-z0-9_-]*)(\|enc)?\}/g;69+const INPUT_NAME = /^[a-z][a-z0-9_-]{0,23}$/;70+const str = (v: unknown, max: number) => (typeof v === "string" ? v.slice(0, max) : "");71+72+// placeholders lists what a text names, as source:field pairs.73+export function placeholders(text: string): { source: string; field: string }[] {74+ return [...text.matchAll(PLACEHOLDER)].map((m) => ({ source: m[1], field: m[2] }));75+}76+77+// checkPlaceholders is the reason a text names something the relay cannot78+// fill, or "" when every placeholder is known.79+function checkPlaceholders(text: string, inputs: string[]): string {80+ for (const { source, field } of placeholders(text)) {81+ if (source === "input") {82+ if (!inputs.includes(field)) return `names {input:${field}} but declares no such input`;83+ } else if (!PLACEHOLDER_FIELDS[source as keyof typeof PLACEHOLDER_FIELDS].includes(field)) return `names {${source}:${field}}, which is not one of ${PLACEHOLDER_FIELDS[source as keyof typeof PLACEHOLDER_FIELDS].map((f) => source + ":" + f).join(", ")}`;84+ }85+ return "";86+}87+88+// safeHref says whether a resolved link may be shown: the web, or a nostr:89+// URI for the viewer's own app. An owner's input cannot smuggle another90+// scheme in. Plain http is admitted here and not in a template, since91+// {relay:web} resolves to it on a local dev host.92+export const safeHref = (href: string) => /^(https?:\/\/|nostr:)/i.test(href);93+const templateHref = (href: string) => /^(https:\/\/|nostr:)/i.test(href);94+95+// parseConnectionTemplate reads one library document and returns the96+// template, or the reason it is not one. The build check runs the same97+// function, so a bad file fails typecheck instead of the fold.98+export function parseConnectionTemplate(name: string, raw: unknown): ConnectionTemplate | string {99+ const d = raw as Record<string, unknown>;100+ if (!d || typeof d !== "object" || Array.isArray(d)) return "not a connection template";101+ if (d.format !== FORMAT) return "format must be " + FORMAT;102+ const title = str(d.title, 40).trim();103+ const about = str(d.about, 200).trim();104+ const app = str(d.app, 40).trim();105+ if (!title || !about || !app) return "needs a title, an about and an app";106+ const icon = d.icon === undefined ? "app" : d.icon;107+ if (!(ICONS as readonly unknown[]).includes(icon)) return `icon must be one of ${ICONS.join(", ")}`;108+ if (d.feature !== undefined && !(FEATURE_NAMES as readonly unknown[]).includes(d.feature)) return `feature must be one of ${FEATURE_NAMES.join(", ")}`;109+ const visibility = d.visibility === undefined ? "public" : d.visibility;110+ if (!(VISIBILITIES as readonly unknown[]).includes(visibility)) return `visibility must be one of ${VISIBILITIES.join(", ")}`;111+ const inputs: ConnectionInput[] = [];112+ if (d.inputs !== undefined) {113+ if (!Array.isArray(d.inputs) || d.inputs.length > MAX_INPUTS) return `inputs must be a list of at most ${MAX_INPUTS}`;114+ for (const x of d.inputs as unknown[]) {115+ const i = (x ?? {}) as Record<string, unknown>;116+ const iname = str(i.name, 24);117+ if (!INPUT_NAME.test(iname)) return "an input name is lowercase letters, digits, dash and underscore";118+ if (inputs.some((o) => o.name === iname)) return `input ${iname} is declared twice`;119+ const label = str(i.label, 60).trim();120+ if (!label) return `input ${iname} needs a label`;121+ const pattern = str(i.pattern, 200);122+ if (pattern) {123+ try {124+ new RegExp(pattern);125+ } catch {126+ return `input ${iname} has a pattern that does not compile`;127+ }128+ }129+ inputs.push({ name: iname, label, placeholder: str(i.placeholder, 200), default: str(i.default, 500), pattern });130+ }131+ }132+ const names = inputs.map((i) => i.name);133+ if (!Array.isArray(d.links) || d.links.length === 0 || d.links.length > MAX_LINKS) return `links must be a list of 1 to ${MAX_LINKS}`;134+ const links: ConnectionLink[] = [];135+ for (const x of d.links as unknown[]) {136+ const l = (x ?? {}) as Record<string, unknown>;137+ const label = str(l.label, 40).trim();138+ if (!label) return "a link needs a label";139+ const href = typeof l.href === "string" ? l.href.trim() : "";140+ const copy = typeof l.copy === "string" ? l.copy : "";141+ if ((href === "") === (copy === "")) return `link ${label} needs an href or a copy text, not both`;142+ if (href.length > MAX_TEXT || copy.length > MAX_TEXT) return `link ${label} is longer than ${MAX_TEXT} characters`;143+ if (href && !templateHref(href)) return `link ${label} must be https:// or nostr:`;144+ const bad = checkPlaceholders(href || copy, names);145+ if (bad) return `link ${label} ${bad}`;146+ links.push(href ? { label, href } : { label, copy });147+ }148+ const qr = d.qr === undefined ? (links.find((l) => l.href)?.href ?? links[0].copy ?? "") : str(d.qr, MAX_TEXT).trim();149+ if (qr) {150+ const bad = checkPlaceholders(qr, names);151+ if (bad) return `qr ${bad}`;152+ }153+ const out: ConnectionTemplate = { name, title, about, app, where: str(d.where, 40).trim(), icon: icon as Icon, visibility: visibility as Visibility, links, qr, inputs };154+ if (d.feature !== undefined) out.feature = d.feature as Feature;155+ return out;156+}157+158+export const CONNECTION_TEMPLATES: ConnectionTemplate[] = CONNECTION_LIBRARY.map(({ name, document }) => {159+ const t = parseConnectionTemplate(name, document);160+ if (typeof t === "string") throw new Error(`connection template ${name}: ${t}`);161+ return t;162+});163+164+export function findConnectionTemplate(name: string): ConnectionTemplate | undefined {165+ return CONNECTION_TEMPLATES.find((t) => t.name === name);166+}167+168+// parseConnections reads an owner's list, from setconnections or a169+// configuration document: each entry names a template, and may set a170+// visibility, a title, an about and the template's inputs. What no relay171+// would take is dropped, one warning each. Not a list at all is the reason.172+export function parseConnections(raw: unknown, label = "connections"): { list: Connection[]; warnings: string[] } | string {173+ if (!Array.isArray(raw)) return `invalid: ${label} must be a list`;174+ const warnings: string[] = [];175+ const list: Connection[] = [];176+ for (const [i, x] of raw.entries()) {177+ const c = (x ?? {}) as Record<string, unknown>;178+ const t = typeof c.template === "string" ? findConnectionTemplate(c.template) : undefined;179+ if (!t) {180+ warnings.push(`${label}[${i}]: no connection template named ${typeof c.template === "string" ? c.template : "(none)"}`);181+ continue;182+ }183+ if (list.length === MAX_CONNECTIONS) {184+ warnings.push(`${label}[${i}]: at most ${MAX_CONNECTIONS} connections`);185+ break;186+ }187+ const out: Connection = { template: t.name, visibility: t.visibility };188+ if (c.visibility !== undefined) {189+ if ((VISIBILITIES as readonly unknown[]).includes(c.visibility)) out.visibility = c.visibility as Visibility;190+ else warnings.push(`${label}[${i}].visibility: must be one of ${VISIBILITIES.join(", ")}`);191+ }192+ const title = str(c.title, 40).trim();193+ if (title) out.title = title;194+ const about = str(c.about, 200).trim();195+ if (about) out.about = about;196+ if (c.inputs !== undefined) {197+ if (!c.inputs || typeof c.inputs !== "object" || Array.isArray(c.inputs)) warnings.push(`${label}[${i}].inputs: must be an object of input name to value`);198+ else {199+ const inputs: Record<string, string> = {};200+ for (const [k, v] of Object.entries(c.inputs as Record<string, unknown>)) {201+ const declared = t.inputs.find((d) => d.name === k);202+ if (!declared) warnings.push(`${label}[${i}].inputs.${k}: ${t.name} has no such input`);203+ else if (typeof v !== "string") warnings.push(`${label}[${i}].inputs.${k}: must be a string`);204+ else if (v.trim() === "") continue;205+ else if (declared.pattern && !new RegExp(declared.pattern).test(v.trim().slice(0, 500))) warnings.push(`${label}[${i}].inputs.${k}: must match ${declared.pattern}`);206+ else inputs[k] = v.trim().slice(0, 500);207+ }208+ if (Object.keys(inputs).length) out.inputs = inputs;209+ }210+ }211+ list.push(out);212+ }213+ return { list, warnings };214+}215+216+// The values a placeholder may take, by source; user is null for a visitor.217+export interface Values {218+ relay: Record<string, string>;219+ owner: Record<string, string>;220+ user: Record<string, string> | null;221+}222+223+// connectionValues is what this relay fills in for a given Host header and224+// viewer. A value the relay does not have yet (no identity, no owner) is225+// "", and a link that names it is left out rather than shown broken.226+export function connectionValues(relay: Relay, host: string, user: string | null): Values {227+ const p = relay.settings.policy;228+ const url = relay.relayURL(host);229+ const self = relay.identity.pubkey;230+ const profile = (pk: string) => (pk ? nprofileEncode({ pubkey: pk, relays: [url] }) : "");231+ const npub = (pk: string) => (pk ? npubEncode(pk) : "");232+ return {233+ relay: { url, host, web: relay.webURL(host), name: relay.slug, domain: relay.domain, hex: self, npub: npub(self), nprofile: profile(self), naddr: self ? naddrEncode({ kind: KIND_GROUP_METADATA, pubkey: self, identifier: relay.slug, relays: [url] }) : "" },234+ owner: { hex: p.owner, npub: npub(p.owner), nprofile: profile(p.owner) },235+ user: user ? { hex: user, npub: npub(user), nprofile: profile(user) } : null,236+ };237+}238+239+// fill resolves a text's placeholders and lists the ones it could not, as240+// source:field, so the caller can tell a visitor's missing user from a241+// relay that has no owner yet.242+export function fill(text: string, values: Values, inputs: Record<string, string>): { text: string; missing: string[] } {243+ const missing: string[] = [];244+ const out = text.replace(PLACEHOLDER, (_, source: string, field: string, enc: string | undefined) => {245+ const v = source === "input" ? (inputs[field] ?? "") : source === "user" ? (values.user?.[field] ?? "") : (values[source as "relay" | "owner"][field] ?? "");246+ if (v === "") {247+ missing.push(source + ":" + field);248+ return "";249+ }250+ return enc ? encodeURIComponent(v) : v;251+ });252+ return { text: out, missing };253+}254+255+// visibleTo applies a shortcut's visibility to the pubkeys the asker proved.256+export function visibleTo(v: Visibility, s: Settings, pubkeys: string[]): boolean {257+ switch (v) {258+ case "public":259+ return true;260+ case "auth":261+ return pubkeys.length > 0;262+ case "members":263+ return pubkeys.some((pk) => s.isAllowed(pk));264+ case "owner":265+ return pubkeys.some((pk) => s.isOwner(pk));266+ }267+}268+269+// A shortcut as the fold shows it: the template's words unless the owner270+// changed them, and the links that resolved for this viewer.271+export interface ResolvedConnection {272+ template: string;273+ title: string;274+ about: string;275+ app: string;276+ where: string;277+ icon: Icon;278+ visibility: Visibility;279+ links: ConnectionLink[];280+ qr: string; // "" when nothing fits in a QR281+ needsUser: boolean; // a link was left out because nobody is signed in282+}283+284+// resolveConnections is the owner's list as this viewer sees it: what285+// their visibility admits, what the features on allow, links filled in.286+export function resolveConnections(relay: Relay, host: string, pubkeys: string[]): ResolvedConnection[] {287+ const s = relay.settings;288+ const values = connectionValues(relay, host, pubkeys[0] ?? null);289+ const out: ResolvedConnection[] = [];290+ for (const c of s.connections()) {291+ const t = findConnectionTemplate(c.template);292+ if (!t) continue;293+ if (t.feature && !featureOn(s.policy, t.feature)) continue;294+ if (!visibleTo(c.visibility, s, pubkeys)) continue;295+ const inputs: Record<string, string> = {};296+ for (const i of t.inputs) inputs[i.name] = c.inputs?.[i.name] || i.default;297+ let needsUser = false;298+ const links: ConnectionLink[] = [];299+ for (const l of t.links) {300+ const r = fill(l.href ?? l.copy ?? "", values, inputs);301+ if (r.missing.length) {302+ if (r.missing.some((m) => m.startsWith("user:"))) needsUser = true;303+ continue;304+ }305+ if (l.href) {306+ if (safeHref(r.text)) links.push({ label: l.label, href: r.text });307+ } else links.push({ label: l.label, copy: r.text });308+ }309+ if (links.length === 0 && !needsUser) continue;310+ const qr = t.qr ? fill(t.qr, values, inputs) : { text: "", missing: [] };311+ const qrText = qr.missing.length === 0 && new TextEncoder().encode(qr.text).length <= QR_MAX_BYTES ? qr.text : "";312+ out.push({ template: t.name, title: c.title || t.title, about: c.about || t.about, app: t.app, where: t.where, icon: t.icon, visibility: c.visibility, links, qr: qrText, needsUser });313+ }314+ return out;315+}316+317+// connectDoor answers GET /connect.json for whoever asks.318+export function connectDoor(relay: Relay, req: Request, url: URL): Response {319+ const who = whoAsks(req, "", null);320+ const cors = { "access-control-allow-origin": "*" };321+ if (typeof who === "string") return Response.json({ error: who }, { status: 401, headers: cors });322+ const values = connectionValues(relay, url.host, null);323+ const signed = who.pubkeys.length > 0;324+ return Response.json(325+ { relay: { url: values.relay.url, host: values.relay.host, web: values.relay.web, name: values.relay.name }, viewer: who.pubkeys[0] ?? null, connections: resolveConnections(relay, url.host, who.pubkeys) },326+ { headers: { ...cors, "cache-control": signed ? "no-store" : "public, max-age=60" } },327+ );328+}@@ -12,16 +12,40 @@ main { max-width: 64rem; }
1212 .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); }1313 .care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }1414 #unclaimed { background: var(--butter); }15-.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; } }15+.doors { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }1616 .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; }17-.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }18-.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; } }17+.appgrid { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; } @media (max-width: 40rem) { .appgrid { grid-template-columns: 1fr; } }1918 .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; }2019 .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); }21-.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }22-.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; }20+.app-acts { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; } .app-acts .btn svg { margin-right: .3rem; }21+#apps { margin-top: .8rem; } #apps-note:empty { display: none; }22+.door.relay { margin-bottom: .8rem; } .door.relay .v { font-size: 14px; }23+.app-ic { display: inline-grid; place-items: center; width: 30px; height: 30px; border: 2px solid var(--ink); border-radius: 9px; background: var(--sun); box-shadow: 2px 2px 0 var(--ink); flex: 0 0 auto; user-select: none; -webkit-user-select: none; }24+.app-ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }25+.app-head { align-items: center; } .app-head .pill { margin-left: auto; font-size: 11px; }26+.app-qr { margin-top: .4rem; } .app-qr img { display: block; width: 150px; height: 150px; border: 2px solid var(--ink); border-radius: 10px; background: #fff; }27+.app .who-note { margin: 0; font-size: 12px; color: var(--ink-3); }28+/* The rest of a shortcut's links, behind a disclosure so one Open leads. */29+details.more summary { cursor: pointer; list-style: none; font: 600 12px var(--sans); color: var(--ink-2); } details.more summary::-webkit-details-marker { display: none; }30+details.more summary::before { content: "+ "; font-family: var(--mono); } details.more[open] summary::before { content: "- "; }31+.app-more { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .4rem; } .app-more .btn { padding: .3rem .7rem; font-size: 13px; }32+/* The doors, the bridge and the Git example, folded under the apps. */33+details.sub { margin-top: 1.4rem; border-top: 2px dashed var(--line); padding-top: 1rem; } details.sub > summary { cursor: pointer; list-style: none; font: 600 14px var(--sans); }34+details.sub > summary::-webkit-details-marker { display: none; } details.sub > summary::before { content: "+ "; font-family: var(--mono); } details.sub[open] > summary::before { content: "- "; }35+details.sub > .doors { margin-top: .8rem; }36+/* The owner's list: one row per shortcut, the fields of the one being edited below it. */37+.conn-bar { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: .8rem; padding: .4rem 0; background: var(--paper); } .conn-bar h3 { margin: 0; flex: 1; } .conn-bar .note:empty { display: none; }38+.conns { display: grid; gap: .5rem; }39+.conn { display: grid; grid-template-columns: auto 1fr auto auto; gap: .6rem; align-items: center; padding: .5rem .7rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); }40+.conn-main { min-width: 0; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; } .conn-main b { font-size: 14px; } .conn-main small { color: var(--ink-3); } .conn-main .pill { font-size: 11px; }41+.conn-vis { width: auto; font-size: 13px; padding: .25rem 1.8rem .25rem .5rem; }42+.conn-acts { display: flex; gap: .3rem; } .conn-acts .ib { width: 30px; height: 30px; box-shadow: 1px 1px 0 var(--ink); } .conn-acts .ib svg { width: 13px; height: 13px; } .conn.editing .ib[data-conn="edit"] { background: var(--sun); }43+.conn-edit { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .5rem 1rem; padding: .6rem 0 .2rem; border-top: 2px dashed var(--line); }44+.conn-edit label { display: grid; gap: .2rem; font-size: 12px; font-weight: 600; color: var(--ink-2); } .conn-edit input.txt { font-size: 14px; padding: .3rem .5rem; } .conn-edit input:invalid { box-shadow: 0 0 0 3px var(--red-soft); }45+.conns .empty { color: var(--ink-3); margin: 0; }46+@media (max-width: 40rem) { .conn { grid-template-columns: auto 1fr; } .conn-vis, .conn-acts { grid-column: 2; } .conn-acts { justify-content: flex-end; } }2347 .door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }24-.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; }48+.door .v { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; white-space: nowrap; overflow-x: auto; scrollbar-width: thin; padding-bottom: 2px; }2549 .door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }2650 #peoplesec { background: var(--peach); }2751 .dir { display: flex; flex-wrap: wrap; gap: .5rem; }@@ -43,10 +67,10 @@ main { max-width: 64rem; }
4367 .invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }4468 .invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }4569 .invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }46-details.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }47-details.disclosure summary::-webkit-details-marker { display: none; }70+details.disclosure > summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }71+details.disclosure > summary::-webkit-details-marker { display: none; }4872 .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; }49-details.disclosure summary::before { content: "+"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: "–"; }73+details.disclosure > summary::before { content: "+"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] > summary::before { content: "–"; }5074 .about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }5175 .about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }5276 .console { margin-top: 3rem; }@@ -101,6 +125,8 @@ td input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width:
101125 @media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }102126 103127 .slegend { justify-content: flex-start; margin: 0 0 1.2rem; }128+/* The storage bar follows the list-history block, so it keeps a block's distance. */129+#listhistoryblock + .kbar { margin-top: 1.8rem; }104130 #s-totals { margin-bottom: .4rem; }105131 td.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }106132 #kinds tr.any td:first-child { font-weight: 600; }@@ -50,8 +50,12 @@
5050 </div>5151 <details class="disclosure" id="aboutsec" name="page"><summary>About, for clients</summary><div id="about" class="about"></div></details>5252 <details class="disclosure" id="connectsec" name="page"><summary>Connect</summary>53+ <div class="door relay"><small>nostr relay</small><div class="v"><span id="d-ws"></span><button class="ib" data-copy="ws" 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></div></div>54+ <p class="note">Each shortcut hands this relay to an app, with a QR code for a phone. Whichever app, add the relay URL above under its relay settings to post here.</p>55+ <div class="appgrid" id="apps"></div>56+ <p class="note" id="apps-note"></p>57+ <details class="sub" id="conn-details"><summary>Connection details</summary>5358 <div class="doors">54- <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>5559 <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>5660 <div class="door"><small>names</small><div class="v"><span id="d-nip05"></span></div></div>5761 <div class="door"><small>HTTP bridge, NIP-98</small><div class="v"><span>POST /events, /query, /count</span></div></div>@@ -65,12 +69,7 @@
6569 <p class="note">To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. <a href="https://github.com/FelineStateMachine/bindws/blob/main/docs/22-grasp-01-git-hosting.md">Git hosting guide</a>.</p>6670 </div>6771 </div>68- <div class="block">69- <h3>Open it in an app</h3>70- <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>71- <div class="apps" id="apps"></div>72- <div class="phones" id="phones"></div>73- </div>72+ </details>7473 </details>7574 </section>7675 @@ -112,6 +111,7 @@
112111 <a href="#moderation" data-tab="moderation">Moderation<span class="count" id="tc-reports"></span></a>113112 <a href="#rules" data-tab="rules">Rules</a>114113 <a href="#identity" data-tab="identity">Identity</a>114+ <a href="#connect" data-tab="connect">Connect</a>115115 <a href="#data" data-tab="data">Data</a>116116 <a href="#sync" data-tab="sync">Sync</a>117117 <a href="#views" data-tab="views">Views</a>@@ -325,6 +325,21 @@
325325 </div>326326 </section>327327 328+ <section class="panel" data-panel="connect">329+ <h2>Connect</h2>330+ <p class="lead">The shortcuts on the Connect fold: which apps, in what order, and who sees each.</p>331+ <div class="block">332+ <div class="conn-bar"><h3>Shortcuts</h3><span class="note" id="connnote"></span><button type="button" id="connsave" class="btn pri" disabled>Save shortcuts</button></div>333+ <p class="note">Top to bottom is the order visitors see. Edit sets a title of your own and the template's inputs; a shortcut whose feature is off waits until it is switched on.</p>334+ <div id="conns" class="conns"></div>335+ </div>336+ <div class="block">337+ <h3>Library</h3>338+ <p class="note">One template per app, from the repository's connection-templates folder; the relay URL, the owner's profile and the viewer's own key fill in when the page opens.</p>339+ <div id="connlib" class="appgrid"></div>340+ </div>341+ </section>342+328343 <section class="panel" data-panel="data">329344 <h2>Data</h2>330345 <p class="lead">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>@@ -40,6 +40,22 @@
4040 bolt: '<svg viewBox="0 0 24 24"><path d="M13 3L5 14h6l-1 7 8-11h-6z"/></svg>',4141 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>',4242 pin: '<svg viewBox="0 0 24 24"><path d="M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6zM12 15v5"/></svg>',43+ up: '<svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',44+ down: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12l7 7 7-7"/></svg>',45+ qr: '<svg viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6"/><rect x="14" y="4" width="6" height="6"/><rect x="4" y="14" width="6" height="6"/><path d="M14 14h3v3M20 14v6h-3M14 20h1"/></svg>',46+ // The connection template icons (connections.ts, ICONS): a template names one.47+ notes: '<svg viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h3"/></svg>',48+ blog: '<svg viewBox="0 0 24 24"><path d="M20 4c-6 0-11 4-13 10l-3 6 6-3c6-2 10-7 10-13z"/><path d="M8 16l6-6"/></svg>',49+ bookmark: '<svg viewBox="0 0 24 24"><path d="M7 3h10v18l-5-4-5 4z"/></svg>',50+ photo: '<svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="14" rx="2"/><circle cx="12" cy="13" r="3.5"/><path d="M8 6l1.5-2h5L16 6"/></svg>',51+ site: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>',52+ git: '<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="9" r="2.5"/><path d="M6 8.5v7M18 11.5c0 3-3 4.5-7 4.5H6"/></svg>',53+ chat: '<svg viewBox="0 0 24 24"><path d="M4 5h16v10H10l-4 4v-4H4z"/></svg>',54+ files: '<svg viewBox="0 0 24 24"><path d="M3 6h6l2 2h10v11H3z"/></svg>',55+ key: '<svg viewBox="0 0 24 24"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg>',56+ search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="M16 16l4 4"/></svg>',57+ feed: '<svg viewBox="0 0 24 24"><path d="M4 4a16 16 0 0 1 16 16M4 10a10 10 0 0 1 10 10"/><circle cx="5.5" cy="18.5" r="1.5"/></svg>',58+ app: '<svg viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/></svg>',4359 };4460 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>";4561 @@ -229,7 +245,6 @@
229245 const clone = "git clone '" + location.origin + "/<npub>/<repo>.git'";230246 $("#git-clone").textContent = clone;231247 $("#git-copy").dataset.copytext = clone;232- renderApps();233248 }234249 235250 async function loadFuel() {@@ -278,7 +293,7 @@
278293 // ---- console ----279294 function showTab(name) {280295 if (name === "content" || name === "storage") name = "data";281- const known = myRole === "moderator" ? ["people", "moderation"] : ["people", "moderation", "rules", "identity", "data", "sync", "views", "health", "owner"];296+ const known = myRole === "moderator" ? ["people", "moderation"] : ["people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner"];282297 if (!known.includes(name)) name = "people";283298 $$(".tabs a").forEach((a) => a.classList.toggle("on", a.dataset.tab === name));284299 $$(".panel").forEach((p) => p.classList.toggle("on", p.dataset.panel === name));@@ -324,6 +339,7 @@
324339 if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc("stats"), rpc("getpolicy")]); myRole = "moderator"; } catch { myRole = ""; } }325340 $("#console").classList.toggle("hidden", !myRole);326341 loadMine();342+ loadConnect();327343 $("#console").classList.toggle("mod", myRole === "moderator");328344 if (!myRole) { policy = null; renderCare(); return; }329345 if (isOwner) [stats, p] = await Promise.all([rpc("stats"), rpc("getpolicy")]);@@ -354,7 +370,7 @@
354370 const fn = $("#notify"), nt = p.notify || {};355371 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;356372 showTab(location.hash.slice(1));357- if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }373+ if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); loadConnectTab(); }358374 await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);359375 }360376 @@ -670,14 +686,92 @@
670686 $("#presets").addEventListener("click", async (ev) => {671687 const b = ev.target.closest("button[data-preset]"); if (!b) return;672688 const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;673- 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;689+ 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" + (p.connections ? ", and the Connect fold's shortcuts" : "") + ". Limits, identity, people and bans stay.")) return;674690 const source = $("#presetsource").value.trim();675691 if (p.source === "required" && !source) { $("#presetnote").textContent = p.title + " needs a source relay to mirror; enter its wss:// URL first."; return; }676692 b.disabled = true;677- 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(); }693+ 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." : "") + (p.connections ? " The Connect fold shows " + p.connections.map((c) => c.template).join(", ") + "." : ""); toast(p.title + " applied"); await loadInfo(); await loadAdmin(); }678694 catch (e) { toast(e.message); } finally { b.disabled = false; }679695 });680696 697+ // ---- connect: the fold's shortcuts, picked from the library ----698+ // The list is edited in place and saved whole, in the order shown; the699+ // relay checks every entry against the library when it takes the list.700+ // One row at a time opens for its title and inputs, and Save stays at the701+ // top, lit while something is unsaved.702+ const VIS = [["public", "anyone"], ["auth", "anyone signed in"], ["members", "members"], ["owner", "only me"]];703+ let connLib = null, conns = null, connEditing = -1, connDirty = false;704+ async function loadConnectTab() {705+ try { [connLib, conns] = await Promise.all([rpc("listconnectiontemplates"), rpc("listconnections")]); } catch (e) { $("#connnote").textContent = e.message; return; }706+ connEditing = -1; setConnDirty(false);707+ renderConns(); renderConnLib();708+ }709+ function setConnDirty(v) { connDirty = v; $("#connsave").disabled = !v; $("#connnote").textContent = v ? "Unsaved changes" : ""; }710+ const connTemplate = (name) => (connLib || []).find((t) => t.name === name) || { name, title: name, app: "", where: "", icon: "app", inputs: [], available: true, feature: "" };711+ function renderConns() {712+ const rows = conns.map((c, i) => {713+ const t = connTemplate(c.template), editing = i === connEditing;714+ const vis = VIS.map(([v, l]) => '<option value="' + v + '"' + (c.visibility === v ? " selected" : "") + ">" + l + "</option>").join("");715+ const inputs = (t.inputs || []).map((inp) => "<label><span>" + esc(inp.label) + '</span><input class="txt" data-input="' + esc(inp.name) + '" value="' + esc((c.inputs || {})[inp.name] || "") + '" placeholder="' + esc(inp.placeholder || inp.default || "") + '"' + (inp.pattern ? ' pattern="' + esc(inp.pattern) + '"' : "") + ' maxlength="500"></label>').join("");716+ const edit = editing ? '<div class="conn-edit"><label><span>Title</span><input class="txt" data-field="title" value="' + esc(c.title || "") + '" placeholder="' + esc(t.title) + '" maxlength="40"></label>' + inputs + "</div>" : "";717+ return '<div class="conn' + (editing ? " editing" : "") + '" data-i="' + i + '"><i class="app-ic">' + (IC[t.icon] || IC.app) + '</i><div class="conn-main"><b>' + esc(c.title || t.title) + "</b><small>" + esc(t.app + (t.where ? ", " + t.where : "")) + "</small>" + (t.available ? "" : '<span class="pill bad">needs ' + esc(t.feature) + ", off</span>") + '</div><select class="txt conn-vis" data-field="visibility" title="Who sees it">' + vis + '</select><div class="conn-acts"><button class="ib" data-conn="edit" title="' + (editing ? "Done" : "Edit title and inputs") + '" aria-label="Edit">' + IC.pen + '</button><button class="ib" data-conn="up" title="Move up" aria-label="Move up">' + IC.up + '</button><button class="ib" data-conn="down" title="Move down" aria-label="Move down">' + IC.down + '</button><button class="ib danger" data-conn="rm" title="Remove" aria-label="Remove">' + IC.x + "</button></div>" + edit + "</div>";718+ });719+ $("#conns").innerHTML = rows.length ? rows.join("") : '<p class="empty">No shortcuts. Add some from the library below.</p>';720+ }721+ function renderConnLib() {722+ $("#connlib").innerHTML = connLib.map((t) => '<div class="app"><div class="app-head"><i class="app-ic">' + (IC[t.icon] || IC.app) + "</i><b>" + esc(t.title) + "</b><small>" + esc(t.app + (t.where ? ", " + t.where : "")) + "</small>" + (t.visibility !== "public" ? '<span class="pill">' + VISIBILITY[t.visibility] + "</span>" : "") + "</div><p>" + esc(t.about) + '</p><div class="app-acts"><button class="btn pri" data-connadd="' + esc(t.name) + '">Add</button>' + (t.feature ? '<span class="pill' + (t.available ? " on" : " bad") + '">' + esc(t.feature) + (t.available ? " on" : " off") + "</span>" : "") + "</div></div>").join("");723+ }724+ // readConns takes the rows as edited back into the list before it is redrawn or saved.725+ function readConns() {726+ $$("#conns .conn").forEach((row) => {727+ const c = conns[+row.dataset.i]; if (!c) return;728+ c.visibility = row.querySelector('[data-field="visibility"]').value;729+ const title = row.querySelector('[data-field="title"]');730+ if (!title) return;731+ if (title.value.trim()) c.title = title.value.trim(); else delete c.title;732+ const inputs = {};733+ row.querySelectorAll("[data-input]").forEach((inp) => { if (inp.value.trim()) inputs[inp.dataset.input] = inp.value.trim(); });734+ if (Object.keys(inputs).length) c.inputs = inputs; else delete c.inputs;735+ });736+ }737+ $("#conns").addEventListener("input", () => setConnDirty(true));738+ $("#conns").addEventListener("click", (ev) => {739+ const b = ev.target.closest("button[data-conn]"); if (!b) return;740+ readConns();741+ const i = +b.closest(".conn").dataset.i, act = b.dataset.conn;742+ if (act === "edit") connEditing = connEditing === i ? -1 : i;743+ else if (act === "rm") { conns.splice(i, 1); connEditing = -1; setConnDirty(true); }744+ else {745+ const j = act === "up" ? i - 1 : i + 1; if (j < 0 || j >= conns.length) return;746+ [conns[i], conns[j]] = [conns[j], conns[i]];747+ if (connEditing === i) connEditing = j; else if (connEditing === j) connEditing = i;748+ setConnDirty(true);749+ }750+ renderConns();751+ if (act === "edit" && connEditing === i) { const f = $("#conns .conn.editing input"); if (f) f.focus(); }752+ });753+ $("#connlib").addEventListener("click", (ev) => {754+ const b = ev.target.closest("button[data-connadd]"); if (!b) return;755+ const t = connTemplate(b.dataset.connadd);756+ readConns();757+ conns.push({ template: t.name, visibility: t.visibility || "public" });758+ connEditing = t.inputs && t.inputs.length ? conns.length - 1 : connEditing;759+ setConnDirty(true);760+ renderConns();761+ $("#conns").lastElementChild.scrollIntoView({ behavior: "smooth", block: "nearest" });762+ });763+ // Not through guard: the button's disabled state is the dirty flag's, so764+ // guard's re-enable after the call would light it up again.765+ $("#connsave").onclick = async () => {766+ const b = $("#connsave"); b.disabled = true;767+ try {768+ readConns();769+ conns = await rpc("setconnections", conns);770+ toast(conns.length ? "Saved " + conns.length + (conns.length === 1 ? " shortcut" : " shortcuts") : "Saved: no shortcuts");771+ setConnDirty(false); renderConns(); loadConnect();772+ } catch (e) { toast(e.message); b.disabled = false; }773+ };774+681775 // ---- wire me in: this relay in the owner's own lists ----682776 // The lists are replaceable, so a fresh list with only this relay would683777 // clobber the real one once it spread. Every publish starts from the@@ -1087,49 +1181,53 @@
10871181 const q = $("#naddrqr");10881182 if (card.naddr) { q.src = "/qr.svg?text=" + encodeURIComponent(card.naddr); q.classList.remove("hidden"); } else q.classList.add("hidden");10891183 } catch { /* the card is decoration */ }1090- renderApps();10911184 }1092- // The app rows on the Connect section: one per client people actually use,1093- // with the link that lands on this relay in it. Relay apps take the relay1094- // or the group address; feed apps take the owner's profile with this relay1095- // as the hint, since they have no notion of opening a relay.1096- function renderApps() {1097- const el = $("#apps"); if (!el) return;1185+ // The Connect fold's shortcuts: the owner's list from /connect.json,1186+ // resolved by the relay for whoever is looking. A signed-in viewer asks1187+ // with a NIP-98 signature, so the relay can show what their visibility1188+ // admits and fill the links made for them; anyone else sees the public1189+ // ones. A shortcut's QR is drawn only when asked for.1190+ const VISIBILITY = { public: "", auth: "signed in", members: "members", owner: "only you" };1191+ async function signedGet(path) {1192+ const url = location.origin + path;1193+ const headers = {};1194+ if (me && signer.ready()) {1195+ try {1196+ const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", url], ["method", "GET"]] });1197+ headers.authorization = "Nostr " + btoa(JSON.stringify(ev));1198+ } catch { /* unsigned, then: the public shortcuts */ }1199+ }1200+ return fetch(url, { headers, cache: "no-store" });1201+ }1202+ let connectSeq = 0;1203+ async function loadConnect() {1204+ const seq = ++connectSeq;1205+ let data;1206+ try { const r = await signedGet("/connect.json"); data = await r.json(); if (data.error) throw new Error(data.error); } catch { data = { connections: [] }; }1207+ if (seq !== connectSeq) return; // a later sign-in or sign-out answered first1208+ renderConnect(data.connections || []);1209+ }1210+ function renderConnect(list) {10981211 const enc = encodeURIComponent;1099- const nprofile = card && card.nprofile ? card.nprofile : "";1100- const naddr = card && card.naddr ? card.naddr : "";1101- const whose = me && owner && me === owner ? "your" : "the owner's";1102- const link = (label, href) => '<a class="btn" href="' + esc(href) + '" target="_blank" rel="noopener">' + label + "</a>";1103- const app = (label, uri) => '<a class="btn" href="' + esc(uri) + '">' + label + "</a>";1104- const copy = (label, text) => '<button class="btn" data-copytext="' + esc(text) + '">' + label + "</button>";1105- 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>";1106- const profileNote = "Opens " + whose + " profile with this relay attached.";1107- const groups = [1108- ["As a place", "These open the relay itself: its feed, its people, its group.", [1109- row("Jumble", "web", "A feed of everything on this relay.", [link("Open", "https://jumble.social/?r=" + enc(wsURL))]),1110- row("Coracle", "web", "The relay's page: its feed and its people.", [link("Open", "https://coracle.social/relays/" + enc(host))]),1111- 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)]),1112- row("0xchat", "phone", "The group, in a chat app.", [naddr && app("Open group", "nostr:" + naddr), naddr && copy("Copy naddr", naddr)]),1113- row("noStrudel", "web", "Relays, add this one, then open its page.", [copy("Copy relay URL", wsURL)]),1114- ]],1115- ["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.", [1116- row("Primal", "web, phone", profileNote, [nprofile && link("Open", "https://primal.net/p/" + nprofile), nprofile && app("Open in app", "nostr:" + nprofile)]),1117- row("YakiHonne", "web, phone", profileNote, [nprofile && link("Open", "https://yakihonne.com/profile/" + nprofile), nprofile && app("Open in app", "nostr:" + nprofile)]),1118- row("Damus", "iPhone", profileNote + " Then Settings, Relays.", [nprofile && app("Open in app", "nostr:" + nprofile), copy("Copy relay URL", wsURL)]),1119- row("Amethyst", "Android", profileNote + " Then Relays in the drawer.", [nprofile && app("Open in app", "nostr:" + nprofile), copy("Copy relay URL", wsURL)]),1120- row("Nostur", "iPhone, Mac", profileNote + " Then Settings, Relays.", [nprofile && app("Open in app", "nostr:" + nprofile), copy("Copy relay URL", wsURL)]),1121- ]],1122- ];1123- if (info?.supported_grasps?.includes("GRASP-01")) groups.push([1124- "Git repositories", "Use this relay with a Git client.", [1125- row("GitWorkshop", "web", "Browse this relay's Git repositories.", [link("Open in app", "https://gitworkshop.dev/relay/" + enc((wsURL.startsWith("ws://") ? "ws:" : "") + host)), copy("Copy relay URL", wsURL)]),1126- ],1127- ]);1128- 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("");1129- $("#apps-ws").textContent = wsURL;1130- 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>';1131- $("#phones").innerHTML = [nprofile && tile("Find me here, for a phone", "nostr:" + nprofile), naddr && tile("The group, for a phone", "nostr:" + naddr)].filter(Boolean).join("");1212+ const open = (l, pri) => '<a class="btn' + (pri ? " pri" : "") + '" href="' + esc(l.href) + '"' + (/^nostr:/i.test(l.href) ? "" : ' target="_blank" rel="noopener"') + ">" + esc(l.label) + "</a>";1213+ const copy = (l) => '<button class="btn" data-copytext="' + esc(l.copy) + '">' + IC.copy + esc(l.label) + "</button>";1214+ // One Open leads; the first copy text rides beside it; every other link waits behind More.1215+ const tile = (c) => {1216+ const hrefs = c.links.filter((l) => l.href), copies = c.links.filter((l) => l.copy);1217+ const acts = hrefs.length ? [open(hrefs[0], true), copies.length ? copy(copies[0]) : ""] : [copies.length ? copy(copies[0]) : ""];1218+ const rest = [...hrefs.slice(1).map((l) => open(l, false)), ...copies.slice(1).map(copy)];1219+ if (c.qr) acts.push('<button class="ib" data-qr="' + enc(c.qr) + '" title="QR code for a phone" aria-label="QR code for a phone">' + IC.qr + "</button>");1220+ return '<div class="app"><div class="app-head"><i class="app-ic">' + (IC[c.icon] || IC.app) + "</i><b>" + esc(c.title) + "</b><small>" + esc(c.app + (c.where ? ", " + c.where : "")) + "</small>" + (VISIBILITY[c.visibility] ? '<span class="pill">' + VISIBILITY[c.visibility] + "</span>" : "") + "</div><p>" + esc(c.about) + '</p><div class="app-acts">' + acts.join("") + "</div>" + (rest.length ? '<details class="more"><summary>More</summary><div class="app-more">' + rest.join("") + "</div></details>" : "") + (c.needsUser ? '<p class="who-note">Sign in and the links made for you appear.</p>' : "") + '<div class="app-qr hidden"></div></div>';1221+ };1222+ $("#apps").innerHTML = list.map(tile).join("");1223+ $("#apps-note").textContent = list.length ? "" : me && owner && me === owner ? "No shortcuts yet. Pick some on the Connect tab." : "The owner has not picked any app shortcuts yet.";11321224 }1225+ $("#apps").addEventListener("click", (ev) => {1226+ const b = ev.target.closest("button[data-qr]"); if (!b) return;1227+ const box = b.closest(".app").querySelector(".app-qr");1228+ if (!box.firstChild) box.innerHTML = '<img src="/qr.svg?text=' + b.dataset.qr + '" alt="QR code" width="150" height="150">';1229+ box.classList.toggle("hidden");1230+ });11331231 // The folds are a group: opening one closes the rest. Browsers with the11341232 // details name attribute do this themselves; this covers the others.11351233 $$(".folds details").forEach((d) => d.addEventListener("toggle", () => { if (d.open) $$(".folds details").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));@@ -1158,7 +1256,6 @@
11581256 else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }11591257 else if (me) me = null;11601258 renderHeader();1161- renderApps();11621259 await loadAdmin();11631260 await loadPeople();11641261 })();@@ -0,0 +1,285 @@
1+// Generated by scripts/build/build-connections.mjs from connection-templates/. Do not edit; run npm run build:connections.2+export const CONNECTION_LIBRARY: { name: string; document: unknown }[] = [3+ {4+ "name": "notes",5+ "document": {6+ "format": "bind.ws/connection-template/1",7+ "title": "Notes",8+ "about": "Everything posted on this relay as one feed.",9+ "app": "Jumble",10+ "where": "web",11+ "icon": "notes",12+ "visibility": "public",13+ "links": [14+ {15+ "label": "Open",16+ "href": "https://jumble.social/?r={relay:url|enc}"17+ }18+ ]19+ }20+ },21+ {22+ "name": "find-me",23+ "document": {24+ "format": "bind.ws/connection-template/1",25+ "title": "Find me here",26+ "about": "The owner's profile with this relay attached, for feed apps.",27+ "app": "Primal",28+ "where": "web, phone",29+ "icon": "person",30+ "visibility": "public",31+ "links": [32+ {33+ "label": "Open",34+ "href": "https://primal.net/p/{owner:nprofile}"35+ },36+ {37+ "label": "Open in app",38+ "href": "nostr:{owner:nprofile}"39+ }40+ ],41+ "qr": "nostr:{owner:nprofile}"42+ }43+ },44+ {45+ "name": "group",46+ "document": {47+ "format": "bind.ws/connection-template/1",48+ "title": "Group",49+ "about": "The relay as a space, with its group as a room.",50+ "app": "Flotilla",51+ "where": "web, phone",52+ "icon": "chat",53+ "visibility": "public",54+ "links": [55+ {56+ "label": "Open",57+ "href": "https://app.flotilla.social/spaces/{relay:host|enc}"58+ },59+ {60+ "label": "Open group in app",61+ "href": "nostr:{relay:naddr}"62+ },63+ {64+ "label": "Copy naddr",65+ "copy": "{relay:naddr}"66+ }67+ ],68+ "qr": "nostr:{relay:naddr}"69+ }70+ },71+ {72+ "name": "blog",73+ "document": {74+ "format": "bind.ws/connection-template/1",75+ "title": "Blog",76+ "about": "The owner's articles, with this relay attached so a reader finds the rest of them.",77+ "app": "YakiHonne",78+ "where": "web, phone",79+ "icon": "blog",80+ "visibility": "public",81+ "links": [82+ {83+ "label": "Open",84+ "href": "https://yakihonne.com/profile/{owner:nprofile}"85+ },86+ {87+ "label": "Open in app",88+ "href": "nostr:{owner:nprofile}"89+ }90+ ],91+ "qr": "nostr:{owner:nprofile}"92+ }93+ },94+ {95+ "name": "repos",96+ "document": {97+ "format": "bind.ws/connection-template/1",98+ "title": "Repos",99+ "about": "This relay's Git repositories, and the clone command for yours.",100+ "app": "GitWorkshop",101+ "where": "web, terminal",102+ "icon": "git",103+ "feature": "grasp",104+ "visibility": "public",105+ "inputs": [106+ {107+ "name": "repo",108+ "label": "Repository name",109+ "placeholder": "my-project",110+ "default": "<repo>",111+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._\\-]{0,63}$"112+ }113+ ],114+ "links": [115+ {116+ "label": "Open",117+ "href": "https://gitworkshop.dev/relay/{relay:host|enc}"118+ },119+ {120+ "label": "Copy clone command",121+ "copy": "git clone '{relay:web}/{user:npub}/{input:repo}.git'"122+ }123+ ]124+ }125+ },126+ {127+ "name": "sites",128+ "document": {129+ "format": "bind.ws/connection-template/1",130+ "title": "Sites",131+ "about": "Static sites this relay hosts, and the command that publishes one here.",132+ "app": "nsyte",133+ "where": "web, terminal",134+ "icon": "site",135+ "feature": "sites",136+ "visibility": "public",137+ "links": [138+ {139+ "label": "Open the owner's site",140+ "href": "https://{owner:npub}.{relay:domain}"141+ },142+ {143+ "label": "Open your site",144+ "href": "https://{user:npub}.{relay:domain}"145+ },146+ {147+ "label": "Copy publish command",148+ "copy": "nsyte deploy ./dist --relays {relay:url} --servers {relay:web}"149+ }150+ ]151+ }152+ },153+ {154+ "name": "bookmarks",155+ "document": {156+ "format": "bind.ws/connection-template/1",157+ "title": "Bookmarks",158+ "about": "Your bookmarks and lists in a list manager; add this relay in its settings so they land here.",159+ "app": "Listr",160+ "where": "web",161+ "icon": "bookmark",162+ "visibility": "auth",163+ "links": [164+ {165+ "label": "Open",166+ "href": "https://listr.lol/{user:npub}"167+ },168+ {169+ "label": "Open in noStrudel",170+ "href": "https://nostrudel.ninja/bookmarks/{user:nprofile}"171+ }172+ ]173+ }174+ },175+ {176+ "name": "photos",177+ "document": {178+ "format": "bind.ws/connection-template/1",179+ "title": "Photo library",180+ "about": "The owner's photos on this relay's file store, in a media manager that takes the store as a server; the read rule says who may see the files.",181+ "app": "bouquet",182+ "where": "web",183+ "icon": "photo",184+ "feature": "files",185+ "visibility": "owner",186+ "links": [187+ {188+ "label": "Open",189+ "href": "https://bouquet.slidestr.net/"190+ },191+ {192+ "label": "Copy Blossom server URL",193+ "copy": "{relay:web}"194+ }195+ ]196+ }197+ },198+ {199+ "name": "files",200+ "document": {201+ "format": "bind.ws/connection-template/1",202+ "title": "Files",203+ "about": "This relay's file store, for members, in a media manager that takes it as a server.",204+ "app": "bouquet",205+ "where": "web",206+ "icon": "files",207+ "feature": "files",208+ "visibility": "members",209+ "links": [210+ {211+ "label": "Open",212+ "href": "https://bouquet.slidestr.net/"213+ },214+ {215+ "label": "Copy Blossom server URL",216+ "copy": "{relay:web}"217+ },218+ {219+ "label": "Copy NIP-96 URL",220+ "copy": "{relay:web}/.well-known/nostr/nip96.json"221+ }222+ ]223+ }224+ },225+ {226+ "name": "dm",227+ "document": {228+ "format": "bind.ws/connection-template/1",229+ "title": "Messages",230+ "about": "Message the owner privately; put this relay in your DM inbox list (kind 10050) so replies land here.",231+ "app": "0xchat",232+ "where": "phone, desktop",233+ "icon": "chat",234+ "visibility": "public",235+ "links": [236+ {237+ "label": "Open in app",238+ "href": "nostr:{owner:nprofile}"239+ }240+ ],241+ "qr": "nostr:{owner:nprofile}"242+ }243+ },244+ {245+ "name": "marmot",246+ "document": {247+ "format": "bind.ws/connection-template/1",248+ "title": "Encrypted groups",249+ "about": "End-to-end encrypted group chat over this relay, in White Noise.",250+ "app": "White Noise",251+ "where": "phone",252+ "icon": "lock",253+ "feature": "marmot",254+ "visibility": "public",255+ "links": [256+ {257+ "label": "Open",258+ "href": "https://whitenoise.chat/"259+ },260+ {261+ "label": "Get the app",262+ "href": "https://whitenoise.chat/download"263+ }264+ ]265+ }266+ },267+ {268+ "name": "relay-page",269+ "document": {270+ "format": "bind.ws/connection-template/1",271+ "title": "Relay page",272+ "about": "This relay's page in Coracle: its feed and its people.",273+ "app": "Coracle",274+ "where": "web",275+ "icon": "feed",276+ "visibility": "public",277+ "links": [278+ {279+ "label": "Open",280+ "href": "https://coracle.social/relays/{relay:host|enc}"281+ }282+ ]283+ }284+ }285+];@@ -1,4 +1,4 @@
11 // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console.2-export const CONSOLE_HTML = "<main>\n <header class=\"mast\">\n <div class=\"banner hidden\" id=\"banner\"><img id=\"bannerimg\" alt=\"\"></div>\n <div class=\"wordmark\"><img id=\"iconimg\" class=\"icon hidden\" alt=\"\"><h1 id=\"title\"></h1></div>\n <div class=\"left\"><div class=\"care\" id=\"care\"></div></div>\n <div class=\"right\">\n <span class=\"urlrow\"><code class=\"pill\" id=\"url\"></code><button class=\"ib\" id=\"copy\" title=\"Copy relay URL\" aria-label=\"Copy relay URL\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></span>\n <span class=\"owner\"><i class=\"av hidden\" id=\"owner-av\"></i><span id=\"ownerline\"></span><em id=\"who\"></em>\n <button id=\"signin\" class=\"btn hidden\">Sign in with extension</button>\n <button id=\"signin46\" class=\"btn hidden\">Remote signer</button>\n <button id=\"signout\" class=\"ib hidden\" title=\"Sign out\" aria-label=\"Sign out\"><svg viewBox=\"0 0 24 24\"><path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4M15 8l5 4-5 4M20 12H9\"/></svg></button>\n </span>\n </div>\n </header>\n\n <section id=\"unclaimed\" class=\"hidden\">\n <h2>Nobody owns this relay yet.</h2>\n <p class=\"lead\">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>\n <div class=\"row\"><button id=\"claim\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"claimnote\">Use a remote signer</button><span class=\"note\" id=\"claimnote\"></span></div>\n <div class=\"block\">\n <h3>Restore a backup</h3>\n <p class=\"note\">Choose a private bind.ws archive to inspect it before restoring. Preview does not claim this relay. Restore accepts only an empty relay and the archive owner's signature.</p>\n <div class=\"form\">\n <label class=\"wide\"><span>Archive <small>up to 8 MB</small></span><input class=\"txt\" type=\"file\" id=\"backupfile\" accept=\"application/json\"></label>\n </div>\n <div class=\"actions\"><button type=\"button\" id=\"backupremote\" class=\"btn\">Use a remote signer</button><button type=\"button\" id=\"backuppreview\" class=\"btn\">Preview restore</button><button type=\"button\" id=\"backuprestore\" class=\"btn pri\" disabled>Restore</button></div>\n <div id=\"backup-preview\" class=\"note\"></div>\n </div>\n </section>\n\n <section id=\"leased\" class=\"hidden\">\n <h2>A temporary relay, for now.</h2>\n <p class=\"lead\">Anyone can read and write here until <b id=\"lease-until\"></b>. Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.</p>\n <div class=\"row\"><button id=\"claimlease\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"leasenote\">Use a remote signer</button><span class=\"note\" id=\"leasenote\"></span></div>\n </section>\n\n <section id=\"remotesec\" class=\"hidden\">\n <h2>Connect a remote signer.</h2>\n <p class=\"lead\">Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the <span class=\"mono\">bunker://</span> URL the app gives you.</p>\n <div class=\"row\"><a id=\"nclink\" class=\"btn pri\" href=\"#\">Open in your signer app</a><button id=\"nccopy\" class=\"btn\">Copy link</button><span class=\"note\" id=\"ncnote\"></span></div>\n <form id=\"bunkerform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"bunker://…\" required><button class=\"btn\">Connect</button></form>\n <img id=\"ncqr\" class=\"hidden\" alt=\"QR code of the signer link\" width=\"180\" height=\"180\">\n <p class=\"note\" id=\"remotenote\"></p>\n </section>\n\n <section id=\"foldsec\" class=\"folds\">\n <div id=\"frontsec\" class=\"hidden\">\n <p class=\"lead hidden\" id=\"desc\"></p>\n <p class=\"metarow hidden\" id=\"metarow\"></p>\n </div>\n <details class=\"disclosure\" id=\"aboutsec\" name=\"page\"><summary>About, for clients</summary><div id=\"about\" class=\"about\"></div></details>\n <details class=\"disclosure\" id=\"connectsec\" name=\"page\"><summary>Connect</summary>\n <div class=\"doors\">\n <div class=\"door\"><small>nostr relay</small><div class=\"v\"><span id=\"d-ws\"></span><button class=\"ib\" data-copy=\"ws\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>Blossom media</small><div class=\"v\"><span id=\"d-blossom\"></span><button class=\"ib\" data-copy=\"blossom\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>names</small><div class=\"v\"><span id=\"d-nip05\"></span></div></div>\n <div class=\"door\"><small>HTTP bridge, NIP-98</small><div class=\"v\"><span>POST /events, /query, /count</span></div></div>\n </div>\n <div class=\"block hidden\" id=\"git-connect\">\n <h3>Git repositories, ntig</h3>\n <div>\n <p class=\"note\">Use ordinary Git to clone a repository hosted here.</p>\n <div class=\"door\"><small>Clone a repository</small><div class=\"v\"><span id=\"git-clone\"></span><button class=\"ib\" id=\"git-copy\" title=\"Copy clone example\" aria-label=\"Copy clone example\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <p class=\"note\">Replace <span class=\"mono\"><npub></span> with the repository owner's npub, yours for your own repository, and <span class=\"mono\"><repo></span> with its repository name, such as <span class=\"mono\">my-project</span>.</p>\n <p class=\"note\">To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. <a href=\"https://github.com/FelineStateMachine/bindws/blob/main/docs/22-grasp-01-git-hosting.md\">Git hosting guide</a>.</p>\n </div>\n </div>\n <div class=\"block\">\n <h3>Open it in an app</h3>\n <p class=\"note\">Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add <span class=\"mono\" id=\"apps-ws\"></span> under the app's relay settings to post here.</p>\n <div class=\"apps\" id=\"apps\"></div>\n <div class=\"phones\" id=\"phones\"></div>\n </div>\n </details>\n </section>\n\n <section id=\"peoplesec\" class=\"hidden\">\n <h2>People <span class=\"count\" id=\"people-count\"></span></h2>\n <p class=\"note hidden\" id=\"people-note\">Hidden from visitors. Only you see this list.</p>\n <div class=\"dir\" id=\"dir\"></div>\n </section>\n\n <section id=\"fuelsec\" class=\"hidden\">\n <h2>Fuel</h2>\n <p class=\"lead\">Free allowance first; past it, usage burns sats. Anyone can zap a top-up.</p>\n <div class=\"gauges\">\n <div class=\"gauge\"><small>Events stored</small><div class=\"bar\"><i id=\"g-events\"></i><b id=\"p-events\"></b></div><span id=\"t-events\"></span></div>\n <div class=\"gauge\"><small>Files stored</small><div class=\"bar\"><i id=\"g-media\"></i><b id=\"p-media\"></b></div><span id=\"t-media\"></span></div>\n <div class=\"gauge\"><small>Awake this month</small><div class=\"bar\"><i id=\"g-active\"></i><b id=\"p-active\"></b></div><span id=\"t-active\"></span></div>\n <div class=\"gauge\"><small>Rows written this month</small><div class=\"bar\"><i id=\"g-rows\"></i><b id=\"p-rows\"></b></div><span id=\"t-rows\"></span></div>\n </div>\n <p class=\"balance\" id=\"fuel-balance\"></p>\n <form id=\"topup\" class=\"topup hidden\"><span class=\"sats\"><input type=\"number\" name=\"sats\" min=\"1\" value=\"1000\"><span>sats</span></span><button class=\"btn pri\">⚡ Zap to top up</button><span class=\"note\" id=\"topup-note\"></span></form>\n <div id=\"invoice\" class=\"invoice hidden\">\n <p>Pay from any lightning wallet. The receipt lands on this relay and credits it automatically.</p>\n <div class=\"row\"><a id=\"inv-link\" class=\"btn\" href=\"#\">Open in wallet</a><button id=\"inv-copy\" class=\"ib\" title=\"Copy invoice\" aria-label=\"Copy invoice\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button><span class=\"state\" id=\"inv-state\"></span></div>\n <textarea id=\"inv-text\" readonly></textarea>\n </div>\n </section>\n\n <section id=\"myinvites\" class=\"hidden\">\n <h2>Your invites</h2>\n <p class=\"lead\">The owner lets members bring people in. Each link admits one person and lasts three days.</p>\n <div class=\"row\"><button id=\"mintmine\" class=\"btn pri\">New invite link</button><span class=\"note\" id=\"minenote\"></span></div>\n <ul id=\"mine\" class=\"plain\"></ul>\n </section>\n\n\n <div class=\"console hidden\" id=\"console\">\n <nav class=\"tabs\" id=\"tabs\">\n <a href=\"#people\" data-tab=\"people\">People<span class=\"count\" id=\"tc-people\"></span></a>\n <a href=\"#moderation\" data-tab=\"moderation\">Moderation<span class=\"count\" id=\"tc-reports\"></span></a>\n <a href=\"#rules\" data-tab=\"rules\">Rules</a>\n <a href=\"#identity\" data-tab=\"identity\">Identity</a>\n <a href=\"#data\" data-tab=\"data\">Data</a>\n <a href=\"#sync\" data-tab=\"sync\">Sync</a>\n <a href=\"#views\" data-tab=\"views\">Views</a>\n <a href=\"#health\" data-tab=\"health\">Health</a>\n <a href=\"#owner\" data-tab=\"owner\">Owner</a>\n </nav>\n\n <section class=\"panel\" data-panel=\"people\">\n <h2>People</h2>\n <p class=\"lead\">The member list is published as a signed roster; a name makes someone <span class=\"mono\" id=\"nip05-example\"></span>.</p>\n <div class=\"block\">\n <form id=\"addmember\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"name\" placeholder=\"name (optional)\" pattern=\"[a-z0-9._-]{1,64}\" style=\"max-width:9rem\"><input class=\"txt\" name=\"note\" placeholder=\"note\" style=\"max-width:9rem\"><button class=\"ib pri\" title=\"Add member\" aria-label=\"Add member\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <div class=\"scroll\"><table id=\"members\" class=\"people-table\"><thead><tr><th>Who</th><th>Name</th><th>Note</th><th>Limits</th><th>Joined</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <div class=\"two\">\n <div>\n <h3>Invites</h3>\n <form id=\"mintinvite\" class=\"addrow\"><select class=\"txt\" name=\"ttl\" style=\"max-width:8rem\"><option value=\"3600\">1 hour</option><option value=\"86400\">1 day</option><option value=\"259200\" selected>3 days</option><option value=\"2592000\">30 days</option></select><input class=\"txt num\" type=\"number\" name=\"max\" min=\"0\" value=\"0\" title=\"uses, 0 = unlimited\" style=\"width:5rem;flex:0 0 auto\"><input class=\"txt\" name=\"note\" placeholder=\"note\"><button class=\"ib pri\" title=\"New invite\" aria-label=\"New invite\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <ul id=\"invites\" class=\"plain\"></ul>\n <form id=\"treeform\" class=\"addrow hidden\" title=\"Members invite members: how many hops from you the tree reaches, and how many live invites each member may hold\"><span class=\"dim\">members invite</span><input class=\"txt num\" type=\"number\" name=\"depth\" min=\"0\" max=\"10\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">hops deep,</span><input class=\"txt num\" type=\"number\" name=\"quota\" min=\"0\" max=\"100\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">each</span><button class=\"btn\">Save</button></form>\n </div>\n <div>\n <h3>Joining</h3>\n <form id=\"joinform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Shown before someone accepts an invite</span><textarea name=\"joinTerms\" placeholder=\"House rules, expectations, anything a newcomer should agree to.\" maxlength=\"20000\"></textarea></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"directoryPublic\"> Show the people directory to visitors</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save</button></div>\n </form>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"moderation\">\n <h2>Moderation</h2>\n <p class=\"lead\">Reports never show in the feed. Banning also deletes the reported thing.</p>\n <div class=\"block\">\n <h3>Reports <span class=\"count\" id=\"reports-count\"></span></h3>\n <form id=\"thresholdform\" class=\"addrow\"><span class=\"dim\">hide an event once</span><input class=\"txt num\" type=\"number\" name=\"reportThreshold\" min=\"0\" max=\"100\" style=\"width:4.5rem;flex:0 0 auto\"><span class=\"dim\">different people report it; 0 never</span><button class=\"btn\">Save</button></form>\n <div class=\"scroll\"><table id=\"reports\" class=\"events\"><thead><tr><th>Time</th><th>Type</th><th>About</th><th>Reason</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Log</h3>\n <p class=\"dim\">Every change made here or by a moderation event, newest first, the last 5,000.</p>\n <div class=\"scroll\"><table id=\"audit\" class=\"events\"><thead><tr><th>Time</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead><tbody></tbody></table></div>\n <button class=\"btn\" id=\"audit-more\" type=\"button\">Older</button>\n </div>\n <div class=\"block two\">\n <div>\n <h3>Bans</h3>\n <form id=\"addban\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><label class=\"note\" title=\"Also delete everything they wrote and uploaded\"><input type=\"checkbox\" name=\"erase\"> erase</label><button class=\"ib danger\" title=\"Ban\" aria-label=\"Ban\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"bans\" class=\"plain\"></ul>\n <ul id=\"banned-events\" class=\"plain\"></ul>\n </div>\n <div>\n <h3>Blocked addresses</h3>\n <form id=\"addblock\" class=\"addrow\"><input class=\"txt mono\" name=\"ip\" placeholder=\"IP address\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><button class=\"ib danger\" title=\"Block\" aria-label=\"Block\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"blocks\" class=\"plain\"></ul>\n </div>\n </div>\n <div class=\"block\">\n <h3>Recent events</h3>\n <p class=\"note\">Delete removes one thing. Ban also refuses it forever. Search covers notes, articles and profiles.</p>\n <form id=\"searchform\" class=\"addrow\"><input class=\"txt\" name=\"q\" placeholder=\"search\" maxlength=\"200\"><button class=\"btn\">Search</button><button type=\"button\" id=\"searchclear\" class=\"btn hidden\">Clear</button></form>\n <div class=\"scroll\"><table id=\"events\" class=\"events\"><thead><tr><th>Time</th><th>Kind</th><th>Author</th><th>Content</th><th></th></tr></thead><tbody></tbody></table></div>\n <div class=\"actions left\"><button id=\"more\" class=\"btn\">Load more</button></div>\n </div>\n <div class=\"block\">\n <h3>Pinned <span class=\"count\" id=\"pins-count\"></span></h3>\n <p class=\"note\">Group clients show these at the top. Up to 20, in this order.</p>\n <form id=\"pinform\" class=\"addrow\"><input class=\"txt mono\" name=\"id\" placeholder=\"event id or address kind:pubkey:d\" required><button class=\"btn\">Pin</button></form>\n <ul id=\"pins\" class=\"plain\"></ul>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"rules\">\n <h2>Rules</h2>\n <p class=\"lead\">Bans apply regardless of these.</p>\n <div class=\"block\">\n <h3>Presets</h3>\n <p class=\"note\">One click sets writes, reads, kinds and keep-for together. Limits, identity and people stay.</p>\n <div id=\"presets\"></div>\n <div class=\"row hidden\" id=\"presetsourcerow\"><input class=\"txt mono\" id=\"presetsource\" placeholder=\"wss://source.relay to mirror\" style=\"max-width:24rem\"><span class=\"note\">Replica presets keep a standing pull of their kinds from this relay.</span></div>\n <p class=\"note\" id=\"presetnote\"></p>\n </div>\n <form id=\"access\">\n <div class=\"two\">\n <div><h3>Writes</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"writes\" value=\"open\"><b>Anyone</b><small>Every valid event is accepted, unless banned.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"allowlist\"><b>Members</b><small>Only people on the member list, and you.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"wot\"><b>Members and their follows</b><small>Members, plus everyone in a member's contact list.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"owner\"><b>Only me</b><small>Nobody else can post.</small></label>\n </div></div>\n <div><h3>Reads</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"reads\" value=\"open\"><b>Anyone</b><small>DMs and gift wraps still go only to their parties.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"auth\"><b>Authenticated only</b><small>Every query needs a NIP-42 sign-in first.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"members\"><b>Members only</b><small>Removing a member ends their subscriptions.</small></label>\n </div></div>\n </div>\n <div class=\"form\" style=\"margin-top:1rem\">\n <label><span>Open to anyone <small>kinds anyone may write, whatever the rule above says</small></span><input class=\"txt mono\" name=\"openKinds\" placeholder=\"7, 9735\"></label>\n <label class=\"switch\"><input type=\"checkbox\" name=\"guestReplies\"> Anyone may reply to a member's note</label>\n </div>\n <div class=\"limits\">\n <label><span>Minimum proof of work <small>bits</small></span><input class=\"txt num\" type=\"number\" name=\"minPow\" min=\"0\" max=\"64\"></label>\n <label><span>Reject events dated in the future by more than <small>seconds</small></span><input class=\"txt num\" type=\"number\" name=\"maxFuture\" min=\"0\"></label>\n <label><span>Max events per query</span><input class=\"txt num\" type=\"number\" name=\"maxLimit\" min=\"1\" max=\"5000\"></label>\n <label><span>Max subscriptions per connection</span><input class=\"txt num\" type=\"number\" name=\"maxSubs\" min=\"1\" max=\"200\"></label>\n <label><span>Max message size <small>KB</small></span><input class=\"txt num\" type=\"number\" name=\"maxMessageKB\" min=\"16\" max=\"1024\"></label>\n <label><span>Events per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"eventsPerMinute\" min=\"1\"></label>\n <label><span>Queries per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"reqsPerMinute\" min=\"1\"></label>\n <label><span>Max upload size <small>MB</small></span><input class=\"txt num\" type=\"number\" name=\"maxBlobMB\" min=\"1\" max=\"95\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save rules</button></div>\n </form>\n <form id=\"git-capacity\" class=\"block\">\n <h3>Git capacity</h3>\n <p class=\"note\">GRASP Git has its own retained-storage and request limits. Storage and compute still use the relay’s normal fuel and billing. Git transfer limits are independent from event and file limits.</p>\n <div class=\"limits\">\n <label><span>Repositories <small>maximum on this relay</small></span><input class=\"txt num\" type=\"number\" name=\"maxRepositories\" min=\"1\" max=\"4096\" required></label>\n <label><span>Relay Git storage <small>GiB, including metadata</small></span><input class=\"txt num\" type=\"number\" name=\"maxRelayGiB\" min=\"0.000001\" max=\"6\" step=\"0.001\" required></label>\n <label><span>Compressed repository data <small>GiB, per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxCompressedGiB\" min=\"0.000001\" max=\"6\" step=\"0.001\" required></label>\n <label><span>Pack size <small>MiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxPackMiB\" min=\"0.000001\" max=\"95\" step=\"0.001\" required></label>\n <label><span>Object size <small>MiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxObjectMiB\" min=\"0.000001\" max=\"64\" step=\"0.001\" required></label>\n </div>\n <details class=\"advanced\"><summary>Advanced Git admission and fetch limits</summary><div class=\"limits\">\n <label><span>Objects <small>per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxObjects\" min=\"1\" max=\"500000\" required></label>\n <label><span>Raw bytes <small>per repository, GiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxRawGiB\" min=\"0.000001\" max=\"64\" step=\"0.001\" required></label>\n <label><span>Transaction bytes <small>MiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxTransactionRawMiB\" min=\"0.000001\" max=\"64\" step=\"0.001\" required></label>\n <label><span>Transaction objects</span><input class=\"txt num\" type=\"number\" name=\"maxTransactionObjects\" min=\"1\" max=\"100000\" required></label>\n <label><span>Metadata <small>MiB per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxMetadataMiB\" min=\"0.000001\" max=\"1024\" step=\"0.001\" required></label>\n <label><span>Refs <small>per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxRefs\" min=\"1\" max=\"16384\" required></label>\n <label><span>Graph edges <small>per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxGraphEdges\" min=\"1\" max=\"4000000\" required></label>\n <label><span>Fetch response <small>GiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxFetchGiB\" min=\"0.000001\" max=\"6\" step=\"0.001\" required></label>\n </div></details>\n <div class=\"actions\"><button class=\"btn pri\">Save Git capacity</button></div>\n </form>\n <div class=\"block\">\n <h3>Kinds</h3>\n <p class=\"note\">An empty allow list means every kind. Blocks always win.</p>\n <form id=\"kindform\" class=\"addrow\"><input class=\"txt num\" type=\"number\" name=\"kind\" min=\"0\" max=\"65535\" placeholder=\"kind\" required style=\"width:7rem;flex:0 0 auto\"><button class=\"ib\" name=\"rule\" value=\"allow\" title=\"Allow kind\" aria-label=\"Allow kind\"><svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg></button><button class=\"ib danger\" name=\"rule\" value=\"block\" title=\"Block kind\" aria-label=\"Block kind\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <p class=\"kindline\">Allowed: <span id=\"kinds-allow\"></span></p>\n <p class=\"kindline\">Blocked: <span id=\"kinds-block\"></span></p>\n </div>\n <div class=\"block\">\n <h3>Features</h3>\n <p class=\"note\">Each is a door or a cost. Off leaves the NIP-11 list, answers 404 at its door and is refused at the socket.</p>\n <div id=\"features\" class=\"limits\"></div>\n <form id=\"push-policy-form\" class=\"limits\">\n <label><span>Push callback origins <small>One HTTPS origin per line, also approved by your host operator. Relay push costs fuel.</small></span><textarea class=\"txt\" name=\"origins\" rows=\"3\" placeholder=\"https://push.example.com\"></textarea></label>\n <label><span>Lettered NIP advertisements <small>Some older clients reject mixed NIP identifiers. Relay push requires them.</small></span><input type=\"checkbox\" name=\"lettered\"></label>\n <label><span>Automatic NIP-65 delivery <small>Send your public events to your write relays and tagged people's read relays. Each event is bounded to a few targets.</small></span><input type=\"checkbox\" name=\"delivery\"></label>\n <label><span>Maximum delivery targets <small>Per event</small></span><input class=\"txt num\" type=\"number\" name=\"deliveryMax\" min=\"1\" max=\"16\" value=\"8\" style=\"width:5rem\"></label>\n <div class=\"actions\"><button class=\"btn\">Save delivery policy</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Blocked words</h3>\n <p class=\"note\">Content containing one is refused. You and your moderators are exempt. An entry written /like this/ is a regular expression.</p>\n <form id=\"wordsform\"><textarea class=\"txt\" name=\"words\" rows=\"4\" placeholder=\"one word, phrase or /pattern/ per line\"></textarea><label class=\"switch\"><input type=\"checkbox\" name=\"inTags\"> Also search tags</label><div class=\"actions\"><button class=\"btn\">Save words</button></div></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"identity\">\n <h2>Identity</h2>\n <form id=\"identity\">\n <h3>Profile</h3>\n <div class=\"form\">\n <label><span>Name</span><input class=\"txt\" name=\"name\" maxlength=\"200\"></label>\n <label><span>Contact</span><input class=\"txt\" name=\"contact\" placeholder=\"mailto:you@example.com\" maxlength=\"500\"></label>\n <label class=\"wide\"><span>Description</span><textarea name=\"description\" maxlength=\"2000\"></textarea></label>\n <label class=\"wide\"><span>Icon URL</span><input class=\"txt\" name=\"icon\" placeholder=\"https://…/icon.png\" maxlength=\"2000\"></label>\n <label class=\"wide\"><span>Banner URL</span><input class=\"txt\" name=\"banner\" placeholder=\"https://…/banner.png\" maxlength=\"2000\"></label>\n </div>\n <h3 class=\"gap\">For directories</h3>\n <div class=\"form\">\n <label><span>Posting policy URL</span><input class=\"txt\" name=\"postingPolicy\" placeholder=\"https://…/posting\" maxlength=\"2000\"></label>\n <label><span>Privacy policy URL</span><input class=\"txt\" name=\"privacyPolicy\" placeholder=\"https://…/privacy\" maxlength=\"2000\"></label>\n <label><span>Tags</span><input class=\"txt\" name=\"tags\" placeholder=\"bitcoin, cats\"></label>\n <label><span>Languages</span><input class=\"txt\" name=\"languageTags\" placeholder=\"en, pt-BR\"></label>\n <label><span>Countries</span><input class=\"txt\" name=\"relayCountries\" placeholder=\"US, DE\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Your own domain</h3>\n <p class=\"note\">Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.</p>\n <form id=\"adddomain\"><div class=\"form\"><label><span>Hostname</span><input class=\"txt mono\" name=\"host\" placeholder=\"relay.example.com\" required></label><label><span>Destination</span><select class=\"txt\" name=\"site\"><option value=\"\">This relay</option></select></label></div><div class=\"actions\"><button class=\"btn\">Add domain</button></div></form>\n <div id=\"domains\"></div>\n <p class=\"note\" id=\"domainnote\"></p>\n </div>\n <div class=\"block\">\n <h3>Your relay lists</h3>\n <p class=\"note\">Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>\n <div id=\"wire\"></div>\n </div>\n <div class=\"block\">\n <h3>Share</h3>\n <p class=\"note\">A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.</p>\n <div class=\"share\">\n <a href=\"/card.svg\" target=\"_blank\" rel=\"noopener\"><img id=\"cardimg\" src=\"/card.svg\" alt=\"relay card\" width=\"600\" height=\"315\"></a>\n <div class=\"share-side\">\n <img id=\"naddrqr\" class=\"hidden\" alt=\"QR code of the group address\" width=\"150\" height=\"150\">\n <code class=\"mono naddr\" id=\"naddr\"></code>\n <div class=\"row\"><button class=\"btn\" id=\"copynaddr\">Copy naddr</button><button class=\"btn\" id=\"copyembed\">Copy embed</button></div>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"data\">\n <h2>Data</h2>\n <p class=\"lead\">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>\n <div class=\"block\" id=\"listhistoryblock\">\n <h3>Recover your lists</h3>\n <p class=\"note\">Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.</p>\n <div class=\"scroll\"><table id=\"listhistory\" class=\"events\"><thead><tr><th>List</th><th>Created</th><th>Saved</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"kbar\" id=\"s-bar\"></div><span class=\"legend slegend\" id=\"s-legend\"></span>\n <div class=\"usage\" id=\"s-totals\"></div>\n <div class=\"block\">\n <h3>By kind</h3>\n <div class=\"scroll\"><table id=\"kinds\" class=\"events\"><thead><tr><th>Kind</th><th class=\"r\">Count</th><th class=\"r\">Size</th><th>Oldest</th><th>Keep for</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Files <span class=\"count\" id=\"blobs-count\"></span></h3>\n <div class=\"scroll\"><table id=\"blobs\" class=\"events\"><thead><tr><th>Time</th><th>File</th><th>Size</th><th>Uploader</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Sites <span class=\"count\" id=\"sites-count\"></span></h3>\n <p class=\"note\">Published NIP-5A manifests and the hostnames where they are served.</p>\n <div class=\"scroll\"><table id=\"sites\" class=\"events\"><thead><tr><th>Author</th><th>Name</th><th>URL</th><th class=\"r\">Files</th><th class=\"r\">Size</th><th>Expiry</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Dumps <span class=\"count\" id=\"dumps-count\"></span></h3>\n <p class=\"note\">Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.</p>\n <form id=\"dumpform\" class=\"addrow\"><select class=\"txt\" name=\"dumps\" style=\"max-width:8rem\"><option value=\"off\">off</option><option value=\"daily\">daily</option><option value=\"weekly\">weekly</option></select><input class=\"txt num\" type=\"number\" name=\"keep\" min=\"1\" max=\"60\" value=\"7\" title=\"how many to keep\" style=\"width:5rem;flex:0 0 auto\"><button class=\"btn\">Save</button><button type=\"button\" id=\"dumpnow\" class=\"btn\">Dump now</button></form>\n <ul id=\"dumps\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Backups <span class=\"count\" id=\"backups-count\"></span></h3>\n <p class=\"note\">Portable copies include configuration, events, site files and Git objects. They are private and bounded at 8 MB.</p>\n <div class=\"form\"><label><span>Archive name</span><input class=\"txt mono\" id=\"backupid\" placeholder=\"backup\" value=\"backup\"></label></div>\n <div class=\"actions\"><button type=\"button\" id=\"backupnow\" class=\"btn\">Create and download</button></div>\n <ul id=\"backups\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Import a file</h3>\n <p class=\"note\">A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.</p>\n <form id=\"importform\" class=\"addrow\"><input class=\"txt\" type=\"file\" name=\"file\" accept=\".jsonl,.json,.ndjson,application/json\" required><button class=\"btn\">Import</button></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"sync\">\n <h2>Sync</h2>\n <p class=\"lead\">Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.</p>\n <div class=\"block\">\n <h3>Jobs</h3>\n <p class=\"note\">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>\n <div class=\"scroll\"><table id=\"jobs\" class=\"events\"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>\n\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><select class=\"txt\" name=\"every\" style=\"max-width:9rem\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select><button class=\"btn\">Pull</button></form>\n <form id=\"backfillform\" class=\"addrow\"><input class=\"txt mono\" name=\"relays\" placeholder=\"relays, comma separated; blank uses your relay list\"><button class=\"btn\">Fetch my history</button></form>\n <form id=\"pushform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Rebroadcast to</span><input class=\"txt mono\" name=\"targets\" placeholder=\"wss://a.relay, wss://b.relay\" required></label>\n <label><span>Kinds <small>blank for all</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label><span>Newer than <small>days, blank for all</small></span><input class=\"txt num\" type=\"number\" name=\"days\" min=\"0\"></label>\n <label><span>Schedule</span><select class=\"txt\" name=\"every\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Rebroadcast</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Automatic delivery</h3>\n <p class=\"note\">Recent per-target results for automatic NIP-65 delivery.</p>\n <div class=\"scroll\"><table id=\"deliveries\"><thead><tr><th>Event</th><th>Target</th><th>Status</th><th>Attempts</th><th>Last error</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Fork this relay</h3>\n <p class=\"note\">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>\n <form id=\"forkform\">\n <div class=\"form\">\n <label><span>Name <small>optional</small></span><input class=\"txt mono\" name=\"name\" placeholder=\"a memorable one if blank\" pattern=\"[a-z0-9-]{3,32}\"></label>\n <label><span>Who claims it <small>npub or hex, you if blank</small></span><input class=\"txt mono\" name=\"holder\"></label>\n <label><span>What to copy</span><select class=\"txt\" name=\"scope\"><option value=\"all\">everything</option><option value=\"mine\">only my events</option><option value=\"kinds\">these kinds</option></select></label>\n <label id=\"forkkinds\" class=\"hidden\"><span>Kinds <small>comma separated</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"people\" checked> Copy the people</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Fork</button></div>\n </form>\n <p class=\"note\" id=\"forknote\"></p>\n <div class=\"row hidden\" id=\"forkresult\"><span class=\"mono\" id=\"forkurl\"></span><button class=\"ib\" id=\"forkcopy\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"views\">\n <h2>Views</h2>\n <p class=\"lead\">Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.</p>\n <div class=\"block\">\n <div id=\"views\"></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"health\">\n <h2>Health</h2>\n <div class=\"counters\">\n <div class=\"counter\"><b class=\"big\" id=\"h-last\">–</b><small>since last event</small><span class=\"sub\" id=\"h-last-sub\"></span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-conns\">–</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">–</b><small id=\"h-fuel-label\">fuel</small><span class=\"sub\" id=\"h-fuel-sub\"></span></div>\n <div class=\"counter\"><div class=\"kbar\" id=\"h-kinds\"></div><small>used for, last 30 days</small><span class=\"sub legend\" id=\"h-kinds-legend\"></span></div>\n </div>\n <div class=\"usage\" id=\"usage\"></div>\n <div class=\"block\">\n <h3>Zaps received</h3>\n <div class=\"scroll\"><table id=\"credits\"><thead><tr><th>When</th><th>From</th><th class=\"r\">Sats</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Notifications</h3>\n <p class=\"note\">The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.</p>\n <form id=\"notify\">\n <div class=\"form\">\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"reports\"> A report arrives</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"fuel\"> Fuel runs low</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"jobs\"> A pull finishes</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"succession\"> The handover clock is running</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"digest\"> A note every week on how the relay is doing</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save notifications</button><button type=\"button\" id=\"notifytest\" class=\"btn\">Send a test message</button></div>\n </form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"owner\">\n <h2>Owner</h2>\n <p class=\"lead\">The relay's key, events, files and fuel stay put through everything here except delete.</p>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.</p>\n <div class=\"row\"><button id=\"exportcfg\" class=\"btn\">Export configuration</button><button id=\"importcfg\" class=\"btn\">Import configuration</button><input type=\"file\" id=\"cfgfile\" accept=\"application/json,.json\" class=\"hidden\"></div>\n </div>\n <div class=\"block danger-zone\">\n <h3>Transfer ownership</h3>\n <p class=\"note\">Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.</p>\n <form id=\"transfer\" class=\"addrow\"><select class=\"txt mono\" name=\"pubkey\" required></select><button class=\"btn danger\">Transfer</button></form>\n </div>\n <div class=\"block danger-zone\">\n <h3>If I lose my key</h3>\n <p class=\"note\">Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.</p>\n <form id=\"succession\">\n <div class=\"form\">\n <label><span>Heir <small>a member</small></span><select class=\"txt mono\" name=\"heir\" required></select></label>\n <label><span>Hand over</span><select class=\"txt\" name=\"afterDays\"><option value=\"90\">after 3 months away</option><option value=\"180\" selected>after 6 months away</option><option value=\"365\">after a year away</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Set heir</button><button type=\"button\" id=\"clearsuccession\" class=\"btn hidden\">Clear</button></div>\n </form>\n <p class=\"note\" id=\"successionnote\"></p>\n </div>\n <div class=\"block danger-zone\">\n <h3>Delete this relay</h3>\n <p class=\"note\">Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.</p>\n <button id=\"deleterelay\" class=\"btn danger\">Delete this relay</button>\n </div>\n </section>\n </div>\n\n <footer class=\"pg\"><p>a bind.ws relay</p><a href=\"https://github.com/FelineStateMachine/bindws\">source</a></footer>\n</main>\n";3-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 MiB = 1024 * 1024, GiB = 1024 * MiB;\n const units = (n, divisor) => n == null ? \"\" : (n / divisor).toFixed(3).replace(/0+$/, \"\").replace(/\\.$/, \"\");\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '<svg viewBox=\"0 0 24 24\"><path d=\"M6 6l12 12M18 6L6 18\"/></svg>',\n undo: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 10h10a5 5 0 0 1 0 10h-3M4 10l4-4M4 10l4 4\"/></svg>',\n check: '<svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg>',\n trash: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 7h16M6 7l1.2 13h9.6L18 7\"/></svg>',\n ban: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg>',\n banuser: '<svg viewBox=\"0 0 24 24\"><circle cx=\"10\" cy=\"8\" r=\"4\"/><path d=\"M3 21a7 7 0 0 1 11-5.7M15 15l6 6M21 15l-6 6\"/></svg>',\n eye: '<svg viewBox=\"0 0 24 24\"><path d=\"M2 12s4-6 10-6 10 6 10 6-4 6-10 6S2 12 2 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>',\n lock: '<svg viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10\" width=\"14\" height=\"10\" rx=\"2\"/><path d=\"M8 10V7a4 4 0 0 1 8 0v3\"/></svg>',\n pen: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 20l4-1 11-11-3-3L5 16l-1 4zM13 8l3 3\"/></svg>',\n people: '<svg viewBox=\"0 0 24 24\"><circle cx=\"9\" cy=\"8\" r=\"3.5\"/><circle cx=\"17\" cy=\"9\" r=\"2.5\"/><path d=\"M3 20a6 6 0 0 1 12 0M15 20a4.5 4.5 0 0 1 7-3.5\"/></svg>',\n person: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/></svg>',\n gauge: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 16a8 8 0 0 1 16 0M12 16l4-5\"/><circle cx=\"12\" cy=\"16\" r=\"1.2\"/></svg>',\n bolt: '<svg viewBox=\"0 0 24 24\"><path d=\"M13 3L5 14h6l-1 7 8-11h-6z\"/></svg>',\n copy: '<svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg>',\n pin: '<svg viewBox=\"0 0 24 24\"><path d=\"M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6zM12 15v5\"/></svg>',\n };\n const ib = (icon, label, act, id, extra) => '<button class=\"ib' + (extra ? \" \" + extra : \"\") + '\" title=\"' + label + '\" aria-label=\"' + label + '\" data-act=\"' + act + '\" data-id=\"' + esc(id) + '\">' + IC[icon] + \"</button>\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('<a href=\"' + esc(u) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(u) + '\">' + esc(label) + \"</a>\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('<span class=\"tag\">' + esc(t) + \"</span>\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"<span>\" + esc(where.join(\", \")) + \"</span>\");\n const link = (label, href) => href ? '<a href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"<span>\" + esc(info.contact) + \"</span>\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('<span class=\"sep\">|</span>'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by <b>' + key(owner) + \"</b>\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"<div><small>\" + k + \"</small><div\" + ([\"Owner\", \"NIPs\", \"Relay key\"].includes(k) ? ' class=\"mono\"' : \"\") + \">\" + (v === undefined || v === \"\" ? '<span class=\"muted\">–</span>' : v instanceof Html ? v.s : esc(v)) + \"</div></div>\").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '<span class=\"g' + (off ? \" off\" : \"\") + '\" title=\"' + label + '\"><i>' + IC[icon] + \"</i><small>\" + label + \"</small></span>\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '<span class=\"who' + (m.pubkey === me ? \" me\" : \"\") + '\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + '<span class=\"mono\">' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"</span>\" + (m.role === \"owner\" ? '<span class=\"role\">owner</span>' : \"\") + \"</span>\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"/<npub>/<repo>.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n renderApps();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? '<span class=\"chip\">out of fuel</span> Writes are paused until someone tops up.'\n : \"Balance <b>\" + sats.toLocaleString() + \" sats</b>. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '<tr><td class=\"dim\">' + esc(fmtTime(c.at)) + '</td><td class=\"mono\" title=\"' + esc(c.payer) + '\">' + av(c.payer) + key(c.payer) + '</td><td class=\"mono r\">' + Math.floor(c.msats / 1000).toLocaleString() + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"3\" class=\"muted\">no zaps yet</td></tr>';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '<i class=\"' + cls + '\" style=\"flex:' + n + '\" title=\"' + name + \" \" + Math.round(n * 100 / total) + '%\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '<span><i class=\"' + cls + '\"></i>' + name + \" \" + Math.round(n * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n const g = p.git || {}, gf = $(\"#git-capacity\");\n if (gf) {\n gf.maxRepositories.value = g.maxRepositories || \"\"; gf.maxRelayGiB.value = units(g.maxRelayBytes, GiB); gf.maxCompressedGiB.value = units(g.maxCompressedBytes, GiB);\n gf.maxPackMiB.value = units(g.maxPackBytes, MiB); gf.maxObjectMiB.value = units(g.maxObjectBytes, MiB); gf.maxObjects.value = g.maxObjects || \"\";\n gf.maxRawGiB.value = units(g.maxRawBytes, GiB); gf.maxTransactionRawMiB.value = units(g.maxTransactionRawBytes, MiB); gf.maxTransactionObjects.value = g.maxTransactionObjects || \"\";\n gf.maxMetadataMiB.value = units(g.maxMetadataBytes, MiB); gf.maxRefs.value = g.maxRefs || \"\"; gf.maxGraphEdges.value = g.maxGraphEdges || \"\"; gf.maxFetchGiB.value = units(g.maxFetchBytes, GiB);\n }\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '<tr data-seq=\"' + r.seq + '\"><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td class=\"mono\" title=\"' + esc(r.actor) + '\">' + av(r.actor) + key(r.actor) + '</td><td><span class=\"kind\">' + esc(r.action) + '</span></td><td class=\"mono\">' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '</td><td class=\"c\">' + esc(r.detail) + '</td></tr>').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || '<tr><td class=\"dim\" colspan=\"5\">Nothing yet</td></tr>';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' <span class=\"chip\">owner</span>' : myRole === \"owner\" ? ' <select class=\"txt role\" title=\"Role\"><option value=\"member\"' + (m.role === \"member\" ? \" selected\" : \"\") + '>member</option><option value=\"moderator\"' + (m.role === \"moderator\" ? \" selected\" : \"\") + '>moderator</option></select>' : m.role === \"moderator\" ? ' <span class=\"chip\">moderator</span>' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? '<input class=\"txt num keep\" type=\"number\" min=\"0\" placeholder=\"days\" title=\"Keep this member\\'s events for this many days; blank follows the relay rules\" value=\"' + (m.keep_days || \"\") + '\"><input class=\"txt num cap\" type=\"number\" min=\"0\" placeholder=\"KB\" title=\"Cap on stored bytes, in KB; blank is unlimited\" value=\"' + (m.max_bytes ? Math.round(m.max_bytes / 1024) : \"\") + '\">'\n : '<span class=\"dim\">' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"</span>\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '<tr data-pk=\"' + m.pubkey + '\"><td class=\"mono\" title=\"' + esc(m.pubkey) + '\" style=\"padding-left:' + (0.5 + m.depth * 1.1) + 'rem\">' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' <span class=\"dim\">via ' + esc(nameOf(m.invited_by)) + \"</span>\" : \"\") + (m.invites ? ' <span class=\"chip\" title=\"live invites\">' + m.invites + \" inv</span>\" : \"\") + '</td><td class=\"name\"><input class=\"txt name\" value=\"' + esc(m.name || \"\") + '\" placeholder=\"name\" pattern=\"[a-z0-9._-]{1,64}\"></td><td><input class=\"txt note\" value=\"' + esc(m.note || \"\") + '\" placeholder=\"note\"></td><td class=\"lim\">' + limits(m) + '</td><td class=\"dim\">' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '</td><td class=\"r\">' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"</td></tr>\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '<option value=\"' + m.pubkey + '\">' + esc(m.name ? m.name + \"@\" + host : key(m.pubkey)) + \"</option>\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + \"</a> \" + esc(i.note || \"\") + ' <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites</li>';\n const person = (r, icon, label, act) => \"<li>\" + av(r.pubkey) + '<span><b class=\"mono\" title=\"' + esc(r.pubkey) + '\">' + short(r.pubkey) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(icon, label, act, r.pubkey) + \"</li>\";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '<li class=\"empty\">nobody banned</li>';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '<li><i class=\"ev\">ip</i><span><b class=\"mono\">' + esc(r.ip) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"</li>\").join(\"\") : '<li class=\"empty\">no addresses blocked</li>';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '<li><i class=\"ev\">ev</i><span><b class=\"mono\" title=\"' + esc(r.id) + '\">' + key(r.id) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"</li>\").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '<tr><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td><span class=\"kind\">' + esc(r.type || \"report\") + '</span></td><td class=\"mono\" title=\"' + esc(r.target_pubkey) + '\">' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' <a class=\"dim\" href=\"/' + esc(r.target_event) + '\" target=\"_blank\" rel=\"noopener\">file ' + key(r.target_event) + \"</a>\" : ' <span class=\"dim\">ev ' + key(r.target_event) + (r.hidden ? ' <span class=\"tag\">hidden</span>' : \"\") + \"</span>\") : \"\") + '</td><td class=\"c\">' + esc(r.content) + '</td><td class=\"r\">' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">nothing reported</td></tr>';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '<tr><td class=\"dim\">' + esc(fmtTime(b.uploaded)) + '</td><td class=\"mono\"><a href=\"' + esc(b.url) + '\" target=\"_blank\" rel=\"noopener\">' + key(b.sha256) + '</a> <span class=\"dim\">' + esc(b.type) + '</span></td><td class=\"mono\">' + fmtBytes(b.size) + '</td><td class=\"mono\" title=\"' + esc(b.uploader) + '\">' + av(b.uploader) + key(b.uploader) + '</td><td class=\"r\">' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no uploads</td></tr>';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '<tr><td class=\"mono\" title=\"' + esc(s.author) + '\">' + av(s.author) + key(s.author) + '</td><td class=\"mono\">' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '</td><td><a href=\"' + esc(s.url) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(s.url) + '\">' + esc(s.url) + '</a></td><td class=\"r mono\">' + esc(s.paths) + (s.missing ? ' <span class=\"chip bad\" title=\"files still being mirrored\">' + esc(s.missing) + ' missing</span>' : '') + '</td><td class=\"r mono\">' + fmtBytes(s.size) + '</td><td class=\"dim\">' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '</td><td class=\"r\">' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '</td></tr>').join(\"\") : '<tr><td colspan=\"7\" class=\"muted\">no sites yet</td></tr>';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '<span class=\"tag ' + cls + '\">' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"</span>\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : '<span class=\"tag plain\">all</span>';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : '<span class=\"tag plain\">none</span>';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '<option value=\"' + v + '\"' + (cur === v ? \" selected\" : \"\") + \">\" + l + \"</option>\"; }).join(\"\");\n return \"<label><span>\" + esc(title) + \" <small>\" + esc(about) + \"</small></span><select class=\\\"txt\\\" data-feature=\\\"\" + k + \"\\\">\" + opts + \"</select></label>\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '<select class=\"txt\" data-view=\"' + esc(v.name) + '\" style=\"width:auto\">' + choices.map((c) => '<option value=\"' + c + '\"' + (c === v.trigger ? \" selected\" : \"\") + \">\" + label(c) + \"</option>\").join(\"\") + \"</select>\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"<span>\" + esc(x) + \"</span>\").join('<span class=\"sep\">|</span>');\n return '<div class=\"wire-row' + (v.on ? \"\" : \" dim\") + '\"><div class=\"wire-main\"><b>' + esc(v.name) + '</b><br><small>' + esc(v.about) + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + pick + (v.on ? '<a class=\"btn\" href=\"' + esc(v.path) + '\" target=\"_blank\" rel=\"noopener\">Open</a>' : \"\") + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '<li><i class=\"ev\">jsonl</i><span><b class=\"mono\">' + esc(d.name) + '</b> <span class=\"dim\">' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"</span></span><span>\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"</span></li>\").join(\"\") : '<li class=\"empty\">no dumps yet</li>';\n }\n async function loadBackups() {\n const list = await rpc(\"listbackups\");\n $(\"#backups-count\").textContent = list.length || \"\";\n $(\"#backups\").innerHTML = list.length ? list.map((b) => '<li><i class=\"ev\">backup</i><span><b class=\"mono\">' + esc(b.id) + '</b> <span class=\"dim\">' + fmtBytes(b.bytes) + '</span></span><span>' + ib(\"copy\", \"Download\", \"downloadbackup\", b.id) + ib(\"trash\", \"Delete\", \"deletebackup\", b.id, \"danger\") + '</span></li>').join(\"\") : '<li class=\"empty\">no backups yet</li>';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n async function downloadBackup(id) {\n const resp = await backupRequest(\"/backups/\" + encodeURIComponent(id), \"GET\");\n if (!resp.ok) throw new Error(\"backup download failed\");\n const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + '</a> <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites yet</li>';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n loadBackups().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<i class=\"' + c + '\" style=\"flex:' + b + '\" title=\"' + esc(n) + \" \" + fmtBytes(b) + '\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<span><i class=\"' + c + '\"></i>' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" <small>\" + fmtBytes(st.eventBytes) + \"</small>\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" <small>\" + fmtBytes(st.mediaBytes) + \"</small>\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '<span class=\"kind' + (k.protected ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + k.kind + \"</span> \" + esc(kindName(k.kind));\n const cells = k.protected\n ? '<td class=\"keep sys\" colspan=\"2\" title=\"The relay depends on this kind, so it is never expired or purged.\">required</td>'\n : '<td class=\"keep\"><input class=\"txt num days\" type=\"number\" min=\"0\" max=\"36500\" placeholder=\"' + placeholder + '\" value=\"' + own(k.kind) + '\"> days</td><td class=\"r\">' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"</td>\";\n return '<tr class=\"' + (k.kind === null ? \"any\" : \"\") + '\" data-kind=\"' + key + '\"><td>' + pill + '</td><td class=\"r mono\">' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '</td><td class=\"r mono\">' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '</td><td class=\"dim\">' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"</td>\" + cells + \"</tr>\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '<tr><td>' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '</td><td class=\"dim\">' + esc(fmtTime(r.created_at)) + '</td><td class=\"dim\">' + esc(fmtTime(r.saved_at)) + '</td><td class=\"r\">' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '</td></tr>').join(\"\") : '<tr><td colspan=\"4\" class=\"muted\">no older list versions yet</td></tr>';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '<tr><td class=\"dim\">' + esc(fmtTime(e.created_at)) + '</td><td><span class=\"kind' + (SYS_KINDS.has(e.kind) ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + e.kind + '</span></td><td class=\"mono\" title=\"' + e.pubkey + '\">' + av(e.pubkey) + key(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no events yet</td></tr>';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '<li><i class=\"ev\">' + (t[0] === \"e\" ? \"event\" : \"address\") + '</i><span class=\"mono\">' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"</span><span>\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"</span></li>\").join(\"\") : '<li class=\"muted\">nothing pinned</li>';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '<details><summary>Source results</summary>' + sources.map((s) => '<p><code>' + esc(s.url) + '</code>: ' + esc(s.status) + ', ' + s.stored + ' stored, ' + s.skipped + ' skipped' + (s.error || s.warning ? '<br>' + esc(s.error || s.warning) : '') + '</p>').join('') + '</details>' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"<br><small>\" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"<br>\") + \"</small>\" : \"\";\n return \"<tr><td>\" + what + \"</td><td class=\\\"mono\\\">\" + j.relays.map(esc).join(\"<br>\") + targets + \"</td><td>\" + (f.join(\", \") || \"everything\") + \"</td><td>\" + when + \"</td><td>\" + esc(res) + details + \"</td><td>\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"</td></tr>\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : '<tr><td colspan=\"6\" class=\"muted\">no jobs yet</td></tr>';\n if (myRole === \"owner\") {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '<tr><td class=\"mono\" title=\"' + esc(d.event_id) + '\">' + esc(d.event_id.slice(0, 12)) + '</td><td class=\"mono\">' + esc(d.target) + '</td><td>' + esc(d.status) + '</td><td>' + d.attempts + '</td><td>' + esc(d.error || \"\") + '</td></tr>').join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no automatic deliveries yet</td></tr>';\n } catch { /* unavailable to non-owners */ }\n }\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '<button class=\"btn\" data-preset=\"' + esc(p.name) + '\" title=\"' + esc(p.about) + '\">' + esc(p.title) + \"</button>\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '<span class=\"pill ' + cls + '\">' + esc(label) + \"</span>\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-wire=\"' + l.kind + '\" data-do=\"' + act + '\"' + (busy ? \" disabled\" : \"\") + \">\" + label + \"</button>\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"<span>\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"</span>\" : w.state && !busy ? \"<span>no list found</span>\" : \"\";\n const sent = (w.results || []).map((r) => '<span class=\"chip' + (r.ok ? \"\" : \" bad\") + '\" title=\"' + esc(r.msg) + '\">' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"</span>\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '<div class=\"wire-row\"><div class=\"wire-main\"><b>' + l.title + '</b> <small class=\"muted\">' + l.nip + \"</small><br><small>\" + l.about + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + buttons + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '<option value=\"' + esc(s.label) + '\"' + (s.label === current ? \" selected\" : \"\") + \">\" + esc(s.title) + \"</option>\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-domain=\"' + esc(d.host) + '\" data-do=\"' + act + '\">' + label + \"</button>\";\n const rows = d.ready ? \"\" : '<div class=\"scroll\"><table class=\"events\"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>' + d.records.map((r) => \"<tr><td>\" + esc(r.type) + '</td><td class=\"mono\">' + esc(r.name) + '</td><td class=\"mono\">' + esc(r.value) + \"</td><td>\" + esc(r.note) + \"</td></tr>\").join(\"\") + \"</tbody></table></div>\";\n return '<div class=\"row\"><b class=\"mono\">' + esc(d.host) + '</b><span class=\"note\">' + esc(state) + \"</span>\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '</div><label class=\"row\"><span>Destination</span><select class=\"txt\" data-domain-site=\"' + esc(d.host) + '\">' + domainOptions(d.site || \"\") + \"</select></label>\" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#git-capacity\").onsubmit = guard(async (ev) => {\n const f = ev.target, bytes = (name, divisor) => Math.round(Number(f[name].value) * divisor);\n const git = {\n maxRepositories: Math.floor(Number(f.maxRepositories.value)), maxRelayBytes: bytes(\"maxRelayGiB\", GiB), maxCompressedBytes: bytes(\"maxCompressedGiB\", GiB),\n maxPackBytes: bytes(\"maxPackMiB\", MiB), maxObjectBytes: bytes(\"maxObjectMiB\", MiB), maxObjects: Math.floor(Number(f.maxObjects.value)), maxRawBytes: bytes(\"maxRawGiB\", GiB),\n maxTransactionRawBytes: bytes(\"maxTransactionRawMiB\", MiB), maxTransactionObjects: Math.floor(Number(f.maxTransactionObjects.value)), maxMetadataBytes: bytes(\"maxMetadataMiB\", MiB),\n maxRefs: Math.floor(Number(f.maxRefs.value)), maxGraphEdges: Math.floor(Number(f.maxGraphEdges.value)), maxFetchBytes: bytes(\"maxFetchGiB\", GiB),\n };\n policy = await rpc(\"setpolicy\", { git });\n toast(\"Git capacity saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n async function backupRequest(path, method, body) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const hash = await sha256hex(body || \"\");\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", location.origin + path], [\"method\", method], [\"payload\", hash]] });\n return fetch(path, { method, headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), ...(body ? { \"content-type\": \"application/json\" } : {}) }, body });\n }\n $(\"#backupnow\").onclick = guard(async () => { const id = $(\"#backupid\").value.trim() || \"backup\"; const r = await rpc(\"backupnow\", id); const resp = await backupRequest(\"/backups/\" + id, \"GET\"); if (!resp.ok) throw new Error(\"backup download failed\"); const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href); toast(\"Backup downloaded\"); await loadStorage(); void r; });\n async function selectedBackup() { const f = $(\"#backupfile\").files[0]; if (!f) throw new Error(\"Choose a backup archive first.\"); if (f.size > 8 * 1024 * 1024) throw new Error(\"Backups are limited to 8 MB.\"); return { file: f, body: await f.text() }; }\n let backupPreview = null;\n $(\"#backupfile\").onchange = () => { backupPreview = null; $(\"#backup-preview\").textContent = \"\"; $(\"#backuprestore\").disabled = true; };\n $(\"#backupremote\").onclick = () => showRemote(null);\n $(\"#backuppreview\").onclick = guard(async () => { const selected = await selectedBackup(); const r = await (await backupRequest(\"/backups/preview\", \"POST\", selected.body)).json(); if (r.error) throw new Error(r.error); backupPreview = { file: selected.file, body: selected.body }; $(\"#backuprestore\").disabled = false; const s = r.result.source; $(\"#backup-preview\").innerHTML = '<p><b>Ready to restore.</b> ' + esc(String(r.result.events)) + ' events, ' + esc(String(r.result.blobs)) + ' files, ' + esc(String(r.result.git)) + ' Git objects, ' + esc(fmtBytes(r.result.bytes)) + '.</p><p>Source relay identity: <code>' + esc(s.relayIdentity || \"unknown\") + '</code></p><pre>' + esc(JSON.stringify(r.result.config, null, 2)) + '</pre>'; });\n $(\"#backuprestore\").onclick = guard(async () => { const selected = await selectedBackup(); if (!backupPreview || backupPreview.file !== selected.file || backupPreview.body !== selected.body) throw new Error(\"Preview this exact archive before restoring.\"); if (!confirm(\"Restore this archive onto this fresh relay? This cannot be undone.\")) return; const r = await (await backupRequest(\"/backups/restore\", \"POST\", selected.body)).json(); if (r.error) throw new Error(r.error); me = await signer.getPublicKey(); localStorage.setItem(\"me\", me); toast(\"Backup restored\"); await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"deletebackup\" && !confirm(\"Delete this backup?\")) return;\n if (act === \"downloadbackup\") { b.disabled = true; try { await downloadBackup(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"deletebackup\") { b.disabled = true; try { await rpc(\"deletebackup\", id); toast(\"Backup deleted\"); await loadBackups(); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '<a class=\"btn\" href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\";\n const app = (label, uri) => '<a class=\"btn\" href=\"' + esc(uri) + '\">' + label + \"</a>\";\n const copy = (label, text) => '<button class=\"btn\" data-copytext=\"' + esc(text) + '\">' + label + \"</button>\";\n const row = (name, where, note, acts) => '<div class=\"app\"><div class=\"app-head\"><b>' + name + \"</b><small>\" + where + \"</small></div><p>\" + note + '</p><div class=\"app-acts\">' + acts.filter(Boolean).join(\"\") + \"</div></div>\";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n if (info?.supported_grasps?.includes(\"GRASP-01\")) groups.push([\n \"Git repositories\", \"Use this relay with a Git client.\", [\n row(\"GitWorkshop\", \"web\", \"Browse this relay's Git repositories.\", [link(\"Open in app\", \"https://gitworkshop.dev/relay/\" + enc((wsURL.startsWith(\"ws://\") ? \"ws:\" : \"\") + host)), copy(\"Copy relay URL\", wsURL)]),\n ],\n ]);\n el.innerHTML = groups.map(([h, note, rows]) => '<div class=\"appgroup\"><h4>' + h + '</h4><p class=\"note\">' + note + '</p><div class=\"appgrid\">' + rows.join(\"\") + \"</div></div>\").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '<div class=\"door\"><small>' + label + '</small><img src=\"/qr.svg?text=' + enc(text) + '\" alt=\"QR code: ' + esc(label) + '\" width=\"150\" height=\"150\"></div>';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('<a href=\"' + location.origin + '/\"><img src=\"' + location.origin + '/card.svg\" alt=\"' + host + '\" width=\"600\" height=\"315\"></a>'); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n";2+export const CONSOLE_HTML = "<main>\n <header class=\"mast\">\n <div class=\"banner hidden\" id=\"banner\"><img id=\"bannerimg\" alt=\"\"></div>\n <div class=\"wordmark\"><img id=\"iconimg\" class=\"icon hidden\" alt=\"\"><h1 id=\"title\"></h1></div>\n <div class=\"left\"><div class=\"care\" id=\"care\"></div></div>\n <div class=\"right\">\n <span class=\"urlrow\"><code class=\"pill\" id=\"url\"></code><button class=\"ib\" id=\"copy\" title=\"Copy relay URL\" aria-label=\"Copy relay URL\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></span>\n <span class=\"owner\"><i class=\"av hidden\" id=\"owner-av\"></i><span id=\"ownerline\"></span><em id=\"who\"></em>\n <button id=\"signin\" class=\"btn hidden\">Sign in with extension</button>\n <button id=\"signin46\" class=\"btn hidden\">Remote signer</button>\n <button id=\"signout\" class=\"ib hidden\" title=\"Sign out\" aria-label=\"Sign out\"><svg viewBox=\"0 0 24 24\"><path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4M15 8l5 4-5 4M20 12H9\"/></svg></button>\n </span>\n </div>\n </header>\n\n <section id=\"unclaimed\" class=\"hidden\">\n <h2>Nobody owns this relay yet.</h2>\n <p class=\"lead\">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>\n <div class=\"row\"><button id=\"claim\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"claimnote\">Use a remote signer</button><span class=\"note\" id=\"claimnote\"></span></div>\n <div class=\"block\">\n <h3>Restore a backup</h3>\n <p class=\"note\">Choose a private bind.ws archive to inspect it before restoring. Preview does not claim this relay. Restore accepts only an empty relay and the archive owner's signature.</p>\n <div class=\"form\">\n <label class=\"wide\"><span>Archive <small>up to 8 MB</small></span><input class=\"txt\" type=\"file\" id=\"backupfile\" accept=\"application/json\"></label>\n </div>\n <div class=\"actions\"><button type=\"button\" id=\"backupremote\" class=\"btn\">Use a remote signer</button><button type=\"button\" id=\"backuppreview\" class=\"btn\">Preview restore</button><button type=\"button\" id=\"backuprestore\" class=\"btn pri\" disabled>Restore</button></div>\n <div id=\"backup-preview\" class=\"note\"></div>\n </div>\n </section>\n\n <section id=\"leased\" class=\"hidden\">\n <h2>A temporary relay, for now.</h2>\n <p class=\"lead\">Anyone can read and write here until <b id=\"lease-until\"></b>. Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.</p>\n <div class=\"row\"><button id=\"claimlease\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"leasenote\">Use a remote signer</button><span class=\"note\" id=\"leasenote\"></span></div>\n </section>\n\n <section id=\"remotesec\" class=\"hidden\">\n <h2>Connect a remote signer.</h2>\n <p class=\"lead\">Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the <span class=\"mono\">bunker://</span> URL the app gives you.</p>\n <div class=\"row\"><a id=\"nclink\" class=\"btn pri\" href=\"#\">Open in your signer app</a><button id=\"nccopy\" class=\"btn\">Copy link</button><span class=\"note\" id=\"ncnote\"></span></div>\n <form id=\"bunkerform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"bunker://…\" required><button class=\"btn\">Connect</button></form>\n <img id=\"ncqr\" class=\"hidden\" alt=\"QR code of the signer link\" width=\"180\" height=\"180\">\n <p class=\"note\" id=\"remotenote\"></p>\n </section>\n\n <section id=\"foldsec\" class=\"folds\">\n <div id=\"frontsec\" class=\"hidden\">\n <p class=\"lead hidden\" id=\"desc\"></p>\n <p class=\"metarow hidden\" id=\"metarow\"></p>\n </div>\n <details class=\"disclosure\" id=\"aboutsec\" name=\"page\"><summary>About, for clients</summary><div id=\"about\" class=\"about\"></div></details>\n <details class=\"disclosure\" id=\"connectsec\" name=\"page\"><summary>Connect</summary>\n <div class=\"door relay\"><small>nostr relay</small><div class=\"v\"><span id=\"d-ws\"></span><button class=\"ib\" data-copy=\"ws\" 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></div></div>\n <p class=\"note\">Each shortcut hands this relay to an app, with a QR code for a phone. Whichever app, add the relay URL above under its relay settings to post here.</p>\n <div class=\"appgrid\" id=\"apps\"></div>\n <p class=\"note\" id=\"apps-note\"></p>\n <details class=\"sub\" id=\"conn-details\"><summary>Connection details</summary>\n <div class=\"doors\">\n <div class=\"door\"><small>Blossom media</small><div class=\"v\"><span id=\"d-blossom\"></span><button class=\"ib\" data-copy=\"blossom\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>names</small><div class=\"v\"><span id=\"d-nip05\"></span></div></div>\n <div class=\"door\"><small>HTTP bridge, NIP-98</small><div class=\"v\"><span>POST /events, /query, /count</span></div></div>\n </div>\n <div class=\"block hidden\" id=\"git-connect\">\n <h3>Git repositories, ntig</h3>\n <div>\n <p class=\"note\">Use ordinary Git to clone a repository hosted here.</p>\n <div class=\"door\"><small>Clone a repository</small><div class=\"v\"><span id=\"git-clone\"></span><button class=\"ib\" id=\"git-copy\" title=\"Copy clone example\" aria-label=\"Copy clone example\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <p class=\"note\">Replace <span class=\"mono\"><npub></span> with the repository owner's npub, yours for your own repository, and <span class=\"mono\"><repo></span> with its repository name, such as <span class=\"mono\">my-project</span>.</p>\n <p class=\"note\">To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. <a href=\"https://github.com/FelineStateMachine/bindws/blob/main/docs/22-grasp-01-git-hosting.md\">Git hosting guide</a>.</p>\n </div>\n </div>\n </details>\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=\"#connect\" data-tab=\"connect\">Connect</a>\n <a href=\"#data\" data-tab=\"data\">Data</a>\n <a href=\"#sync\" data-tab=\"sync\">Sync</a>\n <a href=\"#views\" data-tab=\"views\">Views</a>\n <a href=\"#health\" data-tab=\"health\">Health</a>\n <a href=\"#owner\" data-tab=\"owner\">Owner</a>\n </nav>\n\n <section class=\"panel\" data-panel=\"people\">\n <h2>People</h2>\n <p class=\"lead\">The member list is published as a signed roster; a name makes someone <span class=\"mono\" id=\"nip05-example\"></span>.</p>\n <div class=\"block\">\n <form id=\"addmember\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"name\" placeholder=\"name (optional)\" pattern=\"[a-z0-9._-]{1,64}\" style=\"max-width:9rem\"><input class=\"txt\" name=\"note\" placeholder=\"note\" style=\"max-width:9rem\"><button class=\"ib pri\" title=\"Add member\" aria-label=\"Add member\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <div class=\"scroll\"><table id=\"members\" class=\"people-table\"><thead><tr><th>Who</th><th>Name</th><th>Note</th><th>Limits</th><th>Joined</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <div class=\"two\">\n <div>\n <h3>Invites</h3>\n <form id=\"mintinvite\" class=\"addrow\"><select class=\"txt\" name=\"ttl\" style=\"max-width:8rem\"><option value=\"3600\">1 hour</option><option value=\"86400\">1 day</option><option value=\"259200\" selected>3 days</option><option value=\"2592000\">30 days</option></select><input class=\"txt num\" type=\"number\" name=\"max\" min=\"0\" value=\"0\" title=\"uses, 0 = unlimited\" style=\"width:5rem;flex:0 0 auto\"><input class=\"txt\" name=\"note\" placeholder=\"note\"><button class=\"ib pri\" title=\"New invite\" aria-label=\"New invite\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <ul id=\"invites\" class=\"plain\"></ul>\n <form id=\"treeform\" class=\"addrow hidden\" title=\"Members invite members: how many hops from you the tree reaches, and how many live invites each member may hold\"><span class=\"dim\">members invite</span><input class=\"txt num\" type=\"number\" name=\"depth\" min=\"0\" max=\"10\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">hops deep,</span><input class=\"txt num\" type=\"number\" name=\"quota\" min=\"0\" max=\"100\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">each</span><button class=\"btn\">Save</button></form>\n </div>\n <div>\n <h3>Joining</h3>\n <form id=\"joinform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Shown before someone accepts an invite</span><textarea name=\"joinTerms\" placeholder=\"House rules, expectations, anything a newcomer should agree to.\" maxlength=\"20000\"></textarea></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"directoryPublic\"> Show the people directory to visitors</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save</button></div>\n </form>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"moderation\">\n <h2>Moderation</h2>\n <p class=\"lead\">Reports never show in the feed. Banning also deletes the reported thing.</p>\n <div class=\"block\">\n <h3>Reports <span class=\"count\" id=\"reports-count\"></span></h3>\n <form id=\"thresholdform\" class=\"addrow\"><span class=\"dim\">hide an event once</span><input class=\"txt num\" type=\"number\" name=\"reportThreshold\" min=\"0\" max=\"100\" style=\"width:4.5rem;flex:0 0 auto\"><span class=\"dim\">different people report it; 0 never</span><button class=\"btn\">Save</button></form>\n <div class=\"scroll\"><table id=\"reports\" class=\"events\"><thead><tr><th>Time</th><th>Type</th><th>About</th><th>Reason</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Log</h3>\n <p class=\"dim\">Every change made here or by a moderation event, newest first, the last 5,000.</p>\n <div class=\"scroll\"><table id=\"audit\" class=\"events\"><thead><tr><th>Time</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead><tbody></tbody></table></div>\n <button class=\"btn\" id=\"audit-more\" type=\"button\">Older</button>\n </div>\n <div class=\"block two\">\n <div>\n <h3>Bans</h3>\n <form id=\"addban\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><label class=\"note\" title=\"Also delete everything they wrote and uploaded\"><input type=\"checkbox\" name=\"erase\"> erase</label><button class=\"ib danger\" title=\"Ban\" aria-label=\"Ban\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"bans\" class=\"plain\"></ul>\n <ul id=\"banned-events\" class=\"plain\"></ul>\n </div>\n <div>\n <h3>Blocked addresses</h3>\n <form id=\"addblock\" class=\"addrow\"><input class=\"txt mono\" name=\"ip\" placeholder=\"IP address\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><button class=\"ib danger\" title=\"Block\" aria-label=\"Block\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"blocks\" class=\"plain\"></ul>\n </div>\n </div>\n <div class=\"block\">\n <h3>Recent events</h3>\n <p class=\"note\">Delete removes one thing. Ban also refuses it forever. Search covers notes, articles and profiles.</p>\n <form id=\"searchform\" class=\"addrow\"><input class=\"txt\" name=\"q\" placeholder=\"search\" maxlength=\"200\"><button class=\"btn\">Search</button><button type=\"button\" id=\"searchclear\" class=\"btn hidden\">Clear</button></form>\n <div class=\"scroll\"><table id=\"events\" class=\"events\"><thead><tr><th>Time</th><th>Kind</th><th>Author</th><th>Content</th><th></th></tr></thead><tbody></tbody></table></div>\n <div class=\"actions left\"><button id=\"more\" class=\"btn\">Load more</button></div>\n </div>\n <div class=\"block\">\n <h3>Pinned <span class=\"count\" id=\"pins-count\"></span></h3>\n <p class=\"note\">Group clients show these at the top. Up to 20, in this order.</p>\n <form id=\"pinform\" class=\"addrow\"><input class=\"txt mono\" name=\"id\" placeholder=\"event id or address kind:pubkey:d\" required><button class=\"btn\">Pin</button></form>\n <ul id=\"pins\" class=\"plain\"></ul>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"rules\">\n <h2>Rules</h2>\n <p class=\"lead\">Bans apply regardless of these.</p>\n <div class=\"block\">\n <h3>Presets</h3>\n <p class=\"note\">One click sets writes, reads, kinds and keep-for together. Limits, identity and people stay.</p>\n <div id=\"presets\"></div>\n <div class=\"row hidden\" id=\"presetsourcerow\"><input class=\"txt mono\" id=\"presetsource\" placeholder=\"wss://source.relay to mirror\" style=\"max-width:24rem\"><span class=\"note\">Replica presets keep a standing pull of their kinds from this relay.</span></div>\n <p class=\"note\" id=\"presetnote\"></p>\n </div>\n <form id=\"access\">\n <div class=\"two\">\n <div><h3>Writes</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"writes\" value=\"open\"><b>Anyone</b><small>Every valid event is accepted, unless banned.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"allowlist\"><b>Members</b><small>Only people on the member list, and you.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"wot\"><b>Members and their follows</b><small>Members, plus everyone in a member's contact list.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"owner\"><b>Only me</b><small>Nobody else can post.</small></label>\n </div></div>\n <div><h3>Reads</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"reads\" value=\"open\"><b>Anyone</b><small>DMs and gift wraps still go only to their parties.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"auth\"><b>Authenticated only</b><small>Every query needs a NIP-42 sign-in first.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"members\"><b>Members only</b><small>Removing a member ends their subscriptions.</small></label>\n </div></div>\n </div>\n <div class=\"form\" style=\"margin-top:1rem\">\n <label><span>Open to anyone <small>kinds anyone may write, whatever the rule above says</small></span><input class=\"txt mono\" name=\"openKinds\" placeholder=\"7, 9735\"></label>\n <label class=\"switch\"><input type=\"checkbox\" name=\"guestReplies\"> Anyone may reply to a member's note</label>\n </div>\n <div class=\"limits\">\n <label><span>Minimum proof of work <small>bits</small></span><input class=\"txt num\" type=\"number\" name=\"minPow\" min=\"0\" max=\"64\"></label>\n <label><span>Reject events dated in the future by more than <small>seconds</small></span><input class=\"txt num\" type=\"number\" name=\"maxFuture\" min=\"0\"></label>\n <label><span>Max events per query</span><input class=\"txt num\" type=\"number\" name=\"maxLimit\" min=\"1\" max=\"5000\"></label>\n <label><span>Max subscriptions per connection</span><input class=\"txt num\" type=\"number\" name=\"maxSubs\" min=\"1\" max=\"200\"></label>\n <label><span>Max message size <small>KB</small></span><input class=\"txt num\" type=\"number\" name=\"maxMessageKB\" min=\"16\" max=\"1024\"></label>\n <label><span>Events per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"eventsPerMinute\" min=\"1\"></label>\n <label><span>Queries per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"reqsPerMinute\" min=\"1\"></label>\n <label><span>Max upload size <small>MB</small></span><input class=\"txt num\" type=\"number\" name=\"maxBlobMB\" min=\"1\" max=\"95\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save rules</button></div>\n </form>\n <form id=\"git-capacity\" class=\"block\">\n <h3>Git capacity</h3>\n <p class=\"note\">GRASP Git has its own retained-storage and request limits. Storage and compute still use the relay’s normal fuel and billing. Git transfer limits are independent from event and file limits.</p>\n <div class=\"limits\">\n <label><span>Repositories <small>maximum on this relay</small></span><input class=\"txt num\" type=\"number\" name=\"maxRepositories\" min=\"1\" max=\"4096\" required></label>\n <label><span>Relay Git storage <small>GiB, including metadata</small></span><input class=\"txt num\" type=\"number\" name=\"maxRelayGiB\" min=\"0.000001\" max=\"6\" step=\"0.001\" required></label>\n <label><span>Compressed repository data <small>GiB, per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxCompressedGiB\" min=\"0.000001\" max=\"6\" step=\"0.001\" required></label>\n <label><span>Pack size <small>MiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxPackMiB\" min=\"0.000001\" max=\"95\" step=\"0.001\" required></label>\n <label><span>Object size <small>MiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxObjectMiB\" min=\"0.000001\" max=\"64\" step=\"0.001\" required></label>\n </div>\n <details class=\"advanced\"><summary>Advanced Git admission and fetch limits</summary><div class=\"limits\">\n <label><span>Objects <small>per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxObjects\" min=\"1\" max=\"500000\" required></label>\n <label><span>Raw bytes <small>per repository, GiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxRawGiB\" min=\"0.000001\" max=\"64\" step=\"0.001\" required></label>\n <label><span>Transaction bytes <small>MiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxTransactionRawMiB\" min=\"0.000001\" max=\"64\" step=\"0.001\" required></label>\n <label><span>Transaction objects</span><input class=\"txt num\" type=\"number\" name=\"maxTransactionObjects\" min=\"1\" max=\"100000\" required></label>\n <label><span>Metadata <small>MiB per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxMetadataMiB\" min=\"0.000001\" max=\"1024\" step=\"0.001\" required></label>\n <label><span>Refs <small>per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxRefs\" min=\"1\" max=\"16384\" required></label>\n <label><span>Graph edges <small>per repository</small></span><input class=\"txt num\" type=\"number\" name=\"maxGraphEdges\" min=\"1\" max=\"4000000\" required></label>\n <label><span>Fetch response <small>GiB</small></span><input class=\"txt num\" type=\"number\" name=\"maxFetchGiB\" min=\"0.000001\" max=\"6\" step=\"0.001\" required></label>\n </div></details>\n <div class=\"actions\"><button class=\"btn pri\">Save Git capacity</button></div>\n </form>\n <div class=\"block\">\n <h3>Kinds</h3>\n <p class=\"note\">An empty allow list means every kind. Blocks always win.</p>\n <form id=\"kindform\" class=\"addrow\"><input class=\"txt num\" type=\"number\" name=\"kind\" min=\"0\" max=\"65535\" placeholder=\"kind\" required style=\"width:7rem;flex:0 0 auto\"><button class=\"ib\" name=\"rule\" value=\"allow\" title=\"Allow kind\" aria-label=\"Allow kind\"><svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg></button><button class=\"ib danger\" name=\"rule\" value=\"block\" title=\"Block kind\" aria-label=\"Block kind\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <p class=\"kindline\">Allowed: <span id=\"kinds-allow\"></span></p>\n <p class=\"kindline\">Blocked: <span id=\"kinds-block\"></span></p>\n </div>\n <div class=\"block\">\n <h3>Features</h3>\n <p class=\"note\">Each is a door or a cost. Off leaves the NIP-11 list, answers 404 at its door and is refused at the socket.</p>\n <div id=\"features\" class=\"limits\"></div>\n <form id=\"push-policy-form\" class=\"limits\">\n <label><span>Push callback origins <small>One HTTPS origin per line, also approved by your host operator. Relay push costs fuel.</small></span><textarea class=\"txt\" name=\"origins\" rows=\"3\" placeholder=\"https://push.example.com\"></textarea></label>\n <label><span>Lettered NIP advertisements <small>Some older clients reject mixed NIP identifiers. Relay push requires them.</small></span><input type=\"checkbox\" name=\"lettered\"></label>\n <label><span>Automatic NIP-65 delivery <small>Send your public events to your write relays and tagged people's read relays. Each event is bounded to a few targets.</small></span><input type=\"checkbox\" name=\"delivery\"></label>\n <label><span>Maximum delivery targets <small>Per event</small></span><input class=\"txt num\" type=\"number\" name=\"deliveryMax\" min=\"1\" max=\"16\" value=\"8\" style=\"width:5rem\"></label>\n <div class=\"actions\"><button class=\"btn\">Save delivery policy</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Blocked words</h3>\n <p class=\"note\">Content containing one is refused. You and your moderators are exempt. An entry written /like this/ is a regular expression.</p>\n <form id=\"wordsform\"><textarea class=\"txt\" name=\"words\" rows=\"4\" placeholder=\"one word, phrase or /pattern/ per line\"></textarea><label class=\"switch\"><input type=\"checkbox\" name=\"inTags\"> Also search tags</label><div class=\"actions\"><button class=\"btn\">Save words</button></div></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"identity\">\n <h2>Identity</h2>\n <form id=\"identity\">\n <h3>Profile</h3>\n <div class=\"form\">\n <label><span>Name</span><input class=\"txt\" name=\"name\" maxlength=\"200\"></label>\n <label><span>Contact</span><input class=\"txt\" name=\"contact\" placeholder=\"mailto:you@example.com\" maxlength=\"500\"></label>\n <label class=\"wide\"><span>Description</span><textarea name=\"description\" maxlength=\"2000\"></textarea></label>\n <label class=\"wide\"><span>Icon URL</span><input class=\"txt\" name=\"icon\" placeholder=\"https://…/icon.png\" maxlength=\"2000\"></label>\n <label class=\"wide\"><span>Banner URL</span><input class=\"txt\" name=\"banner\" placeholder=\"https://…/banner.png\" maxlength=\"2000\"></label>\n </div>\n <h3 class=\"gap\">For directories</h3>\n <div class=\"form\">\n <label><span>Posting policy URL</span><input class=\"txt\" name=\"postingPolicy\" placeholder=\"https://…/posting\" maxlength=\"2000\"></label>\n <label><span>Privacy policy URL</span><input class=\"txt\" name=\"privacyPolicy\" placeholder=\"https://…/privacy\" maxlength=\"2000\"></label>\n <label><span>Tags</span><input class=\"txt\" name=\"tags\" placeholder=\"bitcoin, cats\"></label>\n <label><span>Languages</span><input class=\"txt\" name=\"languageTags\" placeholder=\"en, pt-BR\"></label>\n <label><span>Countries</span><input class=\"txt\" name=\"relayCountries\" placeholder=\"US, DE\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Your own domain</h3>\n <p class=\"note\">Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.</p>\n <form id=\"adddomain\"><div class=\"form\"><label><span>Hostname</span><input class=\"txt mono\" name=\"host\" placeholder=\"relay.example.com\" required></label><label><span>Destination</span><select class=\"txt\" name=\"site\"><option value=\"\">This relay</option></select></label></div><div class=\"actions\"><button class=\"btn\">Add domain</button></div></form>\n <div id=\"domains\"></div>\n <p class=\"note\" id=\"domainnote\"></p>\n </div>\n <div class=\"block\">\n <h3>Your relay lists</h3>\n <p class=\"note\">Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>\n <div id=\"wire\"></div>\n </div>\n <div class=\"block\">\n <h3>Share</h3>\n <p class=\"note\">A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.</p>\n <div class=\"share\">\n <a href=\"/card.svg\" target=\"_blank\" rel=\"noopener\"><img id=\"cardimg\" src=\"/card.svg\" alt=\"relay card\" width=\"600\" height=\"315\"></a>\n <div class=\"share-side\">\n <img id=\"naddrqr\" class=\"hidden\" alt=\"QR code of the group address\" width=\"150\" height=\"150\">\n <code class=\"mono naddr\" id=\"naddr\"></code>\n <div class=\"row\"><button class=\"btn\" id=\"copynaddr\">Copy naddr</button><button class=\"btn\" id=\"copyembed\">Copy embed</button></div>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"connect\">\n <h2>Connect</h2>\n <p class=\"lead\">The shortcuts on the Connect fold: which apps, in what order, and who sees each.</p>\n <div class=\"block\">\n <div class=\"conn-bar\"><h3>Shortcuts</h3><span class=\"note\" id=\"connnote\"></span><button type=\"button\" id=\"connsave\" class=\"btn pri\" disabled>Save shortcuts</button></div>\n <p class=\"note\">Top to bottom is the order visitors see. Edit sets a title of your own and the template's inputs; a shortcut whose feature is off waits until it is switched on.</p>\n <div id=\"conns\" class=\"conns\"></div>\n </div>\n <div class=\"block\">\n <h3>Library</h3>\n <p class=\"note\">One template per app, from the repository's connection-templates folder; the relay URL, the owner's profile and the viewer's own key fill in when the page opens.</p>\n <div id=\"connlib\" class=\"appgrid\"></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"data\">\n <h2>Data</h2>\n <p class=\"lead\">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>\n <div class=\"block\" id=\"listhistoryblock\">\n <h3>Recover your lists</h3>\n <p class=\"note\">Older signed versions of your follows, relay lists and bookmarks stay private here. Restore a version to review it, then sign and publish it from this relay.</p>\n <div class=\"scroll\"><table id=\"listhistory\" class=\"events\"><thead><tr><th>List</th><th>Created</th><th>Saved</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"kbar\" id=\"s-bar\"></div><span class=\"legend slegend\" id=\"s-legend\"></span>\n <div class=\"usage\" id=\"s-totals\"></div>\n <div class=\"block\">\n <h3>By kind</h3>\n <div class=\"scroll\"><table id=\"kinds\" class=\"events\"><thead><tr><th>Kind</th><th class=\"r\">Count</th><th class=\"r\">Size</th><th>Oldest</th><th>Keep for</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Files <span class=\"count\" id=\"blobs-count\"></span></h3>\n <div class=\"scroll\"><table id=\"blobs\" class=\"events\"><thead><tr><th>Time</th><th>File</th><th>Size</th><th>Uploader</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Sites <span class=\"count\" id=\"sites-count\"></span></h3>\n <p class=\"note\">Published NIP-5A manifests and the hostnames where they are served.</p>\n <div class=\"scroll\"><table id=\"sites\" class=\"events\"><thead><tr><th>Author</th><th>Name</th><th>URL</th><th class=\"r\">Files</th><th class=\"r\">Size</th><th>Expiry</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Dumps <span class=\"count\" id=\"dumps-count\"></span></h3>\n <p class=\"note\">Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.</p>\n <form id=\"dumpform\" class=\"addrow\"><select class=\"txt\" name=\"dumps\" style=\"max-width:8rem\"><option value=\"off\">off</option><option value=\"daily\">daily</option><option value=\"weekly\">weekly</option></select><input class=\"txt num\" type=\"number\" name=\"keep\" min=\"1\" max=\"60\" value=\"7\" title=\"how many to keep\" style=\"width:5rem;flex:0 0 auto\"><button class=\"btn\">Save</button><button type=\"button\" id=\"dumpnow\" class=\"btn\">Dump now</button></form>\n <ul id=\"dumps\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Backups <span class=\"count\" id=\"backups-count\"></span></h3>\n <p class=\"note\">Portable copies include configuration, events, site files and Git objects. They are private and bounded at 8 MB.</p>\n <div class=\"form\"><label><span>Archive name</span><input class=\"txt mono\" id=\"backupid\" placeholder=\"backup\" value=\"backup\"></label></div>\n <div class=\"actions\"><button type=\"button\" id=\"backupnow\" class=\"btn\">Create and download</button></div>\n <ul id=\"backups\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Import a file</h3>\n <p class=\"note\">A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.</p>\n <form id=\"importform\" class=\"addrow\"><input class=\"txt\" type=\"file\" name=\"file\" accept=\".jsonl,.json,.ndjson,application/json\" required><button class=\"btn\">Import</button></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"sync\">\n <h2>Sync</h2>\n <p class=\"lead\">Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.</p>\n <div class=\"block\">\n <h3>Jobs</h3>\n <p class=\"note\">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>\n <div class=\"scroll\"><table id=\"jobs\" class=\"events\"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>\n\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><select class=\"txt\" name=\"every\" style=\"max-width:9rem\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select><button class=\"btn\">Pull</button></form>\n <form id=\"backfillform\" class=\"addrow\"><input class=\"txt mono\" name=\"relays\" placeholder=\"relays, comma separated; blank uses your relay list\"><button class=\"btn\">Fetch my history</button></form>\n <form id=\"pushform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Rebroadcast to</span><input class=\"txt mono\" name=\"targets\" placeholder=\"wss://a.relay, wss://b.relay\" required></label>\n <label><span>Kinds <small>blank for all</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label><span>Newer than <small>days, blank for all</small></span><input class=\"txt num\" type=\"number\" name=\"days\" min=\"0\"></label>\n <label><span>Schedule</span><select class=\"txt\" name=\"every\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Rebroadcast</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Automatic delivery</h3>\n <p class=\"note\">Recent per-target results for automatic NIP-65 delivery.</p>\n <div class=\"scroll\"><table id=\"deliveries\"><thead><tr><th>Event</th><th>Target</th><th>Status</th><th>Attempts</th><th>Last error</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Fork this relay</h3>\n <p class=\"note\">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>\n <form id=\"forkform\">\n <div class=\"form\">\n <label><span>Name <small>optional</small></span><input class=\"txt mono\" name=\"name\" placeholder=\"a memorable one if blank\" pattern=\"[a-z0-9-]{3,32}\"></label>\n <label><span>Who claims it <small>npub or hex, you if blank</small></span><input class=\"txt mono\" name=\"holder\"></label>\n <label><span>What to copy</span><select class=\"txt\" name=\"scope\"><option value=\"all\">everything</option><option value=\"mine\">only my events</option><option value=\"kinds\">these kinds</option></select></label>\n <label id=\"forkkinds\" class=\"hidden\"><span>Kinds <small>comma separated</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"people\" checked> Copy the people</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Fork</button></div>\n </form>\n <p class=\"note\" id=\"forknote\"></p>\n <div class=\"row hidden\" id=\"forkresult\"><span class=\"mono\" id=\"forkurl\"></span><button class=\"ib\" id=\"forkcopy\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"views\">\n <h2>Views</h2>\n <p class=\"lead\">Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.</p>\n <div class=\"block\">\n <div id=\"views\"></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"health\">\n <h2>Health</h2>\n <div class=\"counters\">\n <div class=\"counter\"><b class=\"big\" id=\"h-last\">–</b><small>since last event</small><span class=\"sub\" id=\"h-last-sub\"></span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-conns\">–</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">–</b><small id=\"h-fuel-label\">fuel</small><span class=\"sub\" id=\"h-fuel-sub\"></span></div>\n <div class=\"counter\"><div class=\"kbar\" id=\"h-kinds\"></div><small>used for, last 30 days</small><span class=\"sub legend\" id=\"h-kinds-legend\"></span></div>\n </div>\n <div class=\"usage\" id=\"usage\"></div>\n <div class=\"block\">\n <h3>Zaps received</h3>\n <div class=\"scroll\"><table id=\"credits\"><thead><tr><th>When</th><th>From</th><th class=\"r\">Sats</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Notifications</h3>\n <p class=\"note\">The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.</p>\n <form id=\"notify\">\n <div class=\"form\">\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"reports\"> A report arrives</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"fuel\"> Fuel runs low</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"jobs\"> A pull finishes</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"succession\"> The handover clock is running</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"digest\"> A note every week on how the relay is doing</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save notifications</button><button type=\"button\" id=\"notifytest\" class=\"btn\">Send a test message</button></div>\n </form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"owner\">\n <h2>Owner</h2>\n <p class=\"lead\">The relay's key, events, files and fuel stay put through everything here except delete.</p>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.</p>\n <div class=\"row\"><button id=\"exportcfg\" class=\"btn\">Export configuration</button><button id=\"importcfg\" class=\"btn\">Import configuration</button><input type=\"file\" id=\"cfgfile\" accept=\"application/json,.json\" class=\"hidden\"></div>\n </div>\n <div class=\"block danger-zone\">\n <h3>Transfer ownership</h3>\n <p class=\"note\">Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.</p>\n <form id=\"transfer\" class=\"addrow\"><select class=\"txt mono\" name=\"pubkey\" required></select><button class=\"btn danger\">Transfer</button></form>\n </div>\n <div class=\"block danger-zone\">\n <h3>If I lose my key</h3>\n <p class=\"note\">Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.</p>\n <form id=\"succession\">\n <div class=\"form\">\n <label><span>Heir <small>a member</small></span><select class=\"txt mono\" name=\"heir\" required></select></label>\n <label><span>Hand over</span><select class=\"txt\" name=\"afterDays\"><option value=\"90\">after 3 months away</option><option value=\"180\" selected>after 6 months away</option><option value=\"365\">after a year away</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Set heir</button><button type=\"button\" id=\"clearsuccession\" class=\"btn hidden\">Clear</button></div>\n </form>\n <p class=\"note\" id=\"successionnote\"></p>\n </div>\n <div class=\"block danger-zone\">\n <h3>Delete this relay</h3>\n <p class=\"note\">Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.</p>\n <button id=\"deleterelay\" class=\"btn danger\">Delete this relay</button>\n </div>\n </section>\n </div>\n\n <footer class=\"pg\"><p>a bind.ws relay</p><a href=\"https://github.com/FelineStateMachine/bindws\">source</a></footer>\n</main>\n";3+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(3, 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.appgrid { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; } @media (max-width: 40rem) { .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; align-items: center; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; } .app-acts .btn svg { margin-right: .3rem; }\n#apps { margin-top: .8rem; } #apps-note:empty { display: none; }\n.door.relay { margin-bottom: .8rem; } .door.relay .v { font-size: 14px; }\n.app-ic { display: inline-grid; place-items: center; width: 30px; height: 30px; border: 2px solid var(--ink); border-radius: 9px; background: var(--sun); box-shadow: 2px 2px 0 var(--ink); flex: 0 0 auto; user-select: none; -webkit-user-select: none; }\n.app-ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }\n.app-head { align-items: center; } .app-head .pill { margin-left: auto; font-size: 11px; }\n.app-qr { margin-top: .4rem; } .app-qr img { display: block; width: 150px; height: 150px; border: 2px solid var(--ink); border-radius: 10px; background: #fff; }\n.app .who-note { margin: 0; font-size: 12px; color: var(--ink-3); }\n/* The rest of a shortcut's links, behind a disclosure so one Open leads. */\ndetails.more summary { cursor: pointer; list-style: none; font: 600 12px var(--sans); color: var(--ink-2); } details.more summary::-webkit-details-marker { display: none; }\ndetails.more summary::before { content: \"+ \"; font-family: var(--mono); } details.more[open] summary::before { content: \"- \"; }\n.app-more { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .4rem; } .app-more .btn { padding: .3rem .7rem; font-size: 13px; }\n/* The doors, the bridge and the Git example, folded under the apps. */\ndetails.sub { margin-top: 1.4rem; border-top: 2px dashed var(--line); padding-top: 1rem; } details.sub > summary { cursor: pointer; list-style: none; font: 600 14px var(--sans); }\ndetails.sub > summary::-webkit-details-marker { display: none; } details.sub > summary::before { content: \"+ \"; font-family: var(--mono); } details.sub[open] > summary::before { content: \"- \"; }\ndetails.sub > .doors { margin-top: .8rem; }\n/* The owner's list: one row per shortcut, the fields of the one being edited below it. */\n.conn-bar { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: .8rem; padding: .4rem 0; background: var(--paper); } .conn-bar h3 { margin: 0; flex: 1; } .conn-bar .note:empty { display: none; }\n.conns { display: grid; gap: .5rem; }\n.conn { display: grid; grid-template-columns: auto 1fr auto auto; gap: .6rem; align-items: center; padding: .5rem .7rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); }\n.conn-main { min-width: 0; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; } .conn-main b { font-size: 14px; } .conn-main small { color: var(--ink-3); } .conn-main .pill { font-size: 11px; }\n.conn-vis { width: auto; font-size: 13px; padding: .25rem 1.8rem .25rem .5rem; }\n.conn-acts { display: flex; gap: .3rem; } .conn-acts .ib { width: 30px; height: 30px; box-shadow: 1px 1px 0 var(--ink); } .conn-acts .ib svg { width: 13px; height: 13px; } .conn.editing .ib[data-conn=\"edit\"] { background: var(--sun); }\n.conn-edit { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .5rem 1rem; padding: .6rem 0 .2rem; border-top: 2px dashed var(--line); }\n.conn-edit label { display: grid; gap: .2rem; font-size: 12px; font-weight: 600; color: var(--ink-2); } .conn-edit input.txt { font-size: 14px; padding: .3rem .5rem; } .conn-edit input:invalid { box-shadow: 0 0 0 3px var(--red-soft); }\n.conns .empty { color: var(--ink-3); margin: 0; }\n@media (max-width: 40rem) { .conn { grid-template-columns: auto 1fr; } .conn-vis, .conn-acts { grid-column: 2; } .conn-acts { justify-content: flex-end; } }\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: center; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; white-space: nowrap; overflow-x: auto; scrollbar-width: thin; padding-bottom: 2px; }\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/* The storage bar follows the list-history block, so it keeps a block's distance. */\n#listhistoryblock + .kbar { margin-top: 1.8rem; }\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 MiB = 1024 * 1024, GiB = 1024 * MiB;\n const units = (n, divisor) => n == null ? \"\" : (n / divisor).toFixed(3).replace(/0+$/, \"\").replace(/\\.$/, \"\");\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]);\n\n const IC = {\n x: '<svg viewBox=\"0 0 24 24\"><path d=\"M6 6l12 12M18 6L6 18\"/></svg>',\n undo: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 10h10a5 5 0 0 1 0 10h-3M4 10l4-4M4 10l4 4\"/></svg>',\n check: '<svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg>',\n trash: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 7h16M6 7l1.2 13h9.6L18 7\"/></svg>',\n ban: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg>',\n banuser: '<svg viewBox=\"0 0 24 24\"><circle cx=\"10\" cy=\"8\" r=\"4\"/><path d=\"M3 21a7 7 0 0 1 11-5.7M15 15l6 6M21 15l-6 6\"/></svg>',\n eye: '<svg viewBox=\"0 0 24 24\"><path d=\"M2 12s4-6 10-6 10 6 10 6-4 6-10 6S2 12 2 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>',\n lock: '<svg viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10\" width=\"14\" height=\"10\" rx=\"2\"/><path d=\"M8 10V7a4 4 0 0 1 8 0v3\"/></svg>',\n pen: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 20l4-1 11-11-3-3L5 16l-1 4zM13 8l3 3\"/></svg>',\n people: '<svg viewBox=\"0 0 24 24\"><circle cx=\"9\" cy=\"8\" r=\"3.5\"/><circle cx=\"17\" cy=\"9\" r=\"2.5\"/><path d=\"M3 20a6 6 0 0 1 12 0M15 20a4.5 4.5 0 0 1 7-3.5\"/></svg>',\n person: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/></svg>',\n gauge: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 16a8 8 0 0 1 16 0M12 16l4-5\"/><circle cx=\"12\" cy=\"16\" r=\"1.2\"/></svg>',\n bolt: '<svg viewBox=\"0 0 24 24\"><path d=\"M13 3L5 14h6l-1 7 8-11h-6z\"/></svg>',\n copy: '<svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg>',\n pin: '<svg viewBox=\"0 0 24 24\"><path d=\"M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6zM12 15v5\"/></svg>',\n up: '<svg viewBox=\"0 0 24 24\"><path d=\"M12 19V5M5 12l7-7 7 7\"/></svg>',\n down: '<svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12l7 7 7-7\"/></svg>',\n qr: '<svg viewBox=\"0 0 24 24\"><rect x=\"4\" y=\"4\" width=\"6\" height=\"6\"/><rect x=\"14\" y=\"4\" width=\"6\" height=\"6\"/><rect x=\"4\" y=\"14\" width=\"6\" height=\"6\"/><path d=\"M14 14h3v3M20 14v6h-3M14 20h1\"/></svg>',\n // The connection template icons (connections.ts, ICONS): a template names one.\n notes: '<svg viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"3\" width=\"14\" height=\"18\" rx=\"2\"/><path d=\"M9 8h6M9 12h6M9 16h3\"/></svg>',\n blog: '<svg viewBox=\"0 0 24 24\"><path d=\"M20 4c-6 0-11 4-13 10l-3 6 6-3c6-2 10-7 10-13z\"/><path d=\"M8 16l6-6\"/></svg>',\n bookmark: '<svg viewBox=\"0 0 24 24\"><path d=\"M7 3h10v18l-5-4-5 4z\"/></svg>',\n photo: '<svg viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"6\" width=\"18\" height=\"14\" rx=\"2\"/><circle cx=\"12\" cy=\"13\" r=\"3.5\"/><path d=\"M8 6l1.5-2h5L16 6\"/></svg>',\n site: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"/></svg>',\n git: '<svg viewBox=\"0 0 24 24\"><circle cx=\"6\" cy=\"6\" r=\"2.5\"/><circle cx=\"6\" cy=\"18\" r=\"2.5\"/><circle cx=\"18\" cy=\"9\" r=\"2.5\"/><path d=\"M6 8.5v7M18 11.5c0 3-3 4.5-7 4.5H6\"/></svg>',\n chat: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 5h16v10H10l-4 4v-4H4z\"/></svg>',\n files: '<svg viewBox=\"0 0 24 24\"><path d=\"M3 6h6l2 2h10v11H3z\"/></svg>',\n key: '<svg viewBox=\"0 0 24 24\"><circle cx=\"8\" cy=\"12\" r=\"4\"/><path d=\"M12 12h9M18 12v3M15 12v2\"/></svg>',\n search: '<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"6\"/><path d=\"M16 16l4 4\"/></svg>',\n feed: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 4a16 16 0 0 1 16 16M4 10a10 10 0 0 1 10 10\"/><circle cx=\"5.5\" cy=\"18.5\" r=\"1.5\"/></svg>',\n app: '<svg viewBox=\"0 0 24 24\"><rect x=\"4\" y=\"4\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"14\" y=\"4\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"4\" y=\"14\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"14\" y=\"14\" width=\"6\" height=\"6\" rx=\"1.5\"/></svg>',\n };\n const ib = (icon, label, act, id, extra) => '<button class=\"ib' + (extra ? \" \" + extra : \"\") + '\" title=\"' + label + '\" aria-label=\"' + label + '\" data-act=\"' + act + '\" data-id=\"' + esc(id) + '\">' + IC[icon] + \"</button>\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('<a href=\"' + esc(u) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(u) + '\">' + esc(label) + \"</a>\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('<span class=\"tag\">' + esc(t) + \"</span>\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"<span>\" + esc(where.join(\", \")) + \"</span>\");\n const link = (label, href) => href ? '<a href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"<span>\" + esc(info.contact) + \"</span>\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('<span class=\"sep\">|</span>'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by <b>' + key(owner) + \"</b>\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"<div><small>\" + k + \"</small><div\" + ([\"Owner\", \"NIPs\", \"Relay key\"].includes(k) ? ' class=\"mono\"' : \"\") + \">\" + (v === undefined || v === \"\" ? '<span class=\"muted\">–</span>' : v instanceof Html ? v.s : esc(v)) + \"</div></div>\").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '<span class=\"g' + (off ? \" off\" : \"\") + '\" title=\"' + label + '\"><i>' + IC[icon] + \"</i><small>\" + label + \"</small></span>\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '<span class=\"who' + (m.pubkey === me ? \" me\" : \"\") + '\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + '<span class=\"mono\">' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"</span>\" + (m.role === \"owner\" ? '<span class=\"role\">owner</span>' : \"\") + \"</span>\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"/<npub>/<repo>.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n }\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\", \"connect\", \"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 loadConnect();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n const g = p.git || {}, gf = $(\"#git-capacity\");\n if (gf) {\n gf.maxRepositories.value = g.maxRepositories || \"\"; gf.maxRelayGiB.value = units(g.maxRelayBytes, GiB); gf.maxCompressedGiB.value = units(g.maxCompressedBytes, GiB);\n gf.maxPackMiB.value = units(g.maxPackBytes, MiB); gf.maxObjectMiB.value = units(g.maxObjectBytes, MiB); gf.maxObjects.value = g.maxObjects || \"\";\n gf.maxRawGiB.value = units(g.maxRawBytes, GiB); gf.maxTransactionRawMiB.value = units(g.maxTransactionRawBytes, MiB); gf.maxTransactionObjects.value = g.maxTransactionObjects || \"\";\n gf.maxMetadataMiB.value = units(g.maxMetadataBytes, MiB); gf.maxRefs.value = g.maxRefs || \"\"; gf.maxGraphEdges.value = g.maxGraphEdges || \"\"; gf.maxFetchGiB.value = units(g.maxFetchBytes, GiB);\n }\n renderFeatures(p.features || {});\n $(\"#push-policy-form\").elements.origins.value = (p.pushCallbacks || []).join(\"\\n\");\n $(\"#push-policy-form\").elements.lettered.checked = !!p.letteredNips;\n $(\"#push-policy-form\").elements.delivery.checked = !!p.delivery?.enabled;\n $(\"#push-policy-form\").elements.deliveryMax.value = p.delivery?.maxTargets || 8;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); loadConnectTab(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '<tr data-seq=\"' + r.seq + '\"><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td class=\"mono\" title=\"' + esc(r.actor) + '\">' + av(r.actor) + key(r.actor) + '</td><td><span class=\"kind\">' + esc(r.action) + '</span></td><td class=\"mono\">' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '</td><td class=\"c\">' + esc(r.detail) + '</td></tr>').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || '<tr><td class=\"dim\" colspan=\"5\">Nothing yet</td></tr>';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' <span class=\"chip\">owner</span>' : myRole === \"owner\" ? ' <select class=\"txt role\" title=\"Role\"><option value=\"member\"' + (m.role === \"member\" ? \" selected\" : \"\") + '>member</option><option value=\"moderator\"' + (m.role === \"moderator\" ? \" selected\" : \"\") + '>moderator</option></select>' : m.role === \"moderator\" ? ' <span class=\"chip\">moderator</span>' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? '<input class=\"txt num keep\" type=\"number\" min=\"0\" placeholder=\"days\" title=\"Keep this member\\'s events for this many days; blank follows the relay rules\" value=\"' + (m.keep_days || \"\") + '\"><input class=\"txt num cap\" type=\"number\" min=\"0\" placeholder=\"KB\" title=\"Cap on stored bytes, in KB; blank is unlimited\" value=\"' + (m.max_bytes ? Math.round(m.max_bytes / 1024) : \"\") + '\">'\n : '<span class=\"dim\">' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"</span>\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '<tr data-pk=\"' + m.pubkey + '\"><td class=\"mono\" title=\"' + esc(m.pubkey) + '\" style=\"padding-left:' + (0.5 + m.depth * 1.1) + 'rem\">' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' <span class=\"dim\">via ' + esc(nameOf(m.invited_by)) + \"</span>\" : \"\") + (m.invites ? ' <span class=\"chip\" title=\"live invites\">' + m.invites + \" inv</span>\" : \"\") + '</td><td class=\"name\"><input class=\"txt name\" value=\"' + esc(m.name || \"\") + '\" placeholder=\"name\" pattern=\"[a-z0-9._-]{1,64}\"></td><td><input class=\"txt note\" value=\"' + esc(m.note || \"\") + '\" placeholder=\"note\"></td><td class=\"lim\">' + limits(m) + '</td><td class=\"dim\">' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '</td><td class=\"r\">' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"</td></tr>\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '<option value=\"' + m.pubkey + '\">' + esc(m.name ? m.name + \"@\" + host : key(m.pubkey)) + \"</option>\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + \"</a> \" + esc(i.note || \"\") + ' <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites</li>';\n const person = (r, icon, label, act) => \"<li>\" + av(r.pubkey) + '<span><b class=\"mono\" title=\"' + esc(r.pubkey) + '\">' + short(r.pubkey) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(icon, label, act, r.pubkey) + \"</li>\";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '<li class=\"empty\">nobody banned</li>';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '<li><i class=\"ev\">ip</i><span><b class=\"mono\">' + esc(r.ip) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"</li>\").join(\"\") : '<li class=\"empty\">no addresses blocked</li>';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '<li><i class=\"ev\">ev</i><span><b class=\"mono\" title=\"' + esc(r.id) + '\">' + key(r.id) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"</li>\").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '<tr><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td><span class=\"kind\">' + esc(r.type || \"report\") + '</span></td><td class=\"mono\" title=\"' + esc(r.target_pubkey) + '\">' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' <a class=\"dim\" href=\"/' + esc(r.target_event) + '\" target=\"_blank\" rel=\"noopener\">file ' + key(r.target_event) + \"</a>\" : ' <span class=\"dim\">ev ' + key(r.target_event) + (r.hidden ? ' <span class=\"tag\">hidden</span>' : \"\") + \"</span>\") : \"\") + '</td><td class=\"c\">' + esc(r.content) + '</td><td class=\"r\">' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">nothing reported</td></tr>';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '<tr><td class=\"dim\">' + esc(fmtTime(b.uploaded)) + '</td><td class=\"mono\"><a href=\"' + esc(b.url) + '\" target=\"_blank\" rel=\"noopener\">' + key(b.sha256) + '</a> <span class=\"dim\">' + esc(b.type) + '</span></td><td class=\"mono\">' + fmtBytes(b.size) + '</td><td class=\"mono\" title=\"' + esc(b.uploader) + '\">' + av(b.uploader) + key(b.uploader) + '</td><td class=\"r\">' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no uploads</td></tr>';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '<tr><td class=\"mono\" title=\"' + esc(s.author) + '\">' + av(s.author) + key(s.author) + '</td><td class=\"mono\">' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '</td><td><a href=\"' + esc(s.url) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(s.url) + '\">' + esc(s.url) + '</a></td><td class=\"r mono\">' + esc(s.paths) + (s.missing ? ' <span class=\"chip bad\" title=\"files still being mirrored\">' + esc(s.missing) + ' missing</span>' : '') + '</td><td class=\"r mono\">' + fmtBytes(s.size) + '</td><td class=\"dim\">' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '</td><td class=\"r\">' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '</td></tr>').join(\"\") : '<tr><td colspan=\"7\" class=\"muted\">no sites yet</td></tr>';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '<span class=\"tag ' + cls + '\">' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"</span>\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : '<span class=\"tag plain\">all</span>';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : '<span class=\"tag plain\">none</span>';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"grasp\", \"Git repositories\", \"GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits.\"],\n [\"push\", \"Relay push\", \"NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '<option value=\"' + v + '\"' + (cur === v ? \" selected\" : \"\") + \">\" + l + \"</option>\"; }).join(\"\");\n return \"<label><span>\" + esc(title) + \" <small>\" + esc(about) + \"</small></span><select class=\\\"txt\\\" data-feature=\\\"\" + k + \"\\\">\" + opts + \"</select></label>\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n $(\"#push-policy-form\").addEventListener(\"submit\", guard(async (ev) => {\n ev.preventDefault();\n const form = ev.target;\n const origins = form.elements.origins.value.split(/\\s+/).filter(Boolean);\n const updated = await rpc(\"setpolicy\", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });\n if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\\/$/, \"\")))])) throw new Error(\"Use up to sixteen exact HTTPS origins, with no path or credentials.\");\n policy = updated;\n toast(\"Saved delivery policy\"); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '<select class=\"txt\" data-view=\"' + esc(v.name) + '\" style=\"width:auto\">' + choices.map((c) => '<option value=\"' + c + '\"' + (c === v.trigger ? \" selected\" : \"\") + \">\" + label(c) + \"</option>\").join(\"\") + \"</select>\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"<span>\" + esc(x) + \"</span>\").join('<span class=\"sep\">|</span>');\n return '<div class=\"wire-row' + (v.on ? \"\" : \" dim\") + '\"><div class=\"wire-main\"><b>' + esc(v.name) + '</b><br><small>' + esc(v.about) + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + pick + (v.on ? '<a class=\"btn\" href=\"' + esc(v.path) + '\" target=\"_blank\" rel=\"noopener\">Open</a>' : \"\") + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '<li><i class=\"ev\">jsonl</i><span><b class=\"mono\">' + esc(d.name) + '</b> <span class=\"dim\">' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"</span></span><span>\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"</span></li>\").join(\"\") : '<li class=\"empty\">no dumps yet</li>';\n }\n async function loadBackups() {\n const list = await rpc(\"listbackups\");\n $(\"#backups-count\").textContent = list.length || \"\";\n $(\"#backups\").innerHTML = list.length ? list.map((b) => '<li><i class=\"ev\">backup</i><span><b class=\"mono\">' + esc(b.id) + '</b> <span class=\"dim\">' + fmtBytes(b.bytes) + '</span></span><span>' + ib(\"copy\", \"Download\", \"downloadbackup\", b.id) + ib(\"trash\", \"Delete\", \"deletebackup\", b.id, \"danger\") + '</span></li>').join(\"\") : '<li class=\"empty\">no backups yet</li>';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n async function downloadBackup(id) {\n const resp = await backupRequest(\"/backups/\" + encodeURIComponent(id), \"GET\");\n if (!resp.ok) throw new Error(\"backup download failed\");\n const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + '</a> <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites yet</li>';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n loadBackups().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<i class=\"' + c + '\" style=\"flex:' + b + '\" title=\"' + esc(n) + \" \" + fmtBytes(b) + '\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<span><i class=\"' + c + '\"></i>' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" <small>\" + fmtBytes(st.eventBytes) + \"</small>\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" <small>\" + fmtBytes(st.mediaBytes) + \"</small>\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '<span class=\"kind' + (k.protected ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + k.kind + \"</span> \" + esc(kindName(k.kind));\n const cells = k.protected\n ? '<td class=\"keep sys\" colspan=\"2\" title=\"The relay depends on this kind, so it is never expired or purged.\">required</td>'\n : '<td class=\"keep\"><input class=\"txt num days\" type=\"number\" min=\"0\" max=\"36500\" placeholder=\"' + placeholder + '\" value=\"' + own(k.kind) + '\"> days</td><td class=\"r\">' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"</td>\";\n return '<tr class=\"' + (k.kind === null ? \"any\" : \"\") + '\" data-kind=\"' + key + '\"><td>' + pill + '</td><td class=\"r mono\">' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '</td><td class=\"r mono\">' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '</td><td class=\"dim\">' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"</td>\" + cells + \"</tr>\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n loadListHistory().catch(() => {});\n }\n\n async function loadListHistory() {\n const rows = await rpc(\"listlisthistory\");\n const labels = { 3: \"follows\", 10002: \"relay list\", 10003: \"bookmarks\", 30003: \"bookmark list\" };\n $(\"#listhistory tbody\").innerHTML = rows.length ? rows.map((r) => '<tr><td>' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '</td><td class=\"dim\">' + esc(fmtTime(r.created_at)) + '</td><td class=\"dim\">' + esc(fmtTime(r.saved_at)) + '</td><td class=\"r\">' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '</td></tr>').join(\"\") : '<tr><td colspan=\"4\" class=\"muted\">no older list versions yet</td></tr>';\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '<tr><td class=\"dim\">' + esc(fmtTime(e.created_at)) + '</td><td><span class=\"kind' + (SYS_KINDS.has(e.kind) ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + e.kind + '</span></td><td class=\"mono\" title=\"' + e.pubkey + '\">' + av(e.pubkey) + key(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no events yet</td></tr>';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '<li><i class=\"ev\">' + (t[0] === \"e\" ? \"event\" : \"address\") + '</i><span class=\"mono\">' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"</span><span>\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"</span></li>\").join(\"\") : '<li class=\"muted\">nothing pinned</li>';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n const sources = j.running ? j.pullSources : l?.sources;\n const details = sources?.length ? '<details><summary>Source results</summary>' + sources.map((s) => '<p><code>' + esc(s.url) + '</code>: ' + esc(s.status) + ', ' + s.stored + ' stored, ' + s.skipped + ' skipped' + (s.error || s.warning ? '<br>' + esc(s.error || s.warning) : '') + '</p>').join('') + '</details>' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"<br><small>\" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"<br>\") + \"</small>\" : \"\";\n return \"<tr><td>\" + what + \"</td><td class=\\\"mono\\\">\" + j.relays.map(esc).join(\"<br>\") + targets + \"</td><td>\" + (f.join(\", \") || \"everything\") + \"</td><td>\" + when + \"</td><td>\" + esc(res) + details + \"</td><td>\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"</td></tr>\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : '<tr><td colspan=\"6\" class=\"muted\">no jobs yet</td></tr>';\n if (myRole === \"owner\") {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '<tr><td class=\"mono\" title=\"' + esc(d.event_id) + '\">' + esc(d.event_id.slice(0, 12)) + '</td><td class=\"mono\">' + esc(d.target) + '</td><td>' + esc(d.status) + '</td><td>' + d.attempts + '</td><td>' + esc(d.error || \"\") + '</td></tr>').join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no automatic deliveries yet</td></tr>';\n } catch { /* unavailable to non-owners */ }\n }\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '<button class=\"btn\" data-preset=\"' + esc(p.name) + '\" title=\"' + esc(p.about) + '\">' + esc(p.title) + \"</button>\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules\" + (p.connections ? \", and the Connect fold's shortcuts\" : \"\") + \". 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.\" : \"\") + (p.connections ? \" The Connect fold shows \" + p.connections.map((c) => c.template).join(\", \") + \".\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- connect: the fold's shortcuts, picked from the library ----\n // The list is edited in place and saved whole, in the order shown; the\n // relay checks every entry against the library when it takes the list.\n // One row at a time opens for its title and inputs, and Save stays at the\n // top, lit while something is unsaved.\n const VIS = [[\"public\", \"anyone\"], [\"auth\", \"anyone signed in\"], [\"members\", \"members\"], [\"owner\", \"only me\"]];\n let connLib = null, conns = null, connEditing = -1, connDirty = false;\n async function loadConnectTab() {\n try { [connLib, conns] = await Promise.all([rpc(\"listconnectiontemplates\"), rpc(\"listconnections\")]); } catch (e) { $(\"#connnote\").textContent = e.message; return; }\n connEditing = -1; setConnDirty(false);\n renderConns(); renderConnLib();\n }\n function setConnDirty(v) { connDirty = v; $(\"#connsave\").disabled = !v; $(\"#connnote\").textContent = v ? \"Unsaved changes\" : \"\"; }\n const connTemplate = (name) => (connLib || []).find((t) => t.name === name) || { name, title: name, app: \"\", where: \"\", icon: \"app\", inputs: [], available: true, feature: \"\" };\n function renderConns() {\n const rows = conns.map((c, i) => {\n const t = connTemplate(c.template), editing = i === connEditing;\n const vis = VIS.map(([v, l]) => '<option value=\"' + v + '\"' + (c.visibility === v ? \" selected\" : \"\") + \">\" + l + \"</option>\").join(\"\");\n const inputs = (t.inputs || []).map((inp) => \"<label><span>\" + esc(inp.label) + '</span><input class=\"txt\" data-input=\"' + esc(inp.name) + '\" value=\"' + esc((c.inputs || {})[inp.name] || \"\") + '\" placeholder=\"' + esc(inp.placeholder || inp.default || \"\") + '\"' + (inp.pattern ? ' pattern=\"' + esc(inp.pattern) + '\"' : \"\") + ' maxlength=\"500\"></label>').join(\"\");\n const edit = editing ? '<div class=\"conn-edit\"><label><span>Title</span><input class=\"txt\" data-field=\"title\" value=\"' + esc(c.title || \"\") + '\" placeholder=\"' + esc(t.title) + '\" maxlength=\"40\"></label>' + inputs + \"</div>\" : \"\";\n return '<div class=\"conn' + (editing ? \" editing\" : \"\") + '\" data-i=\"' + i + '\"><i class=\"app-ic\">' + (IC[t.icon] || IC.app) + '</i><div class=\"conn-main\"><b>' + esc(c.title || t.title) + \"</b><small>\" + esc(t.app + (t.where ? \", \" + t.where : \"\")) + \"</small>\" + (t.available ? \"\" : '<span class=\"pill bad\">needs ' + esc(t.feature) + \", off</span>\") + '</div><select class=\"txt conn-vis\" data-field=\"visibility\" title=\"Who sees it\">' + vis + '</select><div class=\"conn-acts\"><button class=\"ib\" data-conn=\"edit\" title=\"' + (editing ? \"Done\" : \"Edit title and inputs\") + '\" aria-label=\"Edit\">' + IC.pen + '</button><button class=\"ib\" data-conn=\"up\" title=\"Move up\" aria-label=\"Move up\">' + IC.up + '</button><button class=\"ib\" data-conn=\"down\" title=\"Move down\" aria-label=\"Move down\">' + IC.down + '</button><button class=\"ib danger\" data-conn=\"rm\" title=\"Remove\" aria-label=\"Remove\">' + IC.x + \"</button></div>\" + edit + \"</div>\";\n });\n $(\"#conns\").innerHTML = rows.length ? rows.join(\"\") : '<p class=\"empty\">No shortcuts. Add some from the library below.</p>';\n }\n function renderConnLib() {\n $(\"#connlib\").innerHTML = connLib.map((t) => '<div class=\"app\"><div class=\"app-head\"><i class=\"app-ic\">' + (IC[t.icon] || IC.app) + \"</i><b>\" + esc(t.title) + \"</b><small>\" + esc(t.app + (t.where ? \", \" + t.where : \"\")) + \"</small>\" + (t.visibility !== \"public\" ? '<span class=\"pill\">' + VISIBILITY[t.visibility] + \"</span>\" : \"\") + \"</div><p>\" + esc(t.about) + '</p><div class=\"app-acts\"><button class=\"btn pri\" data-connadd=\"' + esc(t.name) + '\">Add</button>' + (t.feature ? '<span class=\"pill' + (t.available ? \" on\" : \" bad\") + '\">' + esc(t.feature) + (t.available ? \" on\" : \" off\") + \"</span>\" : \"\") + \"</div></div>\").join(\"\");\n }\n // readConns takes the rows as edited back into the list before it is redrawn or saved.\n function readConns() {\n $$(\"#conns .conn\").forEach((row) => {\n const c = conns[+row.dataset.i]; if (!c) return;\n c.visibility = row.querySelector('[data-field=\"visibility\"]').value;\n const title = row.querySelector('[data-field=\"title\"]');\n if (!title) return;\n if (title.value.trim()) c.title = title.value.trim(); else delete c.title;\n const inputs = {};\n row.querySelectorAll(\"[data-input]\").forEach((inp) => { if (inp.value.trim()) inputs[inp.dataset.input] = inp.value.trim(); });\n if (Object.keys(inputs).length) c.inputs = inputs; else delete c.inputs;\n });\n }\n $(\"#conns\").addEventListener(\"input\", () => setConnDirty(true));\n $(\"#conns\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-conn]\"); if (!b) return;\n readConns();\n const i = +b.closest(\".conn\").dataset.i, act = b.dataset.conn;\n if (act === \"edit\") connEditing = connEditing === i ? -1 : i;\n else if (act === \"rm\") { conns.splice(i, 1); connEditing = -1; setConnDirty(true); }\n else {\n const j = act === \"up\" ? i - 1 : i + 1; if (j < 0 || j >= conns.length) return;\n [conns[i], conns[j]] = [conns[j], conns[i]];\n if (connEditing === i) connEditing = j; else if (connEditing === j) connEditing = i;\n setConnDirty(true);\n }\n renderConns();\n if (act === \"edit\" && connEditing === i) { const f = $(\"#conns .conn.editing input\"); if (f) f.focus(); }\n });\n $(\"#connlib\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-connadd]\"); if (!b) return;\n const t = connTemplate(b.dataset.connadd);\n readConns();\n conns.push({ template: t.name, visibility: t.visibility || \"public\" });\n connEditing = t.inputs && t.inputs.length ? conns.length - 1 : connEditing;\n setConnDirty(true);\n renderConns();\n $(\"#conns\").lastElementChild.scrollIntoView({ behavior: \"smooth\", block: \"nearest\" });\n });\n // Not through guard: the button's disabled state is the dirty flag's, so\n // guard's re-enable after the call would light it up again.\n $(\"#connsave\").onclick = async () => {\n const b = $(\"#connsave\"); b.disabled = true;\n try {\n readConns();\n conns = await rpc(\"setconnections\", conns);\n toast(conns.length ? \"Saved \" + conns.length + (conns.length === 1 ? \" shortcut\" : \" shortcuts\") : \"Saved: no shortcuts\");\n setConnDirty(false); renderConns(); loadConnect();\n } catch (e) { toast(e.message); b.disabled = false; }\n };\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '<span class=\"pill ' + cls + '\">' + esc(label) + \"</span>\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-wire=\"' + l.kind + '\" data-do=\"' + act + '\"' + (busy ? \" disabled\" : \"\") + \">\" + label + \"</button>\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"<span>\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"</span>\" : w.state && !busy ? \"<span>no list found</span>\" : \"\";\n const sent = (w.results || []).map((r) => '<span class=\"chip' + (r.ok ? \"\" : \" bad\") + '\" title=\"' + esc(r.msg) + '\">' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"</span>\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '<div class=\"wire-row\"><div class=\"wire-main\"><b>' + l.title + '</b> <small class=\"muted\">' + l.nip + \"</small><br><small>\" + l.about + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + buttons + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '<option value=\"' + esc(s.label) + '\"' + (s.label === current ? \" selected\" : \"\") + \">\" + esc(s.title) + \"</option>\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-domain=\"' + esc(d.host) + '\" data-do=\"' + act + '\">' + label + \"</button>\";\n const rows = d.ready ? \"\" : '<div class=\"scroll\"><table class=\"events\"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>' + d.records.map((r) => \"<tr><td>\" + esc(r.type) + '</td><td class=\"mono\">' + esc(r.name) + '</td><td class=\"mono\">' + esc(r.value) + \"</td><td>\" + esc(r.note) + \"</td></tr>\").join(\"\") + \"</tbody></table></div>\";\n return '<div class=\"row\"><b class=\"mono\">' + esc(d.host) + '</b><span class=\"note\">' + esc(state) + \"</span>\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '</div><label class=\"row\"><span>Destination</span><select class=\"txt\" data-domain-site=\"' + esc(d.host) + '\">' + domainOptions(d.site || \"\") + \"</select></label>\" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#git-capacity\").onsubmit = guard(async (ev) => {\n const f = ev.target, bytes = (name, divisor) => Math.round(Number(f[name].value) * divisor);\n const git = {\n maxRepositories: Math.floor(Number(f.maxRepositories.value)), maxRelayBytes: bytes(\"maxRelayGiB\", GiB), maxCompressedBytes: bytes(\"maxCompressedGiB\", GiB),\n maxPackBytes: bytes(\"maxPackMiB\", MiB), maxObjectBytes: bytes(\"maxObjectMiB\", MiB), maxObjects: Math.floor(Number(f.maxObjects.value)), maxRawBytes: bytes(\"maxRawGiB\", GiB),\n maxTransactionRawBytes: bytes(\"maxTransactionRawMiB\", MiB), maxTransactionObjects: Math.floor(Number(f.maxTransactionObjects.value)), maxMetadataBytes: bytes(\"maxMetadataMiB\", MiB),\n maxRefs: Math.floor(Number(f.maxRefs.value)), maxGraphEdges: Math.floor(Number(f.maxGraphEdges.value)), maxFetchBytes: bytes(\"maxFetchGiB\", GiB),\n };\n policy = await rpc(\"setpolicy\", { git });\n toast(\"Git capacity saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n async function backupRequest(path, method, body) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const hash = await sha256hex(body || \"\");\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", location.origin + path], [\"method\", method], [\"payload\", hash]] });\n return fetch(path, { method, headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), ...(body ? { \"content-type\": \"application/json\" } : {}) }, body });\n }\n $(\"#backupnow\").onclick = guard(async () => { const id = $(\"#backupid\").value.trim() || \"backup\"; const r = await rpc(\"backupnow\", id); const resp = await backupRequest(\"/backups/\" + id, \"GET\"); if (!resp.ok) throw new Error(\"backup download failed\"); const a = document.createElement(\"a\"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + id + \".json\"; a.click(); URL.revokeObjectURL(a.href); toast(\"Backup downloaded\"); await loadStorage(); void r; });\n async function selectedBackup() { const f = $(\"#backupfile\").files[0]; if (!f) throw new Error(\"Choose a backup archive first.\"); if (f.size > 8 * 1024 * 1024) throw new Error(\"Backups are limited to 8 MB.\"); return { file: f, body: await f.text() }; }\n let backupPreview = null;\n $(\"#backupfile\").onchange = () => { backupPreview = null; $(\"#backup-preview\").textContent = \"\"; $(\"#backuprestore\").disabled = true; };\n $(\"#backupremote\").onclick = () => showRemote(null);\n $(\"#backuppreview\").onclick = guard(async () => { const selected = await selectedBackup(); const r = await (await backupRequest(\"/backups/preview\", \"POST\", selected.body)).json(); if (r.error) throw new Error(r.error); backupPreview = { file: selected.file, body: selected.body }; $(\"#backuprestore\").disabled = false; const s = r.result.source; $(\"#backup-preview\").innerHTML = '<p><b>Ready to restore.</b> ' + esc(String(r.result.events)) + ' events, ' + esc(String(r.result.blobs)) + ' files, ' + esc(String(r.result.git)) + ' Git objects, ' + esc(fmtBytes(r.result.bytes)) + '.</p><p>Source relay identity: <code>' + esc(s.relayIdentity || \"unknown\") + '</code></p><pre>' + esc(JSON.stringify(r.result.config, null, 2)) + '</pre>'; });\n $(\"#backuprestore\").onclick = guard(async () => { const selected = await selectedBackup(); if (!backupPreview || backupPreview.file !== selected.file || backupPreview.body !== selected.body) throw new Error(\"Preview this exact archive before restoring.\"); if (!confirm(\"Restore this archive onto this fresh relay? This cannot be undone.\")) return; const r = await (await backupRequest(\"/backups/restore\", \"POST\", selected.body)).json(); if (r.error) throw new Error(r.error); me = await signer.getPublicKey(); localStorage.setItem(\"me\", me); toast(\"Backup restored\"); await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"deletebackup\" && !confirm(\"Delete this backup?\")) return;\n if (act === \"downloadbackup\") { b.disabled = true; try { await downloadBackup(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"deletebackup\") { b.disabled = true; try { await rpc(\"deletebackup\", id); toast(\"Backup deleted\"); await loadBackups(); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if (act === \"restorelist\") {\n if (!signer.ready()) { toast(NO_SIGNER); return; }\n try {\n const preview = await rpc(\"restorelist\", id);\n const d = preview.diff || {};\n const added = (d.addedTags || []).map((t) => \"+ \" + JSON.stringify(t)).join(\"\\n\");\n const removed = (d.removedTags || []).map((t) => \"- \" + JSON.stringify(t)).join(\"\\n\");\n const changes = [added, removed, d.contentChanged ? \"content changed\" : \"content unchanged\"].filter(Boolean).join(\"\\n\");\n if (!confirm(\"Restore this list version?\\n\\n\" + (changes || \"No tag or content changes\") + \"\\n\\nIt will be signed and published as the newest version.\")) return;\n const signed = await signer.signEvent(preview.draft);\n const result = await bridge(\"/events\", signed);\n if (!result.accepted) throw new Error(result.message || \"The relay refused the restored list.\");\n toast(\"List restored\"); await loadListHistory(); await loadStorage();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n }\n // The Connect fold's shortcuts: the owner's list from /connect.json,\n // resolved by the relay for whoever is looking. A signed-in viewer asks\n // with a NIP-98 signature, so the relay can show what their visibility\n // admits and fill the links made for them; anyone else sees the public\n // ones. A shortcut's QR is drawn only when asked for.\n const VISIBILITY = { public: \"\", auth: \"signed in\", members: \"members\", owner: \"only you\" };\n async function signedGet(path) {\n const url = location.origin + path;\n const headers = {};\n if (me && signer.ready()) {\n try {\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n headers.authorization = \"Nostr \" + btoa(JSON.stringify(ev));\n } catch { /* unsigned, then: the public shortcuts */ }\n }\n return fetch(url, { headers, cache: \"no-store\" });\n }\n let connectSeq = 0;\n async function loadConnect() {\n const seq = ++connectSeq;\n let data;\n try { const r = await signedGet(\"/connect.json\"); data = await r.json(); if (data.error) throw new Error(data.error); } catch { data = { connections: [] }; }\n if (seq !== connectSeq) return; // a later sign-in or sign-out answered first\n renderConnect(data.connections || []);\n }\n function renderConnect(list) {\n const enc = encodeURIComponent;\n const open = (l, pri) => '<a class=\"btn' + (pri ? \" pri\" : \"\") + '\" href=\"' + esc(l.href) + '\"' + (/^nostr:/i.test(l.href) ? \"\" : ' target=\"_blank\" rel=\"noopener\"') + \">\" + esc(l.label) + \"</a>\";\n const copy = (l) => '<button class=\"btn\" data-copytext=\"' + esc(l.copy) + '\">' + IC.copy + esc(l.label) + \"</button>\";\n // One Open leads; the first copy text rides beside it; every other link waits behind More.\n const tile = (c) => {\n const hrefs = c.links.filter((l) => l.href), copies = c.links.filter((l) => l.copy);\n const acts = hrefs.length ? [open(hrefs[0], true), copies.length ? copy(copies[0]) : \"\"] : [copies.length ? copy(copies[0]) : \"\"];\n const rest = [...hrefs.slice(1).map((l) => open(l, false)), ...copies.slice(1).map(copy)];\n if (c.qr) acts.push('<button class=\"ib\" data-qr=\"' + enc(c.qr) + '\" title=\"QR code for a phone\" aria-label=\"QR code for a phone\">' + IC.qr + \"</button>\");\n return '<div class=\"app\"><div class=\"app-head\"><i class=\"app-ic\">' + (IC[c.icon] || IC.app) + \"</i><b>\" + esc(c.title) + \"</b><small>\" + esc(c.app + (c.where ? \", \" + c.where : \"\")) + \"</small>\" + (VISIBILITY[c.visibility] ? '<span class=\"pill\">' + VISIBILITY[c.visibility] + \"</span>\" : \"\") + \"</div><p>\" + esc(c.about) + '</p><div class=\"app-acts\">' + acts.join(\"\") + \"</div>\" + (rest.length ? '<details class=\"more\"><summary>More</summary><div class=\"app-more\">' + rest.join(\"\") + \"</div></details>\" : \"\") + (c.needsUser ? '<p class=\"who-note\">Sign in and the links made for you appear.</p>' : \"\") + '<div class=\"app-qr hidden\"></div></div>';\n };\n $(\"#apps\").innerHTML = list.map(tile).join(\"\");\n $(\"#apps-note\").textContent = list.length ? \"\" : me && owner && me === owner ? \"No shortcuts yet. Pick some on the Connect tab.\" : \"The owner has not picked any app shortcuts yet.\";\n }\n $(\"#apps\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-qr]\"); if (!b) return;\n const box = b.closest(\".app\").querySelector(\".app-qr\");\n if (!box.firstChild) box.innerHTML = '<img src=\"/qr.svg?text=' + b.dataset.qr + '\" alt=\"QR code\" width=\"150\" height=\"150\">';\n box.classList.toggle(\"hidden\");\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 await loadAdmin();\n await loadPeople();\n})();\n";@@ -40,7 +40,21 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
4040 10594141 ]4242 },43- "retention": []43+ "retention": [],44+ "connections": [45+ {46+ "template": "notes",47+ "visibility": "public"48+ },49+ {50+ "template": "find-me",51+ "visibility": "public"52+ },53+ {54+ "template": "blog",55+ "visibility": "public"56+ }57+ ]4458 }4559 },4660 {@@ -75,6 +89,16 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
7589 "kind": null,7690 "days": 907791 }92+ ],93+ "connections": [94+ {95+ "template": "find-me",96+ "visibility": "public"97+ },98+ {99+ "template": "notes",100+ "visibility": "public"101+ }78102 ]79103 }80104 },@@ -122,7 +146,13 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
122146 ],123147 "block": []124148 },125- "retention": []149+ "retention": [],150+ "connections": [151+ {152+ "template": "group",153+ "visibility": "public"154+ }155+ ]126156 }127157 },128158 {@@ -145,7 +175,17 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
145175 ],146176 "block": []147177 },148- "retention": []178+ "retention": [],179+ "connections": [180+ {181+ "template": "files",182+ "visibility": "members"183+ },184+ {185+ "template": "photos",186+ "visibility": "owner"187+ }188+ ]149189 }150190 },151191 {@@ -175,7 +215,13 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
175215 ],176216 "block": []177217 },178- "retention": []218+ "retention": [],219+ "connections": [220+ {221+ "template": "notes",222+ "visibility": "public"223+ }224+ ]179225 }180226 },181227 {@@ -200,7 +246,17 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
200246 ],201247 "block": []202248 },203- "retention": []249+ "retention": [],250+ "connections": [251+ {252+ "template": "blog",253+ "visibility": "public"254+ },255+ {256+ "template": "notes",257+ "visibility": "public"258+ }259+ ]204260 }205261 },206262 {@@ -223,7 +279,13 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
223279 ],224280 "block": []225281 },226- "retention": []282+ "retention": [],283+ "connections": [284+ {285+ "template": "dm",286+ "visibility": "public"287+ }288+ ]227289 }228290 },229291 {@@ -256,7 +318,8 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
256318 "allow": [],257319 "block": []258320 },259- "retention": []321+ "retention": [],322+ "connections": []260323 }261324 },262325 {@@ -283,7 +346,17 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
283346 "allow": [],284347 "block": []285348 },286- "retention": []349+ "retention": [],350+ "connections": [351+ {352+ "template": "sites",353+ "visibility": "public"354+ },355+ {356+ "template": "notes",357+ "visibility": "public"358+ }359+ ]287360 }288361 },289362 {@@ -312,7 +385,13 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
312385 ],313386 "block": []314387 },315- "retention": []388+ "retention": [],389+ "connections": [390+ {391+ "template": "marmot",392+ "visibility": "public"393+ }394+ ]316395 }317396 },318397 {@@ -335,7 +414,17 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
335414 "allow": [],336415 "block": []337416 },338- "retention": []417+ "retention": [],418+ "connections": [419+ {420+ "template": "repos",421+ "visibility": "public"422+ },423+ {424+ "template": "notes",425+ "visibility": "public"426+ }427+ ]339428 }340429 },341430 {@@ -364,7 +453,75 @@ export const TEMPLATES: { name: string; document: unknown }[] = [
364453 ],365454 "block": []366455 },367- "retention": []456+ "retention": [],457+ "connections": [458+ {459+ "template": "marmot",460+ "visibility": "members"461+ }462+ ]463+ }464+ },465+ {466+ "name": "home",467+ "document": {468+ "format": "bind.ws/relay-config/2",469+ "template": {470+ "title": "Home",471+ "about": "Your relay as your home on nostr. Members write, anyone reads; sites, files and Git hosting on, with notes, blog, bookmarks, sites, repos and a photo library shortcut only you see."472+ },473+ "policy": {474+ "writes": "allowlist",475+ "reads": "open",476+ "directoryPublic": true,477+ "features": {478+ "files": true,479+ "sites": {480+ "enabled": true,481+ "mirror": true482+ },483+ "grasp": true484+ }485+ },486+ "kinds": {487+ "allow": [],488+ "block": []489+ },490+ "retention": [],491+ "connections": [492+ {493+ "template": "notes",494+ "visibility": "public"495+ },496+ {497+ "template": "blog",498+ "visibility": "public"499+ },500+ {501+ "template": "bookmarks",502+ "visibility": "auth"503+ },504+ {505+ "template": "sites",506+ "visibility": "public"507+ },508+ {509+ "template": "repos",510+ "visibility": "public"511+ },512+ {513+ "template": "photos",514+ "visibility": "owner"515+ },516+ {517+ "template": "find-me",518+ "visibility": "public"519+ },520+ {521+ "template": "group",522+ "visibility": "public"523+ }524+ ]368525 }369526 }370527 ];@@ -10,6 +10,7 @@ import { now } from "./event.ts";
1010 import { clientIP, customHost, hostnameKnown, leaseAllowed, siteHost } from "./edge.ts";1111 import { customTarget } from "./domains.ts";1212 import schema from "../relay-config.schema.json";13+import connectionSchema from "../connection-template.schema.json";1314 1415 // The entry module exports the handler and the object only: workerd1516 // refuses any other kind of export here.@@ -86,6 +87,8 @@ export default {
8687 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" } });8788 // The configuration file's schema, for editors: relay-config.schema.json in the repository.8889 if (url.pathname === "/relay-config.schema.json") return Response.json(schema, { headers: { "access-control-allow-origin": "*", "cache-control": "public, max-age=3600" } });90+ // The connection template's schema: connection-template.schema.json in the repository.91+ if (url.pathname === "/connection-template.schema.json") return Response.json(connectionSchema, { headers: { "access-control-allow-origin": "*", "cache-control": "public, max-age=3600" } });8992 if (url.pathname === "/lease" && req.method === "POST") return lease(req, env, url);9093 if (url.pathname === "/lease" && req.method === "OPTIONS") {9194 return new Response(null, { headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "POST, OPTIONS" } });@@ -23,6 +23,7 @@ import { DUMP_NAME_RE, deleteDump, dumpBytes, listDumps, writeDump } from "./dum
2323 import { can, type Action, type Role } from "./roles.ts";2424 import { detailOf } from "./audit.ts";2525 import { PRESETS, applyPreset, findPreset } from "./presets.ts";26+import { CONNECTION_TEMPLATES, parseConnections } from "./connections.ts";2627 import { addDomain, checkDomain, listDomains, removeDomain, setDomainSite } from "./domains.ts";2728 import { verifyNIP98 } from "./auth.ts";2829 import { SITE_KINDS, checkSite, siteLabel, sitePaths } from "./sites.ts";@@ -563,7 +564,27 @@ export const METHODS: Record<string, Method> = {
563564 listpresets: {564565 action: "read", reads: true,565566 run: ({ reply }) => {566- return reply({ result: PRESETS.map((x) => ({ name: x.name, title: x.title, about: x.about, source: x.source, every: x.every })) });567+ return reply({ result: PRESETS.map((x) => ({ name: x.name, title: x.title, about: x.about, source: x.source, every: x.every, connections: x.connections })) });568+ },569+ },570+ // The Connect fold's app shortcuts (connections.ts): the library the571+ // owner picks from, with whether each template's feature is on, the572+ // owner's list, and the list replaced whole, in the order given.573+ listconnectiontemplates: {574+ action: "read", reads: true,575+ run: ({ p, reply }) => reply({ result: CONNECTION_TEMPLATES.map((t) => ({ ...t, available: !t.feature || featureOn(p, t.feature) })) }),576+ },577+ listconnections: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.connections() }) },578+ setconnections: {579+ action: "rules",580+ run: ({ s, params, reply }) => {581+ // (list): every entry must fit; the console builds the list from the582+ // library, so a bad one is a mistake to report, not to drop.583+ const parsed = parseConnections(params[0]);584+ if (typeof parsed === "string") return reply({ error: parsed }, 400);585+ if (parsed.warnings.length) return reply({ error: "invalid: " + parsed.warnings.join("; ") }, 400);586+ s.setConnections(parsed.list);587+ return reply({ result: s.connections() });567588 },568589 },569590 applypreset: {@@ -1,13 +1,14 @@
11 // Presets: the templates in relay-templates/, one click each. A template is22 // a relay configuration with the rules sections only (policy, kinds and3-// retention), so applying one sets writes, reads, the directory, the4-// features, the kind rules and the keep-for rules together and leaves3+// retention, and the Connect fold's shortcuts when it names them), so4+// applying one sets writes, reads, the directory, the features, the kind5+// rules, the keep-for rules and the app shortcuts together and leaves56 // limits, identity, people and bans alone. Haven's four relays are the67 // first four: names are cheap here, so one name per role is the way to get78 // the same split.89 import { TEMPLATES } from "./gen/templates.ts";910 import { applyConfig, parseConfig, type Config } from "./config.ts";10-import type { Policy, Settings } from "./settings.ts";11+import type { Connection, Policy, Settings } from "./settings.ts";1112 1213 export interface Preset {1314 name: string;@@ -19,6 +20,9 @@ export interface Preset {
1920 allow: number[];2021 block: number[];2122 retention: { kind: number | null; days: number }[];23+ // The Connect fold's shortcuts the template sets, when it has a24+ // connections section; left out, the shortcuts stay as they are.25+ connections?: Connection[];2226 // A replica keeps itself in step with a source relay through a standing2327 // pull of its kinds. "required" presets refuse to apply without one.2428 source?: "required" | "optional";@@ -30,7 +34,7 @@ export const PRESETS: Preset[] = TEMPLATES.map(({ name, document }) => {
3034 const config = parseConfig(document);3135 if (typeof config === "string" || !config.template) throw new Error(`template ${name}: ${typeof config === "string" ? config : "no template block"}`);3236 const p = config.policy;33- return { name, title: config.template.title, about: config.template.about, source: config.template.source, every: config.template.every, writes: p.writes ?? "open", reads: p.reads ?? "open", directoryPublic: p.directoryPublic ?? true, allow: config.kinds.allow, block: config.kinds.block, retention: config.retention, config };37+ return { name, title: config.template.title, about: config.template.about, source: config.template.source, every: config.template.every, writes: p.writes ?? "open", reads: p.reads ?? "open", directoryPublic: p.directoryPublic ?? true, allow: config.kinds.allow, block: config.kinds.block, retention: config.retention, ...(config.sections.includes("connections") ? { connections: config.connections } : {}), config };3438 });3539 3640 export function findPreset(name: string): Preset | undefined {@@ -22,6 +22,7 @@ import { blossom, isBlobPath } from "./blossom.ts";
2222 import { nip96 } from "./nip96.ts";2323 import { fuelInvoice } from "./fuel.ts";2424 import { card } from "./card.ts";25+import { connectDoor } from "./connections.ts";2526 import { isPagePath, pages } from "./pages.ts";2627 import { dashboard } from "./dashboard.ts";2728 import { SIGNER_JS } from "./gen/signer.ts";@@ -116,6 +117,8 @@ export const ROUTES: Route[] = [
116117 { when: post(is("/fuel/invoice")), gated: true, answer: fuelInvoice },117118 // The card (card.ts).118119 { when: is("/card.json", "/card.nostr", "/card.svg", "/qr.svg"), answer: card },120+ // The Connect fold's app shortcuts, as the asker may see them (connections.ts).121+ { when: get(is("/connect.json")), answer: connectDoor },119122 // CORS preflight for everything above.120123 { when: (_, req) => req.method === "OPTIONS", answer: () => new Response(null, { headers: { ...CORS, "access-control-allow-headers": "authorization, content-type, accept", "access-control-allow-methods": "GET, POST, OPTIONS" } }) },121124 // Notes and articles as pages, the feed (pages.ts).@@ -117,6 +117,27 @@ export interface Features {
117117 marmot: boolean; // Marmot's opaque MLS transport events118118 }119119 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 }, marmot: false, grasp: false, grasp02: false, grasp03: false, grasp05: false, grasp06: false, push: false };120+121+// A connection is one app shortcut on the Connect fold (connections.ts): a122+// template from connection-templates/, who may see it, the owner's own123+// title and about when they changed the template's, and the template's124+// inputs as the owner filled them. The list is kept in the order shown.125+export const VISIBILITIES = ["public", "auth", "members", "owner"] as const;126+export type Visibility = (typeof VISIBILITIES)[number];127+export interface Connection {128+ template: string;129+ visibility: Visibility;130+ title?: string;131+ about?: string;132+ inputs?: Record<string, string>;133+}134+// What a relay shows until its owner picks: the feed, the profile handoff135+// for feed apps, and the group. A saved list, even an empty one, replaces it.136+export const DEFAULT_CONNECTIONS: Connection[] = [137+ { template: "notes", visibility: "public" },138+ { template: "find-me", visibility: "public" },139+ { template: "group", visibility: "public" },140+];120141 export const featureOn = (p: Policy, f: Feature): boolean => {121142 if (f === "sites") return p.features.sites.enabled;122143 if (["grasp02", "grasp03", "grasp05", "grasp06"].includes(f) && p.features[f] !== true) return false;@@ -453,6 +474,9 @@ export class Settings {
453474 private blockedKinds = new Set<number>();454475 // Days to keep events of a kind; RETENTION_ANY is the rule for kinds without one.455476 private retention = new Map<number, number>();477+ // The Connect fold's shortcuts; null until the owner saves a list, when478+ // the defaults show.479+ private connectionList: Connection[] | null = null;456480 457481 constructor(private sql: SqlStorage) {}458482 @@ -483,6 +507,15 @@ export class Settings {
483507 (r.rule === "allow" ? this.allowedKinds : this.blockedKinds).add(r.kind);484508 }485509 for (const r of this.sql.exec<{ kind: number; days: number }>(`SELECT kind, days FROM retention`)) this.retention.set(r.kind, r.days);510+ const saved = this.sql.exec<{ value: string }>(`SELECT value FROM settings WHERE key='connections'`).toArray()[0];511+ if (saved) {512+ try {513+ const list = JSON.parse(saved.value) as unknown;514+ this.connectionList = Array.isArray(list) ? (list as Connection[]) : [];515+ } catch {516+ this.connectionList = [];517+ }518+ }486519 if (this.policy.writes === "wot") this.rebuildWot();487520 }488521 @@ -601,6 +634,20 @@ export class Settings {
601634 this.sql.exec(`INSERT INTO settings(key,value) VALUES('pins',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, JSON.stringify(tags));602635 }603636 637+ // ---- connections: the Connect fold's app shortcuts, in order ----638+639+ // connections is the owner's list, or the defaults while there is none.640+ // Entries are checked against the library when they are written641+ // (connections.ts, parseConnections) and again when the fold resolves642+ // them, so a template that left the library is skipped, not shown broken.643+ connections(): Connection[] {644+ return (this.connectionList ?? DEFAULT_CONNECTIONS).map((c) => ({ ...c }));645+ }646+ setConnections(list: Connection[]) {647+ this.connectionList = list.map((c) => ({ ...c }));648+ this.sql.exec(`INSERT INTO settings(key,value) VALUES('connections',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, JSON.stringify(this.connectionList));649+ }650+604651 isOwner(pubkey: string) {605652 return this.policy.owner !== "" && this.policy.owner === pubkey;606653 }@@ -0,0 +1,365 @@
1+// The Connect fold's shortcuts (connections.ts): the door that resolves2+// them for whoever asks, the management methods that keep the owner's3+// list, and the list travelling with configurations and presets. The pure4+// parts are test/unit/connections.test.ts. Templates past notes, find-me5+// and group are picked by what they declare, so the library can grow6+// without this file naming each one.7+import { SELF } from "cloudflare:test";8+import { describe, it, expect } from "vitest";9+import { generateSecretKey } from "nostr-tools/pure";10+import { npubEncode, nprofileEncode } from "nostr-tools/nip19";11+import { CONNECTION_TEMPLATES, fill, type ConnectionTemplate, type Values } from "../../src/connections.ts";12+import { DEFAULT_CONNECTIONS, type Feature } from "../../src/settings.ts";13+import { PRESETS } from "../../src/presets.ts";14+import { rpc, get, pk, nip98 } from "../helpers/relay.ts";15+16+// connect reads the door, signed when a key is given, with the cache header along.17+async function connect(host: string, sk: Uint8Array | null = null) {18+ const r = await get(host, "/connect.json", sk);19+ return { status: r.status, cache: r.headers.get("cache-control"), cors: r.headers.get("access-control-allow-origin"), ...(await r.json<any>()) };20+}21+22+// valuesOf is what the relay is expected to fill in, from its card and the23+// keys, so a template's links can be resolved here and compared.24+async function valuesOf(host: string, owner: Uint8Array | null, user: Uint8Array | null): Promise<Values> {25+ const card: any = await (await get(host, "/card.json")).json();26+ const url = `wss://${host}`;27+ const self = card.self ?? "";28+ const profile = (p: string) => (p ? nprofileEncode({ pubkey: p, relays: [url] }) : "");29+ const npub = (p: string) => (p ? npubEncode(p) : "");30+ const o = owner ? pk(owner) : "";31+ const u = user ? pk(user) : "";32+ return {33+ relay: { url, host, web: `https://${host}`, name: host.split(".")[0], domain: "bind.ws", hex: self, npub: npub(self), nprofile: profile(self), naddr: card.naddr ?? "" },34+ owner: { hex: o, npub: npub(o), nprofile: profile(o) },35+ user: user ? { hex: u, npub: npub(u), nprofile: profile(u) } : null,36+ };37+}38+39+// resolved is a template as the fold should show it: the links that fill40+// in for these values, the inputs the owner set or the template's defaults.41+function resolved(t: ConnectionTemplate, values: Values, inputs: Record<string, string> = {}) {42+ const filled: Record<string, string> = {};43+ for (const i of t.inputs) filled[i.name] = inputs[i.name] || i.default;44+ const links: { label: string; href?: string; copy?: string }[] = [];45+ let needsUser = false;46+ for (const l of t.links) {47+ const r = fill(l.href ?? l.copy ?? "", values, filled);48+ if (r.missing.length) {49+ if (r.missing.some((m) => m.startsWith("user:"))) needsUser = true;50+ continue;51+ }52+ links.push(l.href ? { label: l.label, href: r.text } : { label: l.label, copy: r.text });53+ }54+ const qr = t.qr ? fill(t.qr, values, filled) : { text: "", missing: [] as string[] };55+ return { links, needsUser, qr: qr.missing.length ? "" : qr.text };56+}57+58+const template = (name: string) => CONNECTION_TEMPLATES.find((t) => t.name === name)!;59+const names = (answer: { connections: { template: string }[] }) => answer.connections.map((c) => c.template);60+const text = (l: { href?: string; copy?: string }) => l.href ?? l.copy ?? "";61+const namesUser = (l: { href?: string; copy?: string }) => text(l).includes("{user:");62+// everyInput fills each of a template's inputs, so no link is left out for want of one.63+const everyInput = (t: ConnectionTemplate) => Object.fromEntries(t.inputs.map((i) => [i.name, "mine"]));64+65+// turnOn switches a template's feature on; the GRASP extensions need GRASP66+// itself, and GRASP's own doors want open reads.67+async function turnOn(host: string, owner: Uint8Array, f: Feature | undefined) {68+ if (!f) return;69+ const features: Record<string, boolean> = { [f]: true };70+ if (f.startsWith("grasp0")) features.grasp = true;71+ if (f === "grasp03" || f === "grasp05") features.grasp02 = true;72+ expect((await rpc(host, owner, "setpolicy", { reads: "open", features })).status).toBe(200);73+}74+75+describe("connections", () => {76+ it("answers a visitor with the defaults, every link filled in for this relay, cacheable for a minute", async () => {77+ const host = "connect.bind.ws";78+ const owner = generateSecretKey();79+ await rpc(host, owner, "claim");80+ const card: any = await (await get(host, "/card.json")).json();81+ expect(card.naddr).toBeDefined();82+ expect(card.nprofile).toBeDefined();83+ const values = await valuesOf(host, owner, null);84+ const r = await connect(host);85+ expect(r.status).toBe(200);86+ expect(r.cache).toBe("public, max-age=60");87+ expect(r.cors).toBe("*");88+ expect(r.relay).toEqual({ url: "wss://connect.bind.ws", host, web: "https://connect.bind.ws", name: "connect" });89+ expect(r.viewer).toBeNull();90+ expect(names(r)).toEqual(DEFAULT_CONNECTIONS.map((c) => c.template));91+ expect((await rpc(host, owner, "listconnections")).result).toEqual(DEFAULT_CONNECTIONS);92+ for (const c of r.connections) {93+ const t = template(c.template);94+ const want = resolved(t, values);95+ expect(c, c.template).toEqual({ template: t.name, title: t.title, about: t.about, app: t.app, where: t.where, icon: t.icon, visibility: "public", links: want.links, qr: want.qr, needsUser: false });96+ }97+ // The group address and the owner's profile are the card's, |enc98+ // percent-encodes the relay URL, and no placeholder is left.99+ const texts = r.connections.flatMap((c: any) => c.links.map(text));100+ expect(texts.some((x: string) => x.includes(card.naddr))).toBe(true);101+ expect(texts.some((x: string) => x.includes(card.nprofile))).toBe(true);102+ expect(texts.some((x: string) => x.includes(encodeURIComponent("wss://" + host)))).toBe(true);103+ // The fold has one relay URL copy control of its own, so no template carries the plain URL as a copy text.104+ expect(r.connections.flatMap((c: any) => c.links.filter((l: any) => l.copy).map((l: any) => l.copy))).not.toContain("wss://" + host);105+ for (const x of texts) expect(x).not.toMatch(/[{}]/);106+ expect(r.connections.find((c: any) => c.template === "find-me").qr).toBe("nostr:" + card.nprofile);107+ expect(r.connections.find((c: any) => c.template === "group").qr).toBe("nostr:" + card.naddr);108+ });109+110+ it("names a signed viewer, keeps that answer out of shared caches, and refuses a signature that does not fit", async () => {111+ const host = "connect-signed.bind.ws";112+ const owner = generateSecretKey();113+ const viewer = generateSecretKey();114+ await rpc(host, owner, "claim");115+ const r = await connect(host, viewer);116+ expect(r.status).toBe(200);117+ expect(r.cache).toBe("no-store");118+ expect(r.viewer).toBe(pk(viewer));119+ expect(names(r)).toEqual(DEFAULT_CONNECTIONS.map((c) => c.template));120+ const bad = await SELF.fetch(`http://${host}/connect.json`, { headers: { authorization: "Nostr nonsense" } });121+ expect(bad.status).toBe(401);122+ expect((await bad.json<any>()).error).toMatch(/^auth-required/);123+ // A signature for another door does not count as a visitor either.124+ const elsewhere = await SELF.fetch(`http://${host}/connect.json`, { headers: { authorization: await nip98(viewer, `http://${host}/card.json`) } });125+ expect(elsewhere.status).toBe(401);126+ });127+128+ it("leaves out the owner's links on a relay nobody owns yet, and a shortcut with nothing else, without asking anyone to sign in", async () => {129+ const host = "connect-nobody.bind.ws";130+ const r = await connect(host);131+ expect(r.status).toBe(200);132+ expect(r.viewer).toBeNull();133+ // Every link of find-me names the owner, so the whole shortcut waits for one.134+ const t = template("find-me");135+ expect(t.links.every((l) => text(l).includes("{owner:"))).toBe(true);136+ expect(r.connections.find((x: any) => x.template === "find-me")).toBeUndefined();137+ // Notes names only the relay, so it shows whole; the group shows what does not need an identity.138+ const notes = r.connections.find((x: any) => x.template === "notes");139+ expect(notes).toBeDefined();140+ expect(notes.needsUser).toBe(false);141+ expect(notes.links.map((l: any) => l.label)).toEqual(template("notes").links.map((l) => l.label));142+ for (const x of r.connections) {143+ expect(x.needsUser).toBe(false);144+ for (const l of x.links) expect(text(l)).not.toMatch(/[{}]/);145+ }146+ });147+148+ it("shows each shortcut to whom its visibility admits, with the owner's title and about in place of the template's", async () => {149+ const host = "connect-who.bind.ws";150+ const owner = generateSecretKey();151+ const member = generateSecretKey();152+ const outsider = generateSecretKey();153+ await rpc(host, owner, "claim");154+ await rpc(host, owner, "setmember", pk(member), { name: "eve" });155+ const list = [156+ { template: "notes", visibility: "public", title: "Our feed", about: "What we post, as one feed." },157+ { template: "notes", visibility: "auth", title: "Signed in" },158+ { template: "group", visibility: "members", title: "Members" },159+ { template: "find-me", visibility: "owner", title: "Owner" },160+ ];161+ const saved = await rpc(host, owner, "setconnections", list);162+ expect(saved.status, JSON.stringify(saved)).toBe(200);163+ expect(saved.result).toEqual(list);164+ const titles = async (sk: Uint8Array | null) => (await connect(host, sk)).connections.map((c: any) => c.title);165+ expect(await titles(null)).toEqual(["Our feed"]);166+ expect(await titles(outsider)).toEqual(["Our feed", "Signed in"]);167+ expect(await titles(member)).toEqual(["Our feed", "Signed in", "Members"]);168+ expect(await titles(owner)).toEqual(["Our feed", "Signed in", "Members", "Owner"]);169+ const mine = (await connect(host, owner)).connections;170+ expect(mine[0].about).toBe("What we post, as one feed.");171+ expect(mine[0].app).toBe(template("notes").app);172+ expect(mine[0].icon).toBe(template("notes").icon);173+ expect(mine[1].about).toBe(template("notes").about);174+ expect(mine.map((c: any) => c.visibility)).toEqual(["public", "auth", "members", "owner"]);175+ });176+177+ it("shows the defaults until the owner saves a list, nothing after an empty one, and the saved order", async () => {178+ const host = "connect-order.bind.ws";179+ const owner = generateSecretKey();180+ await rpc(host, owner, "claim");181+ expect(names(await connect(host))).toEqual(["notes", "find-me", "group"]);182+ let r = await rpc(host, owner, "setconnections", [{ template: "group" }, { template: "notes" }]);183+ expect(r.status).toBe(200);184+ expect(r.result).toEqual([{ template: "group", visibility: "public" }, { template: "notes", visibility: "public" }]);185+ expect((await rpc(host, owner, "listconnections")).result).toEqual(r.result);186+ expect(names(await connect(host))).toEqual(["group", "notes"]);187+ r = await rpc(host, owner, "setconnections", []);188+ expect(r.status).toBe(200);189+ expect(r.result).toEqual([]);190+ expect((await rpc(host, owner, "listconnections")).result).toEqual([]);191+ expect((await connect(host)).connections).toEqual([]);192+ expect((await rpc(host, owner, "exportconfig")).result.connections).toEqual([]);193+ });194+195+ it("refuses a list that does not fit, whole, to anyone but the owner, and logs the one it saved", async () => {196+ const host = "connect-refuse.bind.ws";197+ const owner = generateSecretKey();198+ const mod = generateSecretKey();199+ await rpc(host, owner, "claim");200+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });201+ const before = (await rpc(host, owner, "listconnections")).result;202+ let r = await rpc(host, owner, "setconnections", [{ template: "notes" }, { template: "nope" }]);203+ expect(r.status).toBe(400);204+ expect(r.error).toBe("invalid: connections[1]: no connection template named nope");205+ r = await rpc(host, owner, "setconnections", [{ template: "notes", visibility: "friends" }]);206+ expect(r.status).toBe(400);207+ expect(r.error).toBe("invalid: connections[0].visibility: must be one of public, auth, members, owner");208+ r = await rpc(host, owner, "setconnections", [{ template: "notes", inputs: { repo: "x" } }]);209+ expect(r.status).toBe(400);210+ expect(r.error).toBe("invalid: connections[0].inputs.repo: notes has no such input");211+ r = await rpc(host, owner, "setconnections", [{ template: "repos", inputs: { repo: "kid's-project" } }]);212+ expect(r.status).toBe(400);213+ expect(r.error).toMatch(/^invalid: connections\[0\]\.inputs\.repo: must match /);214+ r = await rpc(host, owner, "setconnections", { template: "notes" });215+ expect(r.status).toBe(400);216+ expect(r.error).toBe("invalid: connections must be a list");217+ expect((await rpc(host, owner, "setconnections")).status).toBe(400);218+ expect((await rpc(host, mod, "setconnections", [])).status).toBe(403);219+ expect((await rpc(host, null, "setconnections", [])).status).toBe(401);220+ expect((await rpc(host, owner, "listconnections")).result).toEqual(before);221+ expect((await rpc(host, mod, "listconnections")).status).toBe(200);222+ expect((await rpc(host, mod, "listconnectiontemplates")).status).toBe(200);223+ // The moderation log has the saved list and none of the refusals.224+ const list = [{ template: "group", visibility: "members", title: "Room" }];225+ expect((await rpc(host, owner, "setconnections", list)).status).toBe(200);226+ const audit = (await rpc(host, owner, "listaudit")).result as { actor: string; action: string; detail: string }[];227+ expect(audit[0].action).toBe("setconnections");228+ expect(audit[0].actor).toBe(pk(owner));229+ expect(JSON.parse(audit[0].detail)).toEqual(list);230+ expect(audit.filter((row) => row.action === "setconnections").length).toBe(1);231+ });232+233+ const featured = CONNECTION_TEMPLATES.find((t) => t.feature);234+ it.skipIf(!featured)("lists the library with whether each template's feature is on, and skips a shortcut whose feature is off", async () => {235+ const host = "connect-feature.bind.ws";236+ const owner = generateSecretKey();237+ await rpc(host, owner, "claim");238+ const t = featured!;239+ const f = t.feature!;240+ expect((await rpc(host, owner, "setpolicy", { features: { [f]: false } })).status).toBe(200);241+ let lib = (await rpc(host, owner, "listconnectiontemplates")).result as (ConnectionTemplate & { available: boolean })[];242+ expect(lib.map((x) => x.name)).toEqual(CONNECTION_TEMPLATES.map((x) => x.name));243+ expect(lib.find((x) => x.name === "notes")).toEqual({ ...template("notes"), available: true });244+ expect(lib.find((x) => x.name === t.name)!.available).toBe(false);245+ for (const x of lib) if (!x.feature) expect(x.available, x.name).toBe(true);246+ expect((await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs: everyInput(t) }, { template: "notes" }])).status).toBe(200);247+ expect(names(await connect(host, owner))).toEqual(["notes"]);248+ await turnOn(host, owner, f);249+ lib = (await rpc(host, owner, "listconnectiontemplates")).result;250+ expect(lib.find((x) => x.name === t.name)!.available).toBe(true);251+ expect(names(await connect(host, owner))).toEqual([t.name, "notes"]);252+ });253+254+ const personal = CONNECTION_TEMPLATES.find((t) => t.links.some((l) => text(l).includes("{user:npub")));255+ it.skipIf(!personal)("fills {user:*} with whoever signed the request, and for a visitor leaves those links out and says so", async () => {256+ const host = "connect-user.bind.ws";257+ const owner = generateSecretKey();258+ const viewer = generateSecretKey();259+ await rpc(host, owner, "claim");260+ const t = personal!;261+ await turnOn(host, owner, t.feature);262+ const inputs = everyInput(t);263+ expect((await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs }])).status).toBe(200);264+ const visitor = (await connect(host)).connections;265+ expect(visitor.length).toBe(1);266+ expect(visitor[0].needsUser).toBe(true);267+ expect(visitor[0].links).toEqual(resolved(t, await valuesOf(host, owner, null), inputs).links);268+ expect(visitor[0].links.map((l: any) => l.label)).toEqual(t.links.filter((l) => !namesUser(l)).map((l) => l.label));269+ for (const l of visitor[0].links) expect(text(l)).not.toContain("{");270+ // Signed in: the viewer's own key, not the owner's, and nothing missing.271+ const mine = (await connect(host, viewer)).connections;272+ expect(mine.length).toBe(1);273+ expect(mine[0].needsUser).toBe(false);274+ expect(mine[0].links).toEqual(resolved(t, await valuesOf(host, owner, viewer), inputs).links);275+ expect(mine[0].links.map((l: any) => l.label)).toEqual(t.links.map((l) => l.label));276+ const own = t.links.find((l) => text(l).includes("{user:npub"))!;277+ const link = mine[0].links.find((l: any) => l.label === own.label)!;278+ expect(text(link)).toContain(npubEncode(pk(viewer)));279+ expect(text(link)).not.toContain(npubEncode(pk(owner)));280+ });281+282+ const withInput = CONNECTION_TEMPLATES.find((t) => t.inputs.length > 0);283+ it.skipIf(!withInput)("puts the owner's input into the link, and the template's default when the input is blank", async () => {284+ const host = "connect-input.bind.ws";285+ const owner = generateSecretKey();286+ await rpc(host, owner, "claim");287+ const t = withInput!;288+ await turnOn(host, owner, t.feature);289+ const i = t.inputs[0];290+ const named = t.links.find((l) => new RegExp(`\\{input:${i.name}(\\|enc)?\\}`).test(text(l)))!;291+ expect(named, `${t.name} has a link naming {input:${i.name}}`).toBeDefined();292+ let r = await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs: { [i.name]: " rocket-7 " } }]);293+ expect(r.status, JSON.stringify(r)).toBe(200);294+ expect(r.result[0].inputs).toEqual({ [i.name]: "rocket-7" });295+ let c = (await connect(host, owner)).connections[0];296+ expect(c.template).toBe(t.name);297+ expect(text(c.links.find((l: any) => l.label === named.label)!)).toContain("rocket-7");298+ expect(c.links).toEqual(resolved(t, await valuesOf(host, owner, owner), { [i.name]: "rocket-7" }).links);299+ // Blank: not kept, and the template's default fills the link.300+ r = await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs: { [i.name]: " " } }]);301+ expect(r.status).toBe(200);302+ expect(r.result[0].inputs).toBeUndefined();303+ c = (await connect(host, owner)).connections[0];304+ expect(c.links).toEqual(resolved(t, await valuesOf(host, owner, owner), {}).links);305+ const fallback = c.links.find((l: any) => l.label === named.label);306+ if (i.default) expect([i.default, encodeURIComponent(i.default)].some((d) => text(fallback!).includes(d))).toBe(true);307+ else expect(fallback).toBeUndefined();308+ });309+310+ it("travels with the configuration: exported, planned as one summary line with warnings, applied, and left alone when the document has no section", async () => {311+ const a = "connect-cfg-a.bind.ws", b = "connect-cfg-b.bind.ws";312+ const owner = generateSecretKey();313+ await rpc(a, owner, "claim");314+ await rpc(b, owner, "claim");315+ const mine = [{ template: "group", visibility: "members", title: "Room" }, { template: "notes", visibility: "public" }];316+ expect((await rpc(a, owner, "setconnections", mine)).status).toBe(200);317+ const cfg = (await rpc(a, owner, "exportconfig")).result;318+ expect(cfg.connections).toEqual(mine);319+ cfg.connections.push({ template: "nope" });320+ let dry = (await rpc(b, owner, "importconfig", cfg, { dryRun: true })).result;321+ expect(dry.warnings).toEqual(["connections[2]: no connection template named nope"]);322+ expect(dry.changes.connections).toEqual({ add: [mine[0]], remove: [{ template: "find-me", visibility: "public" }, { template: "group", visibility: "public" }], reordered: false });323+ expect(dry.changes.summary.filter((l: string) => l.startsWith("connections:"))).toEqual(["connections: +group (members), -find-me, -group"]);324+ expect((await rpc(b, owner, "listconnections")).result).toEqual(DEFAULT_CONNECTIONS);325+ expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);326+ expect((await rpc(b, owner, "listconnections")).result).toEqual(mine);327+ expect(names(await connect(b, owner))).toEqual(["group", "notes"]);328+ // No section: the list stays. The same list in another order is a reorder. An empty list clears it.329+ delete cfg.connections;330+ expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);331+ expect((await rpc(b, owner, "listconnections")).result).toEqual(mine);332+ cfg.connections = [mine[1], mine[0]];333+ dry = (await rpc(b, owner, "importconfig", cfg, { dryRun: true })).result;334+ expect(dry.changes.connections).toEqual({ add: [], remove: [], reordered: true });335+ expect(dry.changes.summary.filter((l: string) => l.startsWith("connections:"))).toEqual(["connections: reordered"]);336+ expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);337+ expect((await rpc(b, owner, "listconnections")).result).toEqual([mine[1], mine[0]]);338+ cfg.connections = [];339+ dry = (await rpc(b, owner, "importconfig", cfg, { dryRun: true })).result;340+ expect(dry.changes.summary.filter((l: string) => l.startsWith("connections:"))).toEqual(["connections: -notes, -group (members)"]);341+ expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200);342+ expect((await rpc(b, owner, "listconnections")).result).toEqual([]);343+ expect((await rpc(b, owner, "importconfig", { format: cfg.format, connections: "notes" })).status).toBe(400);344+ });345+346+ it("is set by a preset that has a connections section and left alone by one that has not", async () => {347+ const host = "connect-presets.bind.ws";348+ const owner = generateSecretKey();349+ await rpc(host, owner, "claim");350+ const mine = [{ template: "group", visibility: "members", title: "Room" }];351+ expect((await rpc(host, owner, "setconnections", mine)).status).toBe(200);352+ const plain = PRESETS.find((p) => !p.connections)!;353+ expect(plain).toBeDefined();354+ expect((await rpc(host, owner, "applypreset", plain.name, plain.source === "required" ? { source: "wss://elsewhere.bind.ws" } : undefined)).status).toBe(200);355+ expect((await rpc(host, owner, "listconnections")).result).toEqual(mine);356+ for (const p of PRESETS.filter((p) => p.connections)) {357+ const r = await rpc(host, owner, "applypreset", p.name, p.source === "required" ? { source: "wss://elsewhere.bind.ws" } : undefined);358+ expect(r.status, p.name + " " + JSON.stringify(r)).toBe(200);359+ expect((await rpc(host, owner, "listconnections")).result, p.name).toEqual(p.connections);360+ expect((await rpc(host, owner, "exportconfig")).result.connections, p.name).toEqual(p.connections);361+ }362+ const listed = (await rpc(host, owner, "listpresets")).result as { name: string; connections?: unknown }[];363+ for (const x of listed) expect(x.connections, x.name).toEqual(PRESETS.find((p) => p.name === x.name)?.connections);364+ });365+});@@ -33,6 +33,8 @@ async function seed(host: string): Promise<Fixture> {
3333 const stranger = generateSecretKey();3434 await rpc(host, owner, "claim");3535 expect((await rpc(host, owner, "setmember", pk(eve), { name: "evelynq7" })).status).toBe(200);36+ // Two shortcuts with the owner's own titles: one for members, one for the owner alone.37+ expect((await rpc(host, owner, "setconnections", [{ template: "notes", visibility: "public" }, { template: "group", visibility: "members", title: "membersroomq7" }, { template: "find-me", visibility: "owner", title: "ownerdeskq7" }])).status).toBe(200);3638 const body = new TextEncoder().encode("eve's quarterly numbers");3739 const sha = bytesToHex(sha256(body));3840 const up = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(eve, "upload", sha), "content-type": "text/plain" }, body });@@ -45,7 +47,7 @@ async function seed(host: string): Promise<Fixture> {
4547 await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), (r) => r.syncSites());4648 c.ws.close();4749 await rpc(host, owner, "setpolicy", { reads: "members", writes: "allowlist", directoryPublic: false });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"] };50+ return { host, siteHost: siteLabel(site) + ".bind.ws", owner, eve, stranger, sha, noteId: note.id, secrets: [pk(eve), npubEncode(pk(eve)), note.id, sha, "evelynq7", "membersroomq7", "ownerdeskq7"] };4951 }5052 5153 // Every HTTP path a relay answers that could carry something of a member's.@@ -78,6 +80,7 @@ function doors(f: Fixture): { path: string; host?: string; method?: string; gate
7880 { path: "/card.json", gated: false },7981 { path: "/card.nostr", gated: false },8082 { path: "/card.svg", gated: false },83+ { path: "/connect.json", gated: false },8184 { path: `/e/${f.noteId}`, gated: false },8285 { path: `/feed.xml?author=${pk(f.eve)}`, gated: false },8386 { path: "/terms", gated: false },@@ -229,6 +232,12 @@ describe("the read rule at every door", () => {
229232 }230233 r = await get(`/people`, await nip98(f.eve, url(`/people`)));231234 expect(JSON.parse(r.text).people.map((m: any) => m.pubkey)).toContain(pk(f.eve));235+ // The Connect fold shows her the members' shortcut and not the owner's.236+ r = await get(`/connect.json`, await nip98(f.eve, url(`/connect.json`)));237+ expect(r.status).toBe(200);238+ const titles = JSON.parse(r.text).connections.map((c: any) => c.title);239+ expect(titles).toContain("membersroomq7");240+ expect(titles).not.toContain("ownerdeskq7");232241 const c = await WS.connect(f.host);233242 await c.auth(f.eve, f.host);234243 expect((await c.open("r", { authors: [pk(f.eve)], kinds: [1] })).events.map((e) => e.id)).toEqual([f.noteId]);@@ -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", "site", "marmot", "grasp", "marmot-members"]);14+ expect(list.map((p: any) => p.name)).toEqual(["default", "outbox", "inbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site", "marmot", "grasp", "marmot-members", "home"]);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");@@ -26,7 +26,7 @@ describe("templates", () => {
2626 for (const f of FEATURE_NAMES) for (const n of FEATURE_NIPS[f]) expect(doc.supported_nips.includes(n), `${p.name}: NIP-${n}`).toBe(featureOn(policy, f));2727 expect((await get(host, "/.well-known/nostr/nip96.json")).status).toBe(features.files ? 200 : 404);2828 expect((await get(host, "/feed.xml")).status).toBe(features.pages && p.reads === "open" ? 200 : 404);29- const dry = (await rpc(host, owner, "importconfig", p.config === undefined ? {} : { format: "bind.ws/relay-config/2", policy: p.config.policy, kinds: p.config.kinds, retention: p.config.retention }, { dryRun: true })).result;29+ const dry = (await rpc(host, owner, "importconfig", p.config === undefined ? {} : { format: "bind.ws/relay-config/2", policy: p.config.policy, kinds: p.config.kinds, retention: p.config.retention, ...(p.config.sections.includes("connections") ? { connections: p.config.connections } : {}) }, { dryRun: true })).result;3030 expect(dry.changes.summary, p.name).toEqual([]);3131 expect(dry.warnings, p.name).toEqual([]);3232 }@@ -0,0 +1,206 @@
1+// The Connect fold's pure parts (connections.ts): a text's placeholders2+// filled for a viewer, a library document read or refused, an owner's list3+// read with warnings, and the library itself. The door and the settings4+// behind it are test/object/connections.test.ts.5+import { describe, it, expect } from "vitest";6+import { CONNECTION_TEMPLATES, FORMAT, MAX_CONNECTIONS, fill, parseConnectionTemplate, parseConnections, type Values } from "../../src/connections.ts";7+8+const values: Values = {9+ relay: { url: "wss://club.bind.ws", host: "club.bind.ws", web: "https://club.bind.ws", name: "club", domain: "bind.ws", hex: "ab".repeat(32), npub: "npub1relay", nprofile: "nprofile1relay", naddr: "naddr1room" },10+ owner: { hex: "cd".repeat(32), npub: "npub1owner", nprofile: "nprofile1owner" },11+ user: null,12+};13+14+// A library document that every relay can fill in.15+const good = {16+ format: FORMAT,17+ title: "Feed",18+ about: "The relay as a feed.",19+ app: "Jumble",20+ where: "web",21+ icon: "notes",22+ links: [23+ { label: "Open", href: "https://jumble.social/?r={relay:url|enc}" },24+ { label: "Copy relay URL", copy: "{relay:url}" },25+ ],26+};27+28+describe("fill", () => {29+ it("resolves every source, percent-encodes with |enc, and reads inputs by name", () => {30+ const r = fill("{relay:web}/p/{owner:npub}?r={relay:url|enc}&repo={input:repo}", values, { repo: "mine" });31+ expect(r).toEqual({ text: "https://club.bind.ws/p/npub1owner?r=wss%3A%2F%2Fclub.bind.ws&repo=mine", missing: [] });32+ expect(fill("plain text", values, {})).toEqual({ text: "plain text", missing: [] });33+ expect(fill("{relay:naddr}", values, {}).text).toBe("naddr1room");34+ expect(fill("{owner:hex|enc}", values, {}).text).toBe("cd".repeat(32));35+ });36+37+ it("lists what nobody is signed in for as user:npub and leaves the text without it", () => {38+ expect(fill("nostr:{user:npub}", values, {})).toEqual({ text: "nostr:", missing: ["user:npub"] });39+ const signed = { ...values, user: { hex: "ef".repeat(32), npub: "npub1viewer", nprofile: "nprofile1viewer" } };40+ expect(fill("nostr:{user:npub}", signed, {})).toEqual({ text: "nostr:npub1viewer", missing: [] });41+ });42+43+ it("lists an empty owner, an unset input and an unknown field as missing rather than filling a blank", () => {44+ const unclaimed = { ...values, owner: { hex: "", npub: "", nprofile: "" } };45+ expect(fill("https://primal.net/p/{owner:nprofile}", unclaimed, {})).toEqual({ text: "https://primal.net/p/", missing: ["owner:nprofile"] });46+ expect(fill("{relay:url}/{input:repo}.git", values, {}).missing).toEqual(["input:repo"]);47+ expect(fill("{relay:url}/{input:repo}.git", values, { repo: "" }).missing).toEqual(["input:repo"]);48+ expect(fill("{owner:npub} {user:hex}", unclaimed, {}).missing).toEqual(["owner:npub", "user:hex"]);49+ });50+});51+52+describe("parseConnectionTemplate", () => {53+ it("accepts a good document, defaults the visibility to public and the QR to the first href", () => {54+ const t = parseConnectionTemplate("feed", good);55+ expect(typeof t).toBe("object");56+ if (typeof t === "string") return;57+ expect(t.name).toBe("feed");58+ expect(t.title).toBe("Feed");59+ expect(t.visibility).toBe("public");60+ expect(t.icon).toBe("notes");61+ expect(t.feature).toBeUndefined();62+ expect(t.inputs).toEqual([]);63+ expect(t.links).toEqual(good.links);64+ expect(t.qr).toBe("https://jumble.social/?r={relay:url|enc}");65+ });66+67+ it("takes the QR the document names, an icon default, a feature and declared inputs", () => {68+ const t = parseConnectionTemplate("repos", { ...good, icon: undefined, feature: "grasp", visibility: "auth", qr: "nostr:{owner:nprofile}", inputs: [{ name: "repo", label: "Repository", placeholder: "name", default: "notes" }], links: [{ label: "Clone", copy: "{relay:web}/{user:npub}/{input:repo}.git" }] });69+ expect(typeof t).toBe("object");70+ if (typeof t === "string") return;71+ expect(t.icon).toBe("app");72+ expect(t.feature).toBe("grasp");73+ expect(t.visibility).toBe("auth");74+ expect(t.qr).toBe("nostr:{owner:nprofile}");75+ expect(t.inputs).toEqual([{ name: "repo", label: "Repository", placeholder: "name", default: "notes", pattern: "" }]);76+ expect(t.links).toEqual([{ label: "Clone", copy: "{relay:web}/{user:npub}/{input:repo}.git" }]);77+ });78+79+ it("refuses what is not a template, another format, and a document short of a title, an about or an app", () => {80+ expect(parseConnectionTemplate("x", null)).toBe("not a connection template");81+ expect(parseConnectionTemplate("x", [])).toBe("not a connection template");82+ expect(parseConnectionTemplate("x", { ...good, format: "bind.ws/connection-template/2" })).toBe("format must be " + FORMAT);83+ expect(parseConnectionTemplate("x", { ...good, about: " " })).toBe("needs a title, an about and an app");84+ });85+86+ it("refuses a link that is not https or nostr, so an owner cannot smuggle a scheme in", () => {87+ const r = parseConnectionTemplate("x", { ...good, links: [{ label: "Run", href: "javascript:alert(1)" }] });88+ expect(r).toMatch(/^link Run must be https:\/\/ or nostr:/);89+ expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Data", href: "data:text/html,hi" }] })).toMatch(/must be https/);90+ // Plain http is for a resolved {relay:web} on a local host, not for a template.91+ expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Plain", href: "http://insecure.example/" }] })).toBe("link Plain must be https:// or nostr:");92+ expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Copy", copy: "javascript:alert(1)" }] })).not.toBeTypeOf("string");93+ });94+95+ it("refuses a placeholder the relay cannot fill and an input the document does not declare", () => {96+ expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Open", href: "https://a.example/{relay:nonsense}" }] })).toMatch(/^link Open names \{relay:nonsense\}, which is not one of relay:url/);97+ expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Open", href: "https://a.example/{input:x}" }] })).toBe("link Open names {input:x} but declares no such input");98+ expect(parseConnectionTemplate("x", { ...good, qr: "nostr:{user:nonsense}" })).toMatch(/^qr names \{user:nonsense\}/);99+ expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "Repository" }], links: [{ label: "Open", href: "https://a.example/{input:branch}" }] })).toBe("link Open names {input:branch} but declares no such input");100+ });101+102+ it("refuses a link with both an href and a copy text, with neither, without a label, and a list of none", () => {103+ expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Both", href: "https://a.example/", copy: "text" }] })).toBe("link Both needs an href or a copy text, not both");104+ expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Neither" }] })).toBe("link Neither needs an href or a copy text, not both");105+ expect(parseConnectionTemplate("x", { ...good, links: [{ href: "https://a.example/" }] })).toBe("a link needs a label");106+ expect(parseConnectionTemplate("x", { ...good, links: [] })).toMatch(/^links must be a list of 1 to/);107+ });108+109+ it("takes an input's pattern and refuses one that does not compile", () => {110+ const t = parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "Repository", pattern: "^[a-z0-9-]{1,64}$" }], links: [{ label: "Clone", copy: "git clone '{relay:web}/{input:repo}.git'" }] });111+ expect(typeof t).toBe("object");112+ if (typeof t === "string") return;113+ expect(t.inputs[0].pattern).toBe("^[a-z0-9-]{1,64}$");114+ expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "Repository", pattern: "([" }] })).toBe("input repo has a pattern that does not compile");115+ });116+117+ it("refuses an unknown icon, feature or visibility and a badly named input", () => {118+ expect(parseConnectionTemplate("x", { ...good, icon: "rocket" })).toMatch(/^icon must be one of/);119+ expect(parseConnectionTemplate("x", { ...good, feature: "teleport" })).toMatch(/^feature must be one of/);120+ expect(parseConnectionTemplate("x", { ...good, visibility: "friends" })).toBe("visibility must be one of public, auth, members, owner");121+ expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "Repo Name", label: "Repository" }] })).toBe("an input name is lowercase letters, digits, dash and underscore");122+ expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "One" }, { name: "repo", label: "Two" }] })).toBe("input repo is declared twice");123+ });124+});125+126+describe("parseConnections", () => {127+ const withInput = CONNECTION_TEMPLATES.find((t) => t.inputs.length > 0);128+129+ it("keeps the order given, fills the template's default visibility and trims the words", () => {130+ const r = parseConnections([{ template: "group", visibility: "members", title: " The room ", about: " Where we talk. " }, { template: "notes" }]);131+ expect(r).toEqual({ list: [{ template: "group", visibility: "members", title: "The room", about: "Where we talk." }, { template: "notes", visibility: "public" }], warnings: [] });132+ });133+134+ it("drops an unknown template with a warning and keeps the rest in order", () => {135+ const r = parseConnections([{ template: "notes" }, { template: "nope" }, { template: "group" }, {}, null]);136+ expect(typeof r).toBe("object");137+ if (typeof r === "string") return;138+ expect(r.list.map((c) => c.template)).toEqual(["notes", "group"]);139+ expect(r.warnings).toEqual(["connections[1]: no connection template named nope", "connections[3]: no connection template named (none)", "connections[4]: no connection template named (none)"]);140+ });141+142+ it("keeps a bad visibility or an undeclared input as a warning, and the entry with the template's own values", () => {143+ const r = parseConnections([{ template: "notes", visibility: "friends", inputs: { x: "y" } }, { template: "group", inputs: "repo" }]);144+ expect(typeof r).toBe("object");145+ if (typeof r === "string") return;146+ expect(r.list).toEqual([{ template: "notes", visibility: "public" }, { template: "group", visibility: "public" }]);147+ expect(r.warnings).toEqual(["connections[0].visibility: must be one of public, auth, members, owner", "connections[0].inputs.x: notes has no such input", "connections[1].inputs: must be an object of input name to value"]);148+ });149+150+ it.skipIf(!withInput)("trims an input's value, drops a blank one and warns about one that is not a string", () => {151+ const t = withInput!;152+ const name = t.inputs[0].name;153+ const r = parseConnections([{ template: t.name, inputs: { [name]: " mine " } }, { template: t.name, inputs: { [name]: " " } }, { template: t.name, inputs: { [name]: 7 } }]);154+ expect(typeof r).toBe("object");155+ if (typeof r === "string") return;156+ expect(r.list[0].inputs).toEqual({ [name]: "mine" });157+ expect(r.list[1].inputs).toBeUndefined();158+ expect(r.list[2].inputs).toBeUndefined();159+ expect(r.warnings).toEqual([`connections[2].inputs.${name}: must be a string`]);160+ });161+162+ const patterned = CONNECTION_TEMPLATES.find((t) => t.inputs.some((i) => i.pattern));163+ it.skipIf(!patterned)("keeps an input to its pattern, so a value cannot carry a quote or a slash into a command or a path", () => {164+ const t = patterned!;165+ const i = t.inputs.find((x) => x.pattern)!;166+ const r = parseConnections([{ template: t.name, inputs: { [i.name]: "kid's-project" } }, { template: t.name, inputs: { [i.name]: "a/b" } }, { template: t.name, inputs: { [i.name]: "my-project" } }]);167+ expect(typeof r).toBe("object");168+ if (typeof r === "string") return;169+ expect(r.list[0].inputs).toBeUndefined();170+ expect(r.list[1].inputs).toBeUndefined();171+ expect(r.list[2].inputs).toEqual({ [i.name]: "my-project" });172+ expect(r.warnings).toEqual([`connections[0].inputs.${i.name}: must match ${i.pattern}`, `connections[1].inputs.${i.name}: must match ${i.pattern}`]);173+ });174+175+ it("caps the list at 24 with a warning naming the first entry past it", () => {176+ const r = parseConnections(Array.from({ length: MAX_CONNECTIONS + 2 }, () => ({ template: "notes" })));177+ expect(typeof r).toBe("object");178+ if (typeof r === "string") return;179+ expect(r.list.length).toBe(MAX_CONNECTIONS);180+ expect(r.warnings).toEqual([`connections[${MAX_CONNECTIONS}]: at most ${MAX_CONNECTIONS} connections`]);181+ });182+183+ it("is the reason when the list is not one, under the label the caller gave", () => {184+ expect(parseConnections({ template: "notes" })).toBe("invalid: connections must be a list");185+ expect(parseConnections("notes", "shortcuts")).toBe("invalid: shortcuts must be a list");186+ const r = parseConnections([{ template: "nope" }], "shortcuts");187+ expect(typeof r === "string" ? [] : r.warnings).toEqual(["shortcuts[0]: no connection template named nope"]);188+ });189+});190+191+describe("the library", () => {192+ it("has notes, find-me and group, and every template has a unique name, an about and at least one link", () => {193+ const names = CONNECTION_TEMPLATES.map((t) => t.name);194+ expect(names).toContain("notes");195+ expect(names).toContain("find-me");196+ expect(names).toContain("group");197+ expect(new Set(names).size).toBe(names.length);198+ for (const t of CONNECTION_TEMPLATES) {199+ expect(t.links.length, t.name).toBeGreaterThan(0);200+ expect(t.about.length, t.name).toBeGreaterThan(0);201+ expect(t.title.length, t.name).toBeGreaterThan(0);202+ expect(t.app.length, t.name).toBeGreaterThan(0);203+ for (const l of t.links) expect(Boolean(l.href) !== Boolean(l.copy), `${t.name}: ${l.label}`).toBe(true);204+ }205+ });206+});