diff --git a/README.md b/README.md index b354ec0..7a28a1f 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ That answers with a relay at a memorable name, open to anyone for 14 days. Claim - **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. - **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. - **A file host.** Blossom and NIP-96 on the same bucket, with mirroring, pre-flight checks and file reports. -- **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. -- **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/`. +- **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. +- **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/`. - **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. - **Your own domain.** `wss://relay.example.com` onto your relay with one CNAME, or a custom hostname targeting one of your static sites. - **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 - [Hosting bind.ws](docs/11-hosting-bindws.md): run your own on your own domain, and watch it through logs, traces and metrics. - [Hosting without Cloudflare](docs/16-hosting-without-cloudflare.md): the same Worker on celld, with your own bucket and proxy, and what "supported" promises. - [Develop and extend](docs/12-develop-extend.md): layout, tests, adding methods and NIPs, the console, the templates. +- [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. - [Costs and margins](docs/15-costs-and-margins.md): application budgets, retained storage, inventory interpretation and deployment margins. ### Draft NIPs diff --git a/STYLE.md b/STYLE.md index 4b0f278..a6934d3 100644 --- a/STYLE.md +++ b/STYLE.md @@ -40,7 +40,8 @@ docs: main takes commits, not pull requests - **The area** is the module, folder or concern the change belongs to: `views`, `kinds`, `config`, `routes`, `console`, `docs`, `test`, `ci`, - `host`, `rules`, `relay-templates`, `scripts`, `tidy`. + `host`, `rules`, `relay-templates`, `connection-templates`, `connections`, + `scripts`, `tidy`. - **No imperative mood and no Conventional Commits.** There is no `feat:`, `fix:`, `chore:`, and no "add", "update" or "refactor" opening a subject. - **Length is not a constraint.** The median subject is 75 characters and the @@ -55,7 +56,7 @@ Each commit typechecks on its own. ## Documentation -- `docs/` is numbered with two digits: `00` to `05` for users, `10` to `16` +- `docs/` is numbered with two digits: `00` to `05` for users, `10` to `17` for developers, `20` and up for draft NIPs the repository implements ahead of upstream. - Each file opens with frontmatter naming its `title` and its `audience` @@ -164,6 +165,7 @@ file means adding its row. | Catalog | Where | Enforced | |---|---|---| | one row per template | `relay-templates/README.md` | yes, the build fails without it | +| one row per connection template | `connection-templates/README.md` | yes, the build fails without it | | one link per doc | `README.md` | by review | | one entry per generated file | `src/gen/` staleness check | yes, in `npm run typecheck` | | every door | `test/object/exposure.test.ts` | by review | diff --git a/connection-template.schema.json b/connection-template.schema.json new file mode 100644 index 0000000..ec75a5a --- /dev/null +++ b/connection-template.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://bind.ws/connection-template.schema.json", + "title": "bind.ws connection template", + "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.", + "type": "object", + "required": ["format", "title", "about", "app", "links"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "format": { "enum": ["bind.ws/connection-template/1"] }, + "title": { "type": "string", "minLength": 1, "maxLength": 40, "description": "what the shortcut is for, in the owner's words: Repos, Notes, Photo library" }, + "about": { "type": "string", "minLength": 1, "maxLength": 200, "description": "one sentence on what opens and what it does with this relay" }, + "app": { "type": "string", "minLength": 1, "maxLength": 40, "description": "the app the shortcut hands over to" }, + "where": { "type": "string", "maxLength": 40, "description": "where the app runs: web, phone, iPhone, Android, desktop, terminal" }, + "icon": { "enum": ["notes", "blog", "bookmark", "photo", "site", "git", "chat", "person", "files", "key", "search", "feed", "lock", "app"], "description": "one of the console's icons" }, + "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" }, + "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" }, + "links": { + "type": "array", + "minItems": 1, + "maxItems": 6, + "items": { + "type": "object", + "required": ["label"], + "additionalProperties": false, + "properties": { + "label": { "type": "string", "minLength": 1, "maxLength": 40 }, + "href": { "type": "string", "minLength": 1, "maxLength": 2000, "description": "a link to open: https://, or nostr: for the viewer's own app" }, + "copy": { "type": "string", "minLength": 1, "maxLength": 2000, "description": "text to put on the clipboard instead of a link" } + }, + "oneOf": [{ "required": ["href"] }, { "required": ["copy"] }] + } + }, + "qr": { "type": "string", "minLength": 1, "maxLength": 2000, "description": "what the QR code carries; the first link's href when left out" }, + "inputs": { + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "required": ["name", "label"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,23}$", "description": "the {input:name} placeholder it fills" }, + "label": { "type": "string", "minLength": 1, "maxLength": 60 }, + "placeholder": { "type": "string", "maxLength": 200 }, + "default": { "type": "string", "maxLength": 500, "description": "the value when the owner leaves it blank" }, + "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" } + } + } + } + } +} diff --git a/connection-templates/01-notes.jsonc b/connection-templates/01-notes.jsonc new file mode 100644 index 0000000..d1cd218 --- /dev/null +++ b/connection-templates/01-notes.jsonc @@ -0,0 +1,15 @@ +// The relay as a feed: everything posted here, in Jumble, which opens a +// relay by URL. The QR carries the same link for a phone. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Notes", + "about": "Everything posted on this relay as one feed.", + "app": "Jumble", + "where": "web", + "icon": "notes", + "visibility": "public", + "links": [ + { "label": "Open", "href": "https://jumble.social/?r={relay:url|enc}" } + ] +} diff --git a/connection-templates/02-find-me.jsonc b/connection-templates/02-find-me.jsonc new file mode 100644 index 0000000..1a70386 --- /dev/null +++ b/connection-templates/02-find-me.jsonc @@ -0,0 +1,19 @@ +// Feed apps have no relay pages. They meet this relay through the owner's +// profile with the relay attached as the hint, and keep it once it is in +// the app's relay settings. The nostr: link opens whatever app the viewer +// has set for it; the web link is Primal. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Find me here", + "about": "The owner's profile with this relay attached, for feed apps.", + "app": "Primal", + "where": "web, phone", + "icon": "person", + "visibility": "public", + "links": [ + { "label": "Open", "href": "https://primal.net/p/{owner:nprofile}" }, + { "label": "Open in app", "href": "nostr:{owner:nprofile}" } + ], + "qr": "nostr:{owner:nprofile}" +} diff --git a/connection-templates/03-group.jsonc b/connection-templates/03-group.jsonc new file mode 100644 index 0000000..ef64e5a --- /dev/null +++ b/connection-templates/03-group.jsonc @@ -0,0 +1,19 @@ +// The relay's NIP-29 group as a room: Flotilla opens the relay as a space +// on the web and on a phone, and any group-aware app takes the naddr. The +// QR carries the group address for a phone. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Group", + "about": "The relay as a space, with its group as a room.", + "app": "Flotilla", + "where": "web, phone", + "icon": "chat", + "visibility": "public", + "links": [ + { "label": "Open", "href": "https://app.flotilla.social/spaces/{relay:host|enc}" }, + { "label": "Open group in app", "href": "nostr:{relay:naddr}" }, + { "label": "Copy naddr", "copy": "{relay:naddr}" } + ], + "qr": "nostr:{relay:naddr}" +} diff --git a/connection-templates/04-blog.jsonc b/connection-templates/04-blog.jsonc new file mode 100644 index 0000000..9e40f38 --- /dev/null +++ b/connection-templates/04-blog.jsonc @@ -0,0 +1,20 @@ +// The owner's long-form articles (kind 30023), in YakiHonne, which reads +// and writes them and opens a profile at /profile/, the pattern +// the console already uses. Habla has a /p/ route in its source +// (verbiricha/habla.news src/pages/p/[nprofile].tsx) but habla.news +// answered 404 on 2026-09-05, so the second link is the nostr: handoff. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Blog", + "about": "The owner's articles, with this relay attached so a reader finds the rest of them.", + "app": "YakiHonne", + "where": "web, phone", + "icon": "blog", + "visibility": "public", + "links": [ + { "label": "Open", "href": "https://yakihonne.com/profile/{owner:nprofile}" }, + { "label": "Open in app", "href": "nostr:{owner:nprofile}" } + ], + "qr": "nostr:{owner:nprofile}" +} diff --git a/connection-templates/05-repos.jsonc b/connection-templates/05-repos.jsonc new file mode 100644 index 0000000..95bf3f0 --- /dev/null +++ b/connection-templates/05-repos.jsonc @@ -0,0 +1,25 @@ +// The relay's Git repositories: GitWorkshop has a relay page by host, the +// pattern the console already uses. The clone command names the signed-in +// viewer, since a repository here lives at //.git +// (docs/22-grasp-01-git-hosting.md), so a visitor sees that link left out. +// The owner fills in the repository name when adding the shortcut; the +// input's pattern keeps it to letters, digits, dot, dash and underscore, +// since the value lands inside a quoted shell command and a URL path. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Repos", + "about": "This relay's Git repositories, and the clone command for yours.", + "app": "GitWorkshop", + "where": "web, terminal", + "icon": "git", + "feature": "grasp", + "visibility": "public", + "inputs": [ + { "name": "repo", "label": "Repository name", "placeholder": "my-project", "default": "", "pattern": "^[A-Za-z0-9][A-Za-z0-9._\\-]{0,63}$" } + ], + "links": [ + { "label": "Open", "href": "https://gitworkshop.dev/relay/{relay:host|enc}" }, + { "label": "Copy clone command", "copy": "git clone '{relay:web}/{user:npub}/{input:repo}.git'" } + ] +} diff --git a/connection-templates/06-sites.jsonc b/connection-templates/06-sites.jsonc new file mode 100644 index 0000000..c7a12d1 --- /dev/null +++ b/connection-templates/06-sites.jsonc @@ -0,0 +1,21 @@ +// NIP-5A sites: a root site lives at https://., so the +// owner's and the viewer's own are plain links (docs/20-nip-5a-static-websites.md). +// The publish command is nsyte's, with this relay as both the relay and +// the Blossom server; the -r/--relays and -s/--servers flags are read from +// sandwichfarm/nsyte src/commands/deploy.ts on 2026-09-05. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Sites", + "about": "Static sites this relay hosts, and the command that publishes one here.", + "app": "nsyte", + "where": "web, terminal", + "icon": "site", + "feature": "sites", + "visibility": "public", + "links": [ + { "label": "Open the owner's site", "href": "https://{owner:npub}.{relay:domain}" }, + { "label": "Open your site", "href": "https://{user:npub}.{relay:domain}" }, + { "label": "Copy publish command", "copy": "nsyte deploy ./dist --relays {relay:url} --servers {relay:web}" } + ] +} diff --git a/connection-templates/07-bookmarks.jsonc b/connection-templates/07-bookmarks.jsonc new file mode 100644 index 0000000..65eab17 --- /dev/null +++ b/connection-templates/07-bookmarks.jsonc @@ -0,0 +1,19 @@ +// The viewer's own bookmarks and lists (kinds 10003 and 30003), in Listr, +// whose user page takes an npub (erskingardner/listr src/params/user.ts and +// src/routes/(app)/[userId=user]/+page.svelte; both kinds are in +// src/lib/utils/lists.ts SUPPORTED_LIST_KINDS). noStrudel takes an nprofile +// at /bookmarks/ (hzrd149/nostrudel src/views/bookmarks/routes.tsx). +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Bookmarks", + "about": "Your bookmarks and lists in a list manager; add this relay in its settings so they land here.", + "app": "Listr", + "where": "web", + "icon": "bookmark", + "visibility": "auth", + "links": [ + { "label": "Open", "href": "https://listr.lol/{user:npub}" }, + { "label": "Open in noStrudel", "href": "https://nostrudel.ninja/bookmarks/{user:nprofile}" } + ] +} diff --git a/connection-templates/08-photos.jsonc b/connection-templates/08-photos.jsonc new file mode 100644 index 0000000..c26dab6 --- /dev/null +++ b/connection-templates/08-photos.jsonc @@ -0,0 +1,21 @@ +// The owner's photos on this relay's Blossom store, in bouquet, a media +// manager for Blossom and NIP-96 servers (flox1an/bouquet README). No +// client here encrypts blobs, and the owner-only start hides only the +// shortcut: who may read the files is the relay's read rule, which a blob +// follows like an event. bouquet has no server URL parameter +// (src/main.tsx), so the store's address is a copy link. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Photo library", + "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.", + "app": "bouquet", + "where": "web", + "icon": "photo", + "feature": "files", + "visibility": "owner", + "links": [ + { "label": "Open", "href": "https://bouquet.slidestr.net/" }, + { "label": "Copy Blossom server URL", "copy": "{relay:web}" } + ] +} diff --git a/connection-templates/09-files.jsonc b/connection-templates/09-files.jsonc new file mode 100644 index 0000000..ec4e6ec --- /dev/null +++ b/connection-templates/09-files.jsonc @@ -0,0 +1,21 @@ +// The relay's Blossom store for members: bouquet browses, uploads and +// syncs across Blossom and NIP-96 servers (flox1an/bouquet README) once +// this relay is in its server list, which is why the server URL and the +// NIP-96 discovery document are copy links. The discovery path is the +// relay's own (src/nip96.ts). +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Files", + "about": "This relay's file store, for members, in a media manager that takes it as a server.", + "app": "bouquet", + "where": "web", + "icon": "files", + "feature": "files", + "visibility": "members", + "links": [ + { "label": "Open", "href": "https://bouquet.slidestr.net/" }, + { "label": "Copy Blossom server URL", "copy": "{relay:web}" }, + { "label": "Copy NIP-96 URL", "copy": "{relay:web}/.well-known/nostr/nip96.json" } + ] +} diff --git a/connection-templates/10-dm.jsonc b/connection-templates/10-dm.jsonc new file mode 100644 index 0000000..82054cd --- /dev/null +++ b/connection-templates/10-dm.jsonc @@ -0,0 +1,18 @@ +// Private messages (NIP-17): the owner in a DM app by the nostr: handoff, +// since DM apps open a profile and not a relay. 0xchat is the reference, +// on phone and desktop with NIP-17 (0xchat-app/0xchat-core README). The +// relay URL goes in the app's DM inbox list (kind 10050) so messages land here. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Messages", + "about": "Message the owner privately; put this relay in your DM inbox list (kind 10050) so replies land here.", + "app": "0xchat", + "where": "phone, desktop", + "icon": "chat", + "visibility": "public", + "links": [ + { "label": "Open in app", "href": "nostr:{owner:nprofile}" } + ], + "qr": "nostr:{owner:nprofile}" +} diff --git a/connection-templates/11-marmot.jsonc b/connection-templates/11-marmot.jsonc new file mode 100644 index 0000000..b9bfa88 --- /dev/null +++ b/connection-templates/11-marmot.jsonc @@ -0,0 +1,19 @@ +// Marmot (MLS over Nostr) group chat: White Noise is the reference client, +// on iOS by TestFlight and Android by Zapstore or GitHub (whitenoise.chat +// and its /download page, checked 2026-09-05). The app has no relay URL +// form, so the links are its site and the relay URL for its settings. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Encrypted groups", + "about": "End-to-end encrypted group chat over this relay, in White Noise.", + "app": "White Noise", + "where": "phone", + "icon": "lock", + "feature": "marmot", + "visibility": "public", + "links": [ + { "label": "Open", "href": "https://whitenoise.chat/" }, + { "label": "Get the app", "href": "https://whitenoise.chat/download" } + ] +} diff --git a/connection-templates/12-relay-page.jsonc b/connection-templates/12-relay-page.jsonc new file mode 100644 index 0000000..7fcf8ff --- /dev/null +++ b/connection-templates/12-relay-page.jsonc @@ -0,0 +1,16 @@ +// The relay's own page in Coracle: its feed and its people, by host, the +// pattern the console already uses. The host is encoded as the console +// does, which a plain hostname passes through unchanged. +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Relay page", + "about": "This relay's page in Coracle: its feed and its people.", + "app": "Coracle", + "where": "web", + "icon": "feed", + "visibility": "public", + "links": [ + { "label": "Open", "href": "https://coracle.social/relays/{relay:host|enc}" } + ] +} diff --git a/connection-templates/README.md b/connection-templates/README.md new file mode 100644 index 0000000..f02082b --- /dev/null +++ b/connection-templates/README.md @@ -0,0 +1,56 @@ +# Connection templates + +An app shortcut on a relay's Connect fold is one of these files, chosen by +the owner. Each says which app opens, what it does with this relay, and the +links that hand a viewer over to it, written once with placeholders the +relay fills in for whoever is looking. One good template works for anyone. + +| Template | Choose it when | +|---|---| +| [notes](01-notes.jsonc) | the relay is a feed and Jumble should open it as one. | +| [find-me](02-find-me.jsonc) | feed apps should meet this relay through the owner's profile with the relay attached. | +| [group](03-group.jsonc) | the relay's group is a room, in Flotilla or any group-aware app. | +| [blog](04-blog.jsonc) | the owner writes articles and YakiHonne should open them with this relay attached. | +| [repos](05-repos.jsonc) | the relay hosts Git repositories; GitWorkshop opens them and the clone command names yours. | +| [sites](06-sites.jsonc) | the relay hosts NIP-5A sites: the owner's, the viewer's own, and the nsyte command that publishes one. | +| [bookmarks](07-bookmarks.jsonc) | signed-in viewers keep bookmarks and lists here and Listr should manage them. | +| [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. | +| [files](09-files.jsonc) | members keep files on this relay's Blossom store and bouquet should manage them. | +| [dm](10-dm.jsonc) | the relay is a DM inbox and people should message the owner privately from here. | +| [marmot](11-marmot.jsonc) | the relay carries Marmot groups and White Noise is the app. | +| [relay-page](12-relay-page.jsonc) | Coracle should open the relay's page: its feed and its people. | + +## The files + +A template is a JSON document (`$schema` names the copy served at +`https://bind.ws/connection-template.schema.json`, the same file as +`../connection-template.schema.json`) with a title, one sentence about it, +the app, where the app runs, an icon, the links, and optionally the feature +the shortcut needs, the visibility it starts with, what its QR carries and +the inputs the owner fills in when adding it. An input whose value lands in +a URL path or a shell command names a `pattern` the value must match, so +what the owner types cannot change the link or the command around it. The +fold has one relay URL copy control of its own, so a template does not +carry one. + +Links and the QR text carry placeholders: + +| Placeholder | Fills in | +|---|---| +| `{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) | +| `{relay:hex}` `{relay:npub}` `{relay:nprofile}` `{relay:naddr}` | the relay's own key, and the group address | +| `{owner:hex}` `{owner:npub}` `{owner:nprofile}` | the owner, the nprofile with this relay as the hint | +| `{user:hex}` `{user:npub}` `{user:nprofile}` | whoever is signed in and looking; a link that needs one is not shown to a visitor | +| `{input:}` | what the owner typed into the template's input of that name | + +A `|enc` suffix, as in `{relay:url|enc}`, percent-encodes the value for a +query string. The number in the file name is the order the library shows +them in. + +`npm run build:connections` folds the folder into `src/gen/connections.ts`, +which the relay serves as its library; `npm run typecheck` checks every file +here against the schema and the placeholder vocabulary, fails when the +generated module is stale, and fails when a file is missing from the table +above. A relay template in `../relay-templates/` names these in its +`connections` section, so one preset sets the rules and the shortcuts +together ([Connection templates](../docs/17-connection-templates.md)). diff --git a/docs/00-getting-started.md b/docs/00-getting-started.md index c430b9d..548381f 100644 --- a/docs/00-getting-started.md +++ b/docs/00-getting-started.md @@ -47,7 +47,7 @@ wss://.bind.ws 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. -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. +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)). ## Your relay lists diff --git a/docs/01-relay-configuration.md b/docs/01-relay-configuration.md index 790dda5..335e100 100644 --- a/docs/01-relay-configuration.md +++ b/docs/01-relay-configuration.md @@ -5,7 +5,7 @@ audience: user # Relay configuration -Your relay's page at `https://.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. +Your relay's page at `https://.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. 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. @@ -73,12 +73,14 @@ Three features add bounded services beside the relay feed. **Sites** is on by de ### Presets -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. +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. 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. **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. +**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. + ## Identity 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 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). +## Connect + +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. + +- **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. +- **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. + +![The Connect tab: the shortcuts as rows, then the library](img/connect-tab.png) + +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. + ## Data What is here and what leaves. diff --git a/docs/04-data-and-names.md b/docs/04-data-and-names.md index a4eabed..36334e3 100644 --- a/docs/04-data-and-names.md +++ b/docs/04-data-and-names.md @@ -73,8 +73,9 @@ Names are cheap, so a relay does not have to do everything. On the Rules tab, ** | marmot | anyone | anyone | KeyPackages, encrypted group envelopes, private welcomes and relay lists; Marmot enabled | | grasp | members publish events | anyone | every kind, forever; GRASP Git hosting enabled | | marmot-members | members; group writes authenticate | anyone | KeyPackages, encrypted group envelopes, private welcomes and relay lists; Marmot enabled | +| 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 | -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. +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. 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. diff --git a/docs/12-develop-extend.md b/docs/12-develop-extend.md index d1a9964..c4ceef2 100644 --- a/docs/12-develop-extend.md +++ b/docs/12-develop-extend.md @@ -27,6 +27,7 @@ src/ audit.ts the moderation log: one row per change from manage.ts or groups.ts presets.ts the templates as one-click presets, some with a standing pull config.ts the configuration document: parse, plan, apply, export + connections.ts the Connect fold's app shortcuts: the library's parser, the owner's list, /connect.json manage.ts NIP-86 methods over NIP-98 domains.ts custom hostnames: the Cloudflare client and the KV mapping dumps.ts scheduled JSONL dumps to R2 @@ -59,6 +60,7 @@ src/ console/ console.html, console.css, console.js: the console as written gen/signer.ts generated: the NIP-46 client library the console loads from /signer.js gen/templates.ts generated from relay-templates/ + gen/connections.ts generated from connection-templates/ landing.ts the apex, rendered from config ui.ts the shared look test/ @@ -68,11 +70,14 @@ test/ conformance/ black-box suite for any relay URL, one file per NIP, files included relay-templates/ one relay configuration per template, the presets the console offers relay-config.schema.json the configuration file's schema, served at the apex +connection-templates/ one app shortcut per template, the library the Connect tab offers +connection-template.schema.json a connection template's schema, served at the apex scripts/ build/ build-signer.mjs signer-entry.js bundle nostr-tools for the console's remote signing build-console.mjs fold src/console into src/gen/console.ts build-templates.mjs fold relay-templates/ into src/gen/templates.ts - check/ check-console.mjs check-celld.mjs check-config.mjs run by npm run typecheck; check-config also checks any file + build-connections.mjs fold connection-templates/ into src/gen/connections.ts + check/ check-console.mjs check-celld.mjs check-connections.mjs check-config.mjs run by npm run typecheck; check-config also checks any file 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:zaptest ops/ margin.mjs network.mjs relay.mjs npm run margin; npm run test:network; npm run relay check|plan|push|pull @@ -157,9 +162,11 @@ To look at what you changed: `npm run dev`, then `npm run dev:signer`, open `htt 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. +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). + ## Screenshots -`npm run dev:shot [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. +`npm run dev:shot [width] [height] [--nostr] [--eval ]` 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`. ## Pull requests diff --git a/docs/13-scripts-and-agents.md b/docs/13-scripts-and-agents.md index 107f3c5..b6f7cac 100644 --- a/docs/13-scripts-and-agents.md +++ b/docs/13-scripts-and-agents.md @@ -144,7 +144,9 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted, - `stats`: counts, connections, name, owner and fuel. - `getpolicy`: the whole policy. -- `listpresets`: preset names, descriptions and whether one needs a source. +- `listpresets`: preset names, descriptions, whether one needs a source, and the Connect fold's shortcuts a preset sets when it has them. +- `listconnectiontemplates`: the Connect fold's library, each template with `available`, whether the feature it needs is on. +- `listconnections`: the owner's shortcut list, or the defaults while there is none. - `listmembers`, `listpeople`, `listallowedpubkeys`: the member list in three shapes. - `listbannedpubkeys`, `listbannedevents`, `listblockedips`: the bans. - `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, - `setretention kind|null days`, `purgekind kind|null days`. - `resetrules`: the defaults, kind rules and keep-for cleared. - `applypreset name {source?}`: a bundle; `search` needs a source, `articles` takes one. +- `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). **Identity** @@ -210,7 +213,7 @@ The bridge takes the same header. `POST /events` answers `{ event_id, accepted, **Config** -- `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. +- `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. **Jobs** @@ -239,7 +242,7 @@ per live Durable Object instance, so eviction or restart resets it. ## The configuration file -`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. +`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. Four verbs, run from a checkout of the repository with `RELAY_SK` holding the owner's key as hex or nsec: diff --git a/docs/14-http-reference.md b/docs/14-http-reference.md index afcfdf0..2339af8 100644 --- a/docs/14-http-reference.md +++ b/docs/14-http-reference.md @@ -21,6 +21,7 @@ management stay open. | `/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 | | `/favicon.svg` | GET | none | the icon | 200 | | `/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 | +| `/connection-template.schema.json` | GET | none | the JSON Schema of a connection template, for `$schema` ([Connection templates](17-connection-templates.md)) | 200 | | `/.well-known/bindws/hostname?domain=` | 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 | ## Relay page and information @@ -193,6 +194,12 @@ Only while reads are open. Otherwise every path here answers 404, and an unclaim | `/card.svg` | GET | a 600 by 315 picture with the naddr as a QR | 200 | | `/qr.svg?text=` | GET | any text up to 512 bytes as a QR | 200; 400 empty; 413 too long | +## Connect + +| Path | Method | Auth | Answers | Status | +|---|---|---|---|---| +| `/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 | + ## People, invites and names 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. diff --git a/docs/17-connection-templates.md b/docs/17-connection-templates.md new file mode 100644 index 0000000..a9cac29 --- /dev/null +++ b/docs/17-connection-templates.md @@ -0,0 +1,204 @@ +--- +title: Connection templates +audience: developer +--- + +# Connection templates + +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. + +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://.`, 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). + +## The file + +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. + +| Field | Required | Value | +|---|---|---| +| `format` | yes | `bind.ws/connection-template/1` | +| `title` | yes | what the shortcut is for, in the owner's words, up to 40 characters: Notes, Repos, Photo library | +| `about` | yes | one sentence on what opens and what it does with this relay, up to 200 characters | +| `app` | yes | the app the shortcut hands over to, up to 40 characters | +| `where` | no | where the app runs, up to 40 characters: web, phone, iPhone, Android, desktop, terminal | +| `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 | +| `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 | +| `visibility` | no | `public`, `auth`, `members` or `owner`: who sees the shortcut when the owner adds it without saying; `public` when left out | +| `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 | +| `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 | +| `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 | + +The group template, as it is in the repository: + +```jsonc +{ + "$schema": "https://bind.ws/connection-template.schema.json", + "format": "bind.ws/connection-template/1", + "title": "Group", + "about": "The relay as a space, with its group as a room.", + "app": "Flotilla", + "where": "web, phone", + "icon": "chat", + "visibility": "public", + "links": [ + { "label": "Open", "href": "https://app.flotilla.social/spaces/{relay:host|enc}" }, + { "label": "Open group in app", "href": "nostr:{relay:naddr}" }, + { "label": "Copy naddr", "copy": "{relay:naddr}" } + ], + "qr": "nostr:{relay:naddr}" +} +``` + +## Placeholders + +Links, copy texts and the QR text carry placeholders of the form `{source:field}`. `PLACEHOLDER_FIELDS` in `src/connections.ts` is the vocabulary. + +| Placeholder | Fills in | +|---|---| +| `{relay:url}` | the relay's websocket address, `wss://.bind.ws`, from the host the request came in on, so a custom domain fills in its own | +| `{relay:host}` | that host, `.bind.ws` | +| `{relay:web}` | the same address for a browser, `https://.bind.ws` | +| `{relay:name}` | the relay's name, `` | +| `{relay:domain}` | the domain the relay runs under, `bind.ws`, where NIP-5A sites live | +| `{relay:hex}`, `{relay:npub}`, `{relay:nprofile}` | the relay's own key, the nprofile with this relay as the hint | +| `{relay:naddr}` | the group address: kind 39000, the relay's key, the relay's name, this relay as the hint | +| `{owner:hex}`, `{owner:npub}`, `{owner:nprofile}` | the owner, the nprofile with this relay as the hint | +| `{user:hex}`, `{user:npub}`, `{user:nprofile}` | whoever is signed in and looking | +| `{input:}` | what the owner typed into the template's input of that name, or the input's default | + +A `|enc` suffix, as in `{relay:url|enc}`, percent-encodes the value for a query string. + +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. + +## Visibility + +| Visibility | Who sees the shortcut | +|---|---| +| `public` | anyone | +| `auth` | anyone signed in | +| `members` | members and the owner | +| `owner` | the owner | + +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. + +## The owner's list + +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. + +| Field | Value | +|---|---| +| `template` | a file in `connection-templates/`, by name: `notes` for `01-notes.jsonc` | +| `visibility` | `public`, `auth`, `members` or `owner`; the template's own when left out | +| `title` | the owner's title instead of the template's, up to 40 characters | +| `about` | the owner's sentence instead of the template's, up to 200 characters | +| `inputs` | the template's inputs by name, each a string up to 500 characters; a blank one takes the template's default | + +```jsonc +"connections": [ + { "template": "notes" }, + { "template": "repos", "inputs": { "repo": "bindws" } }, + { "template": "photos", "visibility": "owner", "title": "Photo library" } +] +``` + +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. + +## How the fold shows them + +`resolveConnections` in `src/connections.ts` is the owner's list as one viewer sees it. For each entry, in order: + +1. The template is in the library, or the entry is skipped. +2. The template's `feature`, when it names one, is on, or the shortcut is skipped. +3. The shortcut's visibility admits the viewer. +4. Each input takes the owner's value, or the template's default. +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`. +6. A shortcut with no links left and nothing waiting on a sign-in is not shown. +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. +8. The title and the sentence are the owner's when the entry set them, else the template's. + +`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. + +```json +{ + "relay": { "url": "wss://name.bind.ws", "host": "name.bind.ws", "web": "https://name.bind.ws", "name": "name" }, + "viewer": "", + "connections": [ + { + "template": "group", "title": "Group", "about": "The relay as a space, with its group as a room.", + "app": "Flotilla", "where": "web, phone", "icon": "chat", "visibility": "public", + "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..." }], + "qr": "nostr:naddr1...", "needsUser": false + } + ] +} +``` + +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. + +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. + +The fold as the owner sees it on a relay with the `home` preset applied, at a desktop width and at a phone width: + +![The Connect fold as the owner: the doors, the Git clone example, and one tile per shortcut](img/connect.png) + +![The same fold at a phone width: one tile per row](img/connect-phone.png) + +## Management methods + +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)): + +| Method | Action | Answers | +|---|---|---| +| `listconnectiontemplates` | read | the library, each template with its fields and `available`: whether the feature it needs is on, `true` when it needs none | +| `listconnections` | read | the owner's list as saved, or the defaults | +| `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 | + +`listpresets` entries carry `connections` when the template has them, so the console can say what a preset sets. + +## The configuration document + +`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)`. + +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`. + +## Relay templates + +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: + +| Template | Shortcuts | +|---|---| +| default | left alone | +| outbox | notes, find-me, blog | +| inbox | find-me, notes | +| chat | group | +| media | files, photos | +| search | notes | +| articles | blog, notes | +| dm | dm | +| quiet | none: an empty list | +| site | sites, notes | +| marmot, marmot-members | marmot | +| grasp | repos, notes | +| home | notes, blog, bookmarks, sites, repos, photos (the owner alone sees the shortcut), find-me and group | + +`home` (`15-home.jsonc`) is the one-name relay: members write, anyone reads, sites, files and Git on, with a shortcut for each. + +## Build and checks + +`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: + +| Check | Script | Fails when | +|---|---|---| +| the generated module | `scripts/build/build-connections.mjs --check` | `src/gen/connections.ts` is stale | +| the catalog | the same script, in either mode | `connection-templates/README.md` has no row linking the file | +| 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:` | + +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. + +## Add a connection template + +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`. +2. Add a row to the table in `connection-templates/README.md`, linking the file and saying when to choose it. +3. `npm run build:connections`. +4. `npm run typecheck`. +5. Try it: `npm run dev`, then `npm run dev:signer`, open `http://.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://.localhost:8787/connect.json` shows the links as a visitor gets them. +6. Commit the file, the README row and `src/gen/connections.ts` together, so the commit typechecks on its own. diff --git a/docs/img/connect-phone.png b/docs/img/connect-phone.png new file mode 100644 index 0000000..b00acef Binary files /dev/null and b/docs/img/connect-phone.png differ diff --git a/docs/img/connect-tab.png b/docs/img/connect-tab.png new file mode 100644 index 0000000..9e347d7 Binary files /dev/null and b/docs/img/connect-tab.png differ diff --git a/docs/img/connect.png b/docs/img/connect.png new file mode 100644 index 0000000..7ad78db Binary files /dev/null and b/docs/img/connect.png differ diff --git a/package.json b/package.json index 7f69e21..ae3840a 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,13 @@ "deploy": "wrangler deploy", "test": "vitest run", "test:conformance": "vitest run --config vitest.conformance.config.ts", - "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", + "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", "relay": "node scripts/ops/relay.mjs", "margin": "node scripts/ops/margin.mjs", "build:signer": "node scripts/build/build-signer.mjs", "build:console": "node scripts/build/build-console.mjs", "build:templates": "node scripts/build/build-templates.mjs", + "build:connections": "node scripts/build/build-connections.mjs", "check:celld": "node scripts/check/check-celld.mjs", "dev:signer": "node scripts/dev/dev-signer.mjs", "dev:seed": "node scripts/dev/seed.mjs", diff --git a/relay-config.schema.json b/relay-config.schema.json index 5426d13..d849743 100644 --- a/relay-config.schema.json +++ b/relay-config.schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://bind.ws/relay-config.schema.json", "title": "bind.ws relay configuration", - "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.", + "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.", "type": "object", "required": ["format"], "additionalProperties": false, @@ -149,6 +149,23 @@ "retention": { "type": "array", "items": { "type": "object", "required": ["kind", "days"], "additionalProperties": false, "properties": { "kind": { "oneOf": [{ "$ref": "#/definitions/kind" }, { "type": "null" }] }, "days": { "type": "integer", "minimum": 1, "maximum": 36500 } } } + }, + "connections": { + "type": "array", + "maxItems": 24, + "description": "the Connect fold's app shortcuts, in order: each names a connection template from connection-templates/", + "items": { + "type": "object", + "required": ["template"], + "additionalProperties": false, + "properties": { + "template": { "type": "string", "pattern": "^[a-z0-9-]{1,40}$", "description": "a file in connection-templates/, by name" }, + "visibility": { "enum": ["public", "auth", "members", "owner"], "description": "who sees it: anyone, anyone signed in, members, the owner; the template's own when left out" }, + "title": { "type": "string", "maxLength": 40, "description": "the owner's title instead of the template's" }, + "about": { "type": "string", "maxLength": 200, "description": "the owner's sentence instead of the template's" }, + "inputs": { "type": "object", "additionalProperties": { "type": "string", "maxLength": 500 }, "description": "the template's inputs, by name" } + } + } } }, "definitions": { diff --git a/relay-templates/02-outbox.jsonc b/relay-templates/02-outbox.jsonc index ccd1b1b..49ad9fa 100644 --- a/relay-templates/02-outbox.jsonc +++ b/relay-templates/02-outbox.jsonc @@ -20,5 +20,10 @@ 1059 ] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "notes", "visibility": "public"}, + {"template": "find-me", "visibility": "public"}, + {"template": "blog", "visibility": "public"} + ] } diff --git a/relay-templates/03-inbox.jsonc b/relay-templates/03-inbox.jsonc index ea182bd..d8031e2 100644 --- a/relay-templates/03-inbox.jsonc +++ b/relay-templates/03-inbox.jsonc @@ -35,5 +35,9 @@ "kind": null, "days": 90 } + ], + "connections": [ + {"template": "find-me", "visibility": "public"}, + {"template": "notes", "visibility": "public"} ] } diff --git a/relay-templates/05-chat.jsonc b/relay-templates/05-chat.jsonc index 8b748c6..e440241 100644 --- a/relay-templates/05-chat.jsonc +++ b/relay-templates/05-chat.jsonc @@ -26,5 +26,8 @@ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "group", "visibility": "public"} + ] } diff --git a/relay-templates/06-media.jsonc b/relay-templates/06-media.jsonc index 895ceb8..69c7fc8 100644 --- a/relay-templates/06-media.jsonc +++ b/relay-templates/06-media.jsonc @@ -21,5 +21,9 @@ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "files", "visibility": "members"}, + {"template": "photos", "visibility": "owner"} + ] } diff --git a/relay-templates/07-search.jsonc b/relay-templates/07-search.jsonc index 0d87260..7b864bb 100644 --- a/relay-templates/07-search.jsonc +++ b/relay-templates/07-search.jsonc @@ -28,5 +28,8 @@ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "notes", "visibility": "public"} + ] } diff --git a/relay-templates/08-articles.jsonc b/relay-templates/08-articles.jsonc index 44b881c..7bf2682 100644 --- a/relay-templates/08-articles.jsonc +++ b/relay-templates/08-articles.jsonc @@ -22,5 +22,9 @@ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "blog", "visibility": "public"}, + {"template": "notes", "visibility": "public"} + ] } diff --git a/relay-templates/09-dm.jsonc b/relay-templates/09-dm.jsonc index c2f3ca8..4618dce 100644 --- a/relay-templates/09-dm.jsonc +++ b/relay-templates/09-dm.jsonc @@ -20,5 +20,8 @@ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "dm", "visibility": "public"} + ] } diff --git a/relay-templates/10-quiet.jsonc b/relay-templates/10-quiet.jsonc index ee13e8f..a5dd3a9 100644 --- a/relay-templates/10-quiet.jsonc +++ b/relay-templates/10-quiet.jsonc @@ -31,5 +31,6 @@ "allow": [], "block": [] }, - "retention": [] + "retention": [], + "connections": [] } diff --git a/relay-templates/11-site.jsonc b/relay-templates/11-site.jsonc index fd04e20..90a4b24 100644 --- a/relay-templates/11-site.jsonc +++ b/relay-templates/11-site.jsonc @@ -23,5 +23,9 @@ "allow": [], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "sites", "visibility": "public"}, + {"template": "notes", "visibility": "public"} + ] } diff --git a/relay-templates/12-marmot.jsonc b/relay-templates/12-marmot.jsonc index ebffa4f..9ad2bdc 100644 --- a/relay-templates/12-marmot.jsonc +++ b/relay-templates/12-marmot.jsonc @@ -18,5 +18,8 @@ "allow": [10002, 10050, 1059, 30443, 445], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "marmot", "visibility": "public"} + ] } diff --git a/relay-templates/13-grasp.jsonc b/relay-templates/13-grasp.jsonc index 80e4daa..9ad583a 100644 --- a/relay-templates/13-grasp.jsonc +++ b/relay-templates/13-grasp.jsonc @@ -21,5 +21,9 @@ "allow": [], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "repos", "visibility": "public"}, + {"template": "notes", "visibility": "public"} + ] } diff --git a/relay-templates/14-marmot-members.jsonc b/relay-templates/14-marmot-members.jsonc index 710f505..1bb6b95 100644 --- a/relay-templates/14-marmot-members.jsonc +++ b/relay-templates/14-marmot-members.jsonc @@ -20,5 +20,8 @@ "allow": [10002, 10050, 1059, 30443, 445], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + {"template": "marmot", "visibility": "members"} + ] } diff --git a/relay-templates/15-home.jsonc b/relay-templates/15-home.jsonc new file mode 100644 index 0000000..1868689 --- /dev/null +++ b/relay-templates/15-home.jsonc @@ -0,0 +1,41 @@ +// A home on nostr, one name for all of it: notes, articles, bookmarks, +// static sites, Git repositories and a photo library, each one click away +// on the Connect fold. Members write, anyone reads, so family or a team +// can publish here too; sites, files and Git hosting are on because the +// shortcuts need them. The photo library shortcut is the owner's alone, +// which hides the shortcut and nothing else: anyone reads here, files +// included, and a members-only read rule is the way to close the store. +// Search stays on prose and every kind is kept forever. +{ + "$schema": "https://bind.ws/relay-config.schema.json", + "format": "bind.ws/relay-config/2", + "template": { + "title": "Home", + "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." + }, + "policy": { + "writes": "allowlist", + "reads": "open", + "directoryPublic": true, + "features": { + "files": true, + "sites": { "enabled": true, "mirror": true }, + "grasp": true + } + }, + "kinds": { + "allow": [], + "block": [] + }, + "retention": [], + "connections": [ + {"template": "notes", "visibility": "public"}, + {"template": "blog", "visibility": "public"}, + {"template": "bookmarks", "visibility": "auth"}, + {"template": "sites", "visibility": "public"}, + {"template": "repos", "visibility": "public"}, + {"template": "photos", "visibility": "owner"}, + {"template": "find-me", "visibility": "public"}, + {"template": "group", "visibility": "public"} + ] +} diff --git a/relay-templates/README.md b/relay-templates/README.md index 29833ae..25bbbd1 100644 --- a/relay-templates/README.md +++ b/relay-templates/README.md @@ -21,6 +21,7 @@ copy as the start of your own. | [marmot](12-marmot.jsonc) | anyone can publish Marmot KeyPackages and opaque group messages; gift wraps keep their recipient checks. | | [grasp](13-grasp.jsonc) | it hosts GRASP Git repositories: admitted members publish repository state and related events, with reads open. | | [marmot-members](14-marmot-members.jsonc) | members publish Marmot transport, with authenticated group writes and member limits; encrypted messages stay publicly readable. | +| [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. | Each file says more in its comment: what the template does, which Haven relay it mirrors, and why its kinds are what they are. @@ -30,11 +31,14 @@ it mirrors, and why its kinds are what they are. A template is a relay configuration (`$schema` names the copy served at `https://bind.ws/relay-config.schema.json`, the same file as `../relay-config.schema.json`) with a `template` block for the title and the blurb, and the rules sections only: -policy, kinds and retention. People, bans and addresses are never in a -template, and a section a template leaves out is left alone when it is -applied. Default resets the write, read, directory, kind and retention rules; -it leaves feature settings alone. Applying Default after Quiet keeps the -features switched off. It is a rules reset, not a full relay reset. +policy, kinds, retention, and connections, the app shortcuts the Connect +fold shows, each naming a file in `../connection-templates/` with who may +see it ([Connection templates](../docs/17-connection-templates.md)). People, +bans and addresses are never in a template, and a section a template leaves +out is left alone when it is applied. Default resets the write, read, +directory, kind and retention rules; it leaves feature settings and the +shortcuts alone. Applying Default after Quiet keeps the features switched +off. It is a rules reset, not a full relay reset. The number in the file name is the order the console shows them in. diff --git a/scripts/build/build-connections.mjs b/scripts/build/build-connections.mjs new file mode 100644 index 0000000..0d9b8d8 --- /dev/null +++ b/scripts/build/build-connections.mjs @@ -0,0 +1,40 @@ +// Folds connection-templates/*.jsonc into src/gen/connections.ts, the app +// shortcut library the relay offers, in file order. `--check` rebuilds in +// memory and fails when the committed file is stale, so typecheck catches a +// forgotten rebuild. +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { stripComments } from "../check/jsonc.mjs"; + +const DIR = new URL("../../connection-templates/", import.meta.url); +const OUT = new URL("../../src/gen/connections.ts", import.meta.url); +const files = readdirSync(DIR).filter((f) => f.endsWith(".jsonc")).sort(); +// The folder's README is the catalog: every template has a row there. +const readme = readFileSync(new URL("README.md", DIR), "utf8"); +const missing = files.filter((f) => !readme.includes(`](${f})`)); +if (missing.length) { + console.error(`connection-templates/README.md has no row for: ${missing.join(", ")}`); + process.exit(1); +} +const templates = files.map((f) => { + const doc = JSON.parse(stripComments(readFileSync(new URL(f, DIR), "utf8"))); + delete doc.$schema; + return { name: f.replace(/^\d+-/, "").replace(/\.jsonc$/, ""), document: doc }; +}); +const file = `// Generated by scripts/build/build-connections.mjs from connection-templates/. Do not edit; run npm run build:connections. +export const CONNECTION_LIBRARY: { name: string; document: unknown }[] = ${JSON.stringify(templates, null, 2)}; +`; +if (process.argv.includes("--check")) { + let cur = ""; + try { + cur = readFileSync(OUT, "utf8"); + } catch { + /* missing */ + } + if (cur !== file) { + console.error("src/gen/connections.ts is stale: run npm run build:connections"); + process.exit(1); + } +} else { + writeFileSync(OUT, file); + console.log(`src/gen/connections.ts: ${templates.length} connection templates`); +} diff --git a/scripts/check/check-connections.mjs b/scripts/check/check-connections.mjs new file mode 100644 index 0000000..d91cbb3 --- /dev/null +++ b/scripts/check/check-connections.mjs @@ -0,0 +1,39 @@ +// Checks every connection template in connection-templates/ two ways: against +// the schema, for the editor's sake, and with the relay's own parser +// (src/connections.ts), which is the last word on placeholders. A template +// whose link names a placeholder the relay cannot fill, or an input it does +// not declare, would show a visitor a broken link, so it fails here instead. +import { readdirSync, readFileSync } from "node:fs"; +import { buildSync } from "esbuild"; +import Ajv from "ajv"; +import { stripComments } from "./jsonc.mjs"; + +const ROOT = new URL("../../", import.meta.url); +const DIR = new URL("connection-templates/", ROOT); +const schema = JSON.parse(readFileSync(new URL("connection-template.schema.json", ROOT), "utf8")); +const validate = new Ajv({ allErrors: true }).compile(schema); +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; +const { parseConnectionTemplate } = await import("data:text/javascript;base64," + Buffer.from(js).toString("base64")); + +const files = readdirSync(DIR).filter((f) => f.endsWith(".jsonc")).sort(); +let failed = false; +for (const f of files) { + const label = `connection-templates/${f}`; + const problems = []; + let doc; + try { + doc = JSON.parse(stripComments(readFileSync(new URL(f, DIR), "utf8"))); + } catch (e) { + problems.push(`${label}: ${e.message}`); + } + if (doc) { + if (!validate(doc)) for (const e of validate.errors) problems.push(`${label}: ${e.instancePath || "/"} ${e.message}`); + const parsed = parseConnectionTemplate(f.replace(/^\d+-/, "").replace(/\.jsonc$/, ""), doc); + if (typeof parsed === "string") problems.push(`${label}: ${parsed}`); + } + if (problems.length) { + failed = true; + console.error(problems.join("\n")); + } else console.log(`${label}: ok`); +} +process.exit(failed ? 1 : 0); diff --git a/scripts/check/config.mjs b/scripts/check/config.mjs index bd85bba..e006e18 100644 --- a/scripts/check/config.mjs +++ b/scripts/check/config.mjs @@ -46,5 +46,6 @@ export function describe(cfg) { if (cfg.sections.includes("banned_events")) lines.push(`banned events: ${cfg.banned_events.length}`); 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"}`); if (cfg.sections.includes("retention")) lines.push(`retention: ${cfg.retention.map((r) => `${r.kind === null ? "everything" : "kind " + r.kind} ${r.days} days`).join(", ") || "none"}`); + if (cfg.sections.includes("connections")) lines.push(`connections: ${cfg.connections.map((c) => c.template + (c.visibility === "public" ? "" : " (" + c.visibility + ")")).join(", ") || "none"}`); return lines; } diff --git a/scripts/dev/shot.mjs b/scripts/dev/shot.mjs index ffca147..d796b2d 100644 --- a/scripts/dev/shot.mjs +++ b/scripts/dev/shot.mjs @@ -1,8 +1,21 @@ -// Screenshot a page with phone emulation over CDP. Usage: npm run dev:shot [width] [height] +// Screenshot a page with phone emulation over CDP. +// +// npm run dev:shot [width] [height] [--nostr] [--eval ] +// +// --nostr signs in as the dev signer's key (scripts/dev/dev-signer.mjs 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. import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; import WebSocket from "ws"; -const [url, out, w = "390", h = "1600"] = process.argv.slice(2); +const args = process.argv.slice(2); +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; }; +const nostr = flag("--nostr"); +const evalJS = flag("--eval"); +const [url, out, w = "390", h = "1600"] = args; +// The dev signer's key, never real: new Uint8Array(32).fill(7). +const DEV_PUBKEY = "989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f"; +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() };`; const bin = "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"; const port = 9333; 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) const call = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); await call("Emulation.setDeviceMetricsOverride", { width: +w, height: +h, deviceScaleFactor: 2, mobile: true }); await call("Page.enable"); +if (nostr) await call("Page.addScriptToEvaluateOnNewDocument", { source: NOSTR }); await call("Page.navigate", { url }); await sleep(3500); +if (evalJS) { await call("Runtime.evaluate", { expression: evalJS, awaitPromise: true }); await sleep(1500); } const { result } = await call("Runtime.evaluate", { expression: "JSON.stringify({sw:document.documentElement.scrollWidth, cw:document.documentElement.clientWidth, sh:document.documentElement.scrollHeight})" }); console.log("layout", result.result.value); const shot = await call("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }); diff --git a/src/config.ts b/src/config.ts index e23a24e..fc24681 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,12 +3,15 @@ // an operator's repository holds. parseConfig checks the shape and says // what it dropped; planConfig says what applying it would change; applyConfig // does. A section that is absent is left alone, so a template carries the -// rules and nothing about people. -import { DEFAULT_POLICY, policyPatch, validIP, type Policy, type Settings } from "./settings.ts"; +// rules and nothing about people. The connections section is the Connect +// fold's app shortcuts, so a template can set the rules and the shortcuts +// that go with them in one click. +import { DEFAULT_POLICY, policyPatch, validIP, type Connection, type Policy, type Settings } from "./settings.ts"; +import { parseConnections } from "./connections.ts"; export const FORMAT = "bind.ws/relay-config/2"; const FORMATS = new Set(["bind.ws/relay-config/1", FORMAT]); -export const SECTIONS = ["policy", "members", "bans", "addresses", "banned_events", "kinds", "retention"] as const; +export const SECTIONS = ["policy", "members", "bans", "addresses", "banned_events", "kinds", "retention", "connections"] as const; export type Section = (typeof SECTIONS)[number]; // What an export leaves out: the owner's own, and the platform's. const NOT_IN_A_FILE = new Set(["owner", "lease", "succession", "customHosts"]); @@ -39,6 +42,8 @@ export interface Config { banned_events: { id: string; reason: string }[]; kinds: { allow: number[]; block: number[] }; retention: { kind: number | null; days: number }[]; + // The Connect fold's app shortcuts (connections.ts), in order. + connections: Connection[]; // Entries the document had that no relay would take, one line each. warnings: string[]; } @@ -55,7 +60,7 @@ export function parseConfig(raw: unknown, cur: Policy = DEFAULT_POLICY): Config if (!c || typeof c !== "object" || Array.isArray(c)) return "invalid: not a bind.ws relay configuration"; if (!FORMATS.has(c.format as string)) return "invalid: format must be " + FORMAT; const warnings: string[] = []; - const out: Config = { name: str(c.name, 64), sections: [], policy: {}, members: [], bans: [], addresses: [], banned_events: [], kinds: { allow: [], block: [] }, retention: [], warnings }; + const out: Config = { name: str(c.name, 64), sections: [], policy: {}, members: [], bans: [], addresses: [], banned_events: [], kinds: { allow: [], block: [] }, retention: [], connections: [], warnings }; if (c.template && typeof c.template === "object") { const t = c.template as Record; 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 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 }); else warnings.push(`retention[${i}]: needs a kind (or null) and days from 1 to 36500`); } + if (c.connections !== undefined) { + const parsed = parseConnections(c.connections); + if (typeof parsed === "string") return parsed; + out.connections = parsed.list; + warnings.push(...parsed.warnings); + out.sections.push("connections"); + } return out; } @@ -139,6 +151,7 @@ export interface Changes { banned_events: { add: string[]; remove: string[] }; kinds: { allow: { add: number[]; remove: number[] }; block: { add: number[]; remove: number[] } }; retention: { add: { kind: number | null; days: number }[]; remove: { kind: number | null; days: number }[] }; + connections: { add: Connection[]; remove: Connection[]; reordered: boolean }; // One line per change, for a person; empty when applying would change nothing. summary: string[]; } @@ -161,7 +174,7 @@ const show = (v: unknown) => { // sections the document has are compared. export function planConfig(s: Settings, cfg: Config): Changes { const has = (k: Section) => cfg.sections.includes(k); - 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: [] }; + 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: [] }; if (has("policy")) { for (const [k, to] of Object.entries(cfg.policy)) { const from = (s.policy as unknown as Record)[k]; @@ -206,6 +219,16 @@ export function planConfig(s: Settings, cfg: Config): Changes { const say = (r: { kind: number | null; days: number }) => `${r.kind === null ? "everything" : "kind " + r.kind} ${r.days} days`; if (d.add.length || d.remove.length) ch.summary.push(`retention: ${[...d.add.map((r) => "+" + say(r)), ...d.remove.map((r) => "-" + say(r))].join(", ")}`); } + if (has("connections")) { + // A shortcut is the whole of its entry: the same template with another + // visibility or input is one removed and one added. + const cur = s.connections(); + const d = diff(cur, cfg.connections, (c) => JSON.stringify(c)); + ch.connections = { ...d, reordered: d.add.length === 0 && d.remove.length === 0 && JSON.stringify(cur) !== JSON.stringify(cfg.connections) }; + const say = (c: Connection) => c.template + (c.visibility === "public" ? "" : " (" + c.visibility + ")"); + if (d.add.length || d.remove.length) ch.summary.push(`connections: ${[...d.add.map((c) => "+" + say(c)), ...d.remove.map((c) => "-" + say(c))].join(", ")}`); + else if (ch.connections.reordered) ch.summary.push("connections: reordered"); + } return ch; } @@ -241,6 +264,7 @@ export function applyConfig(s: Settings, cfg: Config, now: number) { for (const r of s.listRetention()) s.setRetention(r.kind, 0); for (const r of cfg.retention) s.setRetention(r.kind, r.days); } + if (has("connections")) s.setConnections(cfg.connections); } // exportConfig writes the relay as a document, every section, lists in a @@ -258,5 +282,6 @@ export function exportConfig(s: Settings, name: string) { addresses: byKey(s.listIPBlocks(), (a) => a.ip), kinds: { allow: [...s.listKinds("allow")].sort((a, b) => a - b), block: [...s.listKinds("block")].sort((a, b) => a - b) }, retention: s.listRetention(), + connections: s.connections(), }; } diff --git a/src/connections.ts b/src/connections.ts new file mode 100644 index 0000000..b379206 --- /dev/null +++ b/src/connections.ts @@ -0,0 +1,328 @@ +// Connection shortcuts: the owner's list of apps on the Connect fold. Each +// is a connection template from connection-templates/ (the library, folded +// into gen/connections.ts) with a visibility and the template's inputs +// filled in, kept in settings as the ordered list the fold shows. +// +// GET /connect.json the shortcuts the asker may see, links resolved +// +// A template writes its links once, with placeholders: {relay:url}, +// {owner:nprofile}, {user:npub}, {input:repo} and so on. The relay fills +// them in here, for whoever is looking, so one good template works on any +// relay and every client sees the same handoff. The door takes an optional +// NIP-98 signature: it decides which shortcuts the asker sees (public, +// signed in, members, owner) and fills the {user:*} placeholders. A link +// that needs a user is left out for a visitor, and the shortcut says so. +import { naddrEncode, npubEncode, nprofileEncode } from "nostr-tools/nip19"; +import { CONNECTION_LIBRARY } from "./gen/connections.ts"; +import { KIND_GROUP_METADATA } from "./kinds.ts"; +import { FEATURE_NAMES, VISIBILITIES, featureOn, type Connection, type Feature, type Settings, type Visibility } from "./settings.ts"; +import { whoAsks } from "./auth.ts"; +import { QR_MAX_BYTES } from "./card.ts"; +import type { Relay } from "./relay.ts"; + +export const FORMAT = "bind.ws/connection-template/1"; +// The console draws these; a template names one (console.js, IC). +export const ICONS = ["notes", "blog", "bookmark", "photo", "site", "git", "chat", "person", "files", "key", "search", "feed", "lock", "app"] as const; +export type Icon = (typeof ICONS)[number]; +export const MAX_CONNECTIONS = 24; +const MAX_LINKS = 6; +const MAX_INPUTS = 4; +const MAX_TEXT = 2000; + +export interface ConnectionLink { + label: string; + href?: string; // a link to open: https://, or nostr: for the viewer's own app + copy?: string; // text for the clipboard instead +} +export interface ConnectionInput { + name: string; // fills {input:name} + label: string; + placeholder: string; + default: string; // the value when the owner leaves it blank + // A regular expression the owner's value must match, or "" for any text. + // A value that lands in a URL path or a shell command names one, so what + // the owner types cannot change the link or the command around it. + pattern: string; +} +export interface ConnectionTemplate { + name: string; + title: string; + about: string; + app: string; + where: string; + icon: Icon; + feature?: Feature; // shown only while this feature is on + visibility: Visibility; // what a shortcut starts with + links: ConnectionLink[]; + qr: string; // what the QR carries; "" for none + inputs: ConnectionInput[]; +} + +// What a placeholder may name, by source. {input:*} names one of the +// template's own inputs instead. +export const PLACEHOLDER_FIELDS: Record<"relay" | "owner" | "user", string[]> = { + relay: ["url", "host", "web", "name", "domain", "hex", "npub", "nprofile", "naddr"], + owner: ["hex", "npub", "nprofile"], + user: ["hex", "npub", "nprofile"], +}; +const PLACEHOLDER = /\{(relay|owner|user|input):([a-z][a-z0-9_-]*)(\|enc)?\}/g; +const INPUT_NAME = /^[a-z][a-z0-9_-]{0,23}$/; +const str = (v: unknown, max: number) => (typeof v === "string" ? v.slice(0, max) : ""); + +// placeholders lists what a text names, as source:field pairs. +export function placeholders(text: string): { source: string; field: string }[] { + return [...text.matchAll(PLACEHOLDER)].map((m) => ({ source: m[1], field: m[2] })); +} + +// checkPlaceholders is the reason a text names something the relay cannot +// fill, or "" when every placeholder is known. +function checkPlaceholders(text: string, inputs: string[]): string { + for (const { source, field } of placeholders(text)) { + if (source === "input") { + if (!inputs.includes(field)) return `names {input:${field}} but declares no such input`; + } 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(", ")}`; + } + return ""; +} + +// safeHref says whether a resolved link may be shown: the web, or a nostr: +// URI for the viewer's own app. An owner's input cannot smuggle another +// scheme in. Plain http is admitted here and not in a template, since +// {relay:web} resolves to it on a local dev host. +export const safeHref = (href: string) => /^(https?:\/\/|nostr:)/i.test(href); +const templateHref = (href: string) => /^(https:\/\/|nostr:)/i.test(href); + +// parseConnectionTemplate reads one library document and returns the +// template, or the reason it is not one. The build check runs the same +// function, so a bad file fails typecheck instead of the fold. +export function parseConnectionTemplate(name: string, raw: unknown): ConnectionTemplate | string { + const d = raw as Record; + if (!d || typeof d !== "object" || Array.isArray(d)) return "not a connection template"; + if (d.format !== FORMAT) return "format must be " + FORMAT; + const title = str(d.title, 40).trim(); + const about = str(d.about, 200).trim(); + const app = str(d.app, 40).trim(); + if (!title || !about || !app) return "needs a title, an about and an app"; + const icon = d.icon === undefined ? "app" : d.icon; + if (!(ICONS as readonly unknown[]).includes(icon)) return `icon must be one of ${ICONS.join(", ")}`; + if (d.feature !== undefined && !(FEATURE_NAMES as readonly unknown[]).includes(d.feature)) return `feature must be one of ${FEATURE_NAMES.join(", ")}`; + const visibility = d.visibility === undefined ? "public" : d.visibility; + if (!(VISIBILITIES as readonly unknown[]).includes(visibility)) return `visibility must be one of ${VISIBILITIES.join(", ")}`; + const inputs: ConnectionInput[] = []; + if (d.inputs !== undefined) { + if (!Array.isArray(d.inputs) || d.inputs.length > MAX_INPUTS) return `inputs must be a list of at most ${MAX_INPUTS}`; + for (const x of d.inputs as unknown[]) { + const i = (x ?? {}) as Record; + const iname = str(i.name, 24); + if (!INPUT_NAME.test(iname)) return "an input name is lowercase letters, digits, dash and underscore"; + if (inputs.some((o) => o.name === iname)) return `input ${iname} is declared twice`; + const label = str(i.label, 60).trim(); + if (!label) return `input ${iname} needs a label`; + const pattern = str(i.pattern, 200); + if (pattern) { + try { + new RegExp(pattern); + } catch { + return `input ${iname} has a pattern that does not compile`; + } + } + inputs.push({ name: iname, label, placeholder: str(i.placeholder, 200), default: str(i.default, 500), pattern }); + } + } + const names = inputs.map((i) => i.name); + 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}`; + const links: ConnectionLink[] = []; + for (const x of d.links as unknown[]) { + const l = (x ?? {}) as Record; + const label = str(l.label, 40).trim(); + if (!label) return "a link needs a label"; + const href = typeof l.href === "string" ? l.href.trim() : ""; + const copy = typeof l.copy === "string" ? l.copy : ""; + if ((href === "") === (copy === "")) return `link ${label} needs an href or a copy text, not both`; + if (href.length > MAX_TEXT || copy.length > MAX_TEXT) return `link ${label} is longer than ${MAX_TEXT} characters`; + if (href && !templateHref(href)) return `link ${label} must be https:// or nostr:`; + const bad = checkPlaceholders(href || copy, names); + if (bad) return `link ${label} ${bad}`; + links.push(href ? { label, href } : { label, copy }); + } + const qr = d.qr === undefined ? (links.find((l) => l.href)?.href ?? links[0].copy ?? "") : str(d.qr, MAX_TEXT).trim(); + if (qr) { + const bad = checkPlaceholders(qr, names); + if (bad) return `qr ${bad}`; + } + const out: ConnectionTemplate = { name, title, about, app, where: str(d.where, 40).trim(), icon: icon as Icon, visibility: visibility as Visibility, links, qr, inputs }; + if (d.feature !== undefined) out.feature = d.feature as Feature; + return out; +} + +export const CONNECTION_TEMPLATES: ConnectionTemplate[] = CONNECTION_LIBRARY.map(({ name, document }) => { + const t = parseConnectionTemplate(name, document); + if (typeof t === "string") throw new Error(`connection template ${name}: ${t}`); + return t; +}); + +export function findConnectionTemplate(name: string): ConnectionTemplate | undefined { + return CONNECTION_TEMPLATES.find((t) => t.name === name); +} + +// parseConnections reads an owner's list, from setconnections or a +// configuration document: each entry names a template, and may set a +// visibility, a title, an about and the template's inputs. What no relay +// would take is dropped, one warning each. Not a list at all is the reason. +export function parseConnections(raw: unknown, label = "connections"): { list: Connection[]; warnings: string[] } | string { + if (!Array.isArray(raw)) return `invalid: ${label} must be a list`; + const warnings: string[] = []; + const list: Connection[] = []; + for (const [i, x] of raw.entries()) { + const c = (x ?? {}) as Record; + const t = typeof c.template === "string" ? findConnectionTemplate(c.template) : undefined; + if (!t) { + warnings.push(`${label}[${i}]: no connection template named ${typeof c.template === "string" ? c.template : "(none)"}`); + continue; + } + if (list.length === MAX_CONNECTIONS) { + warnings.push(`${label}[${i}]: at most ${MAX_CONNECTIONS} connections`); + break; + } + const out: Connection = { template: t.name, visibility: t.visibility }; + if (c.visibility !== undefined) { + if ((VISIBILITIES as readonly unknown[]).includes(c.visibility)) out.visibility = c.visibility as Visibility; + else warnings.push(`${label}[${i}].visibility: must be one of ${VISIBILITIES.join(", ")}`); + } + const title = str(c.title, 40).trim(); + if (title) out.title = title; + const about = str(c.about, 200).trim(); + if (about) out.about = about; + if (c.inputs !== undefined) { + 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`); + else { + const inputs: Record = {}; + for (const [k, v] of Object.entries(c.inputs as Record)) { + const declared = t.inputs.find((d) => d.name === k); + if (!declared) warnings.push(`${label}[${i}].inputs.${k}: ${t.name} has no such input`); + else if (typeof v !== "string") warnings.push(`${label}[${i}].inputs.${k}: must be a string`); + else if (v.trim() === "") continue; + else if (declared.pattern && !new RegExp(declared.pattern).test(v.trim().slice(0, 500))) warnings.push(`${label}[${i}].inputs.${k}: must match ${declared.pattern}`); + else inputs[k] = v.trim().slice(0, 500); + } + if (Object.keys(inputs).length) out.inputs = inputs; + } + } + list.push(out); + } + return { list, warnings }; +} + +// The values a placeholder may take, by source; user is null for a visitor. +export interface Values { + relay: Record; + owner: Record; + user: Record | null; +} + +// connectionValues is what this relay fills in for a given Host header and +// viewer. A value the relay does not have yet (no identity, no owner) is +// "", and a link that names it is left out rather than shown broken. +export function connectionValues(relay: Relay, host: string, user: string | null): Values { + const p = relay.settings.policy; + const url = relay.relayURL(host); + const self = relay.identity.pubkey; + const profile = (pk: string) => (pk ? nprofileEncode({ pubkey: pk, relays: [url] }) : ""); + const npub = (pk: string) => (pk ? npubEncode(pk) : ""); + return { + 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] }) : "" }, + owner: { hex: p.owner, npub: npub(p.owner), nprofile: profile(p.owner) }, + user: user ? { hex: user, npub: npub(user), nprofile: profile(user) } : null, + }; +} + +// fill resolves a text's placeholders and lists the ones it could not, as +// source:field, so the caller can tell a visitor's missing user from a +// relay that has no owner yet. +export function fill(text: string, values: Values, inputs: Record): { text: string; missing: string[] } { + const missing: string[] = []; + const out = text.replace(PLACEHOLDER, (_, source: string, field: string, enc: string | undefined) => { + const v = source === "input" ? (inputs[field] ?? "") : source === "user" ? (values.user?.[field] ?? "") : (values[source as "relay" | "owner"][field] ?? ""); + if (v === "") { + missing.push(source + ":" + field); + return ""; + } + return enc ? encodeURIComponent(v) : v; + }); + return { text: out, missing }; +} + +// visibleTo applies a shortcut's visibility to the pubkeys the asker proved. +export function visibleTo(v: Visibility, s: Settings, pubkeys: string[]): boolean { + switch (v) { + case "public": + return true; + case "auth": + return pubkeys.length > 0; + case "members": + return pubkeys.some((pk) => s.isAllowed(pk)); + case "owner": + return pubkeys.some((pk) => s.isOwner(pk)); + } +} + +// A shortcut as the fold shows it: the template's words unless the owner +// changed them, and the links that resolved for this viewer. +export interface ResolvedConnection { + template: string; + title: string; + about: string; + app: string; + where: string; + icon: Icon; + visibility: Visibility; + links: ConnectionLink[]; + qr: string; // "" when nothing fits in a QR + needsUser: boolean; // a link was left out because nobody is signed in +} + +// resolveConnections is the owner's list as this viewer sees it: what +// their visibility admits, what the features on allow, links filled in. +export function resolveConnections(relay: Relay, host: string, pubkeys: string[]): ResolvedConnection[] { + const s = relay.settings; + const values = connectionValues(relay, host, pubkeys[0] ?? null); + const out: ResolvedConnection[] = []; + for (const c of s.connections()) { + const t = findConnectionTemplate(c.template); + if (!t) continue; + if (t.feature && !featureOn(s.policy, t.feature)) continue; + if (!visibleTo(c.visibility, s, pubkeys)) continue; + const inputs: Record = {}; + for (const i of t.inputs) inputs[i.name] = c.inputs?.[i.name] || i.default; + let needsUser = false; + const links: ConnectionLink[] = []; + for (const l of t.links) { + const r = fill(l.href ?? l.copy ?? "", values, inputs); + if (r.missing.length) { + if (r.missing.some((m) => m.startsWith("user:"))) needsUser = true; + continue; + } + if (l.href) { + if (safeHref(r.text)) links.push({ label: l.label, href: r.text }); + } else links.push({ label: l.label, copy: r.text }); + } + if (links.length === 0 && !needsUser) continue; + const qr = t.qr ? fill(t.qr, values, inputs) : { text: "", missing: [] }; + const qrText = qr.missing.length === 0 && new TextEncoder().encode(qr.text).length <= QR_MAX_BYTES ? qr.text : ""; + 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 }); + } + return out; +} + +// connectDoor answers GET /connect.json for whoever asks. +export function connectDoor(relay: Relay, req: Request, url: URL): Response { + const who = whoAsks(req, "", null); + const cors = { "access-control-allow-origin": "*" }; + if (typeof who === "string") return Response.json({ error: who }, { status: 401, headers: cors }); + const values = connectionValues(relay, url.host, null); + const signed = who.pubkeys.length > 0; + return Response.json( + { 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) }, + { headers: { ...cors, "cache-control": signed ? "no-store" : "public, max-age=60" } }, + ); +} diff --git a/src/console/console.css b/src/console/console.css index 25cdf69..ffdf7f4 100644 --- a/src/console/console.css +++ b/src/console/console.css @@ -12,16 +12,40 @@ main { max-width: 64rem; } .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); } .care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; } #unclaimed { background: var(--butter); } -.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; } } +.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; } } .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; } -.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; } -.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; } } +.appgrid { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; } @media (max-width: 40rem) { .appgrid { grid-template-columns: 1fr; } } .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; } .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); } -.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; } -.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; } +.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; } +#apps { margin-top: .8rem; } #apps-note:empty { display: none; } +.door.relay { margin-bottom: .8rem; } .door.relay .v { font-size: 14px; } +.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; } +.app-ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; } +.app-head { align-items: center; } .app-head .pill { margin-left: auto; font-size: 11px; } +.app-qr { margin-top: .4rem; } .app-qr img { display: block; width: 150px; height: 150px; border: 2px solid var(--ink); border-radius: 10px; background: #fff; } +.app .who-note { margin: 0; font-size: 12px; color: var(--ink-3); } +/* The rest of a shortcut's links, behind a disclosure so one Open leads. */ +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; } +details.more summary::before { content: "+ "; font-family: var(--mono); } details.more[open] summary::before { content: "- "; } +.app-more { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .4rem; } .app-more .btn { padding: .3rem .7rem; font-size: 13px; } +/* The doors, the bridge and the Git example, folded under the apps. */ +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); } +details.sub > summary::-webkit-details-marker { display: none; } details.sub > summary::before { content: "+ "; font-family: var(--mono); } details.sub[open] > summary::before { content: "- "; } +details.sub > .doors { margin-top: .8rem; } +/* The owner's list: one row per shortcut, the fields of the one being edited below it. */ +.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; } +.conns { display: grid; gap: .5rem; } +.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); } +.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; } +.conn-vis { width: auto; font-size: 13px; padding: .25rem 1.8rem .25rem .5rem; } +.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); } +.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); } +.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); } +.conns .empty { color: var(--ink-3); margin: 0; } +@media (max-width: 40rem) { .conn { grid-template-columns: auto 1fr; } .conn-vis, .conn-acts { grid-column: 2; } .conn-acts { justify-content: flex-end; } } .door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); } -.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; } +.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; } .door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; } #peoplesec { background: var(--peach); } .dir { display: flex; flex-wrap: wrap; gap: .5rem; } @@ -43,10 +67,10 @@ main { max-width: 64rem; } .invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); } .invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); } .invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); } -details.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; } -details.disclosure summary::-webkit-details-marker { display: none; } +details.disclosure > summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; } +details.disclosure > summary::-webkit-details-marker { display: none; } .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; } -details.disclosure summary::before { content: "+"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: "–"; } +details.disclosure > summary::before { content: "+"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] > summary::before { content: "–"; } .about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; } .about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; } .console { margin-top: 3rem; } @@ -101,6 +125,8 @@ td input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: @media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } } .slegend { justify-content: flex-start; margin: 0 0 1.2rem; } +/* The storage bar follows the list-history block, so it keeps a block's distance. */ +#listhistoryblock + .kbar { margin-top: 1.8rem; } #s-totals { margin-bottom: .4rem; } 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); } #kinds tr.any td:first-child { font-weight: 600; } diff --git a/src/console/console.html b/src/console/console.html index c38776e..3748042 100644 --- a/src/console/console.html +++ b/src/console/console.html @@ -50,8 +50,12 @@

Connect a remote signer.

About, for clients
Connect +
nostr relay
+

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.

+
+

+
Connection details
-
nostr relay
Blossom media
names
HTTP bridge, NIP-98
POST /events, /query, /count
@@ -65,12 +69,7 @@

Git repositories, ntig

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. Git hosting guide.

-
-

Open it in an app

-

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 under the app's relay settings to post here.

-
-
-
+
@@ -112,6 +111,7 @@

Your invites

Moderation Rules Identity + Connect Data Sync Views @@ -325,6 +325,21 @@

Share

+
+

Connect

+

The shortcuts on the Connect fold: which apps, in what order, and who sees each.

+
+

Shortcuts

+

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.

+
+
+
+

Library

+

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.

+
+
+
+

Data

Keep-for rules run once a day. Purges happen now and cannot be undone.

diff --git a/src/console/console.js b/src/console/console.js index 5a1c781..3d71831 100644 --- a/src/console/console.js +++ b/src/console/console.js @@ -40,6 +40,22 @@ bolt: '', copy: '', pin: '', + up: '', + down: '', + qr: '', + // The connection template icons (connections.ts, ICONS): a template names one. + notes: '', + blog: '', + bookmark: '', + photo: '', + site: '', + git: '', + chat: '', + files: '', + key: '', + search: '', + feed: '', + app: '', }; const ib = (icon, label, act, id, extra) => '"; @@ -229,7 +245,6 @@ const clone = "git clone '" + location.origin + "//.git'"; $("#git-clone").textContent = clone; $("#git-copy").dataset.copytext = clone; - renderApps(); } async function loadFuel() { @@ -278,7 +293,7 @@ // ---- console ---- function showTab(name) { if (name === "content" || name === "storage") name = "data"; - const known = myRole === "moderator" ? ["people", "moderation"] : ["people", "moderation", "rules", "identity", "data", "sync", "views", "health", "owner"]; + const known = myRole === "moderator" ? ["people", "moderation"] : ["people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner"]; if (!known.includes(name)) name = "people"; $$(".tabs a").forEach((a) => a.classList.toggle("on", a.dataset.tab === name)); $$(".panel").forEach((p) => p.classList.toggle("on", p.dataset.panel === name)); @@ -324,6 +339,7 @@ if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc("stats"), rpc("getpolicy")]); myRole = "moderator"; } catch { myRole = ""; } } $("#console").classList.toggle("hidden", !myRole); loadMine(); + loadConnect(); $("#console").classList.toggle("mod", myRole === "moderator"); if (!myRole) { policy = null; renderCare(); return; } if (isOwner) [stats, p] = await Promise.all([rpc("stats"), rpc("getpolicy")]); @@ -354,7 +370,7 @@ const fn = $("#notify"), nt = p.notify || {}; 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; showTab(location.hash.slice(1)); - if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); } + if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); loadConnectTab(); } await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]); } @@ -670,14 +686,92 @@ $("#presets").addEventListener("click", async (ev) => { const b = ev.target.closest("button[data-preset]"); if (!b) return; const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return; - 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; + 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; const source = $("#presetsource").value.trim(); if (p.source === "required" && !source) { $("#presetnote").textContent = p.title + " needs a source relay to mirror; enter its wss:// URL first."; return; } b.disabled = true; - 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(); } + 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(); } catch (e) { toast(e.message); } finally { b.disabled = false; } }); + // ---- connect: the fold's shortcuts, picked from the library ---- + // The list is edited in place and saved whole, in the order shown; the + // relay checks every entry against the library when it takes the list. + // One row at a time opens for its title and inputs, and Save stays at the + // top, lit while something is unsaved. + const VIS = [["public", "anyone"], ["auth", "anyone signed in"], ["members", "members"], ["owner", "only me"]]; + let connLib = null, conns = null, connEditing = -1, connDirty = false; + async function loadConnectTab() { + try { [connLib, conns] = await Promise.all([rpc("listconnectiontemplates"), rpc("listconnections")]); } catch (e) { $("#connnote").textContent = e.message; return; } + connEditing = -1; setConnDirty(false); + renderConns(); renderConnLib(); + } + function setConnDirty(v) { connDirty = v; $("#connsave").disabled = !v; $("#connnote").textContent = v ? "Unsaved changes" : ""; } + const connTemplate = (name) => (connLib || []).find((t) => t.name === name) || { name, title: name, app: "", where: "", icon: "app", inputs: [], available: true, feature: "" }; + function renderConns() { + const rows = conns.map((c, i) => { + const t = connTemplate(c.template), editing = i === connEditing; + const vis = VIS.map(([v, l]) => '").join(""); + const inputs = (t.inputs || []).map((inp) => "').join(""); + const edit = editing ? '
' + inputs + "
" : ""; + return '
' + (IC[t.icon] || IC.app) + '
' + esc(c.title || t.title) + "" + esc(t.app + (t.where ? ", " + t.where : "")) + "" + (t.available ? "" : 'needs ' + esc(t.feature) + ", off") + '
" + edit + "
"; + }); + $("#conns").innerHTML = rows.length ? rows.join("") : '

No shortcuts. Add some from the library below.

'; + } + function renderConnLib() { + $("#connlib").innerHTML = connLib.map((t) => '
' + (IC[t.icon] || IC.app) + "" + esc(t.title) + "" + esc(t.app + (t.where ? ", " + t.where : "")) + "" + (t.visibility !== "public" ? '' + VISIBILITY[t.visibility] + "" : "") + "

" + esc(t.about) + '

' + (t.feature ? '' + esc(t.feature) + (t.available ? " on" : " off") + "" : "") + "
").join(""); + } + // readConns takes the rows as edited back into the list before it is redrawn or saved. + function readConns() { + $$("#conns .conn").forEach((row) => { + const c = conns[+row.dataset.i]; if (!c) return; + c.visibility = row.querySelector('[data-field="visibility"]').value; + const title = row.querySelector('[data-field="title"]'); + if (!title) return; + if (title.value.trim()) c.title = title.value.trim(); else delete c.title; + const inputs = {}; + row.querySelectorAll("[data-input]").forEach((inp) => { if (inp.value.trim()) inputs[inp.dataset.input] = inp.value.trim(); }); + if (Object.keys(inputs).length) c.inputs = inputs; else delete c.inputs; + }); + } + $("#conns").addEventListener("input", () => setConnDirty(true)); + $("#conns").addEventListener("click", (ev) => { + const b = ev.target.closest("button[data-conn]"); if (!b) return; + readConns(); + const i = +b.closest(".conn").dataset.i, act = b.dataset.conn; + if (act === "edit") connEditing = connEditing === i ? -1 : i; + else if (act === "rm") { conns.splice(i, 1); connEditing = -1; setConnDirty(true); } + else { + const j = act === "up" ? i - 1 : i + 1; if (j < 0 || j >= conns.length) return; + [conns[i], conns[j]] = [conns[j], conns[i]]; + if (connEditing === i) connEditing = j; else if (connEditing === j) connEditing = i; + setConnDirty(true); + } + renderConns(); + if (act === "edit" && connEditing === i) { const f = $("#conns .conn.editing input"); if (f) f.focus(); } + }); + $("#connlib").addEventListener("click", (ev) => { + const b = ev.target.closest("button[data-connadd]"); if (!b) return; + const t = connTemplate(b.dataset.connadd); + readConns(); + conns.push({ template: t.name, visibility: t.visibility || "public" }); + connEditing = t.inputs && t.inputs.length ? conns.length - 1 : connEditing; + setConnDirty(true); + renderConns(); + $("#conns").lastElementChild.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + // Not through guard: the button's disabled state is the dirty flag's, so + // guard's re-enable after the call would light it up again. + $("#connsave").onclick = async () => { + const b = $("#connsave"); b.disabled = true; + try { + readConns(); + conns = await rpc("setconnections", conns); + toast(conns.length ? "Saved " + conns.length + (conns.length === 1 ? " shortcut" : " shortcuts") : "Saved: no shortcuts"); + setConnDirty(false); renderConns(); loadConnect(); + } catch (e) { toast(e.message); b.disabled = false; } + }; + // ---- wire me in: this relay in the owner's own lists ---- // The lists are replaceable, so a fresh list with only this relay would // clobber the real one once it spread. Every publish starts from the @@ -1087,49 +1181,53 @@ const q = $("#naddrqr"); if (card.naddr) { q.src = "/qr.svg?text=" + encodeURIComponent(card.naddr); q.classList.remove("hidden"); } else q.classList.add("hidden"); } catch { /* the card is decoration */ } - renderApps(); } - // The app rows on the Connect section: one per client people actually use, - // with the link that lands on this relay in it. Relay apps take the relay - // or the group address; feed apps take the owner's profile with this relay - // as the hint, since they have no notion of opening a relay. - function renderApps() { - const el = $("#apps"); if (!el) return; + // The Connect fold's shortcuts: the owner's list from /connect.json, + // resolved by the relay for whoever is looking. A signed-in viewer asks + // with a NIP-98 signature, so the relay can show what their visibility + // admits and fill the links made for them; anyone else sees the public + // ones. A shortcut's QR is drawn only when asked for. + const VISIBILITY = { public: "", auth: "signed in", members: "members", owner: "only you" }; + async function signedGet(path) { + const url = location.origin + path; + const headers = {}; + if (me && signer.ready()) { + try { + const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", url], ["method", "GET"]] }); + headers.authorization = "Nostr " + btoa(JSON.stringify(ev)); + } catch { /* unsigned, then: the public shortcuts */ } + } + return fetch(url, { headers, cache: "no-store" }); + } + let connectSeq = 0; + async function loadConnect() { + const seq = ++connectSeq; + let data; + try { const r = await signedGet("/connect.json"); data = await r.json(); if (data.error) throw new Error(data.error); } catch { data = { connections: [] }; } + if (seq !== connectSeq) return; // a later sign-in or sign-out answered first + renderConnect(data.connections || []); + } + function renderConnect(list) { const enc = encodeURIComponent; - const nprofile = card && card.nprofile ? card.nprofile : ""; - const naddr = card && card.naddr ? card.naddr : ""; - const whose = me && owner && me === owner ? "your" : "the owner's"; - const link = (label, href) => '' + label + ""; - const app = (label, uri) => '' + label + ""; - const copy = (label, text) => '"; - const row = (name, where, note, acts) => '
' + name + "" + where + "

" + note + '

' + acts.filter(Boolean).join("") + "
"; - const profileNote = "Opens " + whose + " profile with this relay attached."; - const groups = [ - ["As a place", "These open the relay itself: its feed, its people, its group.", [ - row("Jumble", "web", "A feed of everything on this relay.", [link("Open", "https://jumble.social/?r=" + enc(wsURL))]), - row("Coracle", "web", "The relay's page: its feed and its people.", [link("Open", "https://coracle.social/relays/" + enc(host))]), - 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)]), - row("0xchat", "phone", "The group, in a chat app.", [naddr && app("Open group", "nostr:" + naddr), naddr && copy("Copy naddr", naddr)]), - row("noStrudel", "web", "Relays, add this one, then open its page.", [copy("Copy relay URL", wsURL)]), - ]], - ["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.", [ - row("Primal", "web, phone", profileNote, [nprofile && link("Open", "https://primal.net/p/" + nprofile), nprofile && app("Open in app", "nostr:" + nprofile)]), - row("YakiHonne", "web, phone", profileNote, [nprofile && link("Open", "https://yakihonne.com/profile/" + nprofile), nprofile && app("Open in app", "nostr:" + nprofile)]), - row("Damus", "iPhone", profileNote + " Then Settings, Relays.", [nprofile && app("Open in app", "nostr:" + nprofile), copy("Copy relay URL", wsURL)]), - row("Amethyst", "Android", profileNote + " Then Relays in the drawer.", [nprofile && app("Open in app", "nostr:" + nprofile), copy("Copy relay URL", wsURL)]), - row("Nostur", "iPhone, Mac", profileNote + " Then Settings, Relays.", [nprofile && app("Open in app", "nostr:" + nprofile), copy("Copy relay URL", wsURL)]), - ]], - ]; - if (info?.supported_grasps?.includes("GRASP-01")) groups.push([ - "Git repositories", "Use this relay with a Git client.", [ - 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)]), - ], - ]); - el.innerHTML = groups.map(([h, note, rows]) => '

' + h + '

' + note + '

' + rows.join("") + "
").join(""); - $("#apps-ws").textContent = wsURL; - const tile = (label, text) => '
' + label + 'QR code: ' + esc(label) + '
'; - $("#phones").innerHTML = [nprofile && tile("Find me here, for a phone", "nostr:" + nprofile), naddr && tile("The group, for a phone", "nostr:" + naddr)].filter(Boolean).join(""); + const open = (l, pri) => '" + esc(l.label) + ""; + const copy = (l) => '"; + // One Open leads; the first copy text rides beside it; every other link waits behind More. + const tile = (c) => { + const hrefs = c.links.filter((l) => l.href), copies = c.links.filter((l) => l.copy); + const acts = hrefs.length ? [open(hrefs[0], true), copies.length ? copy(copies[0]) : ""] : [copies.length ? copy(copies[0]) : ""]; + const rest = [...hrefs.slice(1).map((l) => open(l, false)), ...copies.slice(1).map(copy)]; + if (c.qr) acts.push('"); + return '
' + (IC[c.icon] || IC.app) + "" + esc(c.title) + "" + esc(c.app + (c.where ? ", " + c.where : "")) + "" + (VISIBILITY[c.visibility] ? '' + VISIBILITY[c.visibility] + "" : "") + "

" + esc(c.about) + '

' + acts.join("") + "
" + (rest.length ? '
More
' + rest.join("") + "
" : "") + (c.needsUser ? '

Sign in and the links made for you appear.

' : "") + '
'; + }; + $("#apps").innerHTML = list.map(tile).join(""); + $("#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."; } + $("#apps").addEventListener("click", (ev) => { + const b = ev.target.closest("button[data-qr]"); if (!b) return; + const box = b.closest(".app").querySelector(".app-qr"); + if (!box.firstChild) box.innerHTML = 'QR code'; + box.classList.toggle("hidden"); + }); // The folds are a group: opening one closes the rest. Browsers with the // details name attribute do this themselves; this covers the others. $$(".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 @@ else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } } else if (me) me = null; renderHeader(); - renderApps(); await loadAdmin(); await loadPeople(); })(); diff --git a/src/gen/connections.ts b/src/gen/connections.ts new file mode 100644 index 0000000..9bf9de6 --- /dev/null +++ b/src/gen/connections.ts @@ -0,0 +1,285 @@ +// Generated by scripts/build/build-connections.mjs from connection-templates/. Do not edit; run npm run build:connections. +export const CONNECTION_LIBRARY: { name: string; document: unknown }[] = [ + { + "name": "notes", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Notes", + "about": "Everything posted on this relay as one feed.", + "app": "Jumble", + "where": "web", + "icon": "notes", + "visibility": "public", + "links": [ + { + "label": "Open", + "href": "https://jumble.social/?r={relay:url|enc}" + } + ] + } + }, + { + "name": "find-me", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Find me here", + "about": "The owner's profile with this relay attached, for feed apps.", + "app": "Primal", + "where": "web, phone", + "icon": "person", + "visibility": "public", + "links": [ + { + "label": "Open", + "href": "https://primal.net/p/{owner:nprofile}" + }, + { + "label": "Open in app", + "href": "nostr:{owner:nprofile}" + } + ], + "qr": "nostr:{owner:nprofile}" + } + }, + { + "name": "group", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Group", + "about": "The relay as a space, with its group as a room.", + "app": "Flotilla", + "where": "web, phone", + "icon": "chat", + "visibility": "public", + "links": [ + { + "label": "Open", + "href": "https://app.flotilla.social/spaces/{relay:host|enc}" + }, + { + "label": "Open group in app", + "href": "nostr:{relay:naddr}" + }, + { + "label": "Copy naddr", + "copy": "{relay:naddr}" + } + ], + "qr": "nostr:{relay:naddr}" + } + }, + { + "name": "blog", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Blog", + "about": "The owner's articles, with this relay attached so a reader finds the rest of them.", + "app": "YakiHonne", + "where": "web, phone", + "icon": "blog", + "visibility": "public", + "links": [ + { + "label": "Open", + "href": "https://yakihonne.com/profile/{owner:nprofile}" + }, + { + "label": "Open in app", + "href": "nostr:{owner:nprofile}" + } + ], + "qr": "nostr:{owner:nprofile}" + } + }, + { + "name": "repos", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Repos", + "about": "This relay's Git repositories, and the clone command for yours.", + "app": "GitWorkshop", + "where": "web, terminal", + "icon": "git", + "feature": "grasp", + "visibility": "public", + "inputs": [ + { + "name": "repo", + "label": "Repository name", + "placeholder": "my-project", + "default": "", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._\\-]{0,63}$" + } + ], + "links": [ + { + "label": "Open", + "href": "https://gitworkshop.dev/relay/{relay:host|enc}" + }, + { + "label": "Copy clone command", + "copy": "git clone '{relay:web}/{user:npub}/{input:repo}.git'" + } + ] + } + }, + { + "name": "sites", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Sites", + "about": "Static sites this relay hosts, and the command that publishes one here.", + "app": "nsyte", + "where": "web, terminal", + "icon": "site", + "feature": "sites", + "visibility": "public", + "links": [ + { + "label": "Open the owner's site", + "href": "https://{owner:npub}.{relay:domain}" + }, + { + "label": "Open your site", + "href": "https://{user:npub}.{relay:domain}" + }, + { + "label": "Copy publish command", + "copy": "nsyte deploy ./dist --relays {relay:url} --servers {relay:web}" + } + ] + } + }, + { + "name": "bookmarks", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Bookmarks", + "about": "Your bookmarks and lists in a list manager; add this relay in its settings so they land here.", + "app": "Listr", + "where": "web", + "icon": "bookmark", + "visibility": "auth", + "links": [ + { + "label": "Open", + "href": "https://listr.lol/{user:npub}" + }, + { + "label": "Open in noStrudel", + "href": "https://nostrudel.ninja/bookmarks/{user:nprofile}" + } + ] + } + }, + { + "name": "photos", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Photo library", + "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.", + "app": "bouquet", + "where": "web", + "icon": "photo", + "feature": "files", + "visibility": "owner", + "links": [ + { + "label": "Open", + "href": "https://bouquet.slidestr.net/" + }, + { + "label": "Copy Blossom server URL", + "copy": "{relay:web}" + } + ] + } + }, + { + "name": "files", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Files", + "about": "This relay's file store, for members, in a media manager that takes it as a server.", + "app": "bouquet", + "where": "web", + "icon": "files", + "feature": "files", + "visibility": "members", + "links": [ + { + "label": "Open", + "href": "https://bouquet.slidestr.net/" + }, + { + "label": "Copy Blossom server URL", + "copy": "{relay:web}" + }, + { + "label": "Copy NIP-96 URL", + "copy": "{relay:web}/.well-known/nostr/nip96.json" + } + ] + } + }, + { + "name": "dm", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Messages", + "about": "Message the owner privately; put this relay in your DM inbox list (kind 10050) so replies land here.", + "app": "0xchat", + "where": "phone, desktop", + "icon": "chat", + "visibility": "public", + "links": [ + { + "label": "Open in app", + "href": "nostr:{owner:nprofile}" + } + ], + "qr": "nostr:{owner:nprofile}" + } + }, + { + "name": "marmot", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Encrypted groups", + "about": "End-to-end encrypted group chat over this relay, in White Noise.", + "app": "White Noise", + "where": "phone", + "icon": "lock", + "feature": "marmot", + "visibility": "public", + "links": [ + { + "label": "Open", + "href": "https://whitenoise.chat/" + }, + { + "label": "Get the app", + "href": "https://whitenoise.chat/download" + } + ] + } + }, + { + "name": "relay-page", + "document": { + "format": "bind.ws/connection-template/1", + "title": "Relay page", + "about": "This relay's page in Coracle: its feed and its people.", + "app": "Coracle", + "where": "web", + "icon": "feed", + "visibility": "public", + "links": [ + { + "label": "Open", + "href": "https://coracle.social/relays/{relay:host|enc}" + } + ] + } + } +]; diff --git a/src/gen/console.ts b/src/gen/console.ts index 7ab5d72..87dffaa 100644 --- a/src/gen/console.ts +++ b/src/gen/console.ts @@ -1,4 +1,4 @@ // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console. -export const CONSOLE_HTML = "
\n
\n
\"\"
\n
\"\"

\n
\n
\n \n \n \n \n \n \n
\n
\n\n
\n

Nobody owns this relay yet.

\n

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.

\n
\n
\n

Restore a backup

\n

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.

\n
\n \n
\n
\n
\n
\n
\n\n
\n

A temporary relay, for now.

\n

Anyone can read and write here until . 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.

\n
\n
\n\n
\n

Connect a remote signer.

\n

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 bunker:// URL the app gives you.

\n \n
\n \"QR\n

\n
\n\n
\n
\n

\n

\n
\n
About, for clients
\n
Connect\n
\n
nostr relay
\n
Blossom media
\n
names
\n
HTTP bridge, NIP-98
POST /events, /query, /count
\n
\n
\n

Git repositories, ntig

\n
\n

Use ordinary Git to clone a repository hosted here.

\n
Clone a repository
\n

Replace <npub> with the repository owner's npub, yours for your own repository, and <repo> with its repository name, such as my-project.

\n

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. Git hosting guide.

\n
\n
\n
\n

Open it in an app

\n

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 under the app's relay settings to post here.

\n
\n
\n
\n
\n
\n\n
\n

People

\n

Hidden from visitors. Only you see this list.

\n
\n
\n\n
\n

Fuel

\n

Free allowance first; past it, usage burns sats. Anyone can zap a top-up.

\n
\n
Events stored
\n
Files stored
\n
Awake this month
\n
Rows written this month
\n
\n

\n
sats
\n
\n

Pay from any lightning wallet. The receipt lands on this relay and credits it automatically.

\n \n \n
\n
\n\n
\n

Your invites

\n

The owner lets members bring people in. Each link admits one person and lasts three days.

\n
\n
    \n
    \n\n\n
    \n \n\n
    \n

    People

    \n

    The member list is published as a signed roster; a name makes someone .

    \n
    \n
    \n
    WhoNameNoteLimitsJoined
    \n
    \n
    \n
    \n
    \n

    Invites

    \n
    \n
      \n
      members invitehops deep,each
      \n
      \n
      \n

      Joining

      \n
      \n
      \n \n \n
      \n
      \n
      \n
      \n
      \n
      \n
      \n\n
      \n

      Moderation

      \n

      Reports never show in the feed. Banning also deletes the reported thing.

      \n
      \n

      Reports

      \n
      hide an event oncedifferent people report it; 0 never
      \n
      TimeTypeAboutReason
      \n
      \n
      \n

      Log

      \n

      Every change made here or by a moderation event, newest first, the last 5,000.

      \n
      TimeWhoActionTargetDetail
      \n \n
      \n
      \n
      \n

      Bans

      \n
      \n
        \n
          \n
          \n
          \n

          Blocked addresses

          \n
          \n
            \n
            \n
            \n
            \n

            Recent events

            \n

            Delete removes one thing. Ban also refuses it forever. Search covers notes, articles and profiles.

            \n
            \n
            TimeKindAuthorContent
            \n
            \n
            \n
            \n

            Pinned

            \n

            Group clients show these at the top. Up to 20, in this order.

            \n
            \n
              \n
              \n
              \n\n
              \n

              Rules

              \n

              Bans apply regardless of these.

              \n
              \n

              Presets

              \n

              One click sets writes, reads, kinds and keep-for together. Limits, identity and people stay.

              \n
              \n
              Replica presets keep a standing pull of their kinds from this relay.
              \n

              \n
              \n
              \n
              \n

              Writes

              \n \n \n \n \n
              \n

              Reads

              \n \n \n \n
              \n
              \n
              \n \n \n
              \n
              \n \n \n \n \n \n \n \n \n
              \n
              \n
              \n
              \n

              Git capacity

              \n

              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.

              \n
              \n \n \n \n \n \n
              \n
              Advanced Git admission and fetch limits
              \n \n \n \n \n \n \n \n \n
              \n
              \n
              \n
              \n

              Kinds

              \n

              An empty allow list means every kind. Blocks always win.

              \n
              \n

              Allowed:

              \n

              Blocked:

              \n
              \n
              \n

              Features

              \n

              Each is a door or a cost. Off leaves the NIP-11 list, answers 404 at its door and is refused at the socket.

              \n
              \n
              \n \n \n \n \n
              \n
              \n
              \n
              \n

              Blocked words

              \n

              Content containing one is refused. You and your moderators are exempt. An entry written /like this/ is a regular expression.

              \n
              \n
              \n
              \n\n
              \n

              Identity

              \n
              \n

              Profile

              \n
              \n \n \n \n \n \n
              \n

              For directories

              \n
              \n \n \n \n \n \n
              \n
              \n
              \n
              \n

              Your own domain

              \n

              Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.

              \n
              \n
              \n

              \n
              \n
              \n

              Your relay lists

              \n

              Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.

              \n
              \n
              \n
              \n

              Share

              \n

              A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.

              \n
              \n \"relay\n
              \n \"QR\n \n
              \n
              \n
              \n
              \n
              \n\n
              \n

              Data

              \n

              Keep-for rules run once a day. Purges happen now and cannot be undone.

              \n
              \n

              Recover your lists

              \n

              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.

              \n
              ListCreatedSaved
              \n
              \n
              \n
              \n
              \n

              By kind

              \n
              KindCountSizeOldestKeep for
              \n
              \n
              \n

              Files

              \n
              TimeFileSizeUploader
              \n
              \n
              \n

              Sites

              \n

              Published NIP-5A manifests and the hostnames where they are served.

              \n
              AuthorNameURLFilesSizeExpiry
              \n
              \n
              \n

              Dumps

              \n

              Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.

              \n
              \n
                \n
                \n
                \n

                Backups

                \n

                Portable copies include configuration, events, site files and Git objects. They are private and bounded at 8 MB.

                \n
                \n
                \n
                  \n
                  \n
                  \n

                  Import a file

                  \n

                  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.

                  \n
                  \n
                  \n
                  \n\n
                  \n

                  Sync

                  \n

                  Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.

                  \n
                  \n

                  Jobs

                  \n

                  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.

                  \n
                  JobRelaysFilterScheduleResult
                  \n\n
                  \n
                  \n
                  \n
                  \n \n \n \n \n
                  \n
                  \n
                  \n
                  \n
                  \n

                  Automatic delivery

                  \n

                  Recent per-target results for automatic NIP-65 delivery.

                  \n
                  EventTargetStatusAttemptsLast error
                  \n
                  \n
                  \n

                  Fork this relay

                  \n

                  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.

                  \n
                  \n
                  \n \n \n \n \n \n
                  \n
                  \n
                  \n

                  \n
                  \n
                  \n
                  \n\n
                  \n

                  Views

                  \n

                  Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.

                  \n
                  \n
                  \n
                  \n
                  \n\n
                  \n

                  Health

                  \n
                  \n
                  –since last event
                  \n
                  –connected nowwebsockets open
                  \n
                  –fuel
                  \n
                  used for, last 30 days
                  \n
                  \n
                  \n
                  \n

                  Zaps received

                  \n
                  WhenFromSats
                  \n
                  \n
                  \n

                  Notifications

                  \n

                  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.

                  \n
                  \n
                  \n \n \n \n \n \n
                  \n
                  \n
                  \n
                  \n
                  \n\n
                  \n

                  Owner

                  \n

                  The relay's key, events, files and fuel stay put through everything here except delete.

                  \n
                  \n

                  Configuration

                  \n

                  Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.

                  \n
                  \n
                  \n
                  \n

                  Transfer ownership

                  \n

                  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.

                  \n
                  \n
                  \n
                  \n

                  If I lose my key

                  \n

                  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.

                  \n
                  \n
                  \n \n \n
                  \n
                  \n
                  \n

                  \n
                  \n
                  \n

                  Delete this relay

                  \n

                  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.

                  \n \n
                  \n
                  \n
                  \n\n \n
                  \n"; -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"; -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 ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\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: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\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('' + esc(label) + \"\");\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('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\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('|'); $(\"#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 ' + key(owner) + \"\" : 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]) => \"
                  \" + k + \"\" + (v === undefined || v === \"\" ? '–' : v instanceof Html ? v.s : esc(v)) + \"
                  \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\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) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").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 + \"//.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 ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. 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) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\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]) => \"
                  \" + k + \"\" + v + \"
                  \").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]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\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) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\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\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\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 ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").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) => '\").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) => '
                • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                • \").join(\"\") : '
                • no invites
                • ';\n const person = (r, icon, label, act) => \"
                • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                • nobody banned
                • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                • \").join(\"\") : '
                • no addresses blocked
                • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + 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\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\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 '\"; }).join(\"\");\n return \"\";\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 = '\";\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) => \"\" + esc(x) + \"\").join('|');\n return '
                  ' + esc(v.name) + '
                  ' + esc(v.about) + '
                  ' + pick + (v.on ? 'Open' : \"\") + '
                  ' + meta + \"
                  \";\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) => '
                • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                • \").join(\"\") : '
                • no dumps yet
                • ';\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) => '
                • backup' + esc(b.id) + ' ' + fmtBytes(b.bytes) + '' + ib(\"copy\", \"Download\", \"downloadbackup\", b.id) + ib(\"trash\", \"Delete\", \"deletebackup\", b.id, \"danger\") + '
                • ').join(\"\") : '
                • no backups yet
                • ';\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) => '
                • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                • \").join(\"\") : '
                • no invites yet
                • ';\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]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                  \" + k + \"\" + v + \"
                  \").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\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\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) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\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) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + 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\") + \"\").join(\"\") : 'no events yet';\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) => '
                • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                • \").join(\"\") : '
                • nothing pinned
                • ';\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 ? '
                  Source results' + sources.map((s) => '

                  ' + esc(s.url) + ': ' + esc(s.status) + ', ' + s.stored + ' stored, ' + s.skipped + ' skipped' + (s.error || s.warning ? '
                  ' + esc(s.error || s.warning) : '') + '

                  ').join('') + '
                  ' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"
                  \" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"
                  \") + \"
                  \" : \"\";\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                  \") + targets + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\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(\"\") : 'no jobs yet';\n if (myRole === \"owner\") {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '' + esc(d.event_id.slice(0, 12)) + '' + esc(d.target) + '' + esc(d.status) + '' + d.attempts + '' + esc(d.error || \"\") + '').join(\"\") : 'no automatic deliveries yet';\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) => '\").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 '' + esc(label) + \"\";\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) => '\";\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 ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                  ' + l.title + ' ' + l.nip + \"
                  \" + l.about + '
                  ' + buttons + '
                  ' + meta + \"
                  \";\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) => '\").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) => '\";\n const rows = d.ready ? \"\" : '
                  ' + d.records.map((r) => \"\").join(\"\") + \"
                  TypeNameValue
                  \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                  \";\n return '
                  ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                  \" + 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 = '

                  Ready to restore. ' + esc(String(r.result.events)) + ' events, ' + esc(String(r.result.blobs)) + ' files, ' + esc(String(r.result.git)) + ' Git objects, ' + esc(fmtBytes(r.result.bytes)) + '.

                  Source relay identity: ' + esc(s.relayIdentity || \"unknown\") + '

                  ' + esc(JSON.stringify(r.result.config, null, 2)) + '
                  '; });\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) => '' + label + \"\";\n const app = (label, uri) => '' + label + \"\";\n const copy = (label, text) => '\";\n const row = (name, where, note, acts) => '
                  ' + name + \"\" + where + \"

                  \" + note + '

                  ' + acts.filter(Boolean).join(\"\") + \"
                  \";\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]) => '

                  ' + h + '

                  ' + note + '

                  ' + rows.join(\"\") + \"
                  \").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '
                  ' + label + '\"QR
                  ';\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('\"''); 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"; +export const CONSOLE_HTML = "
                  \n
                  \n
                  \"\"
                  \n
                  \"\"

                  \n
                  \n
                  \n \n \n \n \n \n \n
                  \n
                  \n\n
                  \n

                  Nobody owns this relay yet.

                  \n

                  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.

                  \n
                  \n
                  \n

                  Restore a backup

                  \n

                  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.

                  \n
                  \n \n
                  \n
                  \n
                  \n
                  \n
                  \n\n
                  \n

                  A temporary relay, for now.

                  \n

                  Anyone can read and write here until . 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.

                  \n
                  \n
                  \n\n
                  \n

                  Connect a remote signer.

                  \n

                  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 bunker:// URL the app gives you.

                  \n \n
                  \n \"QR\n

                  \n
                  \n\n
                  \n
                  \n

                  \n

                  \n
                  \n
                  About, for clients
                  \n
                  Connect\n
                  nostr relay
                  \n

                  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.

                  \n
                  \n

                  \n
                  Connection details\n
                  \n
                  Blossom media
                  \n
                  names
                  \n
                  HTTP bridge, NIP-98
                  POST /events, /query, /count
                  \n
                  \n
                  \n

                  Git repositories, ntig

                  \n
                  \n

                  Use ordinary Git to clone a repository hosted here.

                  \n
                  Clone a repository
                  \n

                  Replace <npub> with the repository owner's npub, yours for your own repository, and <repo> with its repository name, such as my-project.

                  \n

                  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. Git hosting guide.

                  \n
                  \n
                  \n
                  \n
                  \n
                  \n\n
                  \n

                  People

                  \n

                  Hidden from visitors. Only you see this list.

                  \n
                  \n
                  \n\n
                  \n

                  Fuel

                  \n

                  Free allowance first; past it, usage burns sats. Anyone can zap a top-up.

                  \n
                  \n
                  Events stored
                  \n
                  Files stored
                  \n
                  Awake this month
                  \n
                  Rows written this month
                  \n
                  \n

                  \n
                  sats
                  \n
                  \n

                  Pay from any lightning wallet. The receipt lands on this relay and credits it automatically.

                  \n \n \n
                  \n
                  \n\n
                  \n

                  Your invites

                  \n

                  The owner lets members bring people in. Each link admits one person and lasts three days.

                  \n
                  \n
                    \n
                    \n\n\n
                    \n \n\n
                    \n

                    People

                    \n

                    The member list is published as a signed roster; a name makes someone .

                    \n
                    \n
                    \n
                    WhoNameNoteLimitsJoined
                    \n
                    \n
                    \n
                    \n
                    \n

                    Invites

                    \n
                    \n
                      \n
                      members invitehops deep,each
                      \n
                      \n
                      \n

                      Joining

                      \n
                      \n
                      \n \n \n
                      \n
                      \n
                      \n
                      \n
                      \n
                      \n
                      \n\n
                      \n

                      Moderation

                      \n

                      Reports never show in the feed. Banning also deletes the reported thing.

                      \n
                      \n

                      Reports

                      \n
                      hide an event oncedifferent people report it; 0 never
                      \n
                      TimeTypeAboutReason
                      \n
                      \n
                      \n

                      Log

                      \n

                      Every change made here or by a moderation event, newest first, the last 5,000.

                      \n
                      TimeWhoActionTargetDetail
                      \n \n
                      \n
                      \n
                      \n

                      Bans

                      \n
                      \n
                        \n
                          \n
                          \n
                          \n

                          Blocked addresses

                          \n
                          \n
                            \n
                            \n
                            \n
                            \n

                            Recent events

                            \n

                            Delete removes one thing. Ban also refuses it forever. Search covers notes, articles and profiles.

                            \n
                            \n
                            TimeKindAuthorContent
                            \n
                            \n
                            \n
                            \n

                            Pinned

                            \n

                            Group clients show these at the top. Up to 20, in this order.

                            \n
                            \n
                              \n
                              \n
                              \n\n
                              \n

                              Rules

                              \n

                              Bans apply regardless of these.

                              \n
                              \n

                              Presets

                              \n

                              One click sets writes, reads, kinds and keep-for together. Limits, identity and people stay.

                              \n
                              \n
                              Replica presets keep a standing pull of their kinds from this relay.
                              \n

                              \n
                              \n
                              \n
                              \n

                              Writes

                              \n \n \n \n \n
                              \n

                              Reads

                              \n \n \n \n
                              \n
                              \n
                              \n \n \n
                              \n
                              \n \n \n \n \n \n \n \n \n
                              \n
                              \n
                              \n
                              \n

                              Git capacity

                              \n

                              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.

                              \n
                              \n \n \n \n \n \n
                              \n
                              Advanced Git admission and fetch limits
                              \n \n \n \n \n \n \n \n \n
                              \n
                              \n
                              \n
                              \n

                              Kinds

                              \n

                              An empty allow list means every kind. Blocks always win.

                              \n
                              \n

                              Allowed:

                              \n

                              Blocked:

                              \n
                              \n
                              \n

                              Features

                              \n

                              Each is a door or a cost. Off leaves the NIP-11 list, answers 404 at its door and is refused at the socket.

                              \n
                              \n
                              \n \n \n \n \n
                              \n
                              \n
                              \n
                              \n

                              Blocked words

                              \n

                              Content containing one is refused. You and your moderators are exempt. An entry written /like this/ is a regular expression.

                              \n
                              \n
                              \n
                              \n\n
                              \n

                              Identity

                              \n
                              \n

                              Profile

                              \n
                              \n \n \n \n \n \n
                              \n

                              For directories

                              \n
                              \n \n \n \n \n \n
                              \n
                              \n
                              \n
                              \n

                              Your own domain

                              \n

                              Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.

                              \n
                              \n
                              \n

                              \n
                              \n
                              \n

                              Your relay lists

                              \n

                              Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.

                              \n
                              \n
                              \n
                              \n

                              Share

                              \n

                              A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.

                              \n
                              \n \"relay\n
                              \n \"QR\n \n
                              \n
                              \n
                              \n
                              \n
                              \n\n
                              \n

                              Connect

                              \n

                              The shortcuts on the Connect fold: which apps, in what order, and who sees each.

                              \n
                              \n

                              Shortcuts

                              \n

                              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.

                              \n
                              \n
                              \n
                              \n

                              Library

                              \n

                              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.

                              \n
                              \n
                              \n
                              \n\n
                              \n

                              Data

                              \n

                              Keep-for rules run once a day. Purges happen now and cannot be undone.

                              \n
                              \n

                              Recover your lists

                              \n

                              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.

                              \n
                              ListCreatedSaved
                              \n
                              \n
                              \n
                              \n
                              \n

                              By kind

                              \n
                              KindCountSizeOldestKeep for
                              \n
                              \n
                              \n

                              Files

                              \n
                              TimeFileSizeUploader
                              \n
                              \n
                              \n

                              Sites

                              \n

                              Published NIP-5A manifests and the hostnames where they are served.

                              \n
                              AuthorNameURLFilesSizeExpiry
                              \n
                              \n
                              \n

                              Dumps

                              \n

                              Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.

                              \n
                              \n
                                \n
                                \n
                                \n

                                Backups

                                \n

                                Portable copies include configuration, events, site files and Git objects. They are private and bounded at 8 MB.

                                \n
                                \n
                                \n
                                  \n
                                  \n
                                  \n

                                  Import a file

                                  \n

                                  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.

                                  \n
                                  \n
                                  \n
                                  \n\n
                                  \n

                                  Sync

                                  \n

                                  Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.

                                  \n
                                  \n

                                  Jobs

                                  \n

                                  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.

                                  \n
                                  JobRelaysFilterScheduleResult
                                  \n\n
                                  \n
                                  \n
                                  \n
                                  \n \n \n \n \n
                                  \n
                                  \n
                                  \n
                                  \n
                                  \n

                                  Automatic delivery

                                  \n

                                  Recent per-target results for automatic NIP-65 delivery.

                                  \n
                                  EventTargetStatusAttemptsLast error
                                  \n
                                  \n
                                  \n

                                  Fork this relay

                                  \n

                                  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.

                                  \n
                                  \n
                                  \n \n \n \n \n \n
                                  \n
                                  \n
                                  \n

                                  \n
                                  \n
                                  \n
                                  \n\n
                                  \n

                                  Views

                                  \n

                                  Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.

                                  \n
                                  \n
                                  \n
                                  \n
                                  \n\n
                                  \n

                                  Health

                                  \n
                                  \n
                                  –since last event
                                  \n
                                  –connected nowwebsockets open
                                  \n
                                  –fuel
                                  \n
                                  used for, last 30 days
                                  \n
                                  \n
                                  \n
                                  \n

                                  Zaps received

                                  \n
                                  WhenFromSats
                                  \n
                                  \n
                                  \n

                                  Notifications

                                  \n

                                  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.

                                  \n
                                  \n
                                  \n \n \n \n \n \n
                                  \n
                                  \n
                                  \n
                                  \n
                                  \n\n
                                  \n

                                  Owner

                                  \n

                                  The relay's key, events, files and fuel stay put through everything here except delete.

                                  \n
                                  \n

                                  Configuration

                                  \n

                                  Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.

                                  \n
                                  \n
                                  \n
                                  \n

                                  Transfer ownership

                                  \n

                                  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.

                                  \n
                                  \n
                                  \n
                                  \n

                                  If I lose my key

                                  \n

                                  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.

                                  \n
                                  \n
                                  \n \n \n
                                  \n
                                  \n
                                  \n

                                  \n
                                  \n
                                  \n

                                  Delete this relay

                                  \n

                                  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.

                                  \n \n
                                  \n
                                  \n
                                  \n\n \n
                                  \n"; +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"; +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 ? '' + hex + '' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '';\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: '',\n undo: '',\n check: '',\n trash: '',\n ban: '',\n banuser: '',\n eye: '',\n lock: '',\n pen: '',\n people: '',\n person: '',\n gauge: '',\n bolt: '',\n copy: '',\n pin: '',\n up: '',\n down: '',\n qr: '',\n // The connection template icons (connections.ts, ICONS): a template names one.\n notes: '',\n blog: '',\n bookmark: '',\n photo: '',\n site: '',\n git: '',\n chat: '',\n files: '',\n key: '',\n search: '',\n feed: '',\n app: '',\n };\n const ib = (icon, label, act, id, extra) => '\";\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('' + esc(label) + \"\");\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('' + esc(t) + \"\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"\" + esc(where.join(\", \")) + \"\");\n const link = (label, href) => href ? '' + label + \"\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"\" + esc(info.contact) + \"\") : \"\";\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('|'); $(\"#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 ' + key(owner) + \"\" : 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]) => \"
                                  \" + k + \"\" + (v === undefined || v === \"\" ? '–' : v instanceof Html ? v.s : esc(v)) + \"
                                  \").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '' + IC[icon] + \"\" + label + \"\";\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) => '' + av(m.pubkey) + '' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"\" + (m.role === \"owner\" ? 'owner' : \"\") + \"\").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 + \"//.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 ? 'out of fuel Writes are paused until someone tops up.'\n : \"Balance \" + sats.toLocaleString() + \" sats. 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) => '' + esc(fmtTime(c.at)) + '' + av(c.payer) + key(c.payer) + '' + Math.floor(c.msats / 1000).toLocaleString() + \"\").join(\"\") : 'no zaps yet';\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]) => \"
                                  \" + k + \"\" + v + \"
                                  \").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]) => '').join(\"\") : '';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '' + name + \" \" + Math.round(n * 100 / total) + \"%\").join(\"\") : \"no events yet\";\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) => '' + esc(fmtTime(r.at)) + '' + av(r.actor) + key(r.actor) + '' + esc(r.action) + '' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '' + esc(r.detail) + '').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || 'Nothing yet';\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\" ? ' owner' : myRole === \"owner\" ? ' ' : m.role === \"moderator\" ? ' moderator' : \"\";\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 ? ''\n : '' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' via ' + esc(nameOf(m.invited_by)) + \"\" : \"\") + (m.invites ? ' ' + m.invites + \" inv\" : \"\") + '' + limits(m) + '' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"\").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) => '\").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) => '
                                • inv…' + i.code.slice(-8) + \" \" + esc(i.note || \"\") + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                • \").join(\"\") : '
                                • no invites
                                • ';\n const person = (r, icon, label, act) => \"
                                • \" + av(r.pubkey) + '' + short(r.pubkey) + \" \" + esc(r.reason || \"\") + \"\" + ib(icon, label, act, r.pubkey) + \"
                                • \";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '
                                • nobody banned
                                • ';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '
                                • ip' + esc(r.ip) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"
                                • \").join(\"\") : '
                                • no addresses blocked
                                • ';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '
                                • ev' + key(r.id) + \" \" + esc(r.reason || \"\") + \"\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"
                                • \").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '' + esc(fmtTime(r.at)) + '' + esc(r.type || \"report\") + '' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' file ' + key(r.target_event) + \"\" : ' ev ' + key(r.target_event) + (r.hidden ? ' hidden' : \"\") + \"\") : \"\") + '' + esc(r.content) + '' + 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\") + \"\").join(\"\") : 'nothing reported';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '' + esc(fmtTime(b.uploaded)) + '' + key(b.sha256) + ' ' + esc(b.type) + '' + fmtBytes(b.size) + '' + av(b.uploader) + key(b.uploader) + '' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"\").join(\"\") : 'no uploads';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '' + av(s.author) + key(s.author) + '' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '' + esc(s.url) + '' + esc(s.paths) + (s.missing ? ' ' + esc(s.missing) + ' missing' : '') + '' + fmtBytes(s.size) + '' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '').join(\"\") : 'no sites yet';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : 'all';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : 'none';\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 '\"; }).join(\"\");\n return \"\";\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 = '\";\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) => \"\" + esc(x) + \"\").join('|');\n return '
                                  ' + esc(v.name) + '
                                  ' + esc(v.about) + '
                                  ' + pick + (v.on ? 'Open' : \"\") + '
                                  ' + meta + \"
                                  \";\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) => '
                                • jsonl' + esc(d.name) + ' ' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"
                                • \").join(\"\") : '
                                • no dumps yet
                                • ';\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) => '
                                • backup' + esc(b.id) + ' ' + fmtBytes(b.bytes) + '' + ib(\"copy\", \"Download\", \"downloadbackup\", b.id) + ib(\"trash\", \"Delete\", \"deletebackup\", b.id, \"danger\") + '
                                • ').join(\"\") : '
                                • no backups yet
                                • ';\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) => '
                                • inv…' + i.code.slice(-8) + ' ' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"
                                • \").join(\"\") : '
                                • no invites yet
                                • ';\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]) => '').join(\"\") : '';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%\").join(\"\") : \"no events yet\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" \" + fmtBytes(st.eventBytes) + \"\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" \" + fmtBytes(st.mediaBytes) + \"\"]]\n .map(([k, v]) => \"
                                  \" + k + \"\" + v + \"
                                  \").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\" : '' + k.kind + \" \" + esc(kindName(k.kind));\n const cells = k.protected\n ? 'required'\n : ' days' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"\";\n return '' + pill + '' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"\" + cells + \"\";\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) => '' + esc((labels[r.kind] || (\"kind \" + r.kind)) + (r.d ? \" / \" + r.d : \"\")) + '' + esc(fmtTime(r.created_at)) + '' + esc(fmtTime(r.saved_at)) + '' + ib(\"undo\", \"Restore this version\", \"restorelist\", r.event_id) + '').join(\"\") : 'no older list versions yet';\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) => '' + esc(fmtTime(e.created_at)) + '' + e.kind + '' + av(e.pubkey) + key(e.pubkey) + '' + esc(e.content) + '' + 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\") + \"\").join(\"\") : 'no events yet';\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) => '
                                • ' + (t[0] === \"e\" ? \"event\" : \"address\") + '' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"
                                • \").join(\"\") : '
                                • nothing pinned
                                • ';\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 ? '
                                  Source results' + sources.map((s) => '

                                  ' + esc(s.url) + ': ' + esc(s.status) + ', ' + s.stored + ' stored, ' + s.skipped + ' skipped' + (s.error || s.warning ? '
                                  ' + esc(s.error || s.warning) : '') + '

                                  ').join('') + '
                                  ' : '';\n const targets = j.kind === \"push\" && j.targetStatus ? \"
                                  \" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + \": \" + esc(s.status)).join(\"
                                  \") + \"
                                  \" : \"\";\n return \"\" + what + \"\" + j.relays.map(esc).join(\"
                                  \") + targets + \"\" + (f.join(\", \") || \"everything\") + \"\" + when + \"\" + esc(res) + details + \"\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"\";\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(\"\") : 'no jobs yet';\n if (myRole === \"owner\") {\n try {\n const deliveries = await rpc(\"deliverystatus\");\n $(\"#deliveries tbody\").innerHTML = deliveries.length ? deliveries.map((d) => '' + esc(d.event_id.slice(0, 12)) + '' + esc(d.target) + '' + esc(d.status) + '' + d.attempts + '' + esc(d.error || \"\") + '').join(\"\") : 'no automatic deliveries yet';\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) => '\").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]) => '\").join(\"\");\n const inputs = (t.inputs || []).map((inp) => \"').join(\"\");\n const edit = editing ? '
                                  ' + inputs + \"
                                  \" : \"\";\n return '
                                  ' + (IC[t.icon] || IC.app) + '
                                  ' + esc(c.title || t.title) + \"\" + esc(t.app + (t.where ? \", \" + t.where : \"\")) + \"\" + (t.available ? \"\" : 'needs ' + esc(t.feature) + \", off\") + '
                                  \" + edit + \"
                                  \";\n });\n $(\"#conns\").innerHTML = rows.length ? rows.join(\"\") : '

                                  No shortcuts. Add some from the library below.

                                  ';\n }\n function renderConnLib() {\n $(\"#connlib\").innerHTML = connLib.map((t) => '
                                  ' + (IC[t.icon] || IC.app) + \"\" + esc(t.title) + \"\" + esc(t.app + (t.where ? \", \" + t.where : \"\")) + \"\" + (t.visibility !== \"public\" ? '' + VISIBILITY[t.visibility] + \"\" : \"\") + \"

                                  \" + esc(t.about) + '

                                  ' + (t.feature ? '' + esc(t.feature) + (t.available ? \" on\" : \" off\") + \"\" : \"\") + \"
                                  \").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 '' + esc(label) + \"\";\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) => '\";\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 ? \"\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"\" : w.state && !busy ? \"no list found\" : \"\";\n const sent = (w.results || []).map((r) => '' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '
                                  ' + l.title + ' ' + l.nip + \"
                                  \" + l.about + '
                                  ' + buttons + '
                                  ' + meta + \"
                                  \";\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) => '\").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) => '\";\n const rows = d.ready ? \"\" : '
                                  ' + d.records.map((r) => \"\").join(\"\") + \"
                                  TypeNameValue
                                  \" + esc(r.type) + '' + esc(r.name) + '' + esc(r.value) + \"\" + esc(r.note) + \"
                                  \";\n return '
                                  ' + esc(d.host) + '' + esc(state) + \"\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '
                                  \" + 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 = '

                                  Ready to restore. ' + esc(String(r.result.events)) + ' events, ' + esc(String(r.result.blobs)) + ' files, ' + esc(String(r.result.git)) + ' Git objects, ' + esc(fmtBytes(r.result.bytes)) + '.

                                  Source relay identity: ' + esc(s.relayIdentity || \"unknown\") + '

                                  ' + esc(JSON.stringify(r.result.config, null, 2)) + '
                                  '; });\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) => '\" + esc(l.label) + \"\";\n const copy = (l) => '\";\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('\");\n return '
                                  ' + (IC[c.icon] || IC.app) + \"\" + esc(c.title) + \"\" + esc(c.app + (c.where ? \", \" + c.where : \"\")) + \"\" + (VISIBILITY[c.visibility] ? '' + VISIBILITY[c.visibility] + \"\" : \"\") + \"

                                  \" + esc(c.about) + '

                                  ' + acts.join(\"\") + \"
                                  \" + (rest.length ? '
                                  More
                                  ' + rest.join(\"\") + \"
                                  \" : \"\") + (c.needsUser ? '

                                  Sign in and the links made for you appear.

                                  ' : \"\") + '
                                  ';\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 = '\"QR';\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('\"''); 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"; diff --git a/src/gen/templates.ts b/src/gen/templates.ts index 7f53146..04a1c3c 100644 --- a/src/gen/templates.ts +++ b/src/gen/templates.ts @@ -40,7 +40,21 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ 1059 ] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "notes", + "visibility": "public" + }, + { + "template": "find-me", + "visibility": "public" + }, + { + "template": "blog", + "visibility": "public" + } + ] } }, { @@ -75,6 +89,16 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ "kind": null, "days": 90 } + ], + "connections": [ + { + "template": "find-me", + "visibility": "public" + }, + { + "template": "notes", + "visibility": "public" + } ] } }, @@ -122,7 +146,13 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "group", + "visibility": "public" + } + ] } }, { @@ -145,7 +175,17 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "files", + "visibility": "members" + }, + { + "template": "photos", + "visibility": "owner" + } + ] } }, { @@ -175,7 +215,13 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "notes", + "visibility": "public" + } + ] } }, { @@ -200,7 +246,17 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "blog", + "visibility": "public" + }, + { + "template": "notes", + "visibility": "public" + } + ] } }, { @@ -223,7 +279,13 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "dm", + "visibility": "public" + } + ] } }, { @@ -256,7 +318,8 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ "allow": [], "block": [] }, - "retention": [] + "retention": [], + "connections": [] } }, { @@ -283,7 +346,17 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ "allow": [], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "sites", + "visibility": "public" + }, + { + "template": "notes", + "visibility": "public" + } + ] } }, { @@ -312,7 +385,13 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "marmot", + "visibility": "public" + } + ] } }, { @@ -335,7 +414,17 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ "allow": [], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "repos", + "visibility": "public" + }, + { + "template": "notes", + "visibility": "public" + } + ] } }, { @@ -364,7 +453,75 @@ export const TEMPLATES: { name: string; document: unknown }[] = [ ], "block": [] }, - "retention": [] + "retention": [], + "connections": [ + { + "template": "marmot", + "visibility": "members" + } + ] + } + }, + { + "name": "home", + "document": { + "format": "bind.ws/relay-config/2", + "template": { + "title": "Home", + "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." + }, + "policy": { + "writes": "allowlist", + "reads": "open", + "directoryPublic": true, + "features": { + "files": true, + "sites": { + "enabled": true, + "mirror": true + }, + "grasp": true + } + }, + "kinds": { + "allow": [], + "block": [] + }, + "retention": [], + "connections": [ + { + "template": "notes", + "visibility": "public" + }, + { + "template": "blog", + "visibility": "public" + }, + { + "template": "bookmarks", + "visibility": "auth" + }, + { + "template": "sites", + "visibility": "public" + }, + { + "template": "repos", + "visibility": "public" + }, + { + "template": "photos", + "visibility": "owner" + }, + { + "template": "find-me", + "visibility": "public" + }, + { + "template": "group", + "visibility": "public" + } + ] } } ]; diff --git a/src/index.ts b/src/index.ts index 51aa598..84d26f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { now } from "./event.ts"; import { clientIP, customHost, hostnameKnown, leaseAllowed, siteHost } from "./edge.ts"; import { customTarget } from "./domains.ts"; import schema from "../relay-config.schema.json"; +import connectionSchema from "../connection-template.schema.json"; // The entry module exports the handler and the object only: workerd // refuses any other kind of export here. @@ -86,6 +87,8 @@ export default { 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" } }); // The configuration file's schema, for editors: relay-config.schema.json in the repository. if (url.pathname === "/relay-config.schema.json") return Response.json(schema, { headers: { "access-control-allow-origin": "*", "cache-control": "public, max-age=3600" } }); + // The connection template's schema: connection-template.schema.json in the repository. + if (url.pathname === "/connection-template.schema.json") return Response.json(connectionSchema, { headers: { "access-control-allow-origin": "*", "cache-control": "public, max-age=3600" } }); if (url.pathname === "/lease" && req.method === "POST") return lease(req, env, url); if (url.pathname === "/lease" && req.method === "OPTIONS") { return new Response(null, { headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "POST, OPTIONS" } }); diff --git a/src/manage.ts b/src/manage.ts index 89d411c..891e6c1 100644 --- a/src/manage.ts +++ b/src/manage.ts @@ -23,6 +23,7 @@ import { DUMP_NAME_RE, deleteDump, dumpBytes, listDumps, writeDump } from "./dum import { can, type Action, type Role } from "./roles.ts"; import { detailOf } from "./audit.ts"; import { PRESETS, applyPreset, findPreset } from "./presets.ts"; +import { CONNECTION_TEMPLATES, parseConnections } from "./connections.ts"; import { addDomain, checkDomain, listDomains, removeDomain, setDomainSite } from "./domains.ts"; import { verifyNIP98 } from "./auth.ts"; import { SITE_KINDS, checkSite, siteLabel, sitePaths } from "./sites.ts"; @@ -563,7 +564,27 @@ export const METHODS: Record = { listpresets: { action: "read", reads: true, run: ({ reply }) => { - return reply({ result: PRESETS.map((x) => ({ name: x.name, title: x.title, about: x.about, source: x.source, every: x.every })) }); + return reply({ result: PRESETS.map((x) => ({ name: x.name, title: x.title, about: x.about, source: x.source, every: x.every, connections: x.connections })) }); + }, + }, + // The Connect fold's app shortcuts (connections.ts): the library the + // owner picks from, with whether each template's feature is on, the + // owner's list, and the list replaced whole, in the order given. + listconnectiontemplates: { + action: "read", reads: true, + run: ({ p, reply }) => reply({ result: CONNECTION_TEMPLATES.map((t) => ({ ...t, available: !t.feature || featureOn(p, t.feature) })) }), + }, + listconnections: { action: "read", reads: true, run: ({ s, reply }) => reply({ result: s.connections() }) }, + setconnections: { + action: "rules", + run: ({ s, params, reply }) => { + // (list): every entry must fit; the console builds the list from the + // library, so a bad one is a mistake to report, not to drop. + const parsed = parseConnections(params[0]); + if (typeof parsed === "string") return reply({ error: parsed }, 400); + if (parsed.warnings.length) return reply({ error: "invalid: " + parsed.warnings.join("; ") }, 400); + s.setConnections(parsed.list); + return reply({ result: s.connections() }); }, }, applypreset: { diff --git a/src/presets.ts b/src/presets.ts index 097104b..2fcf77d 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -1,13 +1,14 @@ // Presets: the templates in relay-templates/, one click each. A template is // a relay configuration with the rules sections only (policy, kinds and -// retention), so applying one sets writes, reads, the directory, the -// features, the kind rules and the keep-for rules together and leaves +// retention, and the Connect fold's shortcuts when it names them), so +// applying one sets writes, reads, the directory, the features, the kind +// rules, the keep-for rules and the app shortcuts together and leaves // limits, identity, people and bans alone. Haven's four relays are the // first four: names are cheap here, so one name per role is the way to get // the same split. import { TEMPLATES } from "./gen/templates.ts"; import { applyConfig, parseConfig, type Config } from "./config.ts"; -import type { Policy, Settings } from "./settings.ts"; +import type { Connection, Policy, Settings } from "./settings.ts"; export interface Preset { name: string; @@ -19,6 +20,9 @@ export interface Preset { allow: number[]; block: number[]; retention: { kind: number | null; days: number }[]; + // The Connect fold's shortcuts the template sets, when it has a + // connections section; left out, the shortcuts stay as they are. + connections?: Connection[]; // A replica keeps itself in step with a source relay through a standing // pull of its kinds. "required" presets refuse to apply without one. source?: "required" | "optional"; @@ -30,7 +34,7 @@ export const PRESETS: Preset[] = TEMPLATES.map(({ name, document }) => { const config = parseConfig(document); if (typeof config === "string" || !config.template) throw new Error(`template ${name}: ${typeof config === "string" ? config : "no template block"}`); const p = config.policy; - 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 }; + 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 }; }); export function findPreset(name: string): Preset | undefined { diff --git a/src/routes.ts b/src/routes.ts index a156139..7de5bea 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -22,6 +22,7 @@ import { blossom, isBlobPath } from "./blossom.ts"; import { nip96 } from "./nip96.ts"; import { fuelInvoice } from "./fuel.ts"; import { card } from "./card.ts"; +import { connectDoor } from "./connections.ts"; import { isPagePath, pages } from "./pages.ts"; import { dashboard } from "./dashboard.ts"; import { SIGNER_JS } from "./gen/signer.ts"; @@ -116,6 +117,8 @@ export const ROUTES: Route[] = [ { when: post(is("/fuel/invoice")), gated: true, answer: fuelInvoice }, // The card (card.ts). { when: is("/card.json", "/card.nostr", "/card.svg", "/qr.svg"), answer: card }, + // The Connect fold's app shortcuts, as the asker may see them (connections.ts). + { when: get(is("/connect.json")), answer: connectDoor }, // CORS preflight for everything above. { 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" } }) }, // Notes and articles as pages, the feed (pages.ts). diff --git a/src/settings.ts b/src/settings.ts index 7525543..3e7983c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -117,6 +117,27 @@ export interface Features { marmot: boolean; // Marmot's opaque MLS transport events } 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 }; + +// A connection is one app shortcut on the Connect fold (connections.ts): a +// template from connection-templates/, who may see it, the owner's own +// title and about when they changed the template's, and the template's +// inputs as the owner filled them. The list is kept in the order shown. +export const VISIBILITIES = ["public", "auth", "members", "owner"] as const; +export type Visibility = (typeof VISIBILITIES)[number]; +export interface Connection { + template: string; + visibility: Visibility; + title?: string; + about?: string; + inputs?: Record; +} +// What a relay shows until its owner picks: the feed, the profile handoff +// for feed apps, and the group. A saved list, even an empty one, replaces it. +export const DEFAULT_CONNECTIONS: Connection[] = [ + { template: "notes", visibility: "public" }, + { template: "find-me", visibility: "public" }, + { template: "group", visibility: "public" }, +]; export const featureOn = (p: Policy, f: Feature): boolean => { if (f === "sites") return p.features.sites.enabled; if (["grasp02", "grasp03", "grasp05", "grasp06"].includes(f) && p.features[f] !== true) return false; @@ -453,6 +474,9 @@ export class Settings { private blockedKinds = new Set(); // Days to keep events of a kind; RETENTION_ANY is the rule for kinds without one. private retention = new Map(); + // The Connect fold's shortcuts; null until the owner saves a list, when + // the defaults show. + private connectionList: Connection[] | null = null; constructor(private sql: SqlStorage) {} @@ -483,6 +507,15 @@ export class Settings { (r.rule === "allow" ? this.allowedKinds : this.blockedKinds).add(r.kind); } for (const r of this.sql.exec<{ kind: number; days: number }>(`SELECT kind, days FROM retention`)) this.retention.set(r.kind, r.days); + const saved = this.sql.exec<{ value: string }>(`SELECT value FROM settings WHERE key='connections'`).toArray()[0]; + if (saved) { + try { + const list = JSON.parse(saved.value) as unknown; + this.connectionList = Array.isArray(list) ? (list as Connection[]) : []; + } catch { + this.connectionList = []; + } + } if (this.policy.writes === "wot") this.rebuildWot(); } @@ -601,6 +634,20 @@ export class Settings { this.sql.exec(`INSERT INTO settings(key,value) VALUES('pins',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, JSON.stringify(tags)); } + // ---- connections: the Connect fold's app shortcuts, in order ---- + + // connections is the owner's list, or the defaults while there is none. + // Entries are checked against the library when they are written + // (connections.ts, parseConnections) and again when the fold resolves + // them, so a template that left the library is skipped, not shown broken. + connections(): Connection[] { + return (this.connectionList ?? DEFAULT_CONNECTIONS).map((c) => ({ ...c })); + } + setConnections(list: Connection[]) { + this.connectionList = list.map((c) => ({ ...c })); + this.sql.exec(`INSERT INTO settings(key,value) VALUES('connections',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, JSON.stringify(this.connectionList)); + } + isOwner(pubkey: string) { return this.policy.owner !== "" && this.policy.owner === pubkey; } diff --git a/test/object/connections.test.ts b/test/object/connections.test.ts new file mode 100644 index 0000000..e017dc7 --- /dev/null +++ b/test/object/connections.test.ts @@ -0,0 +1,365 @@ +// The Connect fold's shortcuts (connections.ts): the door that resolves +// them for whoever asks, the management methods that keep the owner's +// list, and the list travelling with configurations and presets. The pure +// parts are test/unit/connections.test.ts. Templates past notes, find-me +// and group are picked by what they declare, so the library can grow +// without this file naming each one. +import { SELF } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import { generateSecretKey } from "nostr-tools/pure"; +import { npubEncode, nprofileEncode } from "nostr-tools/nip19"; +import { CONNECTION_TEMPLATES, fill, type ConnectionTemplate, type Values } from "../../src/connections.ts"; +import { DEFAULT_CONNECTIONS, type Feature } from "../../src/settings.ts"; +import { PRESETS } from "../../src/presets.ts"; +import { rpc, get, pk, nip98 } from "../helpers/relay.ts"; + +// connect reads the door, signed when a key is given, with the cache header along. +async function connect(host: string, sk: Uint8Array | null = null) { + const r = await get(host, "/connect.json", sk); + return { status: r.status, cache: r.headers.get("cache-control"), cors: r.headers.get("access-control-allow-origin"), ...(await r.json()) }; +} + +// valuesOf is what the relay is expected to fill in, from its card and the +// keys, so a template's links can be resolved here and compared. +async function valuesOf(host: string, owner: Uint8Array | null, user: Uint8Array | null): Promise { + const card: any = await (await get(host, "/card.json")).json(); + const url = `wss://${host}`; + const self = card.self ?? ""; + const profile = (p: string) => (p ? nprofileEncode({ pubkey: p, relays: [url] }) : ""); + const npub = (p: string) => (p ? npubEncode(p) : ""); + const o = owner ? pk(owner) : ""; + const u = user ? pk(user) : ""; + return { + relay: { url, host, web: `https://${host}`, name: host.split(".")[0], domain: "bind.ws", hex: self, npub: npub(self), nprofile: profile(self), naddr: card.naddr ?? "" }, + owner: { hex: o, npub: npub(o), nprofile: profile(o) }, + user: user ? { hex: u, npub: npub(u), nprofile: profile(u) } : null, + }; +} + +// resolved is a template as the fold should show it: the links that fill +// in for these values, the inputs the owner set or the template's defaults. +function resolved(t: ConnectionTemplate, values: Values, inputs: Record = {}) { + const filled: Record = {}; + for (const i of t.inputs) filled[i.name] = inputs[i.name] || i.default; + const links: { label: string; href?: string; copy?: string }[] = []; + let needsUser = false; + for (const l of t.links) { + const r = fill(l.href ?? l.copy ?? "", values, filled); + if (r.missing.length) { + if (r.missing.some((m) => m.startsWith("user:"))) needsUser = true; + continue; + } + links.push(l.href ? { label: l.label, href: r.text } : { label: l.label, copy: r.text }); + } + const qr = t.qr ? fill(t.qr, values, filled) : { text: "", missing: [] as string[] }; + return { links, needsUser, qr: qr.missing.length ? "" : qr.text }; +} + +const template = (name: string) => CONNECTION_TEMPLATES.find((t) => t.name === name)!; +const names = (answer: { connections: { template: string }[] }) => answer.connections.map((c) => c.template); +const text = (l: { href?: string; copy?: string }) => l.href ?? l.copy ?? ""; +const namesUser = (l: { href?: string; copy?: string }) => text(l).includes("{user:"); +// everyInput fills each of a template's inputs, so no link is left out for want of one. +const everyInput = (t: ConnectionTemplate) => Object.fromEntries(t.inputs.map((i) => [i.name, "mine"])); + +// turnOn switches a template's feature on; the GRASP extensions need GRASP +// itself, and GRASP's own doors want open reads. +async function turnOn(host: string, owner: Uint8Array, f: Feature | undefined) { + if (!f) return; + const features: Record = { [f]: true }; + if (f.startsWith("grasp0")) features.grasp = true; + if (f === "grasp03" || f === "grasp05") features.grasp02 = true; + expect((await rpc(host, owner, "setpolicy", { reads: "open", features })).status).toBe(200); +} + +describe("connections", () => { + it("answers a visitor with the defaults, every link filled in for this relay, cacheable for a minute", async () => { + const host = "connect.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + const card: any = await (await get(host, "/card.json")).json(); + expect(card.naddr).toBeDefined(); + expect(card.nprofile).toBeDefined(); + const values = await valuesOf(host, owner, null); + const r = await connect(host); + expect(r.status).toBe(200); + expect(r.cache).toBe("public, max-age=60"); + expect(r.cors).toBe("*"); + expect(r.relay).toEqual({ url: "wss://connect.bind.ws", host, web: "https://connect.bind.ws", name: "connect" }); + expect(r.viewer).toBeNull(); + expect(names(r)).toEqual(DEFAULT_CONNECTIONS.map((c) => c.template)); + expect((await rpc(host, owner, "listconnections")).result).toEqual(DEFAULT_CONNECTIONS); + for (const c of r.connections) { + const t = template(c.template); + const want = resolved(t, values); + 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 }); + } + // The group address and the owner's profile are the card's, |enc + // percent-encodes the relay URL, and no placeholder is left. + const texts = r.connections.flatMap((c: any) => c.links.map(text)); + expect(texts.some((x: string) => x.includes(card.naddr))).toBe(true); + expect(texts.some((x: string) => x.includes(card.nprofile))).toBe(true); + expect(texts.some((x: string) => x.includes(encodeURIComponent("wss://" + host)))).toBe(true); + // The fold has one relay URL copy control of its own, so no template carries the plain URL as a copy text. + expect(r.connections.flatMap((c: any) => c.links.filter((l: any) => l.copy).map((l: any) => l.copy))).not.toContain("wss://" + host); + for (const x of texts) expect(x).not.toMatch(/[{}]/); + expect(r.connections.find((c: any) => c.template === "find-me").qr).toBe("nostr:" + card.nprofile); + expect(r.connections.find((c: any) => c.template === "group").qr).toBe("nostr:" + card.naddr); + }); + + it("names a signed viewer, keeps that answer out of shared caches, and refuses a signature that does not fit", async () => { + const host = "connect-signed.bind.ws"; + const owner = generateSecretKey(); + const viewer = generateSecretKey(); + await rpc(host, owner, "claim"); + const r = await connect(host, viewer); + expect(r.status).toBe(200); + expect(r.cache).toBe("no-store"); + expect(r.viewer).toBe(pk(viewer)); + expect(names(r)).toEqual(DEFAULT_CONNECTIONS.map((c) => c.template)); + const bad = await SELF.fetch(`http://${host}/connect.json`, { headers: { authorization: "Nostr nonsense" } }); + expect(bad.status).toBe(401); + expect((await bad.json()).error).toMatch(/^auth-required/); + // A signature for another door does not count as a visitor either. + const elsewhere = await SELF.fetch(`http://${host}/connect.json`, { headers: { authorization: await nip98(viewer, `http://${host}/card.json`) } }); + expect(elsewhere.status).toBe(401); + }); + + 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 () => { + const host = "connect-nobody.bind.ws"; + const r = await connect(host); + expect(r.status).toBe(200); + expect(r.viewer).toBeNull(); + // Every link of find-me names the owner, so the whole shortcut waits for one. + const t = template("find-me"); + expect(t.links.every((l) => text(l).includes("{owner:"))).toBe(true); + expect(r.connections.find((x: any) => x.template === "find-me")).toBeUndefined(); + // Notes names only the relay, so it shows whole; the group shows what does not need an identity. + const notes = r.connections.find((x: any) => x.template === "notes"); + expect(notes).toBeDefined(); + expect(notes.needsUser).toBe(false); + expect(notes.links.map((l: any) => l.label)).toEqual(template("notes").links.map((l) => l.label)); + for (const x of r.connections) { + expect(x.needsUser).toBe(false); + for (const l of x.links) expect(text(l)).not.toMatch(/[{}]/); + } + }); + + it("shows each shortcut to whom its visibility admits, with the owner's title and about in place of the template's", async () => { + const host = "connect-who.bind.ws"; + const owner = generateSecretKey(); + const member = generateSecretKey(); + const outsider = generateSecretKey(); + await rpc(host, owner, "claim"); + await rpc(host, owner, "setmember", pk(member), { name: "eve" }); + const list = [ + { template: "notes", visibility: "public", title: "Our feed", about: "What we post, as one feed." }, + { template: "notes", visibility: "auth", title: "Signed in" }, + { template: "group", visibility: "members", title: "Members" }, + { template: "find-me", visibility: "owner", title: "Owner" }, + ]; + const saved = await rpc(host, owner, "setconnections", list); + expect(saved.status, JSON.stringify(saved)).toBe(200); + expect(saved.result).toEqual(list); + const titles = async (sk: Uint8Array | null) => (await connect(host, sk)).connections.map((c: any) => c.title); + expect(await titles(null)).toEqual(["Our feed"]); + expect(await titles(outsider)).toEqual(["Our feed", "Signed in"]); + expect(await titles(member)).toEqual(["Our feed", "Signed in", "Members"]); + expect(await titles(owner)).toEqual(["Our feed", "Signed in", "Members", "Owner"]); + const mine = (await connect(host, owner)).connections; + expect(mine[0].about).toBe("What we post, as one feed."); + expect(mine[0].app).toBe(template("notes").app); + expect(mine[0].icon).toBe(template("notes").icon); + expect(mine[1].about).toBe(template("notes").about); + expect(mine.map((c: any) => c.visibility)).toEqual(["public", "auth", "members", "owner"]); + }); + + it("shows the defaults until the owner saves a list, nothing after an empty one, and the saved order", async () => { + const host = "connect-order.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + expect(names(await connect(host))).toEqual(["notes", "find-me", "group"]); + let r = await rpc(host, owner, "setconnections", [{ template: "group" }, { template: "notes" }]); + expect(r.status).toBe(200); + expect(r.result).toEqual([{ template: "group", visibility: "public" }, { template: "notes", visibility: "public" }]); + expect((await rpc(host, owner, "listconnections")).result).toEqual(r.result); + expect(names(await connect(host))).toEqual(["group", "notes"]); + r = await rpc(host, owner, "setconnections", []); + expect(r.status).toBe(200); + expect(r.result).toEqual([]); + expect((await rpc(host, owner, "listconnections")).result).toEqual([]); + expect((await connect(host)).connections).toEqual([]); + expect((await rpc(host, owner, "exportconfig")).result.connections).toEqual([]); + }); + + it("refuses a list that does not fit, whole, to anyone but the owner, and logs the one it saved", async () => { + const host = "connect-refuse.bind.ws"; + const owner = generateSecretKey(); + const mod = generateSecretKey(); + await rpc(host, owner, "claim"); + await rpc(host, owner, "setmember", pk(mod), { role: "moderator" }); + const before = (await rpc(host, owner, "listconnections")).result; + let r = await rpc(host, owner, "setconnections", [{ template: "notes" }, { template: "nope" }]); + expect(r.status).toBe(400); + expect(r.error).toBe("invalid: connections[1]: no connection template named nope"); + r = await rpc(host, owner, "setconnections", [{ template: "notes", visibility: "friends" }]); + expect(r.status).toBe(400); + expect(r.error).toBe("invalid: connections[0].visibility: must be one of public, auth, members, owner"); + r = await rpc(host, owner, "setconnections", [{ template: "notes", inputs: { repo: "x" } }]); + expect(r.status).toBe(400); + expect(r.error).toBe("invalid: connections[0].inputs.repo: notes has no such input"); + r = await rpc(host, owner, "setconnections", [{ template: "repos", inputs: { repo: "kid's-project" } }]); + expect(r.status).toBe(400); + expect(r.error).toMatch(/^invalid: connections\[0\]\.inputs\.repo: must match /); + r = await rpc(host, owner, "setconnections", { template: "notes" }); + expect(r.status).toBe(400); + expect(r.error).toBe("invalid: connections must be a list"); + expect((await rpc(host, owner, "setconnections")).status).toBe(400); + expect((await rpc(host, mod, "setconnections", [])).status).toBe(403); + expect((await rpc(host, null, "setconnections", [])).status).toBe(401); + expect((await rpc(host, owner, "listconnections")).result).toEqual(before); + expect((await rpc(host, mod, "listconnections")).status).toBe(200); + expect((await rpc(host, mod, "listconnectiontemplates")).status).toBe(200); + // The moderation log has the saved list and none of the refusals. + const list = [{ template: "group", visibility: "members", title: "Room" }]; + expect((await rpc(host, owner, "setconnections", list)).status).toBe(200); + const audit = (await rpc(host, owner, "listaudit")).result as { actor: string; action: string; detail: string }[]; + expect(audit[0].action).toBe("setconnections"); + expect(audit[0].actor).toBe(pk(owner)); + expect(JSON.parse(audit[0].detail)).toEqual(list); + expect(audit.filter((row) => row.action === "setconnections").length).toBe(1); + }); + + const featured = CONNECTION_TEMPLATES.find((t) => t.feature); + it.skipIf(!featured)("lists the library with whether each template's feature is on, and skips a shortcut whose feature is off", async () => { + const host = "connect-feature.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + const t = featured!; + const f = t.feature!; + expect((await rpc(host, owner, "setpolicy", { features: { [f]: false } })).status).toBe(200); + let lib = (await rpc(host, owner, "listconnectiontemplates")).result as (ConnectionTemplate & { available: boolean })[]; + expect(lib.map((x) => x.name)).toEqual(CONNECTION_TEMPLATES.map((x) => x.name)); + expect(lib.find((x) => x.name === "notes")).toEqual({ ...template("notes"), available: true }); + expect(lib.find((x) => x.name === t.name)!.available).toBe(false); + for (const x of lib) if (!x.feature) expect(x.available, x.name).toBe(true); + expect((await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs: everyInput(t) }, { template: "notes" }])).status).toBe(200); + expect(names(await connect(host, owner))).toEqual(["notes"]); + await turnOn(host, owner, f); + lib = (await rpc(host, owner, "listconnectiontemplates")).result; + expect(lib.find((x) => x.name === t.name)!.available).toBe(true); + expect(names(await connect(host, owner))).toEqual([t.name, "notes"]); + }); + + const personal = CONNECTION_TEMPLATES.find((t) => t.links.some((l) => text(l).includes("{user:npub"))); + it.skipIf(!personal)("fills {user:*} with whoever signed the request, and for a visitor leaves those links out and says so", async () => { + const host = "connect-user.bind.ws"; + const owner = generateSecretKey(); + const viewer = generateSecretKey(); + await rpc(host, owner, "claim"); + const t = personal!; + await turnOn(host, owner, t.feature); + const inputs = everyInput(t); + expect((await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs }])).status).toBe(200); + const visitor = (await connect(host)).connections; + expect(visitor.length).toBe(1); + expect(visitor[0].needsUser).toBe(true); + expect(visitor[0].links).toEqual(resolved(t, await valuesOf(host, owner, null), inputs).links); + expect(visitor[0].links.map((l: any) => l.label)).toEqual(t.links.filter((l) => !namesUser(l)).map((l) => l.label)); + for (const l of visitor[0].links) expect(text(l)).not.toContain("{"); + // Signed in: the viewer's own key, not the owner's, and nothing missing. + const mine = (await connect(host, viewer)).connections; + expect(mine.length).toBe(1); + expect(mine[0].needsUser).toBe(false); + expect(mine[0].links).toEqual(resolved(t, await valuesOf(host, owner, viewer), inputs).links); + expect(mine[0].links.map((l: any) => l.label)).toEqual(t.links.map((l) => l.label)); + const own = t.links.find((l) => text(l).includes("{user:npub"))!; + const link = mine[0].links.find((l: any) => l.label === own.label)!; + expect(text(link)).toContain(npubEncode(pk(viewer))); + expect(text(link)).not.toContain(npubEncode(pk(owner))); + }); + + const withInput = CONNECTION_TEMPLATES.find((t) => t.inputs.length > 0); + it.skipIf(!withInput)("puts the owner's input into the link, and the template's default when the input is blank", async () => { + const host = "connect-input.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + const t = withInput!; + await turnOn(host, owner, t.feature); + const i = t.inputs[0]; + const named = t.links.find((l) => new RegExp(`\\{input:${i.name}(\\|enc)?\\}`).test(text(l)))!; + expect(named, `${t.name} has a link naming {input:${i.name}}`).toBeDefined(); + let r = await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs: { [i.name]: " rocket-7 " } }]); + expect(r.status, JSON.stringify(r)).toBe(200); + expect(r.result[0].inputs).toEqual({ [i.name]: "rocket-7" }); + let c = (await connect(host, owner)).connections[0]; + expect(c.template).toBe(t.name); + expect(text(c.links.find((l: any) => l.label === named.label)!)).toContain("rocket-7"); + expect(c.links).toEqual(resolved(t, await valuesOf(host, owner, owner), { [i.name]: "rocket-7" }).links); + // Blank: not kept, and the template's default fills the link. + r = await rpc(host, owner, "setconnections", [{ template: t.name, visibility: "public", inputs: { [i.name]: " " } }]); + expect(r.status).toBe(200); + expect(r.result[0].inputs).toBeUndefined(); + c = (await connect(host, owner)).connections[0]; + expect(c.links).toEqual(resolved(t, await valuesOf(host, owner, owner), {}).links); + const fallback = c.links.find((l: any) => l.label === named.label); + if (i.default) expect([i.default, encodeURIComponent(i.default)].some((d) => text(fallback!).includes(d))).toBe(true); + else expect(fallback).toBeUndefined(); + }); + + it("travels with the configuration: exported, planned as one summary line with warnings, applied, and left alone when the document has no section", async () => { + const a = "connect-cfg-a.bind.ws", b = "connect-cfg-b.bind.ws"; + const owner = generateSecretKey(); + await rpc(a, owner, "claim"); + await rpc(b, owner, "claim"); + const mine = [{ template: "group", visibility: "members", title: "Room" }, { template: "notes", visibility: "public" }]; + expect((await rpc(a, owner, "setconnections", mine)).status).toBe(200); + const cfg = (await rpc(a, owner, "exportconfig")).result; + expect(cfg.connections).toEqual(mine); + cfg.connections.push({ template: "nope" }); + let dry = (await rpc(b, owner, "importconfig", cfg, { dryRun: true })).result; + expect(dry.warnings).toEqual(["connections[2]: no connection template named nope"]); + expect(dry.changes.connections).toEqual({ add: [mine[0]], remove: [{ template: "find-me", visibility: "public" }, { template: "group", visibility: "public" }], reordered: false }); + expect(dry.changes.summary.filter((l: string) => l.startsWith("connections:"))).toEqual(["connections: +group (members), -find-me, -group"]); + expect((await rpc(b, owner, "listconnections")).result).toEqual(DEFAULT_CONNECTIONS); + expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200); + expect((await rpc(b, owner, "listconnections")).result).toEqual(mine); + expect(names(await connect(b, owner))).toEqual(["group", "notes"]); + // No section: the list stays. The same list in another order is a reorder. An empty list clears it. + delete cfg.connections; + expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200); + expect((await rpc(b, owner, "listconnections")).result).toEqual(mine); + cfg.connections = [mine[1], mine[0]]; + dry = (await rpc(b, owner, "importconfig", cfg, { dryRun: true })).result; + expect(dry.changes.connections).toEqual({ add: [], remove: [], reordered: true }); + expect(dry.changes.summary.filter((l: string) => l.startsWith("connections:"))).toEqual(["connections: reordered"]); + expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200); + expect((await rpc(b, owner, "listconnections")).result).toEqual([mine[1], mine[0]]); + cfg.connections = []; + dry = (await rpc(b, owner, "importconfig", cfg, { dryRun: true })).result; + expect(dry.changes.summary.filter((l: string) => l.startsWith("connections:"))).toEqual(["connections: -notes, -group (members)"]); + expect((await rpc(b, owner, "importconfig", cfg)).status).toBe(200); + expect((await rpc(b, owner, "listconnections")).result).toEqual([]); + expect((await rpc(b, owner, "importconfig", { format: cfg.format, connections: "notes" })).status).toBe(400); + }); + + it("is set by a preset that has a connections section and left alone by one that has not", async () => { + const host = "connect-presets.bind.ws"; + const owner = generateSecretKey(); + await rpc(host, owner, "claim"); + const mine = [{ template: "group", visibility: "members", title: "Room" }]; + expect((await rpc(host, owner, "setconnections", mine)).status).toBe(200); + const plain = PRESETS.find((p) => !p.connections)!; + expect(plain).toBeDefined(); + expect((await rpc(host, owner, "applypreset", plain.name, plain.source === "required" ? { source: "wss://elsewhere.bind.ws" } : undefined)).status).toBe(200); + expect((await rpc(host, owner, "listconnections")).result).toEqual(mine); + for (const p of PRESETS.filter((p) => p.connections)) { + const r = await rpc(host, owner, "applypreset", p.name, p.source === "required" ? { source: "wss://elsewhere.bind.ws" } : undefined); + expect(r.status, p.name + " " + JSON.stringify(r)).toBe(200); + expect((await rpc(host, owner, "listconnections")).result, p.name).toEqual(p.connections); + expect((await rpc(host, owner, "exportconfig")).result.connections, p.name).toEqual(p.connections); + } + const listed = (await rpc(host, owner, "listpresets")).result as { name: string; connections?: unknown }[]; + for (const x of listed) expect(x.connections, x.name).toEqual(PRESETS.find((p) => p.name === x.name)?.connections); + }); +}); diff --git a/test/object/exposure.test.ts b/test/object/exposure.test.ts index b0b7be1..3761dd8 100644 --- a/test/object/exposure.test.ts +++ b/test/object/exposure.test.ts @@ -33,6 +33,8 @@ async function seed(host: string): Promise { const stranger = generateSecretKey(); await rpc(host, owner, "claim"); expect((await rpc(host, owner, "setmember", pk(eve), { name: "evelynq7" })).status).toBe(200); + // Two shortcuts with the owner's own titles: one for members, one for the owner alone. + 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); const body = new TextEncoder().encode("eve's quarterly numbers"); const sha = bytesToHex(sha256(body)); 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 { await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), (r) => r.syncSites()); c.ws.close(); await rpc(host, owner, "setpolicy", { reads: "members", writes: "allowlist", directoryPublic: false }); - return { host, siteHost: siteLabel(site) + ".bind.ws", owner, eve, stranger, sha, noteId: note.id, secrets: [pk(eve), npubEncode(pk(eve)), note.id, sha, "evelynq7"] }; + 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"] }; } // 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 { path: "/card.json", gated: false }, { path: "/card.nostr", gated: false }, { path: "/card.svg", gated: false }, + { path: "/connect.json", gated: false }, { path: `/e/${f.noteId}`, gated: false }, { path: `/feed.xml?author=${pk(f.eve)}`, gated: false }, { path: "/terms", gated: false }, @@ -229,6 +232,12 @@ describe("the read rule at every door", () => { } r = await get(`/people`, await nip98(f.eve, url(`/people`))); expect(JSON.parse(r.text).people.map((m: any) => m.pubkey)).toContain(pk(f.eve)); + // The Connect fold shows her the members' shortcut and not the owner's. + r = await get(`/connect.json`, await nip98(f.eve, url(`/connect.json`))); + expect(r.status).toBe(200); + const titles = JSON.parse(r.text).connections.map((c: any) => c.title); + expect(titles).toContain("membersroomq7"); + expect(titles).not.toContain("ownerdeskq7"); const c = await WS.connect(f.host); await c.auth(f.eve, f.host); expect((await c.open("r", { authors: [pk(f.eve)], kinds: [1] })).events.map((e) => e.id)).toEqual([f.noteId]); diff --git a/test/object/presets.test.ts b/test/object/presets.test.ts index 4d28300..f5651e0 100644 --- a/test/object/presets.test.ts +++ b/test/object/presets.test.ts @@ -11,7 +11,7 @@ describe("presets", () => { const owner = generateSecretKey(); await rpc(host, owner, "claim"); const list = (await rpc(host, owner, "listpresets")).result; - expect(list.map((p: any) => p.name)).toEqual(["default", "outbox", "inbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site", "marmot", "grasp", "marmot-members"]); + expect(list.map((p: any) => p.name)).toEqual(["default", "outbox", "inbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site", "marmot", "grasp", "marmot-members", "home"]); for (const p of list) expect(p.about.length).toBeGreaterThan(10); expect(list.find((p: any) => p.name === "search").source).toBe("required"); expect(list.find((p: any) => p.name === "articles").source).toBe("optional"); diff --git a/test/object/templates.test.ts b/test/object/templates.test.ts index 57c5cd8..6ea8e97 100644 --- a/test/object/templates.test.ts +++ b/test/object/templates.test.ts @@ -26,7 +26,7 @@ describe("templates", () => { 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)); expect((await get(host, "/.well-known/nostr/nip96.json")).status).toBe(features.files ? 200 : 404); expect((await get(host, "/feed.xml")).status).toBe(features.pages && p.reads === "open" ? 200 : 404); - 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; + 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; expect(dry.changes.summary, p.name).toEqual([]); expect(dry.warnings, p.name).toEqual([]); } diff --git a/test/unit/connections.test.ts b/test/unit/connections.test.ts new file mode 100644 index 0000000..0a46957 --- /dev/null +++ b/test/unit/connections.test.ts @@ -0,0 +1,206 @@ +// The Connect fold's pure parts (connections.ts): a text's placeholders +// filled for a viewer, a library document read or refused, an owner's list +// read with warnings, and the library itself. The door and the settings +// behind it are test/object/connections.test.ts. +import { describe, it, expect } from "vitest"; +import { CONNECTION_TEMPLATES, FORMAT, MAX_CONNECTIONS, fill, parseConnectionTemplate, parseConnections, type Values } from "../../src/connections.ts"; + +const values: Values = { + 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" }, + owner: { hex: "cd".repeat(32), npub: "npub1owner", nprofile: "nprofile1owner" }, + user: null, +}; + +// A library document that every relay can fill in. +const good = { + format: FORMAT, + title: "Feed", + about: "The relay as a feed.", + app: "Jumble", + where: "web", + icon: "notes", + links: [ + { label: "Open", href: "https://jumble.social/?r={relay:url|enc}" }, + { label: "Copy relay URL", copy: "{relay:url}" }, + ], +}; + +describe("fill", () => { + it("resolves every source, percent-encodes with |enc, and reads inputs by name", () => { + const r = fill("{relay:web}/p/{owner:npub}?r={relay:url|enc}&repo={input:repo}", values, { repo: "mine" }); + expect(r).toEqual({ text: "https://club.bind.ws/p/npub1owner?r=wss%3A%2F%2Fclub.bind.ws&repo=mine", missing: [] }); + expect(fill("plain text", values, {})).toEqual({ text: "plain text", missing: [] }); + expect(fill("{relay:naddr}", values, {}).text).toBe("naddr1room"); + expect(fill("{owner:hex|enc}", values, {}).text).toBe("cd".repeat(32)); + }); + + it("lists what nobody is signed in for as user:npub and leaves the text without it", () => { + expect(fill("nostr:{user:npub}", values, {})).toEqual({ text: "nostr:", missing: ["user:npub"] }); + const signed = { ...values, user: { hex: "ef".repeat(32), npub: "npub1viewer", nprofile: "nprofile1viewer" } }; + expect(fill("nostr:{user:npub}", signed, {})).toEqual({ text: "nostr:npub1viewer", missing: [] }); + }); + + it("lists an empty owner, an unset input and an unknown field as missing rather than filling a blank", () => { + const unclaimed = { ...values, owner: { hex: "", npub: "", nprofile: "" } }; + expect(fill("https://primal.net/p/{owner:nprofile}", unclaimed, {})).toEqual({ text: "https://primal.net/p/", missing: ["owner:nprofile"] }); + expect(fill("{relay:url}/{input:repo}.git", values, {}).missing).toEqual(["input:repo"]); + expect(fill("{relay:url}/{input:repo}.git", values, { repo: "" }).missing).toEqual(["input:repo"]); + expect(fill("{owner:npub} {user:hex}", unclaimed, {}).missing).toEqual(["owner:npub", "user:hex"]); + }); +}); + +describe("parseConnectionTemplate", () => { + it("accepts a good document, defaults the visibility to public and the QR to the first href", () => { + const t = parseConnectionTemplate("feed", good); + expect(typeof t).toBe("object"); + if (typeof t === "string") return; + expect(t.name).toBe("feed"); + expect(t.title).toBe("Feed"); + expect(t.visibility).toBe("public"); + expect(t.icon).toBe("notes"); + expect(t.feature).toBeUndefined(); + expect(t.inputs).toEqual([]); + expect(t.links).toEqual(good.links); + expect(t.qr).toBe("https://jumble.social/?r={relay:url|enc}"); + }); + + it("takes the QR the document names, an icon default, a feature and declared inputs", () => { + 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" }] }); + expect(typeof t).toBe("object"); + if (typeof t === "string") return; + expect(t.icon).toBe("app"); + expect(t.feature).toBe("grasp"); + expect(t.visibility).toBe("auth"); + expect(t.qr).toBe("nostr:{owner:nprofile}"); + expect(t.inputs).toEqual([{ name: "repo", label: "Repository", placeholder: "name", default: "notes", pattern: "" }]); + expect(t.links).toEqual([{ label: "Clone", copy: "{relay:web}/{user:npub}/{input:repo}.git" }]); + }); + + it("refuses what is not a template, another format, and a document short of a title, an about or an app", () => { + expect(parseConnectionTemplate("x", null)).toBe("not a connection template"); + expect(parseConnectionTemplate("x", [])).toBe("not a connection template"); + expect(parseConnectionTemplate("x", { ...good, format: "bind.ws/connection-template/2" })).toBe("format must be " + FORMAT); + expect(parseConnectionTemplate("x", { ...good, about: " " })).toBe("needs a title, an about and an app"); + }); + + it("refuses a link that is not https or nostr, so an owner cannot smuggle a scheme in", () => { + const r = parseConnectionTemplate("x", { ...good, links: [{ label: "Run", href: "javascript:alert(1)" }] }); + expect(r).toMatch(/^link Run must be https:\/\/ or nostr:/); + expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Data", href: "data:text/html,hi" }] })).toMatch(/must be https/); + // Plain http is for a resolved {relay:web} on a local host, not for a template. + expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Plain", href: "http://insecure.example/" }] })).toBe("link Plain must be https:// or nostr:"); + expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Copy", copy: "javascript:alert(1)" }] })).not.toBeTypeOf("string"); + }); + + it("refuses a placeholder the relay cannot fill and an input the document does not declare", () => { + 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/); + expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Open", href: "https://a.example/{input:x}" }] })).toBe("link Open names {input:x} but declares no such input"); + expect(parseConnectionTemplate("x", { ...good, qr: "nostr:{user:nonsense}" })).toMatch(/^qr names \{user:nonsense\}/); + 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"); + }); + + it("refuses a link with both an href and a copy text, with neither, without a label, and a list of none", () => { + 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"); + expect(parseConnectionTemplate("x", { ...good, links: [{ label: "Neither" }] })).toBe("link Neither needs an href or a copy text, not both"); + expect(parseConnectionTemplate("x", { ...good, links: [{ href: "https://a.example/" }] })).toBe("a link needs a label"); + expect(parseConnectionTemplate("x", { ...good, links: [] })).toMatch(/^links must be a list of 1 to/); + }); + + it("takes an input's pattern and refuses one that does not compile", () => { + 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'" }] }); + expect(typeof t).toBe("object"); + if (typeof t === "string") return; + expect(t.inputs[0].pattern).toBe("^[a-z0-9-]{1,64}$"); + expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "Repository", pattern: "([" }] })).toBe("input repo has a pattern that does not compile"); + }); + + it("refuses an unknown icon, feature or visibility and a badly named input", () => { + expect(parseConnectionTemplate("x", { ...good, icon: "rocket" })).toMatch(/^icon must be one of/); + expect(parseConnectionTemplate("x", { ...good, feature: "teleport" })).toMatch(/^feature must be one of/); + expect(parseConnectionTemplate("x", { ...good, visibility: "friends" })).toBe("visibility must be one of public, auth, members, owner"); + expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "Repo Name", label: "Repository" }] })).toBe("an input name is lowercase letters, digits, dash and underscore"); + expect(parseConnectionTemplate("x", { ...good, inputs: [{ name: "repo", label: "One" }, { name: "repo", label: "Two" }] })).toBe("input repo is declared twice"); + }); +}); + +describe("parseConnections", () => { + const withInput = CONNECTION_TEMPLATES.find((t) => t.inputs.length > 0); + + it("keeps the order given, fills the template's default visibility and trims the words", () => { + const r = parseConnections([{ template: "group", visibility: "members", title: " The room ", about: " Where we talk. " }, { template: "notes" }]); + expect(r).toEqual({ list: [{ template: "group", visibility: "members", title: "The room", about: "Where we talk." }, { template: "notes", visibility: "public" }], warnings: [] }); + }); + + it("drops an unknown template with a warning and keeps the rest in order", () => { + const r = parseConnections([{ template: "notes" }, { template: "nope" }, { template: "group" }, {}, null]); + expect(typeof r).toBe("object"); + if (typeof r === "string") return; + expect(r.list.map((c) => c.template)).toEqual(["notes", "group"]); + 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)"]); + }); + + it("keeps a bad visibility or an undeclared input as a warning, and the entry with the template's own values", () => { + const r = parseConnections([{ template: "notes", visibility: "friends", inputs: { x: "y" } }, { template: "group", inputs: "repo" }]); + expect(typeof r).toBe("object"); + if (typeof r === "string") return; + expect(r.list).toEqual([{ template: "notes", visibility: "public" }, { template: "group", visibility: "public" }]); + 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"]); + }); + + it.skipIf(!withInput)("trims an input's value, drops a blank one and warns about one that is not a string", () => { + const t = withInput!; + const name = t.inputs[0].name; + const r = parseConnections([{ template: t.name, inputs: { [name]: " mine " } }, { template: t.name, inputs: { [name]: " " } }, { template: t.name, inputs: { [name]: 7 } }]); + expect(typeof r).toBe("object"); + if (typeof r === "string") return; + expect(r.list[0].inputs).toEqual({ [name]: "mine" }); + expect(r.list[1].inputs).toBeUndefined(); + expect(r.list[2].inputs).toBeUndefined(); + expect(r.warnings).toEqual([`connections[2].inputs.${name}: must be a string`]); + }); + + const patterned = CONNECTION_TEMPLATES.find((t) => t.inputs.some((i) => i.pattern)); + 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", () => { + const t = patterned!; + const i = t.inputs.find((x) => x.pattern)!; + 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" } }]); + expect(typeof r).toBe("object"); + if (typeof r === "string") return; + expect(r.list[0].inputs).toBeUndefined(); + expect(r.list[1].inputs).toBeUndefined(); + expect(r.list[2].inputs).toEqual({ [i.name]: "my-project" }); + expect(r.warnings).toEqual([`connections[0].inputs.${i.name}: must match ${i.pattern}`, `connections[1].inputs.${i.name}: must match ${i.pattern}`]); + }); + + it("caps the list at 24 with a warning naming the first entry past it", () => { + const r = parseConnections(Array.from({ length: MAX_CONNECTIONS + 2 }, () => ({ template: "notes" }))); + expect(typeof r).toBe("object"); + if (typeof r === "string") return; + expect(r.list.length).toBe(MAX_CONNECTIONS); + expect(r.warnings).toEqual([`connections[${MAX_CONNECTIONS}]: at most ${MAX_CONNECTIONS} connections`]); + }); + + it("is the reason when the list is not one, under the label the caller gave", () => { + expect(parseConnections({ template: "notes" })).toBe("invalid: connections must be a list"); + expect(parseConnections("notes", "shortcuts")).toBe("invalid: shortcuts must be a list"); + const r = parseConnections([{ template: "nope" }], "shortcuts"); + expect(typeof r === "string" ? [] : r.warnings).toEqual(["shortcuts[0]: no connection template named nope"]); + }); +}); + +describe("the library", () => { + it("has notes, find-me and group, and every template has a unique name, an about and at least one link", () => { + const names = CONNECTION_TEMPLATES.map((t) => t.name); + expect(names).toContain("notes"); + expect(names).toContain("find-me"); + expect(names).toContain("group"); + expect(new Set(names).size).toBe(names.length); + for (const t of CONNECTION_TEMPLATES) { + expect(t.links.length, t.name).toBeGreaterThan(0); + expect(t.about.length, t.name).toBeGreaterThan(0); + expect(t.title.length, t.name).toBeGreaterThan(0); + expect(t.app.length, t.name).toBeGreaterThan(0); + for (const l of t.links) expect(Boolean(l.href) !== Boolean(l.copy), `${t.name}: ${l.label}`).toBe(true); + } + }); +});