clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

grasp: relays host bounded Git repositories through ntig with signed repository state

FelineStateMachine committed 8f146fd
commit
8f146fd30c7b93ca460f013d644e848450f5746a
parent
3d96d168d46b38f97bb99358b1642a54d9cd1059
author
FelineStateMachine <dami.etoile@gmail.com>
41 files changed, +1631 -63
MREADME.md17+-
Mdocs/01-relay-configuration.md2+
Mdocs/02-understanding-fuel.md48++++-
Mdocs/04-data-and-names.md14+-
Mdocs/05-your-relay-on-the-web.md23++
Mdocs/10-architecture.md7+
Mdocs/11-hosting-bindws.md2+-
Mdocs/12-develop-extend.md13+-
Mdocs/14-http-reference.md35+++
Mdocs/15-costs-and-margins.md50++++-
Mdocs/20-nip-5a-static-websites.md4+-
Adocs/22-grasp-01-git-hosting.md174+++++++++++++++++
Mpackage-lock.json91+++++++--
Mpackage.json3+-
Mrelay-config.schema.json3+-
Arelay-templates/13-grasp.jsonc25++
Mrelay-templates/README.md1+
Msrc/console/console.html9+
Msrc/console/console.js6+
Msrc/gates.ts4+
Msrc/gen/console.ts4+-
Msrc/gen/templates.ts23++
Asrc/grasp-policy.ts226++++++++++++++++++++++
Asrc/grasp-state.ts102++++++++++
Asrc/grasp.ts175+++++++++++++++++
Msrc/kinds.ts7+
Msrc/landing.ts22+-
Msrc/nip11.ts7+-
Msrc/relay.ts29++-
Msrc/routes.ts2+
Msrc/settings.ts5+-
Msrc/store.ts3+
Atest/conformance/grasp.test.ts110+++++++++++
Mtest/object/exposure.test.ts1+
Mtest/object/features.test.ts16+-
Atest/object/grasp.test.ts302++++++++++++++++++++++++++++++
Mtest/object/presets.test.ts2+-
Atest/unit/grasp-policy.test.ts103++++++++++
Avendor/README.md24++
Avendor/ntig-0.1.1.tgzbin
Avendor/ntig-source.tar.gzbin
M README.md +12 -5
@@ -1,7 +1,7 @@
11 <p align="center"><img src="docs/img/wordmark.svg" alt="bind.ws" width="320"></p>22 <p align="center">Relay on demand. Sign once, and it's yours.</p>33 4-Every name under bind.ws is a nostr relay. Claim one with your nostr key, point any client at `wss://<name>.bind.ws` and it's yours. An idle relay costs nothing. A busy one pays its way with zaps.4+Every name under bind.ws is a nostr relay. Claim one with your nostr key, point any client at `wss://<name>.bind.ws` and it's yours. A relay within its fuel allowances owes nothing. Use beyond them is paid with zaps; retained storage and scheduled work still count when nobody is connected.55 66 Not ready to pick a name? Ask for one:77
@@ -17,18 +17,22 @@ That answers with a relay at a memorable name, open to anyone for 14 days. Claim
1717 1818 - **A relay.** The standard protocol plus search, counts, sync, expiry, deletion, proof of work, a discovery record for relay directories and an HTTP door for scripts. Works with every client. Everything past the core, search, sync, counts, the discovery record, names, files, pages and signer traffic, switches off per relay.1919 - **A community.** Members, invite links, an invite tree, moderators with a log of what they did, a signed roster and one NIP-29 group per relay, so group-aware clients show it as a place.20-- **A site.** Notes and articles as pages with link previews, an Atom feed, and a card with a QR of the group address.20+- **A site.** Notes and articles as pages with link previews, an Atom feed, and a card with a QR of the group address. NIP-5A also serves root, named and snapshot websites from signed manifests on isolated hostnames, with verified files, optional mirroring and sign-in that follows the relay's read rule.21+- **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.22+- **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 and transaction limits](docs/22-grasp-01-git-hosting.md#git-http).2123 - **A file host.** Blossom and NIP-96 on the same bucket, with mirroring, pre-flight checks and file reports.22-- **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.24+- **A console.** Nine tabs on the relay's own page, signed by a browser extension or a remote signer app on a phone. Presets set up an outbox, an inbox, a private relay, a chat, a search replica, a media host or a quiet relay with every costly feature off, in one click. The template collection also includes static sites, Marmot transport and Git repositories.2325 - **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/`.2426 - **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.25-- **Your own domain.** `wss://relay.example.com` onto your relay with one CNAME.27+- **Your own domain.** `wss://relay.example.com` onto your relay with one CNAME, or a custom hostname targeting one of your static sites.2628 - **Succession.** Name an heir. If your key goes silent, the relay warns you for a month and then hands itself over.2729 2830 <p align="center"><img src="docs/img/console.png" alt="The console's People tab" width="720"></p>2931 3032 It runs on Cloudflare Workers with one Durable Object per name, SQLite inside it and R2 beside it. The same code runs without Cloudflare, on [celld](https://celld.dev) with an S3 bucket of your own; that path is checked on demand and [documented](docs/16-hosting-without-cloudflare.md).3133 34+Site hosting and mirroring are on by default; Marmot and GRASP are off until enabled. Their storage shares the existing fuel allowances: manifests and messages count as events, while mirrored files and retained Git objects count as files. The Health tab and public `/fuel` endpoint show aggregate usage, prices and balance. Traffic is visible but uncharged; provider operations are not itemized there. See [Understanding fuel](docs/02-understanding-fuel.md) and [Costs and margins](docs/15-costs-and-margins.md).35+3236 ## Docs3337 3438 **For users**
@@ -38,7 +42,7 @@ It runs on Cloudflare Workers with one Durable Object per name, SQLite inside it
3842 - [Understanding fuel](docs/02-understanding-fuel.md): what is measured, what is free, what a zap buys.3943 - [People and groups](docs/03-people-and-groups.md): members, invites, moderators, groups, handing over.4044 - [Data and names](docs/04-data-and-names.md): jobs, dumps, presets, forks, leaving.41-- [Your relay on the web](docs/05-your-relay-on-the-web.md): pages, feed, card, custom domain, media.45+- [Your relay on the web](docs/05-your-relay-on-the-web.md): pages, static sites, Git hosting, feed, card, custom domains and media.4246 4347 **For scripts and agents**4448
@@ -56,6 +60,7 @@ It runs on Cloudflare Workers with one Durable Object per name, SQLite inside it
5660 ### Draft NIPs5761 5862 - [NIP-5A static websites](docs/20-nip-5a-static-websites.md): publish and serve static sites from signed Nostr manifests.63+- [GRASP-01 Git hosting](docs/22-grasp-01-git-hosting.md): host bounded NIP-34 repositories through Git Smart HTTP.5964 6065 ### Protocol guides6166
@@ -75,6 +80,8 @@ npm run deploy
7580 7681 NIP-01, 05, 09, 11, 13, 17, 29, 40, 42, 43, 45, 46 transport, 50, 56, 57, 59, 62, 66, 67, 70, 77, 86, 94, 96, 98, Marmot transport and Blossom BUD-01, 02, 04, 06, 08, 09. A relay's information document lists what its owner left on.7782 83+NIP-5A site discovery uses the `nsites` field. GRASP advertises `supported_grasps: ["GRASP-01"]` only while enabled with open reads. Later GRASP specifications and server-side MLS group management are outside this implementation.84+7885 ## License7986 8087 MIT
M docs/01-relay-configuration.md +2 -0
@@ -69,6 +69,8 @@ The per-address limit is what stops a client from opening many sockets to multip
6969 7070 **Features** are the doors and the costs an owner may do without. Search indexes the prose kinds by default (notes, threads, comments, highlights, articles, wiki pages); *full* indexes every public kind with content, and *off* refuses search filters. A change applies to events from then on; the index is not rebuilt. Sync (NIP-77) reads the whole matching set per reconciliation, counts (NIP-45) keep sketches, the discovery record (NIP-66) is what crawlers find, names (NIP-05) publish members under the domain, files are the Blossom and NIP-96 doors, pages are notes and articles as web pages with the feed, and signer traffic is NIP-46 carried for anyone. What is off leaves `supported_nips`, answers 404 at its door, and is refused at the socket with an `unsupported:` reason. The bridge, groups, the roster, reports and management are not features: the console runs on them.7171 72+Three features add bounded services beside the relay feed. **Sites** is on by default and **Mirror site files** is on by default inside it: members can publish NIP-5A manifests, and the relay can verify and retain their files or fetch a verified cache miss from the manifest's sources. A site hostname is isolated from the relay's event and management doors. The [NIP-5A static websites](20-nip-5a-static-websites.md) guide describes the origins and custom-domain choices. **Marmot** is off by default and carries signed KeyPackages and opaque MLS group envelopes, with the ordinary write, read, ban, rate and fuel rules still applied. **GRASP Git hosting** is off by default, needs open reads, and admits repository announcements, state and related NIP-34 events before exposing Git Smart HTTP; the [GRASP-01 Git hosting](22-grasp-01-git-hosting.md) guide lists its bounded repository and object rules. The `marmot` and `grasp` templates select these features for you.73+7274 ### Presets7375 7476 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** is what a fresh claim has. **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.
M docs/02-understanding-fuel.md +44 -4
@@ -9,7 +9,9 @@ Your relay is free until it passes a monthly allowance. Past that, use is paid f
99 1010 ## What is measured1111 12-Four things cost the host money, and those four are measured. Each mirrors a line on the hosting bill.12+Four resource lines are measured and priced in fuel. Each mirrors a major line13+on the hosting bill, while provider request charges and other deployment costs14+remain outside the tenant meter.1315 1416 | Measured | Free each month |1517 |---|---|
@@ -18,14 +20,41 @@ Four things cost the host money, and those four are measured. Each mirrors a lin
1820 | Time awake | 100 hours |1921 | Rows written | 250,000 |2022 21-Traffic in and out is free and not counted. The console shows it so you can see what the relay does.23+Traffic bytes in and out have no fuel price. The console shows them so you can24+see what the relay does. The host still pays any provider request or platform25+charge attached to moving or storing those bytes.2226 23-**Time awake** is how long the relay is in memory. It sleeps between messages and wakes when one arrives, so a relay nobody uses is awake for zero hours. A relay with a client connected all day is awake only while messages pass, plus a few seconds after each. Jobs on the Sync tab, pulls, backfills and rebroadcasts, wake the relay on their own and spend this line while they run; a standing job spends it on every run.27+**Time awake** estimates how long the relay is in memory from its entrypoints.28+It can sleep between messages, while alarms and scheduled jobs wake it even29+when no client is connected. A relay with a client connected all day is awake only while messages30+pass, plus a few seconds after each. Jobs on the Sync tab, pulls, backfills and31+rebroadcasts wake the relay on their own and spend this line while they run; a32+standing job spends it on every run. Git requests and site work also wake the33+relay, although the exact provider duration can differ from the relay's34+entrypoint meter.2435 2536 Dumps, the JSONL files the Data tab can write on a schedule, live next to uploaded files and count as files stored. What each job and dump does is in [Data and names](04-data-and-names.md).2637 2738 **Rows written** is database work. A note costs a few rows. Reactions and other small events cost about the same.2839 40+The newer doors use these same meters. NIP-5A files mirrored into the relay and41+GRASP Git objects are part of **Files stored**; a site cache miss, site mirror,42+Git clone or Git push also wakes the relay and can write events or accounting43+rows. Marmot KeyPackages and encrypted group envelopes are ordinary stored44+events, so they use **Events stored** and **Rows written**. HTTP request and45+response bytes are shown as traffic for your information, but traffic has no46+fuel price. There is no separate tenant charge for a site, Marmot or GRASP47+request. The relay's existing write, read, storage and rate rules still decide48+whether the request is admitted.49+50+GRASP allows 4 MiB per pack, 16 MiB packed history and 128 transactions per51+repository, with at most 16 repositories and 320 MiB retained Git data per52+relay. These limits share the existing file allowance rather than adding a53+new one. Signed events wait for their required Git objects before publication.54+Deleting a ref or expiring metadata does not reclaim immutable Git history;55+those bytes remain in file storage until the relay is deleted. See56+[GRASP-01 Git hosting](22-grasp-01-git-hosting.md) for the full limits.57+2958 ## Prices past the allowance3059 3160 | Measured | Price |
@@ -45,8 +74,19 @@ You do not have to be the owner. Anyone can zap any relay.
4574 4675 ## Running out4776 48-When a relay is over an allowance with no balance, it goes read-only. Clients get a clear message when they try to publish. Nothing is deleted. Reading, searching and syncing keep working. One zap lifts the restriction.77+When a relay is over an allowance with no balance, it goes read-only. Clients78+get a clear message when they try to publish. Nothing is deleted. Ordinary79+reading, searching and syncing keep working, while GRASP Git hosting refuses80+requests until the balance or allowance permits it. Remote site fetches and81+mirroring also stop; already stored site files remain readable under the82+relay's read rule. A sufficient top-up lifts the fuel restriction.4983 5084 ## Seeing the numbers5185 5286 `https://<name>.bind.ws/fuel` is public and returns the meters, allowances, prices, balance and recent zaps as JSON. The Health tab shows the same numbers.87+88+The meters are the relay's view of usage, not a complete provider invoice.89+Cloudflare storage and database request charges can include R2 operations, KV90+operations and SQLite work that does not map one-for-one to stored bytes or91+the rows counted by the relay. The host checks those provider bills separately;92+the four fuel lines are the transparent budget shown to you.
M docs/04-data-and-names.md +12 -2
@@ -46,7 +46,10 @@ Names are cheap, so a relay does not have to do everything. On the Rules tab, **
4646 | search | only you | anyone | a copy of another relay's searchable kinds, refreshed every six hours |4747 | articles | only you | anyone | long-form articles and profiles; mirrored daily from a source if you give one |4848 | dm | anyone | members | private messages only; directory hidden |49-| quiet | members | members | every kind, forever; search, sync, counts, discovery, names, files, pages and signer traffic off |49+| quiet | members | members | every kind, forever; optional features off |50+| site | members | anyone | every kind, forever; site hosting, mirroring and files enabled |51+| marmot | anyone | anyone | KeyPackages, encrypted group envelopes, private welcomes and relay lists; Marmot enabled |52+| grasp | members publish events | anyone | every kind, forever; GRASP Git hosting enabled |5053 5154 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.5255
@@ -65,7 +68,7 @@ The result is the new console URL to hand over. The new name is temporary until
6568 6669 ## Leave6770 68-Your data is yours. Any relay that speaks sync can pull everything from yours:71+Your data is yours. A relay that speaks sync can pull the events your read rule exposes:6972 7073 ```7174 nak sync -a <your-pubkey> wss://<name>.bind.ws wss://<other-relay>
@@ -73,6 +76,13 @@ nak sync -a <your-pubkey> wss://<name>.bind.ws wss://<other-relay>
7376 7477 Or download a dump.7578 79+Event sync and JSONL dumps do not contain site files or Git packs. An unfiltered80+bind.ws pull can copy Blossom files, but it does not copy GRASP repositories.81+Copy site blobs through Blossom and clone Git repositories separately before82+leaving. Pending GRASP events remain outside normal event queries until their83+Git data arrives. A relay fork does not replace these separate file and Git84+backups.85+7686 ## What it spends7787 7888 Jobs wake the relay, so they spend awake time on every run. Pulled events count as events stored and rows written. Dumps count as files stored. See [Understanding fuel](02-understanding-fuel.md).
M docs/05-your-relay-on-the-web.md +23 -0
@@ -64,6 +64,29 @@ Your relay checks that cookie against the read rule on every request. Scripts
6464 send an exact NIP-98 Authorization header instead. Site sign-in uses no6565 password.6666 67+## Git repositories68+69+With the **GRASP-01** feature enabled, your relay can host NIP-34 Git70+repositories alongside its sites. A repository owner publishes its71+announcement and current refs to the relay, then Git clients use the72+percent-encoded path `/<npub>/<identifier>.git`. The relay accepts only the73+repository's announced service URLs, and the owner or its recursive74+maintainers control the accepted branch and tag state.75+76+The Git door requires open reads and includes cross-origin support. Choosing77+signed-in or members-only reads disables public Git access and removes the78+GRASP advertisement; it does not create a private Git host. The relay also accepts temporary79+`refs/nostr/<event-id>` refs for NIP-34 pull requests while their event and Git80+data arrive. Unmatched refs become eligible for cleanup after 20 minutes, and81+event or state purgatory expires after 30 minutes. Retained Git bytes remain82+in file storage even after a ref is hidden or removed.83+84+GRASP-01 is a bounded Git host. It supports partial clones with85+`blob:none` or `tree:0` filters, with pack and repository limits set by the86+host's object store. It does not synchronize repositories between relays,87+archive them or host private repositories. See [GRASP-01 Git hosting](22-grasp-01-git-hosting.md)88+for the protocol and the limits.89+6790 ## Media6891 6992 Your relay stores images and files too. Who may upload follows the write rule: anyone, members or only you. The largest upload is set on the Rules tab, 25 MB by default and up to 95. Files count as files stored for fuel.
M docs/10-architecture.md +7 -0
M docs/11-hosting-bindws.md +1 -1
M docs/12-develop-extend.md +12 -1
@@ -35,6 +35,9 @@ src/
3535 sites.ts NIP-5A manifest validation, label parsing and site serving3636 site-mirror.ts proxy and alarm-driven copies of missing site blobs3737 site-auth.ts NIP-07 challenge and relay-signed site cookie38+ grasp.ts GRASP-01 Git Smart HTTP and the object-store boundary39+ grasp-state.ts NIP-34 authority events, purgatory and cleanup state40+ grasp-policy.ts GRASP-01 NIP-34 repository and ref policy3841 card.ts the status card, signed and as SVG3942 qr.ts a QR encoder for the card and the console4043 fuel.ts meters, prices, receipts, LNURL
@@ -87,7 +90,7 @@ The conformance suite needs a claimed relay. Against a dev server:
8790 CLAIM=1 RELAY_URL=ws://dev.localhost:8787 npm run test:conformance8891 ```8992 90-CI runs typecheck and the object tests on every push; the conformance suite against the Worker on `celld dev` is a separate workflow run on demand ([Hosting without Cloudflare](16-hosting-without-cloudflare.md)). Work lands on `main` directly, in small commits that each typecheck on their own; a red check on `main` is fixed forward with the next commit.93+CI runs typecheck and the object tests on every push; the conformance suite against the Worker on `celld dev` is a separate workflow run on demand ([Hosting without Cloudflare](16-hosting-without-cloudflare.md)). Work lands through pull requests in small commits that each typecheck on their own; the branch checks and review carry the change to `main`.9194 9295 ## Add a management method9396
@@ -119,6 +122,14 @@ advertising for them.
119122 120123 A NIP that changes the query surface touches `store.ts`. Add the number to `SUPPORTED_NIPS` in `nip11.ts` only when the NIP says relays advertise it, with a word in the comment there for the less obvious ones. Add a file named after the NIP in `test/conformance` so the behavior is checked from outside, and a Durable Object test in `test/object`, named after the module it exercises; a NIP number names a test file only where the feature has no other name (`nip05`, `nip11`, `nip66`).121124 125+GRASP-01 follows the same split: `grasp-policy.ts` keeps NIP-34 parsing and126+authorization pure, while the Git door owns Smart HTTP and the object-store127+seam. The accepted kind 30618 state is the complete ref map the door may128+serve. Keep the Git path separate from relay and NIP-5A routing, and record129+any new GRASP capability in the information document and the numbered draft130+specification. GRASP-02, GRASP-03, GRASP-05, GRASP-06 and GRASP-08 need their131+own design and must not be implied by a GRASP-01 change.132+122133 ## The signer bundle123134 124135 Remote signing needs NIP-44 and secp256k1, which browsers do not ship. `scripts/build/signer-entry.js` imports the pieces of nostr-tools the console uses and `npm run build:signer` bundles them with esbuild into `src/gen/signer.ts`, a string the relay serves at `/signer.js`. The console loads it only when someone picks a remote signer. The generated file is committed; `npm run typecheck` fails when it is stale.
M docs/14-http-reference.md +35 -0
@@ -34,6 +34,11 @@ Any request with `Accept: application/nostr+json` answers the NIP-11 document, w
3434 3535 A door that belongs to a feature the owner switched off (Relay configuration, Rules) answers 404: names, files (Blossom and NIP-96), pages and the feed. `supported_nips` drops the numbers of features that are off.3636 37+When GRASP-01 is on and reads are open, NIP-11 also has38+`supported_grasps: ["GRASP-01"]`, a `repo_acceptance_criteria` string and, only39+for policy beyond ordinary spam controls, `curation`. See [GRASP-01 Git40+hosting](22-grasp-01-git-hosting.md) for the acceptance and Git protocol rules.41+3742 ## NIP-5A sites3843 3944 Site hostnames are `https://<npub>.<domain>`,
@@ -62,6 +67,36 @@ The sign-in cookie is Secure, HttpOnly and SameSite=Lax. The site door checks
6267 it against the current read rule on every request. A caller can use exact6368 NIP-98 directly instead of a cookie.6469 70+## GRASP-01 Git71+72+When the feature is on, reads are open and the repository announcement is73+accepted, the percent-encoded repository path exposes Git Smart HTTP. It uses74+public HTTP with signed NIP-34 state authorizing writes. A restricted read75+rule disables this door rather than offering authenticated private Git.76+77+| Path | Method | Auth | Answers | Status |78+|---|---|---|---|---|79+| `/<npub>/<identifier>.git` | GET, HEAD | public | repository page | 200; 404 unhosted repository |80+| `/<npub>/<identifier>.git/info/refs?service=git-upload-pack` or `service=git-receive-pack` | GET | public | service and ref advertisement | 200; 400 unsupported service |81+| `/<npub>/<identifier>.git/git-receive-pack` | POST | signed NIP-34 state and PR rules | receive-pack report | 200, including protocol-level rejection; 409 rejection without report-status; 400 malformed request; 413 request limit; 415 media type |82+| `/<npub>/<identifier>.git/git-upload-pack` | POST | public | bounded pack for reachable, tip or filtered wants | 200; 400 malformed or invalid want; 413 request or response limit; 415 media type |83+| a Git path | OPTIONS | none | CORS preflight | 204 |84+85+The door also returns 403 for a restricted read rule, inactive relay or86+exhausted fuel; 429 for rate limits or an operation already in progress; and87+503 when repository storage is unavailable. Receive-pack clients must read88+the Git report: HTTP 200 alone does not mean a push was accepted.89+90+Git responses include `Access-Control-Allow-Origin: *`,91+`Access-Control-Allow-Methods: GET, POST` and92+`Access-Control-Allow-Headers: Content-Type, Authorization, Git-Protocol, X-Git-Request-Id`.93+Upload-pack advertises `allow-reachable-sha1-in-want`,94+`allow-tip-sha1-in-want` and `filter`, and accepts `blob:none` and `tree:0`.95+An unknown `refs/nostr/<event-id>` has a 20-minute holding window. Expired96+unmatched refs are hidden and scheduled for deletion, but immutable Git97+objects remain in retained storage. The 128-transaction ceiling can prevent98+physical cleanup; it does not make those bytes free.99+65100 ## Custom domains66101 67102 These NIP-86 methods are owner-only and use the relay's normal NIP-98 RPC
M docs/15-costs-and-margins.md +43 -7
@@ -17,7 +17,28 @@ It prints markdown and exits with code 1 when any priced line is under the targe
1717 1818 ## The model1919 20-Fuel prices four things because Cloudflare bills four things: SQLite storage, R2 storage, Durable Object duration and rows written. Each fuel price is a sats figure in `wrangler.jsonc`. Each Cloudflare price is a dollar figure on their pricing pages, copied into the script with the date it was checked. The bitcoin price joins the two.20+Fuel prices four resource lines: SQLite event storage, R2 media storage,21+Durable Object duration and rows written. Each fuel price is a sats figure in22+`wrangler.jsonc`. Each Cloudflare price is a dollar figure on their pricing23+pages, copied into the script with the date it was checked. The bitcoin price24+joins the two.25+26+The four lines are the tenant-facing budget, not a complete provider invoice.27+The relay records stored event bytes, stored media bytes, its awake-time28+counter and rows written by its SQLite work. GRASP objects share the media29+storage allowance with Blossom files and dumps; NIP-5A mirroring does the same.30+Marmot events share event storage and ordinary row accounting. Site proxy and31+Git request bytes are visible traffic with no fuel price, and tenant usage does32+not gain a separate feature surcharge.33+34+Some provider costs remain outside the four tenant-priced lines. R2 operations, KV reads and35+writes, and platform requests can be billed by operation even when the relay36+only exposes bytes. The row meter drains cursors recorded by `Store.x`; direct SQL bookkeeping37+in GRASP, site queues and other modules bypasses it. The awake-time meter38+uses entrypoint gaps capped at ten seconds, so a long Git or remote-fetch39+operation is not measured as an exact request duration.40+Those costs belong in the host's deployment review and margin target; they do41+not silently change the four meters shown to a relay owner.2142 2243 For a line with cost `c` dollars per unit and price `p` sats per unit at `B` dollars per bitcoin, revenue per unit is `p / 1e8 * B` and margin is `1 - c / revenue`. The margin target is 33% of revenue, which is a 1.5x markup on cost. The floor is 20%. Under the target the weekly run flags it; under the floor, reprice.2344
@@ -34,11 +55,11 @@ At $81,500 per bitcoin:
3455 | Time awake | $0.0058 per hour | 11 sats | 36% | $78,200 |3556 | Rows written | $1.00 per million | 2,000 sats | 39% | $74,600 |3657 37-Time awake is an hour of a Durable Object held in memory at 128 MB, which Cloudflare bills as 460.8 GB-seconds. Hibernating objects are not billed, which is why an idle relay costs nothing.58+Time awake is an hour of a Durable Object held in memory at 128 MB, which Cloudflare bills as 460.8 GB-seconds. Hibernating objects incur no duration charge, but retained storage and alarm work can still cost the host.3859 3960 ## The free tier4061 41-A relay that spends its whole allowance costs the host about $0.87 a month. Time awake is most of it. Storage is nearly free at these sizes. The rows allowance was a million until Sept. 2026, which made it $1.00 of a $1.61 tier; it came down to 250,000, about 15 times what a personal relay writes, because rows are the one line Cloudflare charges real money per unit.62+At the dated rates above, a relay that spends its whole allowance costs the host about $0.87 a month. Time awake is most of it. Storage is nearly free at these sizes. The rows allowance was a million until Sept. 2026, which made it $1.00 of a $1.61 tier; it came down to 250,000, about 15 times what a personal relay writes, because rows are the one line Cloudflare charges real money per unit.4263 4364 | Allowance | Amount | Cost |4465 |---|---|---|
@@ -47,13 +68,28 @@ A relay that spends its whole allowance costs the host about $0.87 a month. Time
4768 | Time awake | 100 hours | $0.58 |4869 | Rows written | 250,000 | $0.25 |4970 50-Few relays get near the allowance. A typical personal relay, 20 MB of events, 100 MB of files, eight hours awake and 100,000 rows a month, costs about 15 cents once the plan's included quotas are used up, and those quotas cover the first few dozen relays outright.71+Few relays get near the allowance. At the dated rates above, a typical personal relay, 20 MB of events, 100 MB of files, eight hours awake and 100,000 rows a month, costs about 15 cents once the plan's included quotas are used up, and those quotas cover the first few dozen relays outright.5172 5273 ## The deployment5374 54-The script projects a fleet from two shapes, a typical relay and a heavy one that passes its allowances and pays fuel, at a chosen share of heavy relays. At 1,000 relays and 5% heavy, the Cloudflare bill is about $420 a month, fuel brings in about $280, and the gap is the free tier. Rows written are the largest line on the bill, then duration, then object requests, which nobody pays for because traffic is free to relays.75+The script projects a fleet from two shapes, a typical relay and a heavy one that passes its allowances and pays fuel, at a chosen share of heavy relays. In the dated 1,000-relay example with 5% heavy, the Cloudflare bill is about $420 a month, fuel brings in about $280, and the gap is the free tier. Rows written are the largest modeled line on the bill, then duration. The fleet projection includes platform requests, rows read and R2 operations as host costs even though these have no separate tenant price. It lists KV read pricing, but does not project site-index KV usage or custom-hostname fees.5576 56-Two things follow. The free tier is a cost the paid relays and the host share, and its size is the lever: `FREE_ROWS_WRITTEN` is the expensive allowance. And the unpriced lines, requests above all, are real; the 33% target exists to absorb them, which is why the target is not lower.77+Two things follow. The free tier is a cost the paid relays and the host share, and its size is the lever: `FREE_ROWS_WRITTEN` is the expensive allowance. And the unpriced provider lines, especially request operations, are real; the 33% target exists to absorb them, which is why the target is not lower.78+79+The bounded GRASP implementation adds a relay-wide ceiling of 320 MiB for80+retained Git objects, with 4 MiB packs, 16 MiB packed history and bounded81+transaction, ref and object counts. This is a safety limit and shares the82+existing media allowance; it is not a new free tier. Retained bytes can remain83+billable after expired metadata is hidden when immutable Git history or a84+cleanup transaction still keeps them.85+86+The current [R2 pricing](https://developers.cloudflare.com/r2/pricing/),87+[Durable Object pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/)88+and [KV pricing](https://developers.cloudflare.com/kv/platform/pricing/)89+distinguish storage, execution and operation charges. The historical fleet90+shapes below the script's pricing tables are not measured site or Git workloads.91+Use deployment telemetry before treating their projected margin as coverage92+for these features.5793 5894 ## Weekly5995
@@ -63,4 +99,4 @@ The workflow is `.github/workflows/margin.yml`. Read the summary. When a line is
6399 2. Change the `SATS_PER_*` values in `wrangler.jsonc`. The comment above them records the rate they were set at; update it.64100 3. Deploy. Fuel meters are unchanged; only the price of what is over the allowance moves, from the next charge.65101 66-Prices go up when bitcoin falls. When it rises, do nothing until a line has sat above twice the target for a month, then bring it down. Relay owners see prices on the front page and on their Fuel section, so every change is visible the moment it deploys.102+Prices go up when bitcoin falls. When it rises, do nothing until a line has sat above twice the target for a month, then bring it down. Relay owners see prices on the front page and on their Fuel section, so every change is visible the moment it deploys. The table above was checked on Sept. 3, 2026; rerun `npm run margin` and verify current provider pricing before using it for a new forecast.
M docs/20-nip-5a-static-websites.md +3 -1
A docs/22-grasp-01-git-hosting.md +174 -0
@@ -0,0 +1,174 @@
1+---2+title: GRASP-01 Git hosting3+audience: user4+---5+6+# GRASP-01 Git hosting7+8+GRASP-01 gives a relay an optional Git Smart HTTP door. A repository owner9+publishes the NIP-34 repository announcement and its current state on the10+relay; the relay serves the Git objects that belong to that accepted state.11+The Nostr relay remains the source of authorization and discussion events.12+13+This document tracks the GRASP-01 specification at [commit14+f35b4f9a4ed2f0aaaf46926e4b1733c79e21b377](https://github.com/DanConwayDev/grasp/tree/f35b4f9a4ed2f0aaaf46926e4b1733c79e21b377),15+checked on 2026-09-04. The implementation is bounded to the core service. It16+does not claim GRASP-02 proactive sync,17+GRASP-03 discussion sync, GRASP-05 archive, GRASP-06 alternative PR hosting or18+GRASP-08 private repositories.19+20+## Turn it on21+22+The `grasp` feature is off unless the owner enables it. The **Git repositories** preset23+turns on open reads and the GRASP Git door. The repository still applies its24+ordinary fuel, storage and event rules, and the information document lists25+the exact acceptance criteria. The relay advertises `GRASP-01` in26+`supported_grasps` only while the feature is on and reads are open, because27+GRASP-01's Git service is public.28+29+The [relay templates](../relay-templates/README.md) include a GRASP template30+for a relay whose public purpose is hosting NIP-34 repositories. A template31+does not add members, bans or addresses. The owner supplies those separately32+when the relay's event policy requires them.33+34+## Announce a repository35+36+The repository owner publishes a kind 30617 event with a `d` identifier,37+`clone` URLs and `relays` URLs. An announcement that hosts a repository38+here must list this service in both lists. A companion announcement39+from an already recognized maintainer may omit those service tags because the40+repository is already hosted here. The service URL is the relay's HTTPS Git41+endpoint and its WebSocket relay URL. The identifier is percent-encoded in the42+Git path:43+44+```45+/<npub>/<percent-encoded-identifier>.git46+```47+48+The relay accepts the announcement only after its normal signature, ban,49+fuel, size and event policy checks. A malformed identifier, URL or maintainer50+key is refused. A repository's `private` tag does not make a private Git door;51+private repository hosting is GRASP-08 and remains outside this feature.52+53+The accepted announcement's address is54+`30617:<owner-pubkey>:<identifier>`. NIP-34 issues, patches, pull requests and55+updates that refer to an accepted address can be stored by the normal relay56+rules. An event that has no accepted repository relationship remains subject57+to the ordinary policy.58+59+## Publish a state60+61+The repository maintainer publishes a kind 30618 event with the same `d`62+identifier, a `HEAD` tag and the complete `refs/heads/*` and `refs/tags/*` map.63+The [NIP-34 HEAD tag](https://github.com/nostr-protocol/nips/blob/master/34.md#repository-state-announcements)64+uses `["HEAD", "ref: refs/heads/main"]`; a bare branch path is refused.65+The latest valid state is selected by NIP-34 addressable-event ordering among66+the owner and the recursive maintainer set. The recursive set starts at the67+owner and follows the maintainer announcements for that repository, with68+bounded depth and membership limits.69+70+The Git door accepts a normal branch or tag update only when each changed ref71+and the requested HEAD agree with the accepted state. Unchanged refs remain72+untouched, so a push does not need to repeat the entire state map. The state73+event therefore authorizes the bytes a push makes visible; a maintainer74+signature alone does not authorize an arbitrary branch. A state with no HEAD75+represents an unborn repository. A pending state keeps the previously76+materialized branch and HEAD in the Git repository, so a stock clone still77+checks out the prior branch. The repository moves to the new HEAD when the78+new tip's objects arrive.79+80+## Pull requests and purgatory81+82+A pull request or update can name a Git ref as83+`refs/nostr/<event-id>`. The event ID is checked as a 64-character lowercase84+hex value. The relay can hold the event and the Git data while it waits for85+the other half of the pair. Until both are present, the event is not served86+from the normal read path and the response explains87+`purgatory: won't be served until git data arrives`. Unresolved entries expire88+after 30 minutes.89+90+The relay accepts every syntactically valid `refs/nostr/<event-id>` push,91+including one whose Nostr event has not arrived yet. It rejects a ref whose92+known event names a different tip. A pending PR ref that has no accepted PR93+or PR update with a matching `c` tag is hidden and eligible for cleanup after94+20 minutes. The cleanup window limits abandoned uploads and does not implement95+the broader archive or sync behavior of later GRASP documents.96+97+## Git HTTP98+99+Your relay's **Connect** section includes a copyable `git clone` example with100+its own hostname, the repository owner's npub and repository name.101+102+The Git door is unauthenticated when the relay's reads are open. It serves103+Git Smart HTTP at the repository path and returns a repository page or a 404104+for a path that is not hosted. Upload-pack advertises and serves105+`allow-reachable-sha1-in-want`, `allow-tip-sha1-in-want` and `filter`, including106+`blob:none` and `tree:0` requests. Receive-pack applies the accepted state,107+maintainer and `refs/nostr` rules before storing anything.108+109+The relay root, Git repository page and protocol responses include the GRASP110+CORS headers. `OPTIONS` returns 204. GET and POST are the allowed methods;111+`Content-Type`, `Authorization`, `Git-Protocol` and `X-Git-Request-Id` are112+allowed request headers. The NIP-5A site origins remain isolated from relay113+events and repository data.114+115+The Worker depends on the `ntig` package, whose source and package artifact116+are recorded in [Vendored dependencies](../vendor/README.md). Its object-store seam validates pack117+structure, deltas, object dependencies, trees and commits before publication.118+The hard limits are:119+120+| Scope | Limit | What it controls |121+|---|---:|---|122+| Relay | 16 repositories, 320 MiB Git storage | accepted repositories and billed retained Git objects across the relay |123+| Repository | 4 MiB per pack, 16 MiB packed history | one upload and the aggregate packed history read into memory |124+| Repository | 128 transactions, 1,024 refs, 4,096 objects | WAL history, ref maps and verified object graphs |125+126+Checkpoints, compaction, orphan collection, large-pack streaming and127+production capacity measurements remain follow-up work. Once a repository128+reaches its transaction limit, all further Git writes are refused, including129+the timed cleanup transaction. An expired unknown PR ref therefore stays130+hidden when cleanup cannot commit, while immutable WAL history keeps its old131+object bytes billed until the relay is torn down. The same retention behavior132+applies when a ref is cleaned after its 20-minute window.133+134+## Information document and limits135+136+When GRASP is on and reads are open, NIP-11 includes:137+138+| Field | Meaning |139+|---|---|140+| `supported_grasps` | `GRASP-01` for this service |141+| `repo_acceptance_criteria` | the required NIP-34 announcement, relay listing, maintainer and state checks, plus ordinary relay policy |142+| `curation` | omitted unless the owner applies curation beyond generic spam, bans or fuel rules |143+144+NIP-11 does not list GRASP-02, GRASP-03, GRASP-05, GRASP-06 or GRASP-08. A145+relay's regular read rule still governs the Nostr side, and turning GRASP off146+removes its Git door and advertisement without changing the NIP-5A site147+origins.148+149+## Limits under load150+151+One Git operation runs at a time for a relay. The operation fence also blocks152+relay-side event and control mutations while a Git transaction reads or153+writes its object graph. This bounds Worker memory and keeps the WAL root and154+the relay's authority view from changing underneath one operation.155+156+Git requests consume the relay's normal address rate limits. A busy fence or157+rate limit answers `429` with a retry reason. Fuel exhaustion, an unclaimed158+relay, a restricted read rule or a repository limit answers a `restricted:`159+response. A caller retries after the stated condition clears; it does not160+assume that a rejected receive-pack changed refs.161+162+## What this does not promise163+164+GRASP-01 does not synchronize repositories between relays, fetch Git data165+hourly, host private repositories, accept PRs for an unannounced repository,166+or preserve an archive after ordinary retention. Those behaviors belong to167+the later GRASP specifications and need separate storage and policy work.168+The repository's Git object store is an implementation detail; clients rely on169+the NIP-34 events and the GRASP HTTP contract, not on a particular backend.170+171+See [Your relay on the web](05-your-relay-on-the-web.md) for the user-facing172+site and domain doors, [Architecture](10-architecture.md) for the Worker and173+Durable Object boundaries, and [HTTP reference](14-http-reference.md) for the174+exact request paths and statuses.
M package-lock.json +70 -21
@@ -8,7 +8,8 @@
88 "license": "MIT",99 "dependencies": {1010 "@noble/hashes": "^2.4.0",11- "nostr-tools": "^2.25.1"11+ "nostr-tools": "^2.25.1",12+ "ntig": "file:vendor/ntig-0.1.1.tgz"1213 },1314 "devDependencies": {1415 "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -1780,7 +1781,8 @@
17801781 ],17811782 "engines": {17821783 "node": ">=16.20.0"1783- }1784+ },1785+ "dev": true17841786 },17851787 "node_modules/@typescript/typescript-darwin-arm64": {17861788 "version": "7.0.2",
@@ -1796,7 +1798,8 @@
17961798 ],17971799 "engines": {17981800 "node": ">=16.20.0"1799- }1801+ },1802+ "dev": true18001803 },18011804 "node_modules/@typescript/typescript-darwin-x64": {18021805 "version": "7.0.2",
@@ -1812,7 +1815,8 @@
18121815 ],18131816 "engines": {18141817 "node": ">=16.20.0"1815- }1818+ },1819+ "dev": true18161820 },18171821 "node_modules/@typescript/typescript-freebsd-arm64": {18181822 "version": "7.0.2",
@@ -1828,7 +1832,8 @@
18281832 ],18291833 "engines": {18301834 "node": ">=16.20.0"1831- }1835+ },1836+ "dev": true18321837 },18331838 "node_modules/@typescript/typescript-freebsd-x64": {18341839 "version": "7.0.2",
@@ -1844,7 +1849,8 @@
18441849 ],18451850 "engines": {18461851 "node": ">=16.20.0"1847- }1852+ },1853+ "dev": true18481854 },18491855 "node_modules/@typescript/typescript-linux-arm": {18501856 "version": "7.0.2",
@@ -1860,7 +1866,8 @@
18601866 ],18611867 "engines": {18621868 "node": ">=16.20.0"1863- }1869+ },1870+ "dev": true18641871 },18651872 "node_modules/@typescript/typescript-linux-arm64": {18661873 "version": "7.0.2",
@@ -1876,7 +1883,8 @@
18761883 ],18771884 "engines": {18781885 "node": ">=16.20.0"1879- }1886+ },1887+ "dev": true18801888 },18811889 "node_modules/@typescript/typescript-linux-loong64": {18821890 "version": "7.0.2",
@@ -1892,7 +1900,8 @@
18921900 ],18931901 "engines": {18941902 "node": ">=16.20.0"1895- }1903+ },1904+ "dev": true18961905 },18971906 "node_modules/@typescript/typescript-linux-mips64el": {18981907 "version": "7.0.2",
@@ -1908,7 +1917,8 @@
19081917 ],19091918 "engines": {19101919 "node": ">=16.20.0"1911- }1920+ },1921+ "dev": true19121922 },19131923 "node_modules/@typescript/typescript-linux-ppc64": {19141924 "version": "7.0.2",
@@ -1924,7 +1934,8 @@
19241934 ],19251935 "engines": {19261936 "node": ">=16.20.0"1927- }1937+ },1938+ "dev": true19281939 },19291940 "node_modules/@typescript/typescript-linux-riscv64": {19301941 "version": "7.0.2",
@@ -1940,7 +1951,8 @@
19401951 ],19411952 "engines": {19421953 "node": ">=16.20.0"1943- }1954+ },1955+ "dev": true19441956 },19451957 "node_modules/@typescript/typescript-linux-s390x": {19461958 "version": "7.0.2",
@@ -1956,7 +1968,8 @@
19561968 ],19571969 "engines": {19581970 "node": ">=16.20.0"1959- }1971+ },1972+ "dev": true19601973 },19611974 "node_modules/@typescript/typescript-linux-x64": {19621975 "version": "7.0.2",
@@ -1972,7 +1985,8 @@
19721985 ],19731986 "engines": {19741987 "node": ">=16.20.0"1975- }1988+ },1989+ "dev": true19761990 },19771991 "node_modules/@typescript/typescript-netbsd-arm64": {19781992 "version": "7.0.2",
@@ -1988,7 +2002,8 @@
19882002 ],19892003 "engines": {19902004 "node": ">=16.20.0"1991- }2005+ },2006+ "dev": true19922007 },19932008 "node_modules/@typescript/typescript-netbsd-x64": {19942009 "version": "7.0.2",
@@ -2004,7 +2019,8 @@
20042019 ],20052020 "engines": {20062021 "node": ">=16.20.0"2007- }2022+ },2023+ "dev": true20082024 },20092025 "node_modules/@typescript/typescript-openbsd-arm64": {20102026 "version": "7.0.2",
@@ -2020,7 +2036,8 @@
20202036 ],20212037 "engines": {20222038 "node": ">=16.20.0"2023- }2039+ },2040+ "dev": true20242041 },20252042 "node_modules/@typescript/typescript-openbsd-x64": {20262043 "version": "7.0.2",
@@ -2036,7 +2053,8 @@
20362053 ],20372054 "engines": {20382055 "node": ">=16.20.0"2039- }2056+ },2057+ "dev": true20402058 },20412059 "node_modules/@typescript/typescript-sunos-x64": {20422060 "version": "7.0.2",
@@ -2052,7 +2070,8 @@
20522070 ],20532071 "engines": {20542072 "node": ">=16.20.0"2055- }2073+ },2074+ "dev": true20562075 },20572076 "node_modules/@typescript/typescript-win32-arm64": {20582077 "version": "7.0.2",
@@ -2068,7 +2087,8 @@
20682087 ],20692088 "engines": {20702089 "node": ">=16.20.0"2071- }2090+ },2091+ "dev": true20722092 },20732093 "node_modules/@typescript/typescript-win32-x64": {20742094 "version": "7.0.2",
@@ -2084,7 +2104,8 @@
20842104 ],20852105 "engines": {20862106 "node": ">=16.20.0"2087- }2107+ },2108+ "dev": true20882109 },20892110 "node_modules/@vitest/expect": {20902111 "version": "4.1.11",
@@ -2402,6 +2423,12 @@
24022423 }24032424 }24042425 },2426+ "node_modules/fflate": {2427+ "version": "0.8.3",2428+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",2429+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",2430+ "license": "MIT"2431+ },24052432 "node_modules/fsevents": {24062433 "version": "2.3.3",24072434 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2824,6 +2851,28 @@
28242851 "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",28252852 "license": "MIT"28262853 },2854+ "node_modules/ntig": {2855+ "version": "0.1.1",2856+ "resolved": "file:vendor/ntig-0.1.1.tgz",2857+ "integrity": "sha512-8Sd4ZrGVmI2ydPLvZxiQyncSp4hld3PBjpXlKgUbyf4X1mOfOMrFMJSb+vDUxm8MBgWz6M8RwhNhsc+E0XNSsA==",2858+ "license": "MIT",2859+ "dependencies": {2860+ "@scure/base": "2.4.0",2861+ "fflate": "0.8.3"2862+ },2863+ "engines": {2864+ "node": ">=22.22"2865+ }2866+ },2867+ "node_modules/ntig/node_modules/@scure/base": {2868+ "version": "2.4.0",2869+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz",2870+ "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==",2871+ "license": "MIT",2872+ "funding": {2873+ "url": "https://paulmillr.com/funding/"2874+ }2875+ },28272876 "node_modules/obug": {28282877 "version": "2.1.4",28292878 "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
M package.json +2 -1
M relay-config.schema.json +2 -1
A relay-templates/13-grasp.jsonc +25 -0
M relay-templates/README.md +1 -0
M src/console/console.html +9 -0
@@ -47,6 +47,15 @@
4747 <div class="door"><small>names</small><div class="v"><span id="d-nip05"></span></div></div>4848 <div class="door"><small>HTTP bridge, NIP-98</small><div class="v"><span>POST /events, /query, /count</span></div></div>4949 </div>50+ <div class="block hidden" id="git-connect">51+ <h3>Git repositories, ntig</h3>52+ <div>53+ <p class="note">Use ordinary Git to clone a repository hosted here.</p>54+ <div class="door"><small>Clone a repository</small><div class="v"><span id="git-clone"></span><button class="ib" id="git-copy" title="Copy clone example" aria-label="Copy clone example"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg></button></div></div>55+ <p class="note">Replace <span class="mono">&lt;npub&gt;</span> with the repository owner's npub, yours for your own repository, and <span class="mono">&lt;repo&gt;</span> with its repository name, such as <span class="mono">my-project</span>.</p>56+ <p class="note">To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. <a href="https://github.com/FelineStateMachine/bindws/blob/main/docs/22-grasp-01-git-hosting.md">Git hosting guide</a>.</p>57+ </div>58+ </div>5059 <div class="block">5160 <h3>Open it in an app</h3>5261 <p class="note">Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add <span class="mono" id="apps-ws"></span> under the app's relay settings to post here.</p>
M src/console/console.js +6 -0
M src/gates.ts +4 -0
M src/gen/console.ts +2 -2
@@ -1,4 +1,4 @@
11 // Generated by scripts/build/build-console.mjs from src/console. Do not edit; run npm run build:console.2-export const CONSOLE_HTML = "<main>\n <header class=\"mast\">\n <div class=\"banner hidden\" id=\"banner\"><img id=\"bannerimg\" alt=\"\"></div>\n <div class=\"wordmark\"><img id=\"iconimg\" class=\"icon hidden\" alt=\"\"><h1 id=\"title\"></h1></div>\n <div class=\"left\"><div class=\"care\" id=\"care\"></div></div>\n <div class=\"right\">\n <span class=\"urlrow\"><code class=\"pill\" id=\"url\"></code><button class=\"ib\" id=\"copy\" title=\"Copy relay URL\" aria-label=\"Copy relay URL\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></span>\n <span class=\"owner\"><i class=\"av hidden\" id=\"owner-av\"></i><span id=\"ownerline\"></span><em id=\"who\"></em>\n <button id=\"signin\" class=\"btn hidden\">Sign in with extension</button>\n <button id=\"signin46\" class=\"btn hidden\">Remote signer</button>\n <button id=\"signout\" class=\"ib hidden\" title=\"Sign out\" aria-label=\"Sign out\"><svg viewBox=\"0 0 24 24\"><path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4M15 8l5 4-5 4M20 12H9\"/></svg></button>\n </span>\n </div>\n </header>\n\n <section id=\"unclaimed\" class=\"hidden\">\n <h2>Nobody owns this relay yet.</h2>\n <p class=\"lead\">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>\n <div class=\"row\"><button id=\"claim\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"claimnote\">Use a remote signer</button><span class=\"note\" id=\"claimnote\"></span></div>\n </section>\n\n <section id=\"leased\" class=\"hidden\">\n <h2>A temporary relay, for now.</h2>\n <p class=\"lead\">Anyone can read and write here until <b id=\"lease-until\"></b>. Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.</p>\n <div class=\"row\"><button id=\"claimlease\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"leasenote\">Use a remote signer</button><span class=\"note\" id=\"leasenote\"></span></div>\n </section>\n\n <section id=\"remotesec\" class=\"hidden\">\n <h2>Connect a remote signer.</h2>\n <p class=\"lead\">Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the <span class=\"mono\">bunker://</span> URL the app gives you.</p>\n <div class=\"row\"><a id=\"nclink\" class=\"btn pri\" href=\"#\">Open in your signer app</a><button id=\"nccopy\" class=\"btn\">Copy link</button><span class=\"note\" id=\"ncnote\"></span></div>\n <form id=\"bunkerform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"bunker://…\" required><button class=\"btn\">Connect</button></form>\n <img id=\"ncqr\" class=\"hidden\" alt=\"QR code of the signer link\" width=\"180\" height=\"180\">\n <p class=\"note\" id=\"remotenote\"></p>\n </section>\n\n <section id=\"foldsec\" class=\"folds\">\n <div id=\"frontsec\" class=\"hidden\">\n <p class=\"lead hidden\" id=\"desc\"></p>\n <p class=\"metarow hidden\" id=\"metarow\"></p>\n </div>\n <details class=\"disclosure\" id=\"aboutsec\" name=\"page\"><summary>About, for clients</summary><div id=\"about\" class=\"about\"></div></details>\n <details class=\"disclosure\" id=\"connectsec\" name=\"page\"><summary>Connect</summary>\n <div class=\"doors\">\n <div class=\"door\"><small>nostr relay</small><div class=\"v\"><span id=\"d-ws\"></span><button class=\"ib\" data-copy=\"ws\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>Blossom media</small><div class=\"v\"><span id=\"d-blossom\"></span><button class=\"ib\" data-copy=\"blossom\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>names</small><div class=\"v\"><span id=\"d-nip05\"></span></div></div>\n <div class=\"door\"><small>HTTP bridge, NIP-98</small><div class=\"v\"><span>POST /events, /query, /count</span></div></div>\n </div>\n <div class=\"block\">\n <h3>Open it in an app</h3>\n <p class=\"note\">Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add <span class=\"mono\" id=\"apps-ws\"></span> under the app's relay settings to post here.</p>\n <div class=\"apps\" id=\"apps\"></div>\n <div class=\"phones\" id=\"phones\"></div>\n </div>\n </details>\n </section>\n\n <section id=\"peoplesec\" class=\"hidden\">\n <h2>People <span class=\"count\" id=\"people-count\"></span></h2>\n <p class=\"note hidden\" id=\"people-note\">Hidden from visitors. Only you see this list.</p>\n <div class=\"dir\" id=\"dir\"></div>\n </section>\n\n <section id=\"fuelsec\" class=\"hidden\">\n <h2>Fuel</h2>\n <p class=\"lead\">Free allowance first; past it, usage burns sats. Anyone can zap a top-up.</p>\n <div class=\"gauges\">\n <div class=\"gauge\"><small>Events stored</small><div class=\"bar\"><i id=\"g-events\"></i><b id=\"p-events\"></b></div><span id=\"t-events\"></span></div>\n <div class=\"gauge\"><small>Files stored</small><div class=\"bar\"><i id=\"g-media\"></i><b id=\"p-media\"></b></div><span id=\"t-media\"></span></div>\n <div class=\"gauge\"><small>Awake this month</small><div class=\"bar\"><i id=\"g-active\"></i><b id=\"p-active\"></b></div><span id=\"t-active\"></span></div>\n <div class=\"gauge\"><small>Rows written this month</small><div class=\"bar\"><i id=\"g-rows\"></i><b id=\"p-rows\"></b></div><span id=\"t-rows\"></span></div>\n </div>\n <p class=\"balance\" id=\"fuel-balance\"></p>\n <form id=\"topup\" class=\"topup hidden\"><span class=\"sats\"><input type=\"number\" name=\"sats\" min=\"1\" value=\"1000\"><span>sats</span></span><button class=\"btn pri\">&#9889; Zap to top up</button><span class=\"note\" id=\"topup-note\"></span></form>\n <div id=\"invoice\" class=\"invoice hidden\">\n <p>Pay from any lightning wallet. The receipt lands on this relay and credits it automatically.</p>\n <div class=\"row\"><a id=\"inv-link\" class=\"btn\" href=\"#\">Open in wallet</a><button id=\"inv-copy\" class=\"ib\" title=\"Copy invoice\" aria-label=\"Copy invoice\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button><span class=\"state\" id=\"inv-state\"></span></div>\n <textarea id=\"inv-text\" readonly></textarea>\n </div>\n </section>\n\n <section id=\"myinvites\" class=\"hidden\">\n <h2>Your invites</h2>\n <p class=\"lead\">The owner lets members bring people in. Each link admits one person and lasts three days.</p>\n <div class=\"row\"><button id=\"mintmine\" class=\"btn pri\">New invite link</button><span class=\"note\" id=\"minenote\"></span></div>\n <ul id=\"mine\" class=\"plain\"></ul>\n </section>\n\n\n <div class=\"console hidden\" id=\"console\">\n <nav class=\"tabs\" id=\"tabs\">\n <a href=\"#people\" data-tab=\"people\">People<span class=\"count\" id=\"tc-people\"></span></a>\n <a href=\"#moderation\" data-tab=\"moderation\">Moderation<span class=\"count\" id=\"tc-reports\"></span></a>\n <a href=\"#rules\" data-tab=\"rules\">Rules</a>\n <a href=\"#identity\" data-tab=\"identity\">Identity</a>\n <a href=\"#data\" data-tab=\"data\">Data</a>\n <a href=\"#sync\" data-tab=\"sync\">Sync</a>\n <a href=\"#views\" data-tab=\"views\">Views</a>\n <a href=\"#health\" data-tab=\"health\">Health</a>\n <a href=\"#owner\" data-tab=\"owner\">Owner</a>\n </nav>\n\n <section class=\"panel\" data-panel=\"people\">\n <h2>People</h2>\n <p class=\"lead\">The member list is published as a signed roster; a name makes someone <span class=\"mono\" id=\"nip05-example\"></span>.</p>\n <div class=\"block\">\n <form id=\"addmember\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"name\" placeholder=\"name (optional)\" pattern=\"[a-z0-9._-]{1,64}\" style=\"max-width:9rem\"><input class=\"txt\" name=\"note\" placeholder=\"note\" style=\"max-width:9rem\"><button class=\"ib pri\" title=\"Add member\" aria-label=\"Add member\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <div class=\"scroll\"><table id=\"members\" class=\"people-table\"><thead><tr><th>Who</th><th>Name</th><th>Note</th><th>Limits</th><th>Joined</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <div class=\"two\">\n <div>\n <h3>Invites</h3>\n <form id=\"mintinvite\" class=\"addrow\"><select class=\"txt\" name=\"ttl\" style=\"max-width:8rem\"><option value=\"3600\">1 hour</option><option value=\"86400\">1 day</option><option value=\"259200\" selected>3 days</option><option value=\"2592000\">30 days</option></select><input class=\"txt num\" type=\"number\" name=\"max\" min=\"0\" value=\"0\" title=\"uses, 0 = unlimited\" style=\"width:5rem;flex:0 0 auto\"><input class=\"txt\" name=\"note\" placeholder=\"note\"><button class=\"ib pri\" title=\"New invite\" aria-label=\"New invite\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <ul id=\"invites\" class=\"plain\"></ul>\n <form id=\"treeform\" class=\"addrow hidden\" title=\"Members invite members: how many hops from you the tree reaches, and how many live invites each member may hold\"><span class=\"dim\">members invite</span><input class=\"txt num\" type=\"number\" name=\"depth\" min=\"0\" max=\"10\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">hops deep,</span><input class=\"txt num\" type=\"number\" name=\"quota\" min=\"0\" max=\"100\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">each</span><button class=\"btn\">Save</button></form>\n </div>\n <div>\n <h3>Joining</h3>\n <form id=\"joinform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Shown before someone accepts an invite</span><textarea name=\"joinTerms\" placeholder=\"House rules, expectations, anything a newcomer should agree to.\" maxlength=\"20000\"></textarea></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"directoryPublic\"> Show the people directory to visitors</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save</button></div>\n </form>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"moderation\">\n <h2>Moderation</h2>\n <p class=\"lead\">Reports never show in the feed. Banning also deletes the reported thing.</p>\n <div class=\"block\">\n <h3>Reports <span class=\"count\" id=\"reports-count\"></span></h3>\n <form id=\"thresholdform\" class=\"addrow\"><span class=\"dim\">hide an event once</span><input class=\"txt num\" type=\"number\" name=\"reportThreshold\" min=\"0\" max=\"100\" style=\"width:4.5rem;flex:0 0 auto\"><span class=\"dim\">different people report it; 0 never</span><button class=\"btn\">Save</button></form>\n <div class=\"scroll\"><table id=\"reports\" class=\"events\"><thead><tr><th>Time</th><th>Type</th><th>About</th><th>Reason</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Log</h3>\n <p class=\"dim\">Every change made here or by a moderation event, newest first, the last 5,000.</p>\n <div class=\"scroll\"><table id=\"audit\" class=\"events\"><thead><tr><th>Time</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead><tbody></tbody></table></div>\n <button class=\"btn\" id=\"audit-more\" type=\"button\">Older</button>\n </div>\n <div class=\"block two\">\n <div>\n <h3>Bans</h3>\n <form id=\"addban\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><label class=\"note\" title=\"Also delete everything they wrote and uploaded\"><input type=\"checkbox\" name=\"erase\"> erase</label><button class=\"ib danger\" title=\"Ban\" aria-label=\"Ban\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"bans\" class=\"plain\"></ul>\n <ul id=\"banned-events\" class=\"plain\"></ul>\n </div>\n <div>\n <h3>Blocked addresses</h3>\n <form id=\"addblock\" class=\"addrow\"><input class=\"txt mono\" name=\"ip\" placeholder=\"IP address\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><button class=\"ib danger\" title=\"Block\" aria-label=\"Block\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"blocks\" class=\"plain\"></ul>\n </div>\n </div>\n <div class=\"block\">\n <h3>Recent events</h3>\n <p class=\"note\">Delete removes one thing. Ban also refuses it forever. Search covers notes, articles and profiles.</p>\n <form id=\"searchform\" class=\"addrow\"><input class=\"txt\" name=\"q\" placeholder=\"search\" maxlength=\"200\"><button class=\"btn\">Search</button><button type=\"button\" id=\"searchclear\" class=\"btn hidden\">Clear</button></form>\n <div class=\"scroll\"><table id=\"events\" class=\"events\"><thead><tr><th>Time</th><th>Kind</th><th>Author</th><th>Content</th><th></th></tr></thead><tbody></tbody></table></div>\n <div class=\"actions left\"><button id=\"more\" class=\"btn\">Load more</button></div>\n </div>\n <div class=\"block\">\n <h3>Pinned <span class=\"count\" id=\"pins-count\"></span></h3>\n <p class=\"note\">Group clients show these at the top. Up to 20, in this order.</p>\n <form id=\"pinform\" class=\"addrow\"><input class=\"txt mono\" name=\"id\" placeholder=\"event id or address kind:pubkey:d\" required><button class=\"btn\">Pin</button></form>\n <ul id=\"pins\" class=\"plain\"></ul>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"rules\">\n <h2>Rules</h2>\n <p class=\"lead\">Bans apply regardless of these.</p>\n <div class=\"block\">\n <h3>Presets</h3>\n <p class=\"note\">One click sets writes, reads, kinds and keep-for together. Limits, identity and people stay.</p>\n <div id=\"presets\"></div>\n <div class=\"row hidden\" id=\"presetsourcerow\"><input class=\"txt mono\" id=\"presetsource\" placeholder=\"wss://source.relay to mirror\" style=\"max-width:24rem\"><span class=\"note\">Replica presets keep a standing pull of their kinds from this relay.</span></div>\n <p class=\"note\" id=\"presetnote\"></p>\n </div>\n <form id=\"access\">\n <div class=\"two\">\n <div><h3>Writes</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"writes\" value=\"open\"><b>Anyone</b><small>Every valid event is accepted, unless banned.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"allowlist\"><b>Members</b><small>Only people on the member list, and you.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"wot\"><b>Members and their follows</b><small>Members, plus everyone in a member's contact list.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"owner\"><b>Only me</b><small>Nobody else can post.</small></label>\n </div></div>\n <div><h3>Reads</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"reads\" value=\"open\"><b>Anyone</b><small>DMs and gift wraps still go only to their parties.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"auth\"><b>Authenticated only</b><small>Every query needs a NIP-42 sign-in first.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"members\"><b>Members only</b><small>Removing a member ends their subscriptions.</small></label>\n </div></div>\n </div>\n <div class=\"form\" style=\"margin-top:1rem\">\n <label><span>Open to anyone <small>kinds anyone may write, whatever the rule above says</small></span><input class=\"txt mono\" name=\"openKinds\" placeholder=\"7, 9735\"></label>\n <label class=\"switch\"><input type=\"checkbox\" name=\"guestReplies\"> Anyone may reply to a member's note</label>\n </div>\n <div class=\"limits\">\n <label><span>Minimum proof of work <small>bits</small></span><input class=\"txt num\" type=\"number\" name=\"minPow\" min=\"0\" max=\"64\"></label>\n <label><span>Reject events dated in the future by more than <small>seconds</small></span><input class=\"txt num\" type=\"number\" name=\"maxFuture\" min=\"0\"></label>\n <label><span>Max events per query</span><input class=\"txt num\" type=\"number\" name=\"maxLimit\" min=\"1\" max=\"5000\"></label>\n <label><span>Max subscriptions per connection</span><input class=\"txt num\" type=\"number\" name=\"maxSubs\" min=\"1\" max=\"200\"></label>\n <label><span>Max message size <small>KB</small></span><input class=\"txt num\" type=\"number\" name=\"maxMessageKB\" min=\"16\" max=\"1024\"></label>\n <label><span>Events per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"eventsPerMinute\" min=\"1\"></label>\n <label><span>Queries per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"reqsPerMinute\" min=\"1\"></label>\n <label><span>Max upload size <small>MB</small></span><input class=\"txt num\" type=\"number\" name=\"maxBlobMB\" min=\"1\" max=\"95\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save rules</button></div>\n </form>\n <div class=\"block\">\n <h3>Kinds</h3>\n <p class=\"note\">An empty allow list means every kind. Blocks always win.</p>\n <form id=\"kindform\" class=\"addrow\"><input class=\"txt num\" type=\"number\" name=\"kind\" min=\"0\" max=\"65535\" placeholder=\"kind\" required style=\"width:7rem;flex:0 0 auto\"><button class=\"ib\" name=\"rule\" value=\"allow\" title=\"Allow kind\" aria-label=\"Allow kind\"><svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg></button><button class=\"ib danger\" name=\"rule\" value=\"block\" title=\"Block kind\" aria-label=\"Block kind\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <p class=\"kindline\">Allowed: <span id=\"kinds-allow\"></span></p>\n <p class=\"kindline\">Blocked: <span id=\"kinds-block\"></span></p>\n </div>\n <div class=\"block\">\n <h3>Features</h3>\n <p class=\"note\">Each is a door or a cost. Off leaves the NIP-11 list, answers 404 at its door and is refused at the socket.</p>\n <div id=\"features\" class=\"limits\"></div>\n </div>\n <div class=\"block\">\n <h3>Blocked words</h3>\n <p class=\"note\">Content containing one is refused. You and your moderators are exempt. An entry written /like this/ is a regular expression.</p>\n <form id=\"wordsform\"><textarea class=\"txt\" name=\"words\" rows=\"4\" placeholder=\"one word, phrase or /pattern/ per line\"></textarea><label class=\"switch\"><input type=\"checkbox\" name=\"inTags\"> Also search tags</label><div class=\"actions\"><button class=\"btn\">Save words</button></div></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"identity\">\n <h2>Identity</h2>\n <form id=\"identity\">\n <h3>Profile</h3>\n <div class=\"form\">\n <label><span>Name</span><input class=\"txt\" name=\"name\" maxlength=\"200\"></label>\n <label><span>Contact</span><input class=\"txt\" name=\"contact\" placeholder=\"mailto:you@example.com\" maxlength=\"500\"></label>\n <label class=\"wide\"><span>Description</span><textarea name=\"description\" maxlength=\"2000\"></textarea></label>\n <label class=\"wide\"><span>Icon URL</span><input class=\"txt\" name=\"icon\" placeholder=\"https://…/icon.png\" maxlength=\"2000\"></label>\n <label class=\"wide\"><span>Banner URL</span><input class=\"txt\" name=\"banner\" placeholder=\"https://…/banner.png\" maxlength=\"2000\"></label>\n </div>\n <h3 class=\"gap\">For directories</h3>\n <div class=\"form\">\n <label><span>Posting policy URL</span><input class=\"txt\" name=\"postingPolicy\" placeholder=\"https://…/posting\" maxlength=\"2000\"></label>\n <label><span>Privacy policy URL</span><input class=\"txt\" name=\"privacyPolicy\" placeholder=\"https://…/privacy\" maxlength=\"2000\"></label>\n <label><span>Tags</span><input class=\"txt\" name=\"tags\" placeholder=\"bitcoin, cats\"></label>\n <label><span>Languages</span><input class=\"txt\" name=\"languageTags\" placeholder=\"en, pt-BR\"></label>\n <label><span>Countries</span><input class=\"txt\" name=\"relayCountries\" placeholder=\"US, DE\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Your own domain</h3>\n <p class=\"note\">Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.</p>\n <form id=\"adddomain\"><div class=\"form\"><label><span>Hostname</span><input class=\"txt mono\" name=\"host\" placeholder=\"relay.example.com\" required></label><label><span>Destination</span><select class=\"txt\" name=\"site\"><option value=\"\">This relay</option></select></label></div><div class=\"actions\"><button class=\"btn\">Add domain</button></div></form>\n <div id=\"domains\"></div>\n <p class=\"note\" id=\"domainnote\"></p>\n </div>\n <div class=\"block\">\n <h3>Your relay lists</h3>\n <p class=\"note\">Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>\n <div id=\"wire\"></div>\n </div>\n <div class=\"block\">\n <h3>Share</h3>\n <p class=\"note\">A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.</p>\n <div class=\"share\">\n <a href=\"/card.svg\" target=\"_blank\" rel=\"noopener\"><img id=\"cardimg\" src=\"/card.svg\" alt=\"relay card\" width=\"600\" height=\"315\"></a>\n <div class=\"share-side\">\n <img id=\"naddrqr\" class=\"hidden\" alt=\"QR code of the group address\" width=\"150\" height=\"150\">\n <code class=\"mono naddr\" id=\"naddr\"></code>\n <div class=\"row\"><button class=\"btn\" id=\"copynaddr\">Copy naddr</button><button class=\"btn\" id=\"copyembed\">Copy embed</button></div>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"data\">\n <h2>Data</h2>\n <p class=\"lead\">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>\n <div class=\"kbar\" id=\"s-bar\"></div><span class=\"legend slegend\" id=\"s-legend\"></span>\n <div class=\"usage\" id=\"s-totals\"></div>\n <div class=\"block\">\n <h3>By kind</h3>\n <div class=\"scroll\"><table id=\"kinds\" class=\"events\"><thead><tr><th>Kind</th><th class=\"r\">Count</th><th class=\"r\">Size</th><th>Oldest</th><th>Keep for</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Files <span class=\"count\" id=\"blobs-count\"></span></h3>\n <div class=\"scroll\"><table id=\"blobs\" class=\"events\"><thead><tr><th>Time</th><th>File</th><th>Size</th><th>Uploader</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Sites <span class=\"count\" id=\"sites-count\"></span></h3>\n <p class=\"note\">Published NIP-5A manifests and the hostnames where they are served.</p>\n <div class=\"scroll\"><table id=\"sites\" class=\"events\"><thead><tr><th>Author</th><th>Name</th><th>URL</th><th class=\"r\">Files</th><th class=\"r\">Size</th><th>Expiry</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Dumps <span class=\"count\" id=\"dumps-count\"></span></h3>\n <p class=\"note\">Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.</p>\n <form id=\"dumpform\" class=\"addrow\"><select class=\"txt\" name=\"dumps\" style=\"max-width:8rem\"><option value=\"off\">off</option><option value=\"daily\">daily</option><option value=\"weekly\">weekly</option></select><input class=\"txt num\" type=\"number\" name=\"keep\" min=\"1\" max=\"60\" value=\"7\" title=\"how many to keep\" style=\"width:5rem;flex:0 0 auto\"><button class=\"btn\">Save</button><button type=\"button\" id=\"dumpnow\" class=\"btn\">Dump now</button></form>\n <ul id=\"dumps\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Import a file</h3>\n <p class=\"note\">A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.</p>\n <form id=\"importform\" class=\"addrow\"><input class=\"txt\" type=\"file\" name=\"file\" accept=\".jsonl,.json,.ndjson,application/json\" required><button class=\"btn\">Import</button></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"sync\">\n <h2>Sync</h2>\n <p class=\"lead\">Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.</p>\n <div class=\"block\">\n <h3>Jobs</h3>\n <p class=\"note\">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>\n <div class=\"scroll\"><table id=\"jobs\" class=\"events\"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><select class=\"txt\" name=\"every\" style=\"max-width:9rem\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select><button class=\"btn\">Pull</button></form>\n <form id=\"backfillform\" class=\"addrow\"><input class=\"txt mono\" name=\"relays\" placeholder=\"relays, comma separated; blank uses your relay list\"><button class=\"btn\">Fetch my history</button></form>\n <form id=\"pushform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Rebroadcast to</span><input class=\"txt mono\" name=\"targets\" placeholder=\"wss://a.relay, wss://b.relay\" required></label>\n <label><span>Kinds <small>blank for all</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label><span>Newer than <small>days, blank for all</small></span><input class=\"txt num\" type=\"number\" name=\"days\" min=\"0\"></label>\n <label><span>Schedule</span><select class=\"txt\" name=\"every\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Rebroadcast</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Fork this relay</h3>\n <p class=\"note\">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>\n <form id=\"forkform\">\n <div class=\"form\">\n <label><span>Name <small>optional</small></span><input class=\"txt mono\" name=\"name\" placeholder=\"a memorable one if blank\" pattern=\"[a-z0-9-]{3,32}\"></label>\n <label><span>Who claims it <small>npub or hex, you if blank</small></span><input class=\"txt mono\" name=\"holder\"></label>\n <label><span>What to copy</span><select class=\"txt\" name=\"scope\"><option value=\"all\">everything</option><option value=\"mine\">only my events</option><option value=\"kinds\">these kinds</option></select></label>\n <label id=\"forkkinds\" class=\"hidden\"><span>Kinds <small>comma separated</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"people\" checked> Copy the people</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Fork</button></div>\n </form>\n <p class=\"note\" id=\"forknote\"></p>\n <div class=\"row hidden\" id=\"forkresult\"><span class=\"mono\" id=\"forkurl\"></span><button class=\"ib\" id=\"forkcopy\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"views\">\n <h2>Views</h2>\n <p class=\"lead\">Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.</p>\n <div class=\"block\">\n <div id=\"views\"></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"health\">\n <h2>Health</h2>\n <div class=\"counters\">\n <div class=\"counter\"><b class=\"big\" id=\"h-last\">–</b><small>since last event</small><span class=\"sub\" id=\"h-last-sub\"></span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-conns\">–</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">–</b><small id=\"h-fuel-label\">fuel</small><span class=\"sub\" id=\"h-fuel-sub\"></span></div>\n <div class=\"counter\"><div class=\"kbar\" id=\"h-kinds\"></div><small>used for, last 30 days</small><span class=\"sub legend\" id=\"h-kinds-legend\"></span></div>\n </div>\n <div class=\"usage\" id=\"usage\"></div>\n <div class=\"block\">\n <h3>Zaps received</h3>\n <div class=\"scroll\"><table id=\"credits\"><thead><tr><th>When</th><th>From</th><th class=\"r\">Sats</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Notifications</h3>\n <p class=\"note\">The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.</p>\n <form id=\"notify\">\n <div class=\"form\">\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"reports\"> A report arrives</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"fuel\"> Fuel runs low</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"jobs\"> A pull finishes</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"succession\"> The handover clock is running</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"digest\"> A note every week on how the relay is doing</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save notifications</button><button type=\"button\" id=\"notifytest\" class=\"btn\">Send a test message</button></div>\n </form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"owner\">\n <h2>Owner</h2>\n <p class=\"lead\">The relay's key, events, files and fuel stay put through everything here except delete.</p>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.</p>\n <div class=\"row\"><button id=\"exportcfg\" class=\"btn\">Export configuration</button><button id=\"importcfg\" class=\"btn\">Import configuration</button><input type=\"file\" id=\"cfgfile\" accept=\"application/json,.json\" class=\"hidden\"></div>\n </div>\n <div class=\"block danger-zone\">\n <h3>Transfer ownership</h3>\n <p class=\"note\">Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.</p>\n <form id=\"transfer\" class=\"addrow\"><select class=\"txt mono\" name=\"pubkey\" required></select><button class=\"btn danger\">Transfer</button></form>\n </div>\n <div class=\"block danger-zone\">\n <h3>If I lose my key</h3>\n <p class=\"note\">Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.</p>\n <form id=\"succession\">\n <div class=\"form\">\n <label><span>Heir <small>a member</small></span><select class=\"txt mono\" name=\"heir\" required></select></label>\n <label><span>Hand over</span><select class=\"txt\" name=\"afterDays\"><option value=\"90\">after 3 months away</option><option value=\"180\" selected>after 6 months away</option><option value=\"365\">after a year away</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Set heir</button><button type=\"button\" id=\"clearsuccession\" class=\"btn hidden\">Clear</button></div>\n </form>\n <p class=\"note\" id=\"successionnote\"></p>\n </div>\n <div class=\"block danger-zone\">\n <h3>Delete this relay</h3>\n <p class=\"note\">Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.</p>\n <button id=\"deleterelay\" class=\"btn danger\">Delete this relay</button>\n </div>\n </section>\n </div>\n\n <footer class=\"pg\"><p>a bind.ws relay</p><a href=\"https://github.com/FelineStateMachine/bindws\">source</a></footer>\n</main>\n";2+export const CONSOLE_HTML = "<main>\n <header class=\"mast\">\n <div class=\"banner hidden\" id=\"banner\"><img id=\"bannerimg\" alt=\"\"></div>\n <div class=\"wordmark\"><img id=\"iconimg\" class=\"icon hidden\" alt=\"\"><h1 id=\"title\"></h1></div>\n <div class=\"left\"><div class=\"care\" id=\"care\"></div></div>\n <div class=\"right\">\n <span class=\"urlrow\"><code class=\"pill\" id=\"url\"></code><button class=\"ib\" id=\"copy\" title=\"Copy relay URL\" aria-label=\"Copy relay URL\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></span>\n <span class=\"owner\"><i class=\"av hidden\" id=\"owner-av\"></i><span id=\"ownerline\"></span><em id=\"who\"></em>\n <button id=\"signin\" class=\"btn hidden\">Sign in with extension</button>\n <button id=\"signin46\" class=\"btn hidden\">Remote signer</button>\n <button id=\"signout\" class=\"ib hidden\" title=\"Sign out\" aria-label=\"Sign out\"><svg viewBox=\"0 0 24 24\"><path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4M15 8l5 4-5 4M20 12H9\"/></svg></button>\n </span>\n </div>\n </header>\n\n <section id=\"unclaimed\" class=\"hidden\">\n <h2>Nobody owns this relay yet.</h2>\n <p class=\"lead\">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>\n <div class=\"row\"><button id=\"claim\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"claimnote\">Use a remote signer</button><span class=\"note\" id=\"claimnote\"></span></div>\n </section>\n\n <section id=\"leased\" class=\"hidden\">\n <h2>A temporary relay, for now.</h2>\n <p class=\"lead\">Anyone can read and write here until <b id=\"lease-until\"></b>. Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.</p>\n <div class=\"row\"><button id=\"claimlease\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"leasenote\">Use a remote signer</button><span class=\"note\" id=\"leasenote\"></span></div>\n </section>\n\n <section id=\"remotesec\" class=\"hidden\">\n <h2>Connect a remote signer.</h2>\n <p class=\"lead\">Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the <span class=\"mono\">bunker://</span> URL the app gives you.</p>\n <div class=\"row\"><a id=\"nclink\" class=\"btn pri\" href=\"#\">Open in your signer app</a><button id=\"nccopy\" class=\"btn\">Copy link</button><span class=\"note\" id=\"ncnote\"></span></div>\n <form id=\"bunkerform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"bunker://…\" required><button class=\"btn\">Connect</button></form>\n <img id=\"ncqr\" class=\"hidden\" alt=\"QR code of the signer link\" width=\"180\" height=\"180\">\n <p class=\"note\" id=\"remotenote\"></p>\n </section>\n\n <section id=\"foldsec\" class=\"folds\">\n <div id=\"frontsec\" class=\"hidden\">\n <p class=\"lead hidden\" id=\"desc\"></p>\n <p class=\"metarow hidden\" id=\"metarow\"></p>\n </div>\n <details class=\"disclosure\" id=\"aboutsec\" name=\"page\"><summary>About, for clients</summary><div id=\"about\" class=\"about\"></div></details>\n <details class=\"disclosure\" id=\"connectsec\" name=\"page\"><summary>Connect</summary>\n <div class=\"doors\">\n <div class=\"door\"><small>nostr relay</small><div class=\"v\"><span id=\"d-ws\"></span><button class=\"ib\" data-copy=\"ws\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>Blossom media</small><div class=\"v\"><span id=\"d-blossom\"></span><button class=\"ib\" data-copy=\"blossom\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <div class=\"door\"><small>names</small><div class=\"v\"><span id=\"d-nip05\"></span></div></div>\n <div class=\"door\"><small>HTTP bridge, NIP-98</small><div class=\"v\"><span>POST /events, /query, /count</span></div></div>\n </div>\n <div class=\"block hidden\" id=\"git-connect\">\n <h3>Git repositories, ntig</h3>\n <div>\n <p class=\"note\">Use ordinary Git to clone a repository hosted here.</p>\n <div class=\"door\"><small>Clone a repository</small><div class=\"v\"><span id=\"git-clone\"></span><button class=\"ib\" id=\"git-copy\" title=\"Copy clone example\" aria-label=\"Copy clone example\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div></div>\n <p class=\"note\">Replace <span class=\"mono\">&lt;npub&gt;</span> with the repository owner's npub, yours for your own repository, and <span class=\"mono\">&lt;repo&gt;</span> with its repository name, such as <span class=\"mono\">my-project</span>.</p>\n <p class=\"note\">To host your own, use a Nostr Git client to publish your repository and signed branch state to this relay before pushing to the same remote. <a href=\"https://github.com/FelineStateMachine/bindws/blob/main/docs/22-grasp-01-git-hosting.md\">Git hosting guide</a>.</p>\n </div>\n </div>\n <div class=\"block\">\n <h3>Open it in an app</h3>\n <p class=\"note\">Relay apps open this relay as a place. Feed apps do not: they find the owner here through a profile link that carries this relay as the hint, and learn the relay from that. Either way, add <span class=\"mono\" id=\"apps-ws\"></span> under the app's relay settings to post here.</p>\n <div class=\"apps\" id=\"apps\"></div>\n <div class=\"phones\" id=\"phones\"></div>\n </div>\n </details>\n </section>\n\n <section id=\"peoplesec\" class=\"hidden\">\n <h2>People <span class=\"count\" id=\"people-count\"></span></h2>\n <p class=\"note hidden\" id=\"people-note\">Hidden from visitors. Only you see this list.</p>\n <div class=\"dir\" id=\"dir\"></div>\n </section>\n\n <section id=\"fuelsec\" class=\"hidden\">\n <h2>Fuel</h2>\n <p class=\"lead\">Free allowance first; past it, usage burns sats. Anyone can zap a top-up.</p>\n <div class=\"gauges\">\n <div class=\"gauge\"><small>Events stored</small><div class=\"bar\"><i id=\"g-events\"></i><b id=\"p-events\"></b></div><span id=\"t-events\"></span></div>\n <div class=\"gauge\"><small>Files stored</small><div class=\"bar\"><i id=\"g-media\"></i><b id=\"p-media\"></b></div><span id=\"t-media\"></span></div>\n <div class=\"gauge\"><small>Awake this month</small><div class=\"bar\"><i id=\"g-active\"></i><b id=\"p-active\"></b></div><span id=\"t-active\"></span></div>\n <div class=\"gauge\"><small>Rows written this month</small><div class=\"bar\"><i id=\"g-rows\"></i><b id=\"p-rows\"></b></div><span id=\"t-rows\"></span></div>\n </div>\n <p class=\"balance\" id=\"fuel-balance\"></p>\n <form id=\"topup\" class=\"topup hidden\"><span class=\"sats\"><input type=\"number\" name=\"sats\" min=\"1\" value=\"1000\"><span>sats</span></span><button class=\"btn pri\">&#9889; Zap to top up</button><span class=\"note\" id=\"topup-note\"></span></form>\n <div id=\"invoice\" class=\"invoice hidden\">\n <p>Pay from any lightning wallet. The receipt lands on this relay and credits it automatically.</p>\n <div class=\"row\"><a id=\"inv-link\" class=\"btn\" href=\"#\">Open in wallet</a><button id=\"inv-copy\" class=\"ib\" title=\"Copy invoice\" aria-label=\"Copy invoice\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button><span class=\"state\" id=\"inv-state\"></span></div>\n <textarea id=\"inv-text\" readonly></textarea>\n </div>\n </section>\n\n <section id=\"myinvites\" class=\"hidden\">\n <h2>Your invites</h2>\n <p class=\"lead\">The owner lets members bring people in. Each link admits one person and lasts three days.</p>\n <div class=\"row\"><button id=\"mintmine\" class=\"btn pri\">New invite link</button><span class=\"note\" id=\"minenote\"></span></div>\n <ul id=\"mine\" class=\"plain\"></ul>\n </section>\n\n\n <div class=\"console hidden\" id=\"console\">\n <nav class=\"tabs\" id=\"tabs\">\n <a href=\"#people\" data-tab=\"people\">People<span class=\"count\" id=\"tc-people\"></span></a>\n <a href=\"#moderation\" data-tab=\"moderation\">Moderation<span class=\"count\" id=\"tc-reports\"></span></a>\n <a href=\"#rules\" data-tab=\"rules\">Rules</a>\n <a href=\"#identity\" data-tab=\"identity\">Identity</a>\n <a href=\"#data\" data-tab=\"data\">Data</a>\n <a href=\"#sync\" data-tab=\"sync\">Sync</a>\n <a href=\"#views\" data-tab=\"views\">Views</a>\n <a href=\"#health\" data-tab=\"health\">Health</a>\n <a href=\"#owner\" data-tab=\"owner\">Owner</a>\n </nav>\n\n <section class=\"panel\" data-panel=\"people\">\n <h2>People</h2>\n <p class=\"lead\">The member list is published as a signed roster; a name makes someone <span class=\"mono\" id=\"nip05-example\"></span>.</p>\n <div class=\"block\">\n <form id=\"addmember\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"name\" placeholder=\"name (optional)\" pattern=\"[a-z0-9._-]{1,64}\" style=\"max-width:9rem\"><input class=\"txt\" name=\"note\" placeholder=\"note\" style=\"max-width:9rem\"><button class=\"ib pri\" title=\"Add member\" aria-label=\"Add member\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <div class=\"scroll\"><table id=\"members\" class=\"people-table\"><thead><tr><th>Who</th><th>Name</th><th>Note</th><th>Limits</th><th>Joined</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <div class=\"two\">\n <div>\n <h3>Invites</h3>\n <form id=\"mintinvite\" class=\"addrow\"><select class=\"txt\" name=\"ttl\" style=\"max-width:8rem\"><option value=\"3600\">1 hour</option><option value=\"86400\">1 day</option><option value=\"259200\" selected>3 days</option><option value=\"2592000\">30 days</option></select><input class=\"txt num\" type=\"number\" name=\"max\" min=\"0\" value=\"0\" title=\"uses, 0 = unlimited\" style=\"width:5rem;flex:0 0 auto\"><input class=\"txt\" name=\"note\" placeholder=\"note\"><button class=\"ib pri\" title=\"New invite\" aria-label=\"New invite\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <ul id=\"invites\" class=\"plain\"></ul>\n <form id=\"treeform\" class=\"addrow hidden\" title=\"Members invite members: how many hops from you the tree reaches, and how many live invites each member may hold\"><span class=\"dim\">members invite</span><input class=\"txt num\" type=\"number\" name=\"depth\" min=\"0\" max=\"10\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">hops deep,</span><input class=\"txt num\" type=\"number\" name=\"quota\" min=\"0\" max=\"100\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">each</span><button class=\"btn\">Save</button></form>\n </div>\n <div>\n <h3>Joining</h3>\n <form id=\"joinform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Shown before someone accepts an invite</span><textarea name=\"joinTerms\" placeholder=\"House rules, expectations, anything a newcomer should agree to.\" maxlength=\"20000\"></textarea></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"directoryPublic\"> Show the people directory to visitors</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save</button></div>\n </form>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"moderation\">\n <h2>Moderation</h2>\n <p class=\"lead\">Reports never show in the feed. Banning also deletes the reported thing.</p>\n <div class=\"block\">\n <h3>Reports <span class=\"count\" id=\"reports-count\"></span></h3>\n <form id=\"thresholdform\" class=\"addrow\"><span class=\"dim\">hide an event once</span><input class=\"txt num\" type=\"number\" name=\"reportThreshold\" min=\"0\" max=\"100\" style=\"width:4.5rem;flex:0 0 auto\"><span class=\"dim\">different people report it; 0 never</span><button class=\"btn\">Save</button></form>\n <div class=\"scroll\"><table id=\"reports\" class=\"events\"><thead><tr><th>Time</th><th>Type</th><th>About</th><th>Reason</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Log</h3>\n <p class=\"dim\">Every change made here or by a moderation event, newest first, the last 5,000.</p>\n <div class=\"scroll\"><table id=\"audit\" class=\"events\"><thead><tr><th>Time</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead><tbody></tbody></table></div>\n <button class=\"btn\" id=\"audit-more\" type=\"button\">Older</button>\n </div>\n <div class=\"block two\">\n <div>\n <h3>Bans</h3>\n <form id=\"addban\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><label class=\"note\" title=\"Also delete everything they wrote and uploaded\"><input type=\"checkbox\" name=\"erase\"> erase</label><button class=\"ib danger\" title=\"Ban\" aria-label=\"Ban\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"bans\" class=\"plain\"></ul>\n <ul id=\"banned-events\" class=\"plain\"></ul>\n </div>\n <div>\n <h3>Blocked addresses</h3>\n <form id=\"addblock\" class=\"addrow\"><input class=\"txt mono\" name=\"ip\" placeholder=\"IP address\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><button class=\"ib danger\" title=\"Block\" aria-label=\"Block\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"blocks\" class=\"plain\"></ul>\n </div>\n </div>\n <div class=\"block\">\n <h3>Recent events</h3>\n <p class=\"note\">Delete removes one thing. Ban also refuses it forever. Search covers notes, articles and profiles.</p>\n <form id=\"searchform\" class=\"addrow\"><input class=\"txt\" name=\"q\" placeholder=\"search\" maxlength=\"200\"><button class=\"btn\">Search</button><button type=\"button\" id=\"searchclear\" class=\"btn hidden\">Clear</button></form>\n <div class=\"scroll\"><table id=\"events\" class=\"events\"><thead><tr><th>Time</th><th>Kind</th><th>Author</th><th>Content</th><th></th></tr></thead><tbody></tbody></table></div>\n <div class=\"actions left\"><button id=\"more\" class=\"btn\">Load more</button></div>\n </div>\n <div class=\"block\">\n <h3>Pinned <span class=\"count\" id=\"pins-count\"></span></h3>\n <p class=\"note\">Group clients show these at the top. Up to 20, in this order.</p>\n <form id=\"pinform\" class=\"addrow\"><input class=\"txt mono\" name=\"id\" placeholder=\"event id or address kind:pubkey:d\" required><button class=\"btn\">Pin</button></form>\n <ul id=\"pins\" class=\"plain\"></ul>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"rules\">\n <h2>Rules</h2>\n <p class=\"lead\">Bans apply regardless of these.</p>\n <div class=\"block\">\n <h3>Presets</h3>\n <p class=\"note\">One click sets writes, reads, kinds and keep-for together. Limits, identity and people stay.</p>\n <div id=\"presets\"></div>\n <div class=\"row hidden\" id=\"presetsourcerow\"><input class=\"txt mono\" id=\"presetsource\" placeholder=\"wss://source.relay to mirror\" style=\"max-width:24rem\"><span class=\"note\">Replica presets keep a standing pull of their kinds from this relay.</span></div>\n <p class=\"note\" id=\"presetnote\"></p>\n </div>\n <form id=\"access\">\n <div class=\"two\">\n <div><h3>Writes</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"writes\" value=\"open\"><b>Anyone</b><small>Every valid event is accepted, unless banned.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"allowlist\"><b>Members</b><small>Only people on the member list, and you.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"wot\"><b>Members and their follows</b><small>Members, plus everyone in a member's contact list.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"owner\"><b>Only me</b><small>Nobody else can post.</small></label>\n </div></div>\n <div><h3>Reads</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"reads\" value=\"open\"><b>Anyone</b><small>DMs and gift wraps still go only to their parties.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"auth\"><b>Authenticated only</b><small>Every query needs a NIP-42 sign-in first.</small></label>\n <label><input type=\"radio\" name=\"reads\" value=\"members\"><b>Members only</b><small>Removing a member ends their subscriptions.</small></label>\n </div></div>\n </div>\n <div class=\"form\" style=\"margin-top:1rem\">\n <label><span>Open to anyone <small>kinds anyone may write, whatever the rule above says</small></span><input class=\"txt mono\" name=\"openKinds\" placeholder=\"7, 9735\"></label>\n <label class=\"switch\"><input type=\"checkbox\" name=\"guestReplies\"> Anyone may reply to a member's note</label>\n </div>\n <div class=\"limits\">\n <label><span>Minimum proof of work <small>bits</small></span><input class=\"txt num\" type=\"number\" name=\"minPow\" min=\"0\" max=\"64\"></label>\n <label><span>Reject events dated in the future by more than <small>seconds</small></span><input class=\"txt num\" type=\"number\" name=\"maxFuture\" min=\"0\"></label>\n <label><span>Max events per query</span><input class=\"txt num\" type=\"number\" name=\"maxLimit\" min=\"1\" max=\"5000\"></label>\n <label><span>Max subscriptions per connection</span><input class=\"txt num\" type=\"number\" name=\"maxSubs\" min=\"1\" max=\"200\"></label>\n <label><span>Max message size <small>KB</small></span><input class=\"txt num\" type=\"number\" name=\"maxMessageKB\" min=\"16\" max=\"1024\"></label>\n <label><span>Events per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"eventsPerMinute\" min=\"1\"></label>\n <label><span>Queries per minute <small>per connection</small></span><input class=\"txt num\" type=\"number\" name=\"reqsPerMinute\" min=\"1\"></label>\n <label><span>Max upload size <small>MB</small></span><input class=\"txt num\" type=\"number\" name=\"maxBlobMB\" min=\"1\" max=\"95\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save rules</button></div>\n </form>\n <div class=\"block\">\n <h3>Kinds</h3>\n <p class=\"note\">An empty allow list means every kind. Blocks always win.</p>\n <form id=\"kindform\" class=\"addrow\"><input class=\"txt num\" type=\"number\" name=\"kind\" min=\"0\" max=\"65535\" placeholder=\"kind\" required style=\"width:7rem;flex:0 0 auto\"><button class=\"ib\" name=\"rule\" value=\"allow\" title=\"Allow kind\" aria-label=\"Allow kind\"><svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg></button><button class=\"ib danger\" name=\"rule\" value=\"block\" title=\"Block kind\" aria-label=\"Block kind\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <p class=\"kindline\">Allowed: <span id=\"kinds-allow\"></span></p>\n <p class=\"kindline\">Blocked: <span id=\"kinds-block\"></span></p>\n </div>\n <div class=\"block\">\n <h3>Features</h3>\n <p class=\"note\">Each is a door or a cost. Off leaves the NIP-11 list, answers 404 at its door and is refused at the socket.</p>\n <div id=\"features\" class=\"limits\"></div>\n </div>\n <div class=\"block\">\n <h3>Blocked words</h3>\n <p class=\"note\">Content containing one is refused. You and your moderators are exempt. An entry written /like this/ is a regular expression.</p>\n <form id=\"wordsform\"><textarea class=\"txt\" name=\"words\" rows=\"4\" placeholder=\"one word, phrase or /pattern/ per line\"></textarea><label class=\"switch\"><input type=\"checkbox\" name=\"inTags\"> Also search tags</label><div class=\"actions\"><button class=\"btn\">Save words</button></div></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"identity\">\n <h2>Identity</h2>\n <form id=\"identity\">\n <h3>Profile</h3>\n <div class=\"form\">\n <label><span>Name</span><input class=\"txt\" name=\"name\" maxlength=\"200\"></label>\n <label><span>Contact</span><input class=\"txt\" name=\"contact\" placeholder=\"mailto:you@example.com\" maxlength=\"500\"></label>\n <label class=\"wide\"><span>Description</span><textarea name=\"description\" maxlength=\"2000\"></textarea></label>\n <label class=\"wide\"><span>Icon URL</span><input class=\"txt\" name=\"icon\" placeholder=\"https://…/icon.png\" maxlength=\"2000\"></label>\n <label class=\"wide\"><span>Banner URL</span><input class=\"txt\" name=\"banner\" placeholder=\"https://…/banner.png\" maxlength=\"2000\"></label>\n </div>\n <h3 class=\"gap\">For directories</h3>\n <div class=\"form\">\n <label><span>Posting policy URL</span><input class=\"txt\" name=\"postingPolicy\" placeholder=\"https://…/posting\" maxlength=\"2000\"></label>\n <label><span>Privacy policy URL</span><input class=\"txt\" name=\"privacyPolicy\" placeholder=\"https://…/privacy\" maxlength=\"2000\"></label>\n <label><span>Tags</span><input class=\"txt\" name=\"tags\" placeholder=\"bitcoin, cats\"></label>\n <label><span>Languages</span><input class=\"txt\" name=\"languageTags\" placeholder=\"en, pt-BR\"></label>\n <label><span>Countries</span><input class=\"txt\" name=\"relayCountries\" placeholder=\"US, DE\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Your own domain</h3>\n <p class=\"note\">Your hostname serves this relay or one of its sites once its CNAME resolves and its certificate is issued.</p>\n <form id=\"adddomain\"><div class=\"form\"><label><span>Hostname</span><input class=\"txt mono\" name=\"host\" placeholder=\"relay.example.com\" required></label><label><span>Destination</span><select class=\"txt\" name=\"site\"><option value=\"\">This relay</option></select></label></div><div class=\"actions\"><button class=\"btn\">Add domain</button></div></form>\n <div id=\"domains\"></div>\n <p class=\"note\" id=\"domainnote\"></p>\n </div>\n <div class=\"block\">\n <h3>Your relay lists</h3>\n <p class=\"note\">Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>\n <div id=\"wire\"></div>\n </div>\n <div class=\"block\">\n <h3>Share</h3>\n <p class=\"note\">A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.</p>\n <div class=\"share\">\n <a href=\"/card.svg\" target=\"_blank\" rel=\"noopener\"><img id=\"cardimg\" src=\"/card.svg\" alt=\"relay card\" width=\"600\" height=\"315\"></a>\n <div class=\"share-side\">\n <img id=\"naddrqr\" class=\"hidden\" alt=\"QR code of the group address\" width=\"150\" height=\"150\">\n <code class=\"mono naddr\" id=\"naddr\"></code>\n <div class=\"row\"><button class=\"btn\" id=\"copynaddr\">Copy naddr</button><button class=\"btn\" id=\"copyembed\">Copy embed</button></div>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"data\">\n <h2>Data</h2>\n <p class=\"lead\">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>\n <div class=\"kbar\" id=\"s-bar\"></div><span class=\"legend slegend\" id=\"s-legend\"></span>\n <div class=\"usage\" id=\"s-totals\"></div>\n <div class=\"block\">\n <h3>By kind</h3>\n <div class=\"scroll\"><table id=\"kinds\" class=\"events\"><thead><tr><th>Kind</th><th class=\"r\">Count</th><th class=\"r\">Size</th><th>Oldest</th><th>Keep for</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Files <span class=\"count\" id=\"blobs-count\"></span></h3>\n <div class=\"scroll\"><table id=\"blobs\" class=\"events\"><thead><tr><th>Time</th><th>File</th><th>Size</th><th>Uploader</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Sites <span class=\"count\" id=\"sites-count\"></span></h3>\n <p class=\"note\">Published NIP-5A manifests and the hostnames where they are served.</p>\n <div class=\"scroll\"><table id=\"sites\" class=\"events\"><thead><tr><th>Author</th><th>Name</th><th>URL</th><th class=\"r\">Files</th><th class=\"r\">Size</th><th>Expiry</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Dumps <span class=\"count\" id=\"dumps-count\"></span></h3>\n <p class=\"note\">Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.</p>\n <form id=\"dumpform\" class=\"addrow\"><select class=\"txt\" name=\"dumps\" style=\"max-width:8rem\"><option value=\"off\">off</option><option value=\"daily\">daily</option><option value=\"weekly\">weekly</option></select><input class=\"txt num\" type=\"number\" name=\"keep\" min=\"1\" max=\"60\" value=\"7\" title=\"how many to keep\" style=\"width:5rem;flex:0 0 auto\"><button class=\"btn\">Save</button><button type=\"button\" id=\"dumpnow\" class=\"btn\">Dump now</button></form>\n <ul id=\"dumps\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Import a file</h3>\n <p class=\"note\">A JSONL of events, one per line, such as a dump or a strfry export, or a JSON array. Up to 64 MB. Signatures are checked; bans and kind rules apply; the write rule does not. Progress shows under Jobs.</p>\n <form id=\"importform\" class=\"addrow\"><input class=\"txt\" type=\"file\" name=\"file\" accept=\".jsonl,.json,.ndjson,application/json\" required><button class=\"btn\">Import</button></form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"sync\">\n <h2>Sync</h2>\n <p class=\"lead\">Events in and out, one round at a time, while the relay sleeps between rounds. Jobs spend awake time, which fuel counts.</p>\n <div class=\"block\">\n <h3>Jobs</h3>\n <p class=\"note\">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>\n <div class=\"scroll\"><table id=\"jobs\" class=\"events\"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><select class=\"txt\" name=\"every\" style=\"max-width:9rem\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select><button class=\"btn\">Pull</button></form>\n <form id=\"backfillform\" class=\"addrow\"><input class=\"txt mono\" name=\"relays\" placeholder=\"relays, comma separated; blank uses your relay list\"><button class=\"btn\">Fetch my history</button></form>\n <form id=\"pushform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Rebroadcast to</span><input class=\"txt mono\" name=\"targets\" placeholder=\"wss://a.relay, wss://b.relay\" required></label>\n <label><span>Kinds <small>blank for all</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label><span>Newer than <small>days, blank for all</small></span><input class=\"txt num\" type=\"number\" name=\"days\" min=\"0\"></label>\n <label><span>Schedule</span><select class=\"txt\" name=\"every\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Rebroadcast</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Fork this relay</h3>\n <p class=\"note\">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>\n <form id=\"forkform\">\n <div class=\"form\">\n <label><span>Name <small>optional</small></span><input class=\"txt mono\" name=\"name\" placeholder=\"a memorable one if blank\" pattern=\"[a-z0-9-]{3,32}\"></label>\n <label><span>Who claims it <small>npub or hex, you if blank</small></span><input class=\"txt mono\" name=\"holder\"></label>\n <label><span>What to copy</span><select class=\"txt\" name=\"scope\"><option value=\"all\">everything</option><option value=\"mine\">only my events</option><option value=\"kinds\">these kinds</option></select></label>\n <label id=\"forkkinds\" class=\"hidden\"><span>Kinds <small>comma separated</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"people\" checked> Copy the people</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Fork</button></div>\n </form>\n <p class=\"note\" id=\"forknote\"></p>\n <div class=\"row hidden\" id=\"forkresult\"><span class=\"mono\" id=\"forkurl\"></span><button class=\"ib\" id=\"forkcopy\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"views\">\n <h2>Views</h2>\n <p class=\"lead\">Records the relay computes and signs, for clients and for anyone. Each run costs the rows it writes.</p>\n <div class=\"block\">\n <div id=\"views\"></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"health\">\n <h2>Health</h2>\n <div class=\"counters\">\n <div class=\"counter\"><b class=\"big\" id=\"h-last\">–</b><small>since last event</small><span class=\"sub\" id=\"h-last-sub\"></span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-conns\">–</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">–</b><small id=\"h-fuel-label\">fuel</small><span class=\"sub\" id=\"h-fuel-sub\"></span></div>\n <div class=\"counter\"><div class=\"kbar\" id=\"h-kinds\"></div><small>used for, last 30 days</small><span class=\"sub legend\" id=\"h-kinds-legend\"></span></div>\n </div>\n <div class=\"usage\" id=\"usage\"></div>\n <div class=\"block\">\n <h3>Zaps received</h3>\n <div class=\"scroll\"><table id=\"credits\"><thead><tr><th>When</th><th>From</th><th class=\"r\">Sats</th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Notifications</h3>\n <p class=\"note\">The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.</p>\n <form id=\"notify\">\n <div class=\"form\">\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"reports\"> A report arrives</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"fuel\"> Fuel runs low</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"jobs\"> A pull finishes</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"succession\"> The handover clock is running</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"digest\"> A note every week on how the relay is doing</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save notifications</button><button type=\"button\" id=\"notifytest\" class=\"btn\">Send a test message</button></div>\n </form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"owner\">\n <h2>Owner</h2>\n <p class=\"lead\">The relay's key, events, files and fuel stay put through everything here except delete.</p>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans, address blocks and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.</p>\n <div class=\"row\"><button id=\"exportcfg\" class=\"btn\">Export configuration</button><button id=\"importcfg\" class=\"btn\">Import configuration</button><input type=\"file\" id=\"cfgfile\" accept=\"application/json,.json\" class=\"hidden\"></div>\n </div>\n <div class=\"block danger-zone\">\n <h3>Transfer ownership</h3>\n <p class=\"note\">Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.</p>\n <form id=\"transfer\" class=\"addrow\"><select class=\"txt mono\" name=\"pubkey\" required></select><button class=\"btn danger\">Transfer</button></form>\n </div>\n <div class=\"block danger-zone\">\n <h3>If I lose my key</h3>\n <p class=\"note\">Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.</p>\n <form id=\"succession\">\n <div class=\"form\">\n <label><span>Heir <small>a member</small></span><select class=\"txt mono\" name=\"heir\" required></select></label>\n <label><span>Hand over</span><select class=\"txt\" name=\"afterDays\"><option value=\"90\">after 3 months away</option><option value=\"180\" selected>after 6 months away</option><option value=\"365\">after a year away</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Set heir</button><button type=\"button\" id=\"clearsuccession\" class=\"btn hidden\">Clear</button></div>\n </form>\n <p class=\"note\" id=\"successionnote\"></p>\n </div>\n <div class=\"block danger-zone\">\n <h3>Delete this relay</h3>\n <p class=\"note\">Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.</p>\n <button id=\"deleterelay\" class=\"btn danger\">Delete this relay</button>\n </div>\n </section>\n </div>\n\n <footer class=\"pg\"><p>a bind.ws relay</p><a href=\"https://github.com/FelineStateMachine/bindws\">source</a></footer>\n</main>\n";33 export const CONSOLE_CSS = "main { max-width: 64rem; }\n.mast { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem 2rem; align-items: end; margin-bottom: 2rem; }\n.mast .wordmark { grid-column: 1 / -1; justify-self: center; margin-bottom: .5rem; }\n.mast .right { display: grid; gap: .7rem; justify-items: end; text-align: right; }\n.urlrow, .owner { display: inline-flex; align-items: center; gap: .4rem; color: var(--ink-2); }\n.owner b { color: var(--ink); } .owner em { font-style: normal; color: var(--forest); }\n.urlrow .ib, .owner .ib { width: 26px; height: 26px; margin-left: .2rem; box-shadow: 1px 1px 0 var(--ink); } .urlrow .ib svg, .owner .ib svg { width: 13px; height: 13px; }\n.care { display: flex; gap: .9rem; flex-wrap: wrap; }\n.care .g { display: grid; justify-items: center; gap: .3rem; width: 5.4rem; }\n.care .g i { display: grid; place-items: center; width: 44px; height: 44px; border: 2px solid var(--ink); border-radius: 10px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); position: relative; }\n.care .g i svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.care .g.off i::after { content: \"\"; position: absolute; width: 3px; height: 40px; background: var(--ink); transform: rotate(45deg); border-radius: 2px; box-shadow: 0 0 0 2px var(--paper); }\n.care .g small { font: 500 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--ink-2); text-align: center; }\n#unclaimed { background: var(--butter); }\n.doors { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; } @media (max-width: 52rem) { .doors { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .doors { grid-template-columns: 1fr; } }\n.door { display: grid; grid-template-rows: auto 1fr; align-content: start; gap: .35rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.apps { display: grid; gap: 1.4rem; margin-top: 1rem; } .appgroup h4 { margin: 0 0 .2rem; font-size: 1rem; } .appgroup > .note { margin-top: 0; }\n.appgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; } @media (max-width: 52rem) { .appgrid { grid-template-columns: 1fr 1fr; } } @media (max-width: 30rem) { .appgrid { grid-template-columns: 1fr; } }\n.app { display: grid; align-content: start; gap: .4rem; padding: .8rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); box-shadow: 3px 3px 0 var(--ink); min-width: 0; }\n.app-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } .app-head b { font-size: 15px; } .app-head small { color: var(--ink-3); } .app p { margin: 0; font-size: 13px; color: var(--ink-2); }\n.app-acts { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; } .app-acts .btn { padding: .3rem .7rem; font-size: 13px; }\n.phones { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, max-content)); gap: .8rem; margin-top: 1rem; } .phones img { display: block; width: 150px; height: 150px; } .phones .door { justify-items: start; }\n.door small { font: 700 11px var(--sans); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }\n.door .v { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: .5rem; font: 500 13px/1.45 var(--mono); } .door .v span { min-width: 0; overflow-wrap: anywhere; }\n.door .ib { width: 24px; height: 24px; box-shadow: 1px 1px 0 var(--ink); flex: 0 0 auto; } .door .ib svg { width: 12px; height: 12px; }\n#peoplesec { background: var(--peach); }\n.dir { display: flex; flex-wrap: wrap; gap: .5rem; }\n.who { display: inline-flex; align-items: center; gap: .1rem; padding: .3rem .7rem .3rem .4rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 2px 2px 0 var(--ink); font-size: 14px; }\n.who .role { font: 700 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--forest); margin-left: .4rem; }\n.who.me { background: var(--sun); }\n#fuelsec { background: var(--mint); }\n.gauges { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }\n.gauge small { display: block; font-weight: 600; font-size: 13px; }\n.gauge .bar { position: relative; height: 22px; border: 2px solid var(--ink); border-radius: 999px; margin: .5rem 0 .35rem; overflow: hidden; padding: 3px; background: var(--paper) linear-gradient(90deg, transparent calc(25% - 1px), var(--line) calc(25% - 1px) 25%, transparent 25% calc(50% - 1px), var(--line) calc(50% - 1px) 50%, transparent 50% calc(75% - 1px), var(--line) calc(75% - 1px) 75%, transparent 75%); }\n.gauge .bar b { position: absolute; right: .6rem; top: 0; bottom: 0; display: flex; align-items: center; font: 600 11px var(--mono); color: var(--ink-3); pointer-events: none; }\n.gauge .bar i { display: block; height: 100%; width: 0; min-width: 14px; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\n.gauge .bar i.warm { background: repeating-linear-gradient(-45deg, #d9a52a 0 8px, var(--sun) 8px 16px); }\n.gauge .bar i.over { background: repeating-linear-gradient(-45deg, var(--red) 0 8px, #d4614c 8px 16px); }\n.gauge span { font-size: 13px; color: var(--ink-3); }\n.balance { margin: 1.2rem 0 0; color: var(--ink-2); } .balance b { color: var(--ink); font-weight: 700; }\n.sats { display: inline-flex; align-items: center; width: auto; } .sats input { font: 15px var(--sans); width: 6rem; text-align: right; border: 0; outline: none; padding: 0; background: transparent; color: var(--ink); } .sats span { color: var(--ink-3); padding-left: .4rem; }\n.topup { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .8rem; }\n.invoice { margin-top: 1rem; border: 2px dashed var(--ink); border-radius: 12px; padding: .9rem 1rem; background: var(--paper); }\n.invoice p { margin: 0 0 .6rem; color: var(--ink-2); font-size: 14px; } .invoice .state { font-size: 13px; color: var(--forest); }\n.invoice textarea { margin-top: .7rem; min-height: 3.4rem; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); }\ndetails.disclosure summary { cursor: pointer; font: 400 1.6rem/1 var(--display); list-style: none; display: flex; align-items: center; gap: .5rem; }\ndetails.disclosure summary::-webkit-details-marker { display: none; }\n.folds details + details, .folds #frontsec:not(.hidden) + details { border-top: 2px solid var(--line); margin-top: 1.2rem; padding-top: 1.2rem; } .folds #frontsec .metarow { margin-bottom: 0; } .folds .doors { margin-top: 1rem; } .folds > details > .block:first-of-type { margin-top: 1.4rem; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"–\"; }\n.about { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .6rem 1.2rem; margin-top: 1rem; font-size: 14px; }\n.about small { display: block; color: var(--ink-3); font-size: 12px; } .about div > div { overflow-wrap: anywhere; }\n.console { margin-top: 3rem; }\n.tabs { display: flex; gap: .45rem; flex-wrap: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 3rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }\n.tabs.fade-r { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2.5rem), transparent); }\n.tabs.fade-l { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem); mask-image: linear-gradient(90deg, transparent, #000 2.5rem); }\n.tabs.fade-l.fade-r { -webkit-mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); mask-image: linear-gradient(90deg, transparent, #000 2.5rem, #000 calc(100% - 2.5rem), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { color: var(--ink); z-index: 3; padding-top: .7rem; } .tabs a.on:nth-child(4n+1) { background: var(--sun); } .tabs a.on:nth-child(4n+2) { background: var(--peach); } .tabs a.on:nth-child(4n+3) { background: var(--sky); } .tabs a.on:nth-child(4n+4) { background: var(--mint); }\n.count { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .4em; margin-left: .45rem; border: 1.5px solid var(--ink); border-radius: 999px; background: var(--sun); color: var(--ink); font: 700 11px/1 var(--mono); vertical-align: middle; box-shadow: 1px 1px 0 var(--ink); } .count:empty { display: none; } h2 .count { font-size: 12px; vertical-align: .45em; }\n.panel { display: none; margin: 0; border-radius: 0 18px 18px 18px; position: relative; z-index: 2; }\n.panel.on { display: block; }\n.panel h2 { font-size: 2rem; }\n.block { margin-top: 1.8rem; }\n.two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } .two > * { min-width: 0; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; align-items: center; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .addrow label { display: inline-flex; align-items: center; gap: .35rem; flex: 0 0 auto; white-space: nowrap; font-size: 14px; color: var(--ink-2); cursor: pointer; } .addrow label input { flex: none; width: auto; margin: 0; accent-color: var(--forest); } .addrow .ib { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; }\n.choices { display: grid; gap: .5rem; }\n.choices label { display: grid; grid-template-columns: auto 1fr; gap: 0 .7rem; cursor: pointer; padding: .7rem .8rem; border: 2px solid var(--line-2); border-radius: 12px; background: var(--paper); }\n.choices label:has(:checked) { border-color: var(--ink); box-shadow: 3px 3px 0 var(--ink); }\n.choices input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--forest); }\n.choices b { font-weight: 600; } .choices small { color: var(--ink-2); font-size: 13px; }\n.limits { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem 1.5rem; margin-top: 1.3rem; }\n.limits label { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .6rem; } .limits label small { color: var(--ink-3); }\n.form { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem 1.5rem; } .form label { display: grid; gap: .3rem; font-weight: 600; font-size: 14px; } .form .wide { grid-column: 1 / -1; }\n.danger-zone { border: 2px solid var(--red); border-radius: 12px; padding: 1rem 1.1rem; background: var(--red-soft); } .danger-zone h3 { color: var(--red); } .form label.switch { display: flex; flex-direction: row; align-items: center; gap: .6rem; font-weight: 600; font-size: 14px; } .switch input { accent-color: var(--forest); width: 18px; height: 18px; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: .4rem .5rem; border-bottom: 2px solid var(--ink); white-space: nowrap; }\ntd { padding: .5rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--line); }\ntbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }\nth.r, td.r { text-align: right; white-space: nowrap; } td.mono, td.dim { white-space: nowrap; } .events td.c:empty::before { content: \"(no content)\"; color: var(--ink-3); }\ntd .ib { width: 26px; height: 26px; box-shadow: 1px 1px 0 var(--ink); } td .ib svg { width: 13px; height: 13px; }\ntd input.txt { padding: .25rem .5rem; font-size: 14px; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li > span { min-width: 0; overflow-wrap: anywhere; } .plain li:last-child { border-bottom: 0; } .plain li.empty { grid-template-columns: 1fr; color: var(--ink-3); }\n.kindline { margin: .4rem 0; color: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; }\n.tag { display: inline-flex; align-items: center; gap: .2rem; font-family: var(--mono); font-size: 13px; padding: .05rem .2rem .05rem .55rem; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); box-shadow: 1px 1px 0 var(--ink); }\n.tag.plain { padding-right: .55rem; box-shadow: none; border-color: var(--line-2); color: var(--ink-3); } .tag.blk { color: var(--red); }\n.tag .ib { width: 18px; height: 18px; border-width: 1.5px; box-shadow: none; margin-left: .2rem; } .tag .ib svg { width: 9px; height: 9px; }\n.counters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; margin-bottom: 1.6rem; }\n.counter { display: grid; justify-items: center; align-content: center; gap: .15rem; min-height: 7.2rem; text-align: center; padding: 1rem .8rem; border: 2px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); }\n.counter:nth-child(1) { background: var(--sun); } .counter:nth-child(2) { background: var(--peach); } .counter:nth-child(3) { background: var(--sky); } .counter:nth-child(4) { background: var(--mint); }\n.counter .big { font: 400 2.6rem/1 var(--display); white-space: nowrap; } .counter small { display: block; margin-top: .35rem; font: 700 12px var(--sans); text-transform: uppercase; letter-spacing: .08em; } .counter .sub { font-size: 12px; color: var(--ink-2); }\n.kbar { display: flex; width: 100%; height: 22px; border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; background: var(--paper); margin-bottom: .35rem; } .kbar i { display: block; height: 100%; border-right: 2px solid var(--ink); } .kbar i:last-child { border-right: 0; }\n.k1 { background: var(--ink); } .k2 { background: var(--forest); } .k3 { background: var(--id-3); } .k4 { background: var(--id-2); } .k5 { background: var(--line-2); }\n.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: .1rem .6rem; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; } .legend i { display: inline-block; width: 9px; height: 9px; border: 1.5px solid var(--ink); border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }\n.usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .8rem; } .usage div { padding: .7rem .9rem; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper); } .usage small { display: block; color: var(--ink-3); font-size: 12px; } .usage b { font: 400 1.5rem/1.2 var(--display); }\n@media (max-width: 52rem) { .two, .gauges, .limits, .form, .counters { grid-template-columns: 1fr !important; } .addrow { flex-wrap: wrap; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events:not(#kinds):not(#jobs) th:first-child, .events:not(#kinds):not(#jobs) td:first-child { display: none; } }\n\n.slegend { justify-content: flex-start; margin: 0 0 1.2rem; }\n#s-totals { margin-bottom: .4rem; }\ntd.keep { white-space: nowrap; } td.keep input { width: 5.5rem; display: inline-block; margin-right: .3rem; } td.keep input::placeholder { color: var(--ink-3); }\n#kinds tr.any td:first-child { font-weight: 600; }\n#kinds td:first-child .kind { display: inline-block; min-width: 5.2em; text-align: center; margin-right: .35rem; }\n/* Tables keep their columns and scroll inside the card on narrow screens instead of pushing the page sideways. */\n.scroll { overflow-x: auto; max-width: 100%; scrollbar-width: thin; } .scroll table { min-width: 100%; }\nsection, .card { overflow-x: clip; }\n.kind.sys { background: var(--mint); border-color: var(--forest); color: var(--forest); }\ntd.keep.sys { color: var(--ink-3); font-size: 13px; white-space: normal; }\n\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; } #console.mod #thresholdform { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } .wire-side { display: contents; } .wire-acts { justify-self: end; } .wire-meta { grid-column: 1 / -1; justify-content: flex-start; text-align: left; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }\n.key { position: relative; display: inline-block; font-family: var(--mono); } .key::before { content: attr(data-short); } .key .full { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; white-space: nowrap; }\n.mast .banner { grid-column: 1 / -1; width: 100%; aspect-ratio: 4 / 1; overflow: hidden; border: 2px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); background: var(--paper); margin-bottom: .5rem; } .mast .banner img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.mast .wordmark { display: flex; align-items: center; gap: 1rem; } .mast .icon { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--ink); background: var(--paper); object-fit: cover; }\n.metarow { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: center; margin: -.4rem 0 1rem; font-size: 13px; color: var(--ink-2); }\n.metarow .tag { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--sun); }\n.metarow .sep { color: var(--ink-3); } .metarow a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); } .metarow a:hover { border-bottom-width: 2px; }\n.about a { color: var(--forest); text-decoration: none; border-bottom: 1.5px solid var(--forest); }\n.wire-acts label { display: inline-flex; align-items: center; gap: .35rem; font-size: 14px; color: var(--ink-2); cursor: pointer; } .wire-acts label input { margin: 0; accent-color: var(--forest); } .wire-row.dim .wire-main { color: var(--ink-3); }\n";4-export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '<span class=\"key\" data-short=\"' + short(hex) + '\" title=\"' + hex + '\"><span class=\"full\">' + hex + '</span></span>' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '<i class=\"av\" style=\"--h:' + hue(hex) + '\"></i>';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c]);\n\n const IC = {\n x: '<svg viewBox=\"0 0 24 24\"><path d=\"M6 6l12 12M18 6L6 18\"/></svg>',\n undo: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 10h10a5 5 0 0 1 0 10h-3M4 10l4-4M4 10l4 4\"/></svg>',\n check: '<svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg>',\n trash: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 7h16M6 7l1.2 13h9.6L18 7\"/></svg>',\n ban: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg>',\n banuser: '<svg viewBox=\"0 0 24 24\"><circle cx=\"10\" cy=\"8\" r=\"4\"/><path d=\"M3 21a7 7 0 0 1 11-5.7M15 15l6 6M21 15l-6 6\"/></svg>',\n eye: '<svg viewBox=\"0 0 24 24\"><path d=\"M2 12s4-6 10-6 10 6 10 6-4 6-10 6S2 12 2 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>',\n lock: '<svg viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10\" width=\"14\" height=\"10\" rx=\"2\"/><path d=\"M8 10V7a4 4 0 0 1 8 0v3\"/></svg>',\n pen: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 20l4-1 11-11-3-3L5 16l-1 4zM13 8l3 3\"/></svg>',\n people: '<svg viewBox=\"0 0 24 24\"><circle cx=\"9\" cy=\"8\" r=\"3.5\"/><circle cx=\"17\" cy=\"9\" r=\"2.5\"/><path d=\"M3 20a6 6 0 0 1 12 0M15 20a4.5 4.5 0 0 1 7-3.5\"/></svg>',\n person: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/></svg>',\n gauge: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 16a8 8 0 0 1 16 0M12 16l4-5\"/><circle cx=\"12\" cy=\"16\" r=\"1.2\"/></svg>',\n bolt: '<svg viewBox=\"0 0 24 24\"><path d=\"M13 3L5 14h6l-1 7 8-11h-6z\"/></svg>',\n copy: '<svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg>',\n pin: '<svg viewBox=\"0 0 24 24\"><path d=\"M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6zM12 15v5\"/></svg>',\n };\n const ib = (icon, label, act, id, extra) => '<button class=\"ib' + (extra ? \" \" + extra : \"\") + '\" title=\"' + label + '\" aria-label=\"' + label + '\" data-act=\"' + act + '\" data-id=\"' + esc(id) + '\">' + IC[icon] + \"</button>\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('<a href=\"' + esc(u) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(u) + '\">' + esc(label) + \"</a>\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('<span class=\"tag\">' + esc(t) + \"</span>\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"<span>\" + esc(where.join(\", \")) + \"</span>\");\n const link = (label, href) => href ? '<a href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"<span>\" + esc(info.contact) + \"</span>\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('<span class=\"sep\">|</span>'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by <b>' + key(owner) + \"</b>\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"<div><small>\" + k + \"</small><div\" + ([\"Owner\", \"NIPs\", \"Relay key\"].includes(k) ? ' class=\"mono\"' : \"\") + \">\" + (v === undefined || v === \"\" ? '<span class=\"muted\">–</span>' : v instanceof Html ? v.s : esc(v)) + \"</div></div>\").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '<span class=\"g' + (off ? \" off\" : \"\") + '\" title=\"' + label + '\"><i>' + IC[icon] + \"</i><small>\" + label + \"</small></span>\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '<span class=\"who' + (m.pubkey === me ? \" me\" : \"\") + '\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + '<span class=\"mono\">' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"</span>\" + (m.role === \"owner\" ? '<span class=\"role\">owner</span>' : \"\") + \"</span>\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? '<span class=\"chip\">out of fuel</span> Writes are paused until someone tops up.'\n : \"Balance <b>\" + sats.toLocaleString() + \" sats</b>. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '<tr><td class=\"dim\">' + esc(fmtTime(c.at)) + '</td><td class=\"mono\" title=\"' + esc(c.payer) + '\">' + av(c.payer) + key(c.payer) + '</td><td class=\"mono r\">' + Math.floor(c.msats / 1000).toLocaleString() + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"3\" class=\"muted\">no zaps yet</td></tr>';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '<i class=\"' + cls + '\" style=\"flex:' + n + '\" title=\"' + name + \" \" + Math.round(n * 100 / total) + '%\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '<span><i class=\"' + cls + '\"></i>' + name + \" \" + Math.round(n * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '<tr data-seq=\"' + r.seq + '\"><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td class=\"mono\" title=\"' + esc(r.actor) + '\">' + av(r.actor) + key(r.actor) + '</td><td><span class=\"kind\">' + esc(r.action) + '</span></td><td class=\"mono\">' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '</td><td class=\"c\">' + esc(r.detail) + '</td></tr>').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || '<tr><td class=\"dim\" colspan=\"5\">Nothing yet</td></tr>';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' <span class=\"chip\">owner</span>' : myRole === \"owner\" ? ' <select class=\"txt role\" title=\"Role\"><option value=\"member\"' + (m.role === \"member\" ? \" selected\" : \"\") + '>member</option><option value=\"moderator\"' + (m.role === \"moderator\" ? \" selected\" : \"\") + '>moderator</option></select>' : m.role === \"moderator\" ? ' <span class=\"chip\">moderator</span>' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? '<input class=\"txt num keep\" type=\"number\" min=\"0\" placeholder=\"days\" title=\"Keep this member\\'s events for this many days; blank follows the relay rules\" value=\"' + (m.keep_days || \"\") + '\"><input class=\"txt num cap\" type=\"number\" min=\"0\" placeholder=\"KB\" title=\"Cap on stored bytes, in KB; blank is unlimited\" value=\"' + (m.max_bytes ? Math.round(m.max_bytes / 1024) : \"\") + '\">'\n : '<span class=\"dim\">' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"</span>\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '<tr data-pk=\"' + m.pubkey + '\"><td class=\"mono\" title=\"' + esc(m.pubkey) + '\" style=\"padding-left:' + (0.5 + m.depth * 1.1) + 'rem\">' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' <span class=\"dim\">via ' + esc(nameOf(m.invited_by)) + \"</span>\" : \"\") + (m.invites ? ' <span class=\"chip\" title=\"live invites\">' + m.invites + \" inv</span>\" : \"\") + '</td><td class=\"name\"><input class=\"txt name\" value=\"' + esc(m.name || \"\") + '\" placeholder=\"name\" pattern=\"[a-z0-9._-]{1,64}\"></td><td><input class=\"txt note\" value=\"' + esc(m.note || \"\") + '\" placeholder=\"note\"></td><td class=\"lim\">' + limits(m) + '</td><td class=\"dim\">' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '</td><td class=\"r\">' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"</td></tr>\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '<option value=\"' + m.pubkey + '\">' + esc(m.name ? m.name + \"@\" + host : key(m.pubkey)) + \"</option>\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + \"</a> \" + esc(i.note || \"\") + ' <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites</li>';\n const person = (r, icon, label, act) => \"<li>\" + av(r.pubkey) + '<span><b class=\"mono\" title=\"' + esc(r.pubkey) + '\">' + short(r.pubkey) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(icon, label, act, r.pubkey) + \"</li>\";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '<li class=\"empty\">nobody banned</li>';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '<li><i class=\"ev\">ip</i><span><b class=\"mono\">' + esc(r.ip) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"</li>\").join(\"\") : '<li class=\"empty\">no addresses blocked</li>';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '<li><i class=\"ev\">ev</i><span><b class=\"mono\" title=\"' + esc(r.id) + '\">' + key(r.id) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"</li>\").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '<tr><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td><span class=\"kind\">' + esc(r.type || \"report\") + '</span></td><td class=\"mono\" title=\"' + esc(r.target_pubkey) + '\">' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' <a class=\"dim\" href=\"/' + esc(r.target_event) + '\" target=\"_blank\" rel=\"noopener\">file ' + key(r.target_event) + \"</a>\" : ' <span class=\"dim\">ev ' + key(r.target_event) + (r.hidden ? ' <span class=\"tag\">hidden</span>' : \"\") + \"</span>\") : \"\") + '</td><td class=\"c\">' + esc(r.content) + '</td><td class=\"r\">' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">nothing reported</td></tr>';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '<tr><td class=\"dim\">' + esc(fmtTime(b.uploaded)) + '</td><td class=\"mono\"><a href=\"' + esc(b.url) + '\" target=\"_blank\" rel=\"noopener\">' + key(b.sha256) + '</a> <span class=\"dim\">' + esc(b.type) + '</span></td><td class=\"mono\">' + fmtBytes(b.size) + '</td><td class=\"mono\" title=\"' + esc(b.uploader) + '\">' + av(b.uploader) + key(b.uploader) + '</td><td class=\"r\">' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no uploads</td></tr>';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '<tr><td class=\"mono\" title=\"' + esc(s.author) + '\">' + av(s.author) + key(s.author) + '</td><td class=\"mono\">' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '</td><td><a href=\"' + esc(s.url) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(s.url) + '\">' + esc(s.url) + '</a></td><td class=\"r mono\">' + esc(s.paths) + (s.missing ? ' <span class=\"chip bad\" title=\"files still being mirrored\">' + esc(s.missing) + ' missing</span>' : '') + '</td><td class=\"r mono\">' + fmtBytes(s.size) + '</td><td class=\"dim\">' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '</td><td class=\"r\">' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '</td></tr>').join(\"\") : '<tr><td colspan=\"7\" class=\"muted\">no sites yet</td></tr>';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '<span class=\"tag ' + cls + '\">' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"</span>\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : '<span class=\"tag plain\">all</span>';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : '<span class=\"tag plain\">none</span>';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"marmot\", \"Marmot transport\", \"Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors.\"],\n [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '<option value=\"' + v + '\"' + (cur === v ? \" selected\" : \"\") + \">\" + l + \"</option>\"; }).join(\"\");\n return \"<label><span>\" + esc(title) + \" <small>\" + esc(about) + \"</small></span><select class=\\\"txt\\\" data-feature=\\\"\" + k + \"\\\">\" + opts + \"</select></label>\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '<select class=\"txt\" data-view=\"' + esc(v.name) + '\" style=\"width:auto\">' + choices.map((c) => '<option value=\"' + c + '\"' + (c === v.trigger ? \" selected\" : \"\") + \">\" + label(c) + \"</option>\").join(\"\") + \"</select>\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"<span>\" + esc(x) + \"</span>\").join('<span class=\"sep\">|</span>');\n return '<div class=\"wire-row' + (v.on ? \"\" : \" dim\") + '\"><div class=\"wire-main\"><b>' + esc(v.name) + '</b><br><small>' + esc(v.about) + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + pick + (v.on ? '<a class=\"btn\" href=\"' + esc(v.path) + '\" target=\"_blank\" rel=\"noopener\">Open</a>' : \"\") + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '<li><i class=\"ev\">jsonl</i><span><b class=\"mono\">' + esc(d.name) + '</b> <span class=\"dim\">' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"</span></span><span>\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"</span></li>\").join(\"\") : '<li class=\"empty\">no dumps yet</li>';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + '</a> <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites yet</li>';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<i class=\"' + c + '\" style=\"flex:' + b + '\" title=\"' + esc(n) + \" \" + fmtBytes(b) + '\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<span><i class=\"' + c + '\"></i>' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" <small>\" + fmtBytes(st.eventBytes) + \"</small>\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" <small>\" + fmtBytes(st.mediaBytes) + \"</small>\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '<span class=\"kind' + (k.protected ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + k.kind + \"</span> \" + esc(kindName(k.kind));\n const cells = k.protected\n ? '<td class=\"keep sys\" colspan=\"2\" title=\"The relay depends on this kind, so it is never expired or purged.\">required</td>'\n : '<td class=\"keep\"><input class=\"txt num days\" type=\"number\" min=\"0\" max=\"36500\" placeholder=\"' + placeholder + '\" value=\"' + own(k.kind) + '\"> days</td><td class=\"r\">' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"</td>\";\n return '<tr class=\"' + (k.kind === null ? \"any\" : \"\") + '\" data-kind=\"' + key + '\"><td>' + pill + '</td><td class=\"r mono\">' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '</td><td class=\"r mono\">' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '</td><td class=\"dim\">' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"</td>\" + cells + \"</tr>\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '<tr><td class=\"dim\">' + esc(fmtTime(e.created_at)) + '</td><td><span class=\"kind' + (SYS_KINDS.has(e.kind) ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + e.kind + '</span></td><td class=\"mono\" title=\"' + e.pubkey + '\">' + av(e.pubkey) + key(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no events yet</td></tr>';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '<li><i class=\"ev\">' + (t[0] === \"e\" ? \"event\" : \"address\") + '</i><span class=\"mono\">' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"</span><span>\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"</span></li>\").join(\"\") : '<li class=\"muted\">nothing pinned</li>';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n return \"<tr><td>\" + what + \"</td><td class=\\\"mono\\\">\" + j.relays.map(esc).join(\"<br>\") + \"</td><td>\" + (f.join(\", \") || \"everything\") + \"</td><td>\" + when + \"</td><td>\" + esc(res) + \"</td><td>\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"</td></tr>\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : '<tr><td colspan=\"6\" class=\"muted\">no jobs yet</td></tr>';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '<button class=\"btn\" data-preset=\"' + esc(p.name) + '\" title=\"' + esc(p.about) + '\">' + esc(p.title) + \"</button>\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '<span class=\"pill ' + cls + '\">' + esc(label) + \"</span>\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-wire=\"' + l.kind + '\" data-do=\"' + act + '\"' + (busy ? \" disabled\" : \"\") + \">\" + label + \"</button>\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"<span>\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"</span>\" : w.state && !busy ? \"<span>no list found</span>\" : \"\";\n const sent = (w.results || []).map((r) => '<span class=\"chip' + (r.ok ? \"\" : \" bad\") + '\" title=\"' + esc(r.msg) + '\">' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"</span>\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '<div class=\"wire-row\"><div class=\"wire-main\"><b>' + l.title + '</b> <small class=\"muted\">' + l.nip + \"</small><br><small>\" + l.about + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + buttons + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '<option value=\"' + esc(s.label) + '\"' + (s.label === current ? \" selected\" : \"\") + \">\" + esc(s.title) + \"</option>\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-domain=\"' + esc(d.host) + '\" data-do=\"' + act + '\">' + label + \"</button>\";\n const rows = d.ready ? \"\" : '<div class=\"scroll\"><table class=\"events\"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>' + d.records.map((r) => \"<tr><td>\" + esc(r.type) + '</td><td class=\"mono\">' + esc(r.name) + '</td><td class=\"mono\">' + esc(r.value) + \"</td><td>\" + esc(r.note) + \"</td></tr>\").join(\"\") + \"</tbody></table></div>\";\n return '<div class=\"row\"><b class=\"mono\">' + esc(d.host) + '</b><span class=\"note\">' + esc(state) + \"</span>\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '</div><label class=\"row\"><span>Destination</span><select class=\"txt\" data-domain-site=\"' + esc(d.host) + '\">' + domainOptions(d.site || \"\") + \"</select></label>\" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '<a class=\"btn\" href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\";\n const app = (label, uri) => '<a class=\"btn\" href=\"' + esc(uri) + '\">' + label + \"</a>\";\n const copy = (label, text) => '<button class=\"btn\" data-copytext=\"' + esc(text) + '\">' + label + \"</button>\";\n const row = (name, where, note, acts) => '<div class=\"app\"><div class=\"app-head\"><b>' + name + \"</b><small>\" + where + \"</small></div><p>\" + note + '</p><div class=\"app-acts\">' + acts.filter(Boolean).join(\"\") + \"</div></div>\";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n el.innerHTML = groups.map(([h, note, rows]) => '<div class=\"appgroup\"><h4>' + h + '</h4><p class=\"note\">' + note + '</p><div class=\"appgrid\">' + rows.join(\"\") + \"</div></div>\").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '<div class=\"door\"><small>' + label + '</small><img src=\"/qr.svg?text=' + enc(text) + '\" alt=\"QR code: ' + esc(label) + '\" width=\"150\" height=\"150\"></div>';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('<a href=\"' + location.origin + '/\"><img src=\"' + location.origin + '/card.svg\" alt=\"' + host + '\" width=\"600\" height=\"315\"></a>'); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n";4+export const CONSOLE_JS = "(async () => {\n const $ = (s) => document.querySelector(s);\n const $$ = (s) => document.querySelectorAll(s);\n const host = location.host;\n const wsURL = (location.protocol === \"https:\" ? \"wss://\" : \"ws://\") + host;\n const rpcURL = location.origin + \"/\";\n let info = null, me = null, owner = \"\", policy = null, fuel = null, people = null, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.\n const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };\n const short = (hex) => hex ? hex.slice(0, 8) + \"…\" + hex.slice(-4) : \"\";\n // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.\n const key = (hex) => hex ? '<span class=\"key\" data-short=\"' + short(hex) + '\" title=\"' + hex + '\"><span class=\"full\">' + hex + '</span></span>' : '';\n const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + \"deg\";\n const av = (hex) => '<i class=\"av\" style=\"--h:' + hue(hex) + '\"></i>';\n const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + \" KB\" : n < 1e9 ? (n / 1e6).toFixed(1) + \" MB\" : (n / 1e9).toFixed(2) + \" GB\";\n const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + \" min\" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + \" h\";\n const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);\n const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }) : \"–\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"–\";\n const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? \"just now\" : s < 3600 ? Math.floor(s / 60) + \" min\" : s < 86400 ? Math.floor(s / 3600) + \" h\" : Math.floor(s / 86400) + \" d\"; };\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c]);\n\n const IC = {\n x: '<svg viewBox=\"0 0 24 24\"><path d=\"M6 6l12 12M18 6L6 18\"/></svg>',\n undo: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 10h10a5 5 0 0 1 0 10h-3M4 10l4-4M4 10l4 4\"/></svg>',\n check: '<svg viewBox=\"0 0 24 24\"><path d=\"M5 12l5 5L20 7\"/></svg>',\n trash: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 7h16M6 7l1.2 13h9.6L18 7\"/></svg>',\n ban: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg>',\n banuser: '<svg viewBox=\"0 0 24 24\"><circle cx=\"10\" cy=\"8\" r=\"4\"/><path d=\"M3 21a7 7 0 0 1 11-5.7M15 15l6 6M21 15l-6 6\"/></svg>',\n eye: '<svg viewBox=\"0 0 24 24\"><path d=\"M2 12s4-6 10-6 10 6 10 6-4 6-10 6S2 12 2 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>',\n lock: '<svg viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10\" width=\"14\" height=\"10\" rx=\"2\"/><path d=\"M8 10V7a4 4 0 0 1 8 0v3\"/></svg>',\n pen: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 20l4-1 11-11-3-3L5 16l-1 4zM13 8l3 3\"/></svg>',\n people: '<svg viewBox=\"0 0 24 24\"><circle cx=\"9\" cy=\"8\" r=\"3.5\"/><circle cx=\"17\" cy=\"9\" r=\"2.5\"/><path d=\"M3 20a6 6 0 0 1 12 0M15 20a4.5 4.5 0 0 1 7-3.5\"/></svg>',\n person: '<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/></svg>',\n gauge: '<svg viewBox=\"0 0 24 24\"><path d=\"M4 16a8 8 0 0 1 16 0M12 16l4-5\"/><circle cx=\"12\" cy=\"16\" r=\"1.2\"/></svg>',\n bolt: '<svg viewBox=\"0 0 24 24\"><path d=\"M13 3L5 14h6l-1 7 8-11h-6z\"/></svg>',\n copy: '<svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg>',\n pin: '<svg viewBox=\"0 0 24 24\"><path d=\"M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6zM12 15v5\"/></svg>',\n };\n const ib = (icon, label, act, id, extra) => '<button class=\"ib' + (extra ? \" \" + extra : \"\") + '\" title=\"' + label + '\" aria-label=\"' + label + '\" data-act=\"' + act + '\" data-id=\"' + esc(id) + '\">' + IC[icon] + \"</button>\";\n\n const CH = \"qpzry9x8gf2tvdw0s3jn54khce6mua7l\";\n function npubToHex(s) {\n s = s.trim().toLowerCase();\n if (/^[0-9a-f]{64}$/.test(s)) return s;\n if (!s.startsWith(\"npub1\")) return null;\n const data = s.slice(5, -6).split(\"\").map((c) => CH.indexOf(c));\n if (data.some((d) => d < 0)) return null;\n let bits = 0, acc = 0, out = [];\n for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }\n return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\") : null;\n }\n\n async function sha256hex(s) {\n const b = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(s));\n return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n }\n // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----\n // The remote path loads the bundled library on first use; its session\n // (client key, signer pubkey, relays) lives in localStorage until sign-out.\n let remote = null, lib = null, pendingNote = null;\n const NO_SIGNER = \"Install a nostr extension (Alby, nos2x, …) or connect a remote signer.\";\n const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + \" did not answer; is the signer app open?\")), ms))]);\n const signer = {\n ready: () => !!remote || !!window.nostr,\n async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, \"The remote signer\"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },\n async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, \"The remote signer\"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },\n };\n async function signerLib() {\n if (lib) return lib;\n if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement(\"script\"); s.src = window.SIGNER_URL || \"/signer.js\"; s.onload = res; s.onerror = () => rej(new Error(\"Could not load the signer library.\")); document.head.appendChild(s); });\n return (lib = window.NostrSigner);\n }\n const onauth = (u) => window.open(u, \"_blank\");\n // The signer conversation rides this relay, and a relay may ask the socket to\n // authenticate before it delivers kind 24133 to its parties; answer with the\n // session's own key.\n const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });\n const saveSession = (sk, s, secret) => localStorage.setItem(\"nip46\", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));\n async function resumeRemote() {\n const raw = localStorage.getItem(\"nip46\");\n if (!raw) return false;\n try {\n const s = JSON.parse(raw); const L = await signerLib();\n remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });\n return true;\n } catch { localStorage.removeItem(\"nip46\"); return false; }\n }\n async function connectBunker(input) {\n const L = await signerLib();\n const bp = await L.parseBunkerInput(input.trim());\n if (!bp) throw new Error(\"That is not a bunker:// URL.\");\n if (!bp.relays.length) throw new Error(\"The bunker URL names no relay.\");\n const sk = L.generateSecretKey();\n const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });\n await withTimeout(s.connect({ name: host, url: location.origin }), 60000, \"The signer\");\n await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);\n remote = s; saveSession(sk, s, bp.secret);\n }\n // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.\n let ncPending = null;\n async function offerNostrConnect() {\n if (ncPending) return ncPending;\n const L = await signerLib();\n const sk = L.generateSecretKey();\n const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));\n const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: [\"sign_event:27235\", \"sign_event:9734\"] });\n $(\"#nclink\").href = uri; $(\"#ncnote\").textContent = \"\";\n $(\"#ncqr\").src = \"/qr.svg?text=\" + encodeURIComponent(uri); $(\"#ncqr\").classList.remove(\"hidden\");\n ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });\n return ncPending;\n }\n async function remoteDone() {\n me = await signer.getPublicKey();\n localStorage.setItem(\"me\", me);\n $(\"#remotesec\").classList.add(\"hidden\");\n toast(\"Remote signer connected\");\n if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }\n pendingNote = null;\n renderHeader(); await loadAdmin(); await loadPeople();\n }\n function showRemote(note) {\n pendingNote = note || null;\n $(\"#remotesec\").classList.remove(\"hidden\");\n $(\"#remotesec\").scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n offerNostrConnect().then(remoteDone).catch((e) => { $(\"#ncnote\").textContent = e.message; });\n }\n\n async function rpc(method, ...params) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const body = JSON.stringify({ method, params });\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", rpcURL], [\"method\", \"POST\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(rpcURL, { method: \"POST\", headers: { \"content-type\": \"application/nostr+json+rpc\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body });\n const json = await resp.json();\n if (json.error) throw new Error(json.error);\n return json.result;\n }\n\n // ---- front of house ----\n // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.\n class Html { constructor(s) { this.s = s; } }\n const urlish = (u) => {\n if (!u) return \"\";\n let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? \"\\u2026\" : \"\") : \"\"); } catch { /* not a URL */ }\n if (!/^https?:\\/\\//.test(u)) return u;\n return new Html('<a href=\"' + esc(u) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(u) + '\">' + esc(label) + \"</a>\");\n };\n function renderHeader() {\n const name = (info && info.name) || host.split(\".\")[0];\n $(\"#title\").textContent = name;\n document.title = name + \" - relay\";\n $(\"#url\").textContent = wsURL;\n $(\"#d-ws\").textContent = wsURL; $(\"#d-blossom\").textContent = location.origin; $(\"#d-nip05\").textContent = \"you@\" + host;\n $(\"#nip05-example\").textContent = \"alice@\" + host;\n const desc = (info && info.description) || \"\";\n $(\"#desc\").textContent = desc; $(\"#desc\").classList.toggle(\"hidden\", !desc);\n // What the owner declared about the relay, on the page, not only in the document clients read.\n const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle(\"hidden\", !url); };\n img(\"#bannerimg\", info?.banner); $(\"#banner\").classList.toggle(\"hidden\", !info?.banner);\n img(\"#iconimg\", info?.icon);\n const meta = [];\n for (const t of info?.tags || []) meta.push('<span class=\"tag\">' + esc(t) + \"</span>\");\n const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];\n if (where.length) meta.push(\"<span>\" + esc(where.join(\", \")) + \"</span>\");\n const link = (label, href) => href ? '<a href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\" : \"\";\n const contact = info?.contact ? (/^(mailto:|https?:\\/\\/)/.test(info.contact) ? link(\"Contact\", info.contact) : \"<span>\" + esc(info.contact) + \"</span>\") : \"\";\n for (const x of [contact, link(\"Terms\", info?.terms_of_service), link(\"Posting policy\", info?.posting_policy), link(\"Privacy\", info?.privacy_policy)]) if (x) meta.push(x);\n $(\"#metarow\").innerHTML = meta.join('<span class=\"sep\">|</span>'); $(\"#metarow\").classList.toggle(\"hidden\", meta.length === 0);\n $(\"#frontsec\").classList.toggle(\"hidden\", !desc && meta.length === 0);\n owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n const lease = !owner && info && info.lease ? info.lease : null;\n $(\"#ownerline\").innerHTML = owner ? 'run by <b>' + key(owner) + \"</b>\" : lease ? \"temporary until \" + fmtDay(lease.expires_at) : \"unclaimed\";\n $(\"#owner-av\").classList.toggle(\"hidden\", !owner);\n if (owner) $(\"#owner-av\").style.setProperty(\"--h\", hue(owner));\n $(\"#who\").textContent = me ? (isOwner ? \"(that's you)\" : \"\") : \"\";\n $(\"#unclaimed\").classList.toggle(\"hidden\", !!owner || !!lease);\n $(\"#leased\").classList.toggle(\"hidden\", !lease);\n if (lease) {\n $(\"#lease-until\").textContent = fmtTime(lease.expires_at);\n $(\"#leasenote\").textContent = lease.holder ? \"Reserved for the key that asked for it; sign with that key.\" : \"\";\n }\n $(\"#signin\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signin46\").classList.toggle(\"hidden\", !!me || !owner);\n $(\"#signout\").classList.toggle(\"hidden\", !me);\n $(\"#about\").innerHTML = [\n [\"Name\", info?.name], [\"Description\", info?.description], [\"Contact\", info?.contact], [\"Owner\", owner ? short(owner) : \"\"], [\"Relay key\", info?.self ? short(info.self) : \"\"], [\"Software\", urlish(info?.software)], [\"Version\", info?.version],\n [\"Max query\", info?.limitation?.max_limit], [\"Auth required\", String(!!info?.limitation?.auth_required)], [\"Restricted writes\", String(!!info?.limitation?.restricted_writes)], [\"Min PoW\", info?.limitation?.min_pow_difficulty || 0],\n [\"Tags\", info?.tags?.join(\", \")], [\"Languages\", info?.language_tags?.join(\", \")], [\"Countries\", info?.relay_countries?.join(\", \")],\n [\"Terms\", urlish(info?.terms_of_service)], [\"Posting policy\", urlish(info?.posting_policy)], [\"Privacy policy\", urlish(info?.privacy_policy)], [\"Icon\", urlish(info?.icon)], [\"Banner\", urlish(info?.banner)],\n [\"NIPs\", info?.supported_nips?.join(\" \")],\n ].map(([k, v]) => \"<div><small>\" + k + \"</small><div\" + ([\"Owner\", \"NIPs\", \"Relay key\"].includes(k) ? ' class=\"mono\"' : \"\") + \">\" + (v === undefined || v === \"\" ? '<span class=\"muted\">–</span>' : v instanceof Html ? v.s : esc(v)) + \"</div></div>\").join(\"\");\n renderCare();\n }\n\n function renderCare() {\n const g = (icon, label, off) => '<span class=\"g' + (off ? \" off\" : \"\") + '\" title=\"' + label + '\"><i>' + IC[icon] + \"</i><small>\" + label + \"</small></span>\";\n const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? \"allowlist\" : \"open\");\n const reads = policy ? policy.reads : (info?.limitation?.auth_required ? \"auth\" : \"open\");\n let out = writes === \"owner\" ? g(\"person\", \"only owner writes\") : writes === \"allowlist\" ? g(\"people\", \"members write\") : writes === \"wot\" ? g(\"people\", \"members and follows write\") : g(\"pen\", \"anyone writes\");\n out += reads === \"members\" ? g(\"people\", \"members read\") : reads === \"auth\" ? g(\"lock\", \"sign in to read\") : g(\"eye\", \"anyone reads\");\n if (fuel) {\n const over = fuelOver();\n out += fuel.outOfFuel ? g(\"gauge\", \"out of fuel\", true) : over ? g(\"bolt\", \"burning sats\") : g(\"gauge\", \"on free allowance\");\n }\n $(\"#care\").innerHTML = out;\n }\n\n async function loadPeople() {\n try { people = await (await fetch(\"/people\")).json(); } catch { return; }\n const isOwner = !!me && me === owner;\n let list = people.people || [];\n if (!people.public && isOwner && policy) list = (window.__members || []);\n $(\"#peoplesec\").classList.toggle(\"hidden\", !owner || list.length === 0);\n $(\"#people-note\").classList.toggle(\"hidden\", people.public);\n $(\"#people-count\").textContent = list.length || \"\";\n $(\"#dir\").innerHTML = list.map((m) => '<span class=\"who' + (m.pubkey === me ? \" me\" : \"\") + '\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + '<span class=\"mono\">' + (m.name ? esc(m.name) + \"@\" + host : key(m.pubkey)) + \"</span>\" + (m.role === \"owner\" ? '<span class=\"role\">owner</span>' : \"\") + \"</span>\").join(\"\");\n }\n\n async function loadInfo() {\n info = await (await fetch(\"/\", { headers: { accept: \"application/nostr+json\" } })).json();\n renderHeader();\n const git = info.supported_grasps?.includes(\"GRASP-01\");\n $(\"#git-connect\").classList.toggle(\"hidden\", !git);\n const clone = \"git clone '\" + location.origin + \"/<npub>/<repo>.git'\";\n $(\"#git-clone\").textContent = clone;\n $(\"#git-copy\").dataset.copytext = clone;\n }\n\n async function loadFuel() {\n try { fuel = await (await fetch(\"/fuel\")).json(); } catch { return; }\n $(\"#fuelsec\").classList.toggle(\"hidden\", !owner);\n const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));\n const gauge = (id, used, free, text) => { const g = $(\"#g-\" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", share > 1); g.classList.toggle(\"warm\", share > 0.75 && share <= 1); $(\"#p-\" + id).textContent = share > 1 ? \"over\" : Math.round(share * 100) + \"%\"; $(\"#t-\" + id).textContent = text; };\n gauge(\"events\", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + \" of \" + fmtBytes(fuel.freeEventBytes) + \" free\");\n gauge(\"media\", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + \" of \" + fmtBytes(fuel.freeMediaBytes) + \" free\");\n gauge(\"active\", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + \" of \" + fmtHours(fuel.freeActiveMs) + \" free\");\n gauge(\"rows\", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + \" of \" + fuel.freeRowsWritten.toLocaleString() + \" free\");\n const sats = Math.floor(fuel.balanceMsats / 1000);\n const r = fuel.rates;\n $(\"#fuel-balance\").innerHTML = fuel.outOfFuel\n ? '<span class=\"chip\">out of fuel</span> Writes are paused until someone tops up.'\n : \"Balance <b>\" + sats.toLocaleString() + \" sats</b>. Past the allowances, prices track what the hosting costs: \" + r.satsPerGBMonthEvents.toLocaleString() + \" sats per GB-month of events, \" + r.satsPerGBMonthMedia.toLocaleString() + \" per GB-month of files, \" + r.satsPerActiveHour.toLocaleString() + \" per hour awake, \" + r.satsPerMillionRows.toLocaleString() + \" per million rows written. Traffic is free.\";\n $(\"#topup\").classList.toggle(\"hidden\", !fuel.enabled);\n // Who paid is the owner's to see: it comes with the signed stats call.\n let credits = [];\n if (owner && signer.ready()) { try { credits = (await rpc(\"stats\")).credits || []; } catch { credits = []; } }\n $(\"#credits tbody\").innerHTML = credits.length ? credits.map((c) => '<tr><td class=\"dim\">' + esc(fmtTime(c.at)) + '</td><td class=\"mono\" title=\"' + esc(c.payer) + '\">' + av(c.payer) + key(c.payer) + '</td><td class=\"mono r\">' + Math.floor(c.msats / 1000).toLocaleString() + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"3\" class=\"muted\">no zaps yet</td></tr>';\n if (!fuel.enabled) $(\"#topup-note\").textContent = \"Top-ups are not enabled on this service yet.\";\n renderCare();\n renderFuelTile();\n }\n\n async function topUp(sats) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const msats = Math.round(sats * 1000);\n const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: \"fuel for \" + host, tags: [[\"p\", fuel.servicePubkey], [\"amount\", String(msats)], [\"relays\", wsURL]] });\n const r = await (await fetch(\"/fuel/invoice\", { method: \"POST\", body: JSON.stringify({ zapRequest }) })).json();\n if (r.error) throw new Error(r.error);\n const inv = r.invoice;\n $(\"#invoice\").classList.remove(\"hidden\");\n $(\"#inv-text\").value = inv; $(\"#inv-link\").href = \"lightning:\" + inv; $(\"#inv-state\").textContent = \"waiting for payment…\";\n if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || \"wallet declined\"); } }\n const before = fuel.creditedMsats;\n for (let i = 0; i < 120; i++) {\n await new Promise((r) => setTimeout(r, 2500));\n await loadFuel();\n if (fuel.creditedMsats > before) { $(\"#inv-state\").textContent = \"paid, thank you\"; toast(\"Fuel credited\"); return; }\n }\n $(\"#inv-state\").textContent = \"no receipt yet; it can take a minute after paying\";\n }\n\n // ---- console ----\n function showTab(name) {\n if (name === \"content\" || name === \"storage\") name = \"data\";\n const known = myRole === \"moderator\" ? [\"people\", \"moderation\"] : [\"people\", \"moderation\", \"rules\", \"identity\", \"data\", \"sync\", \"views\", \"health\", \"owner\"];\n if (!known.includes(name)) name = \"people\";\n $$(\".tabs a\").forEach((a) => a.classList.toggle(\"on\", a.dataset.tab === name));\n $$(\".panel\").forEach((p) => p.classList.toggle(\"on\", p.dataset.panel === name));\n }\n // Tabs switch in place. A fragment still opens a tab when someone arrives with one,\n // but clicking never writes one, and any fragment already there is dropped.\n $$(\".tabs a\").forEach((a) => a.addEventListener(\"click\", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, \"\", location.pathname + location.search); }));\n window.addEventListener(\"hashchange\", () => showTab(location.hash.slice(1)));\n // The strip fades on whichever side has more tabs off screen.\n const tabsFade = () => { const t = $(\"#tabs\"); t.classList.toggle(\"fade-l\", t.scrollLeft > 4); t.classList.toggle(\"fade-r\", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };\n $(\"#tabs\").addEventListener(\"scroll\", tabsFade); window.addEventListener(\"resize\", tabsFade); new ResizeObserver(tabsFade).observe($(\"#tabs\"));\n\n function renderFuelTile() {\n if (!fuel) return;\n const over = fuelOver();\n const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));\n const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));\n if (fuel.outOfFuel) { $(\"#h-fuel\").textContent = \"0 sats\"; $(\"#h-fuel-label\").textContent = \"out of fuel\"; $(\"#h-fuel-sub\").textContent = \"writes are paused\"; }\n else if (over) { $(\"#h-fuel\").textContent = sats.toLocaleString() + \" sats\"; $(\"#h-fuel-label\").textContent = \"fuel left\"; $(\"#h-fuel-sub\").textContent = \"past the free allowance\"; }\n else { $(\"#h-fuel\").textContent = days + (days === 1 ? \" day\" : \" days\"); $(\"#h-fuel-label\").textContent = \"free allowance left\"; $(\"#h-fuel-sub\").textContent = sats ? \"then \" + sats.toLocaleString() + \" sats\" : \"then a top-up is needed\"; }\n $(\"#usage\").innerHTML = [[\"events stored\", fmtBytes(fuel.eventBytes)], [\"files stored\", fmtBytes(fuel.mediaBytes)], [\"awake this month\", fmtHours(fuel.activeMs)], [\"rows written\", fuel.rowsWritten.toLocaleString()], [\"rows read\", fuel.rowsRead.toLocaleString()], [\"received this month\", fmtBytes(fuel.bytesIn)], [\"served this month\", fmtBytes(fuel.bytesOut)], [\"charged\", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + \" sats\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n }\n\n function renderHealth(stats) {\n $(\"#h-last\").textContent = stats.newest ? ago(stats.newest) : \"none\";\n $(\"#h-last-sub\").textContent = stats.newest ? fmtTime(stats.newest) : \"no events yet\";\n $(\"#h-conns\").textContent = stats.connections;\n const buckets = [[\"notes\", \"k1\", [1]], [\"reactions\", \"k2\", [7]], [\"DMs\", \"k3\", [4, 14, 1059]], [\"long-form\", \"k4\", [30023]], [\"other\", \"k5\", null]];\n const sums = buckets.map(() => 0); let total = 0;\n for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }\n const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);\n $(\"#h-kinds\").innerHTML = total ? parts.map(([name, cls, n]) => '<i class=\"' + cls + '\" style=\"flex:' + n + '\" title=\"' + name + \" \" + Math.round(n * 100 / total) + '%\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#h-kinds-legend\").innerHTML = total ? parts.map(([name, cls, n]) => '<span><i class=\"' + cls + '\"></i>' + name + \" \" + Math.round(n * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n renderFuelTile();\n }\n\n async function loadAdmin() {\n const isOwner = !!me && me === owner;\n myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n loadMine();\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(\", \"); fa.guestReplies.checked = !!p.guestReplies;\n $(\"#wordsform\").words.value = (p.blockedWords || []).join(\"\\n\"); $(\"#wordsform\").inTags.checked = !!p.blockedWordsInTags; $(\"#thresholdform\").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n renderFeatures(p.features || {});\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;\n const fj = $(\"#joinform\"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;\n loadCard();\n fi.banner.value = p.banner || \"\"; fi.postingPolicy.value = p.postingPolicy || \"\"; fi.privacyPolicy.value = p.privacyPolicy || \"\";\n fi.tags.value = (p.tags || []).join(\", \"); fi.languageTags.value = (p.languageTags || []).join(\", \"); fi.relayCountries.value = (p.relayCountries || []).join(\", \");\n const fn = $(\"#notify\"), nt = p.notify || {};\n fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;\n showTab(location.hash.slice(1));\n if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); }\n await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);\n }\n\n async function loadAudit(before) {\n const rows = await rpc(\"listaudit\", before || 0);\n const html = rows.map((r) => '<tr data-seq=\"' + r.seq + '\"><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td class=\"mono\" title=\"' + esc(r.actor) + '\">' + av(r.actor) + key(r.actor) + '</td><td><span class=\"kind\">' + esc(r.action) + '</span></td><td class=\"mono\">' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '</td><td class=\"c\">' + esc(r.detail) + '</td></tr>').join(\"\");\n const tb = $(\"#audit tbody\");\n if (before) tb.insertAdjacentHTML(\"beforeend\", html); else tb.innerHTML = html || '<tr><td class=\"dim\" colspan=\"5\">Nothing yet</td></tr>';\n $(\"#audit-more\").style.display = rows.length < 100 ? \"none\" : \"\";\n }\n $(\"#audit-more\").onclick = guard(async () => { const last = $(\"#audit tbody tr:last-child\"); await loadAudit(last ? +last.dataset.seq : 0); });\n async function loadLists() {\n loadAudit(0);\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100), rpc(\"listblockedips\"), rpc(\"listsites\")]);\n const members = mem.members;\n window.__members = members;\n const roleCell = (m) => m.role === \"owner\" ? ' <span class=\"chip\">owner</span>' : myRole === \"owner\" ? ' <select class=\"txt role\" title=\"Role\"><option value=\"member\"' + (m.role === \"member\" ? \" selected\" : \"\") + '>member</option><option value=\"moderator\"' + (m.role === \"moderator\" ? \" selected\" : \"\") + '>moderator</option></select>' : m.role === \"moderator\" ? ' <span class=\"chip\">moderator</span>' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.\n const known = new Set(members.map((m) => m.pubkey));\n const byInviter = new Map();\n for (const m of members) { const k = m.role !== \"owner\" && known.has(m.invited_by) ? m.invited_by : \"\"; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }\n const ordered = [], placed = new Set();\n const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };\n walk(\"\", 0);\n for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }\n const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };\n const limits = (m) => m.role === \"owner\" ? \"\" : myRole === \"owner\"\n ? '<input class=\"txt num keep\" type=\"number\" min=\"0\" placeholder=\"days\" title=\"Keep this member\\'s events for this many days; blank follows the relay rules\" value=\"' + (m.keep_days || \"\") + '\"><input class=\"txt num cap\" type=\"number\" min=\"0\" placeholder=\"KB\" title=\"Cap on stored bytes, in KB; blank is unlimited\" value=\"' + (m.max_bytes ? Math.round(m.max_bytes / 1024) : \"\") + '\">'\n : '<span class=\"dim\">' + (m.keep_days ? m.keep_days + \" d\" : \"\") + (m.max_bytes ? \" \" + fmtBytes(m.max_bytes) : \"\") + \"</span>\";\n $(\"#members tbody\").innerHTML = ordered.map((m) => '<tr data-pk=\"' + m.pubkey + '\"><td class=\"mono\" title=\"' + esc(m.pubkey) + '\" style=\"padding-left:' + (0.5 + m.depth * 1.1) + 'rem\">' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' <span class=\"dim\">via ' + esc(nameOf(m.invited_by)) + \"</span>\" : \"\") + (m.invites ? ' <span class=\"chip\" title=\"live invites\">' + m.invites + \" inv</span>\" : \"\") + '</td><td class=\"name\"><input class=\"txt name\" value=\"' + esc(m.name || \"\") + '\" placeholder=\"name\" pattern=\"[a-z0-9._-]{1,64}\"></td><td><input class=\"txt note\" value=\"' + esc(m.note || \"\") + '\" placeholder=\"note\"></td><td class=\"lim\">' + limits(m) + '</td><td class=\"dim\">' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '</td><td class=\"r\">' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"</td></tr>\").join(\"\");\n const tf = $(\"#treeform\");\n tf.classList.toggle(\"hidden\", myRole !== \"owner\");\n if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '<option value=\"' + m.pubkey + '\">' + esc(m.name ? m.name + \"@\" + host : key(m.pubkey)) + \"</option>\").join(\"\");\n $(\"#succession [name=heir]\").innerHTML = $(\"#transfer [name=pubkey]\").innerHTML;\n $(\"#tc-people\").textContent = members.length;\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#invites\").innerHTML = invites.length ? invites.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + \"</a> \" + esc(i.note || \"\") + ' <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites</li>';\n const person = (r, icon, label, act) => \"<li>\" + av(r.pubkey) + '<span><b class=\"mono\" title=\"' + esc(r.pubkey) + '\">' + short(r.pubkey) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(icon, label, act, r.pubkey) + \"</li>\";\n $(\"#bans\").innerHTML = bans.length ? bans.map((r) => person(r, \"undo\", \"Unban\", \"unrulepubkey\")).join(\"\") : '<li class=\"empty\">nobody banned</li>';\n $(\"#blocks\").innerHTML = blocks.length ? blocks.map((r) => '<li><i class=\"ev\">ip</i><span><b class=\"mono\">' + esc(r.ip) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"undo\", \"Unblock\", \"unblockip\", r.ip) + \"</li>\").join(\"\") : '<li class=\"empty\">no addresses blocked</li>';\n $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '<li><i class=\"ev\">ev</i><span><b class=\"mono\" title=\"' + esc(r.id) + '\">' + key(r.id) + \"</b> \" + esc(r.reason || \"\") + \"</span>\" + ib(\"check\", \"Allow again\", \"allowevent\", r.id) + \"</li>\").join(\"\");\n $(\"#reports tbody\").innerHTML = reports.length ? reports.map((r) => '<tr><td class=\"dim\">' + esc(fmtTime(r.at)) + '</td><td><span class=\"kind\">' + esc(r.type || \"report\") + '</span></td><td class=\"mono\" title=\"' + esc(r.target_pubkey) + '\">' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' <a class=\"dim\" href=\"/' + esc(r.target_event) + '\" target=\"_blank\" rel=\"noopener\">file ' + key(r.target_event) + \"</a>\" : ' <span class=\"dim\">ev ' + key(r.target_event) + (r.hidden ? ' <span class=\"tag\">hidden</span>' : \"\") + \"</span>\") : \"\") + '</td><td class=\"c\">' + esc(r.content) + '</td><td class=\"r\">' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", r.blob ? \"Delete the file\" : \"Delete the event\", \"resolve:delete\", r.id) + ib(\"banuser\", \"Ban the author\", \"resolve:ban\", r.id, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">nothing reported</td></tr>';\n $(\"#reports-count\").textContent = reports.length || \"\"; $(\"#tc-reports\").textContent = reports.length || \"\";\n $(\"#blobs tbody\").innerHTML = blobs.length ? blobs.map((b) => '<tr><td class=\"dim\">' + esc(fmtTime(b.uploaded)) + '</td><td class=\"mono\"><a href=\"' + esc(b.url) + '\" target=\"_blank\" rel=\"noopener\">' + key(b.sha256) + '</a> <span class=\"dim\">' + esc(b.type) + '</span></td><td class=\"mono\">' + fmtBytes(b.size) + '</td><td class=\"mono\" title=\"' + esc(b.uploader) + '\">' + av(b.uploader) + key(b.uploader) + '</td><td class=\"r\">' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no uploads</td></tr>';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n $(\"#sites tbody\").innerHTML = sites.length ? sites.map((s) => '<tr><td class=\"mono\" title=\"' + esc(s.author) + '\">' + av(s.author) + key(s.author) + '</td><td class=\"mono\">' + esc(s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + '</td><td><a href=\"' + esc(s.url) + '\" target=\"_blank\" rel=\"noopener\" title=\"' + esc(s.url) + '\">' + esc(s.url) + '</a></td><td class=\"r mono\">' + esc(s.paths) + (s.missing ? ' <span class=\"chip bad\" title=\"files still being mirrored\">' + esc(s.missing) + ' missing</span>' : '') + '</td><td class=\"r mono\">' + fmtBytes(s.size) + '</td><td class=\"dim\">' + esc(s.expires_at ? fmtTime(s.expires_at) : \"never\") + '</td><td class=\"r\">' + ib(\"trash\", \"Delete site\", \"deleteevent\", s.id, \"danger\") + '</td></tr>').join(\"\") : '<tr><td colspan=\"7\" class=\"muted\">no sites yet</td></tr>';\n $(\"#sites-count\").textContent = sites.length || \"\";\n const tag = (k, cls) => '<span class=\"tag ' + cls + '\">' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"</span>\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : '<span class=\"tag plain\">all</span>';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : '<span class=\"tag plain\">none</span>';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n // Features (settings.ts): a select each; search has three modes, the rest on or off.\n const FEATURES = [\n [\"search\", \"Search\", \"NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.\", [\"prose:prose\", \"full:full\", \"off:off\"]],\n [\"sync\", \"Sync\", \"NIP-77 reconciliation, which reads the whole matching set per sync.\"],\n [\"count\", \"Counts\", \"NIP-45 COUNT, with HLL sketches.\"],\n [\"discovery\", \"Discovery record\", \"NIP-66: the record the relay signs about itself, for crawlers.\"],\n [\"names\", \"Names\", \"NIP-05 addresses under this relay's domain.\"],\n [\"files\", \"Files\", \"Blossom and NIP-96: uploads, downloads and listings.\"],\n [\"pages\", \"Pages and feed\", \"Notes and articles as pages, and the Atom feed.\"],\n [\"sites\", \"Static websites\", \"NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.\", [\"mirror:on, mirror files\", \"proxy:on, fetch as needed\", \"off:off\"]],\n [\"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 [\"signer\", \"Signer traffic\", \"NIP-46 remote signing carried for anyone, never stored.\"],\n ];\n function renderFeatures(f) {\n $(\"#features\").innerHTML = FEATURES.map(([k, title, about, modes]) => {\n const cur = k === \"sites\" ? (f.sites?.enabled === false ? \"off\" : f.sites?.mirror === false ? \"proxy\" : \"mirror\") : modes ? String(f[k] || \"prose\") : String(f[k] !== false);\n const opts = (modes || [\"true:on\", \"false:off\"]).map((m) => { const [v, l] = m.split(\":\"); return '<option value=\"' + v + '\"' + (cur === v ? \" selected\" : \"\") + \">\" + l + \"</option>\"; }).join(\"\");\n return \"<label><span>\" + esc(title) + \" <small>\" + esc(about) + \"</small></span><select class=\\\"txt\\\" data-feature=\\\"\" + k + \"\\\">\" + opts + \"</select></label>\";\n }).join(\"\");\n }\n $(\"#features\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-feature]\"); if (!sel) return;\n const k = sel.dataset.feature, v = k === \"search\" || k === \"sites\" ? sel.value : sel.value === \"true\";\n policy = await rpc(\"setpolicy\", { features: { [k]: k === \"sites\" ? { enabled: v !== \"off\", mirror: v === \"mirror\" } : v } });\n toast(k === \"search\" ? \"Search: \" + v : (v ? \"Switched on \" : \"Switched off \") + k); await loadInfo();\n }));\n\n async function loadViews() {\n let views;\n try { views = await rpc(\"listviews\"); } catch { return; }\n $(\"#views\").innerHTML = views.map((v) => {\n const runs = v.trigger === \"off\" ? \"off\" : v.trigger === \"live\" ? \"live, from memory\" : v.trigger === \"write\" ? \"on write and daily\" : v.trigger;\n const label = (c) => (c === \"off\" ? \"off\" : c === \"write\" ? \"on write\" : c === \"live\" ? \"on\" : c);\n const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);\n const pick = '<select class=\"txt\" data-view=\"' + esc(v.name) + '\" style=\"width:auto\">' + choices.map((c) => '<option value=\"' + c + '\"' + (c === v.trigger ? \" selected\" : \"\") + \">\" + label(c) + \"</option>\").join(\"\") + \"</select>\";\n const who = v.audience === \"members\" ? \"members\" + (v.stored ? \"\" : \", on request\") : \"anyone\";\n const last = v.trigger === \"live\" ? \"\" : v.last ? \"last run \" + fmtTime(v.last.at) : \"not run yet\";\n const rows = v.trigger === \"live\" ? \"no rows\" : v.last ? v.last.rows.toLocaleString() + \" rows\" : \"\";\n const meta = [runs, who, last, rows].filter(Boolean).map((x) => \"<span>\" + esc(x) + \"</span>\").join('<span class=\"sep\">|</span>');\n return '<div class=\"wire-row' + (v.on ? \"\" : \" dim\") + '\"><div class=\"wire-main\"><b>' + esc(v.name) + '</b><br><small>' + esc(v.about) + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + pick + (v.on ? '<a class=\"btn\" href=\"' + esc(v.path) + '\" target=\"_blank\" rel=\"noopener\">Open</a>' : \"\") + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n $(\"#views\").addEventListener(\"change\", guard(async (ev) => {\n const sel = ev.target.closest(\"select[data-view]\"); if (!sel) return;\n const value = sel.value === \"live\" ? true : sel.value;\n await rpc(\"setpolicy\", { views: { [sel.dataset.view]: value } });\n toast(sel.value === \"off\" ? \"Took down \" + sel.dataset.view : sel.dataset.view + \": \" + (sel.value === \"live\" ? \"on\" : sel.value === \"write\" ? \"on write\" : sel.value)); await loadViews(); await loadInfo();\n }));\n async function loadDumps() {\n const list = await rpc(\"listdumps\");\n const f = $(\"#dumpform\");\n if (policy) { f.dumps.value = policy.dumps || \"off\"; f.keep.value = policy.dumpsKeep || 7; }\n $(\"#dumps-count\").textContent = list.length || \"\";\n $(\"#dumps\").innerHTML = list.length ? list.map((d) => '<li><i class=\"ev\">jsonl</i><span><b class=\"mono\">' + esc(d.name) + '</b> <span class=\"dim\">' + d.events.toLocaleString() + \" events, \" + fmtBytes(d.bytes) + \"</span></span><span>\" + ib(\"copy\", \"Download\", \"downloaddump\", d.name) + ib(\"trash\", \"Delete\", \"deletedump\", d.name, \"danger\") + \"</span></li>\").join(\"\") : '<li class=\"empty\">no dumps yet</li>';\n }\n // A dump is fetched with a signed request and handed to the browser as a file.\n async function downloadDump(name) {\n if (!signer.ready()) throw new Error(NO_SIGNER);\n const url = location.origin + \"/dumps/\" + name;\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"GET\"]] });\n const resp = await fetch(url, { headers: { authorization: \"Nostr \" + btoa(JSON.stringify(ev)) } });\n if (!resp.ok) throw new Error((await resp.json()).error || \"download failed\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(\".\")[0] + \"-\" + name; a.click(); URL.revokeObjectURL(a.href);\n }\n // A plain member's own invites, when the owner lets members invite.\n async function loadMine() {\n const sec = $(\"#myinvites\");\n if (!me || !owner || myRole || !signer.ready()) { sec.classList.add(\"hidden\"); return; }\n let mine;\n try { mine = await rpc(\"listinvites\"); } catch { sec.classList.add(\"hidden\"); return; }\n sec.classList.remove(\"hidden\");\n const link = (code) => location.origin + \"/invite/\" + code;\n $(\"#mine\").innerHTML = mine.length ? mine.map((i) => '<li><i class=\"ev\">inv</i><span><a class=\"mono\" href=\"/invite/' + i.code + '\">…' + i.code.slice(-8) + '</a> <span class=\"dim\">' + i.uses + (i.max_uses ? \"/\" + i.max_uses : \"\") + \" used, until \" + esc(fmtDay(i.expires_at)) + \"</span></span><span>\" + ib(\"copy\", \"Copy link\", \"copy\", link(i.code)) + ib(\"x\", \"Revoke\", \"revokeinvite\", i.code) + \"</span></li>\").join(\"\") : '<li class=\"empty\">no invites yet</li>';\n }\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollJobs();\n loadViews();\n loadDumps().catch(() => {});\n const st = storage;\n const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);\n const parts = [...top.map((k, i) => [k.kind + \" \" + kindName(k.kind), \"k\" + (i + 1), k.bytes]), ...(rest ? [[\"other\", \"k5\", rest]] : [])];\n const total = st.eventBytes || 1;\n $(\"#s-bar\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<i class=\"' + c + '\" style=\"flex:' + b + '\" title=\"' + esc(n) + \" \" + fmtBytes(b) + '\"></i>').join(\"\") : '<i class=\"k5\" style=\"flex:1\"></i>';\n $(\"#s-legend\").innerHTML = parts.length ? parts.map(([n, c, b]) => '<span><i class=\"' + c + '\"></i>' + esc(n) + \" \" + Math.round(b * 100 / total) + \"%</span>\").join(\"\") : \"<span>no events yet</span>\";\n const overhead = Math.max(0, st.databaseBytes - st.eventBytes);\n $(\"#s-totals\").innerHTML = [[\"events\", st.events.toLocaleString() + \" <small>\" + fmtBytes(st.eventBytes) + \"</small>\"], [\"index and overhead\", fmtBytes(overhead)], [\"files\", st.blobs.toLocaleString() + \" <small>\" + fmtBytes(st.mediaBytes) + \"</small>\"]]\n .map(([k, v]) => \"<div><small>\" + k + \"</small><b>\" + v + \"</b></div>\").join(\"\");\n const any = st.retention.find((r) => r.kind === null);\n const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : \"\"; };\n const row = (k) => {\n const key = k.kind === null ? \"\" : String(k.kind);\n const placeholder = k.kind === null ? \"forever\" : k.replaceable || !any ? \"forever\" : any.days + \" (everything else)\";\n const pill = k.kind === null ? \"everything else\" : '<span class=\"kind' + (k.protected ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + k.kind + \"</span> \" + esc(kindName(k.kind));\n const cells = k.protected\n ? '<td class=\"keep sys\" colspan=\"2\" title=\"The relay depends on this kind, so it is never expired or purged.\">required</td>'\n : '<td class=\"keep\"><input class=\"txt num days\" type=\"number\" min=\"0\" max=\"36500\" placeholder=\"' + placeholder + '\" value=\"' + own(k.kind) + '\"> days</td><td class=\"r\">' + ib(\"check\", \"Save keep-for rule\", \"saveretention\", key) + ib(\"trash\", \"Purge\", \"purgekind\", key, \"danger\") + \"</td>\";\n return '<tr class=\"' + (k.kind === null ? \"any\" : \"\") + '\" data-kind=\"' + key + '\"><td>' + pill + '</td><td class=\"r mono\">' + (k.n === undefined ? \"\" : k.n.toLocaleString()) + '</td><td class=\"r mono\">' + (k.bytes === undefined ? \"\" : fmtBytes(k.bytes)) + '</td><td class=\"dim\">' + (k.oldest ? esc(fmtDay(k.oldest)) : \"\") + \"</td>\" + cells + \"</tr>\";\n };\n $(\"#kinds tbody\").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join(\"\");\n }\n\n let searchQuery = \"\";\n async function loadEvents(reset) {\n const list = searchQuery ? await rpc(\"searchevents\", searchQuery, 200) : await rpc(\"listrecentevents\", reset ? 50 : 200);\n $(\"#events tbody\").innerHTML = list.length ? list.map((e) => '<tr><td class=\"dim\">' + esc(fmtTime(e.created_at)) + '</td><td><span class=\"kind' + (SYS_KINDS.has(e.kind) ? ' sys\" title=\"required: the relay depends on this kind' : \"\") + '\">' + e.kind + '</span></td><td class=\"mono\" title=\"' + e.pubkey + '\">' + av(e.pubkey) + key(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + ib(\"pin\", \"Pin\", \"pinevent\", e.id) + ib(\"trash\", \"Delete event\", \"deleteevent\", e.id) + ib(\"ban\", \"Ban event\", \"banevent\", e.id, \"danger\") + ib(\"banuser\", \"Ban author\", \"banpubkey\", e.pubkey, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no events yet</td></tr>';\n $(\"#more\").classList.toggle(\"hidden\", searchQuery || list.length < 50);\n }\n $(\"#searchform\").onsubmit = guard(async (ev) => {\n searchQuery = ev.target.q.value.trim();\n $(\"#searchclear\").classList.toggle(\"hidden\", !searchQuery);\n await loadEvents(true);\n });\n $(\"#searchclear\").onclick = guard(async () => {\n searchQuery = \"\"; $(\"#searchform\").reset(); $(\"#searchclear\").classList.add(\"hidden\");\n await loadEvents(true);\n });\n async function loadPins() {\n const pins = await rpc(\"listpins\");\n $(\"#pins-count\").textContent = pins.length || \"\";\n $(\"#pins\").innerHTML = pins.length ? pins.map((t) => '<li><i class=\"ev\">' + (t[0] === \"e\" ? \"event\" : \"address\") + '</i><span class=\"mono\">' + esc(t[1].length > 40 ? t[1].slice(0, 16) + \"\\u2026\" + t[1].slice(-8) : t[1]) + \"</span><span>\" + ib(\"copy\", \"Copy\", \"copy\", t[1]) + ib(\"x\", \"Unpin\", \"unpinevent\", t[1]) + \"</span></li>\").join(\"\") : '<li class=\"muted\">nothing pinned</li>';\n }\n $(\"#pinform\").onsubmit = guard(async (ev) => {\n await rpc(\"pinevent\", ev.target.id.value.trim()); ev.target.reset(); toast(\"Pinned\"); await loadPins();\n });\n\n // ---- actions ----\n const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);\n\n $(\"#copy\").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast(\"copied \" + wsURL); };\n $(\"#signin\").onclick = guard(async () => {\n if (!window.nostr) throw new Error(\"No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signin46\").onclick = () => showRemote(null);\n $$(\".remote\").forEach((b) => { b.onclick = () => showRemote($(\"#\" + b.dataset.note)); });\n $(\"#nccopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#nclink\").href); toast(\"Link copied; paste it into your signer app\"); };\n $(\"#bunkerform\").onsubmit = guard(async (ev) => {\n $(\"#remotenote\").textContent = \"Connecting…\";\n try { await connectBunker(ev.target.url.value); } catch (e) { $(\"#remotenote\").textContent = e.message; throw e; }\n $(\"#remotenote\").textContent = \"\"; ev.target.reset();\n await remoteDone();\n });\n $(\"#signout\").onclick = async () => {\n if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, \"The signer\"); } catch { /* the session is gone either way */ } }\n localStorage.removeItem(\"nip46\");\n me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople();\n };\n const claimNow = async (note) => {\n if (!signer.ready()) { note.textContent = \"Sign the claim with a nostr extension or a remote signer.\"; showRemote(note); return; }\n me = await signer.getPublicKey();\n const r = await rpc(\"claim\");\n if (!r.claimed) throw new Error(\"Somebody else claimed it first.\");\n localStorage.setItem(\"me\", me);\n toast(\"It's yours.\");\n await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();\n if (r.converted && confirm(\"This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.\")) {\n await rpc(\"resetrules\"); toast(\"Rules reset\"); await loadInfo(); await loadAdmin();\n }\n };\n $(\"#claim\").onclick = guard(() => claimNow($(\"#claimnote\")));\n $(\"#claimlease\").onclick = guard(() => claimNow($(\"#leasenote\")));\n // Jobs run in the background; the table follows them while the tab is open.\n let jobsTimer = 0;\n const fmtJob = (j) => {\n const what = j.kind === \"mirror\" ? \"mirror site\" : j.kind === \"import\" ? \"import\" : j.label === \"backfill\" ? \"fetch my history\" : j.kind === \"pull\" ? \"pull\" : \"rebroadcast\";\n const f = [];\n if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? \"my events\" : j.filter.authors.length + \" authors\");\n if (j.filter.kinds) f.push(\"kinds \" + j.filter.kinds.join(\", \"));\n if (j.filter.since) f.push(\"since \" + fmtDay(j.filter.since));\n const when = j.every ? \"every \" + (j.every === 24 ? \"day\" : j.every + \" h\") + (j.nextRun ? \", next \" + fmtTime(j.nextRun) : \"\") : \"once\";\n const l = j.last;\n const count = (stored, blobs, sent, refused) => (j.kind === \"mirror\" ? blobs.toLocaleString() + \" files mirrored\" : j.kind === \"import\" ? stored.toLocaleString() + \" events\" + ((j.last ? j.last.duplicates : j.duplicates) ? \", \" + (j.last ? j.last.duplicates : j.duplicates) + \" already here\" : \"\") : j.kind === \"pull\" ? stored.toLocaleString() + \" events\" + (blobs ? \", \" + blobs + \" files\" : \"\") : sent.toLocaleString() + \" sent\" + (refused ? \", \" + refused + \" refused\" : \"\"));\n const res = j.running ? \"running: \" + count(j.stored, j.blobs, j.sent, j.refused) + \"...\" : !l ? \"waiting\" : l.error ? \"failed: \" + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? \", \" + l.skipped + \" skipped\" : \"\") + \", \" + fmtTime(l.finishedAt);\n return \"<tr><td>\" + what + \"</td><td class=\\\"mono\\\">\" + j.relays.map(esc).join(\"<br>\") + \"</td><td>\" + (f.join(\", \") || \"everything\") + \"</td><td>\" + when + \"</td><td>\" + esc(res) + \"</td><td>\" + (j.running ? \"\" : ib(\"undo\", \"Run now\", \"runjob\", j.id)) + ib(\"x\", \"Remove\", \"removejob\", j.id) + \"</td></tr>\";\n };\n async function pollJobs() {\n clearTimeout(jobsTimer);\n let jobs;\n try { jobs = await rpc(\"listjobs\"); } catch { return; }\n $(\"#jobs tbody\").innerHTML = jobs.length ? jobs.map(fmtJob).join(\"\") : '<tr><td colspan=\"6\" class=\"muted\">no jobs yet</td></tr>';\n if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);\n }\n const urls = (s) => s.split(/[\\s,]+/).map((u) => u.trim()).filter(Boolean);\n const kindsOf = (s) => s.split(/[\\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"addjob\", { kind: \"pull\", relays: [f.url.value.trim()], every: +f.every.value });\n toast(+f.every.value ? \"Mirror scheduled\" : \"Pull started\"); f.reset(); await pollJobs();\n });\n $(\"#backfillform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"backfill\", urls(f.relays.value));\n toast(\"Fetching your history\"); f.reset(); await pollJobs();\n });\n $(\"#pushform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const filter = {};\n const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;\n const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;\n await rpc(\"addjob\", { kind: \"push\", relays: urls(f.targets.value), filter, every: +f.every.value });\n toast(\"Rebroadcast started\"); f.reset(); await pollJobs();\n });\n // ---- presets: writes, reads, kinds and keep-for rules in one click ----\n let presets = null;\n async function renderPresets() {\n if (!presets) { try { presets = await rpc(\"listpresets\"); } catch { presets = []; } }\n $(\"#presets\").innerHTML = presets.map((p) => '<button class=\"btn\" data-preset=\"' + esc(p.name) + '\" title=\"' + esc(p.about) + '\">' + esc(p.title) + \"</button>\").join(\"\");\n $(\"#presetsourcerow\").classList.toggle(\"hidden\", !presets.some((p) => p.source));\n }\n $(\"#presets\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-preset]\"); if (!b) return;\n const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;\n if (!confirm(p.title + \": \" + p.about + \"\\n\\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules. Limits, identity, people and bans stay.\")) return;\n const source = $(\"#presetsource\").value.trim();\n if (p.source === \"required\" && !source) { $(\"#presetnote\").textContent = p.title + \" needs a source relay to mirror; enter its wss:// URL first.\"; return; }\n b.disabled = true;\n try { policy = await rpc(\"applypreset\", p.name, p.source && source ? { source } : undefined); $(\"#presetnote\").textContent = \"Now: \" + p.about + (policy.job ? \" Mirroring \" + source + \" every \" + policy.job.every + \" h.\" : \"\"); toast(p.title + \" applied\"); await loadInfo(); await loadAdmin(); }\n catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- wire me in: this relay in the owner's own lists ----\n // The lists are replaceable, so a fresh list with only this relay would\n // clobber the real one once it spread. Every publish starts from the\n // newest copy found here or on the indexers, verified when the signer\n // library is around, and adds or removes this relay in it.\n const INDEXERS = [\"wss://purplepag.es\", \"wss://relay.nostr.band\", \"wss://relay.damus.io\", \"wss://nos.lol\"];\n const LISTS = [\n { kind: 10002, tag: \"r\", title: \"Relay list\", nip: \"NIP-65\", about: \"where clients read your notes and send you mentions\" },\n { kind: 10050, tag: \"relay\", title: \"DM inbox\", nip: \"NIP-17\", about: \"where people send you private messages\" },\n { kind: 10007, tag: \"relay\", title: \"Search relays\", nip: \"NIP-51\", about: \"where clients run your searches\" },\n { kind: 10063, tag: \"server\", title: \"Blossom servers\", nip: \"BUD-03\", about: \"where clients upload and look for your files\" },\n ];\n const mineFor = (l) => (l.tag === \"server\" ? location.origin : wsURL);\n const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\\/+$/, \"\").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };\n const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || \"\") === normURL(mineFor(l));\n const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));\n // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.\n function mergeList(list, l, include) {\n const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));\n if (include) tags.unshift([l.tag, mineFor(l)]);\n return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : \"\", tags };\n }\n const relaysIn = (list) => list.tags.filter((t) => (t[0] === \"r\" || t[0] === \"relay\") && /^wss?:\\/\\//i.test(t[1] || \"\")).map((t) => t[1].trim());\n // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.\n function overWS(url, ms, first, onMessage) {\n return new Promise((res) => {\n let ws = null, done = false, out = null;\n const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };\n const timer = setTimeout(finish, ms);\n try { ws = new WebSocket(url); } catch { return finish(); }\n ws.onopen = () => ws.send(JSON.stringify(first));\n ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };\n ws.onerror = finish; ws.onclose = finish;\n });\n }\n function fetchNewest(url, filter, ms) {\n let best = null;\n return overWS(url, ms, [\"REQ\", \"w\", filter], (d, set) => {\n if (d[0] === \"EVENT\" && d[1] === \"w\" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }\n return (d[0] === \"EOSE\" || d[0] === \"CLOSED\") && d[1] === \"w\";\n });\n }\n function publishTo(url, event, ms) {\n return overWS(url, ms, [\"EVENT\", event], (d, set) => { if (d[0] === \"OK\" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || \"\" }); return true; } return false; })\n .then((r) => r || { url, ok: false, msg: \"no answer\" });\n }\n // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.\n async function bridge(path, body) {\n const url = location.origin + path, raw = JSON.stringify(body);\n const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"POST\"], [\"payload\", await sha256hex(raw)]] });\n const resp = await fetch(url, { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Nostr \" + btoa(JSON.stringify(ev)) }, body: raw });\n const json = await resp.json();\n if (!resp.ok) throw new Error(json.error || \"HTTP \" + resp.status);\n return json;\n }\n const wire = {};\n function renderWire() {\n const pill = (state) => {\n const cls = state === \"listed\" ? \"on\" : state === \"failed\" ? \"bad\" : state === \"not listed\" ? \"off\" : \"\";\n const label = state === \"checking\" || state === \"publishing\" ? state + \"\\u2026\" : state;\n return '<span class=\"pill ' + cls + '\">' + esc(label) + \"</span>\";\n };\n $(\"#wire\").innerHTML = LISTS.map((l) => {\n const w = wire[l.kind] || { state: \"\", results: [], list: null };\n const busy = w.state === \"checking\" || w.state === \"publishing\";\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-wire=\"' + l.kind + '\" data-do=\"' + act + '\"' + (busy ? \" disabled\" : \"\") + \">\" + label + \"</button>\";\n const buttons = w.state === \"listed\" ? btn(\"remove\", \"\", \"Remove this relay\") : btn(\"add\", \"pri\", \"Add this relay\") + btn(\"check\", \"\", w.state ? \"Check again\" : \"Check\");\n const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;\n const from = w.list ? \"<span>\" + (n === 1 ? \"1 entry\" : n + \" entries\") + \", \" + fmtTime(w.list.created_at) + \"</span>\" : w.state && !busy ? \"<span>no list found</span>\" : \"\";\n const sent = (w.results || []).map((r) => '<span class=\"chip' + (r.ok ? \"\" : \" bad\") + '\" title=\"' + esc(r.msg) + '\">' + esc(r.url.replace(/^wss?:\\/\\//, \"\")) + (r.ok ? \"\" : \" failed\") + \"</span>\").join(\"\");\n const meta = (w.state ? pill(w.state) : \"\") + from + sent;\n return '<div class=\"wire-row\"><div class=\"wire-main\"><b>' + l.title + '</b> <small class=\"muted\">' + l.nip + \"</small><br><small>\" + l.about + '</small></div><div class=\"wire-side\"><div class=\"wire-acts\">' + buttons + '</div><div class=\"wire-meta\">' + meta + \"</div></div></div>\";\n }).join(\"\");\n }\n async function wireCheck(l) {\n const w = (wire[l.kind] = { state: \"checking\", list: null, results: [] }); renderWire();\n const filter = { kinds: [l.kind], authors: [me], limit: 1 };\n const found = [];\n try { found.push(...(await bridge(\"/query\", [filter]))); } catch { /* the relay may hold none */ }\n const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));\n let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }\n for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);\n w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;\n w.state = listHas(w.list, l) ? \"listed\" : \"not listed\"; renderWire();\n }\n async function wirePublish(l, include) {\n if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === \"failed\") await wireCheck(l);\n const w = wire[l.kind]; w.state = \"publishing\"; w.results = []; renderWire();\n try {\n const signed = await signer.signEvent(mergeList(w.list, l, include));\n const here = await bridge(\"/events\", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || \"\" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));\n const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));\n const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));\n w.results = [here, ...rest]; w.list = signed; w.state = include ? \"listed\" : \"not listed\";\n toast((include ? \"Added to your \" : \"Removed from your \") + l.title.toLowerCase());\n } catch (e) { w.state = \"failed\"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }\n renderWire();\n }\n $(\"#wire\").addEventListener(\"click\", (ev) => {\n const b = ev.target.closest(\"button[data-wire]\"); if (!b) return;\n const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;\n if (b.dataset.do === \"check\") wireCheck(l); else wirePublish(l, b.dataset.do === \"add\");\n });\n\n // ---- custom domains: this relay under a hostname the owner controls ----\n let domainSites = [];\n const domainOptions = (current = \"\") => [{ label: \"\", title: \"This relay\" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? \"snapshot\" : \"root\")) + \" / \" + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: \"Site \" + current }] : [])].map((s) => '<option value=\"' + esc(s.label) + '\"' + (s.label === current ? \" selected\" : \"\") + \">\" + esc(s.title) + \"</option>\").join(\"\");\n let domains = null; // null: not enabled on this host\n function renderDomains() {\n $(\"#domains\").innerHTML = (domains || []).map((d) => {\n const state = d.ready ? \"active\" : \"hostname \" + d.status.replace(/_/g, \" \") + \", certificate \" + d.sslStatus.replace(/_/g, \" \");\n const btn = (act, cls, label) => '<button class=\"btn ' + cls + '\" data-domain=\"' + esc(d.host) + '\" data-do=\"' + act + '\">' + label + \"</button>\";\n const rows = d.ready ? \"\" : '<div class=\"scroll\"><table class=\"events\"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>' + d.records.map((r) => \"<tr><td>\" + esc(r.type) + '</td><td class=\"mono\">' + esc(r.name) + '</td><td class=\"mono\">' + esc(r.value) + \"</td><td>\" + esc(r.note) + \"</td></tr>\").join(\"\") + \"</tbody></table></div>\";\n return '<div class=\"row\"><b class=\"mono\">' + esc(d.host) + '</b><span class=\"note\">' + esc(state) + \"</span>\" + btn(\"check\", \"\", \"Check\") + btn(\"remove\", \"danger\", \"Remove\") + '</div><label class=\"row\"><span>Destination</span><select class=\"txt\" data-domain-site=\"' + esc(d.host) + '\">' + domainOptions(d.site || \"\") + \"</select></label>\" + rows;\n }).join(\"\");\n }\n async function loadDomains() {\n try { [domains, domainSites] = await Promise.all([rpc(\"listdomains\"), rpc(\"listsites\")]); $(\"#adddomain select[name=site]\").innerHTML = domainOptions(); $(\"#domainnote\").textContent = domains.length ? \"\" : \"No custom domain yet.\"; $(\"#adddomain\").classList.remove(\"hidden\"); }\n catch (e) { domains = null; $(\"#domainnote\").textContent = /^unsupported/.test(e.message) ? \"Custom domains are not enabled on this host.\" : e.message; $(\"#adddomain\").classList.add(\"hidden\"); }\n renderDomains();\n }\n $(\"#adddomain\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"adddomain\", f.host.value.trim(), f.site.value); f.reset(); toast(\"Domain added; now create the CNAME\"); await loadDomains();\n });\n $(\"#domains\").addEventListener(\"change\", guard(async (ev) => {\n const select = ev.target.closest(\"select[data-domain-site]\"); if (!select) return;\n await rpc(\"setdomainsite\", select.dataset.domainSite, select.value);\n toast(\"Domain destination saved\"); await loadDomains();\n }));\n $(\"#domains\").addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-domain]\"); if (!b) return;\n const host = b.dataset.domain;\n if (b.dataset.do === \"remove\" && !confirm(\"Remove \" + host + \"? Its certificate goes with it and the name stops answering.\")) return;\n b.disabled = true;\n try {\n if (b.dataset.do === \"remove\") { await rpc(\"removedomain\", host); toast(\"Removed\"); }\n else { const d = await rpc(\"checkdomain\", host); toast(d.ready ? host + \" is live\" : \"Not yet: \" + (d.status === \"active\" ? \"certificate pending\" : \"waiting for the CNAME\")); }\n await loadDomains();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const openKinds = f.openKinds.value.split(/[\\s,]+/).filter(Boolean).map(Number);\n if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error(\"Open kinds must be whole numbers.\");\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#notify\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });\n toast(\"Notifications saved\");\n });\n $(\"#notifytest\").onclick = guard(async () => {\n const r = await rpc(\"notifytest\");\n toast(r.sent ? \"Sent. Look for a message from the relay in your DMs.\" : \"Could not send.\");\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const list = (v) => v.split(\",\").map((s) => s.trim()).filter(Boolean);\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,\n banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople(); loadCard();\n });\n $(\"#addmember\").onsubmit = guard(async (ev) => {\n const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"setmember\", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast(\"Member added\"); await loadLists();\n });\n $(\"#addblock\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"blockip\", f.ip.value.trim(), f.reason.value); f.reset(); toast(\"Blocked\"); await loadLists();\n });\n $(\"#wordsform\").onsubmit = guard(async (ev) => {\n const words = ev.target.words.value.split(\"\\n\").map((s) => s.trim()).filter(Boolean);\n const kept = await rpc(\"setblockedwords\", words);\n policy = await rpc(\"setpolicy\", { blockedWordsInTags: ev.target.inTags.checked });\n ev.target.words.value = kept.join(\"\\n\"); toast(kept.length ? kept.length + \" words blocked\" : \"No words blocked\");\n });\n $(\"#thresholdform\").onsubmit = guard(async (ev) => {\n policy = await rpc(\"setpolicy\", { reportThreshold: +ev.target.reportThreshold.value });\n toast(policy.reportThreshold ? \"Hidden after \" + policy.reportThreshold + \" reports\" : \"Never hidden by reports\"); await loadLists();\n });\n $(\"#addban\").onsubmit = guard(async (ev) => {\n const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error(\"That is not a pubkey.\");\n await rpc(\"banpubkey\", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? \"Banned and erased\" : \"Banned\"); await loadLists();\n });\n $(\"#mintinvite\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const inv = await rpc(\"createinvite\", +f.ttl.value, +f.max.value, f.note.value);\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n f.note.value = \"\"; toast(\"Invite created and copied\"); await loadLists();\n });\n $(\"#kindform\").onsubmit = guard(async (ev) => {\n const rule = ev.submitter.value; const k = +ev.target.kind.value;\n await rpc(rule === \"allow\" ? \"allowkind\" : \"disallowkind\", k); ev.target.reset(); toast((rule === \"allow\" ? \"Allowed kind \" : \"Blocked kind \") + k); await loadLists();\n });\n $(\"#more\").onclick = guard(() => loadEvents(false));\n $(\"#dumpform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });\n toast(policy.dumps === \"off\" ? \"Dumps off\" : \"Dumping \" + policy.dumps); await loadDumps();\n });\n $(\"#importform\").onsubmit = guard(async (ev) => {\n const file = ev.target.file.files[0]; if (!file) return;\n if (!signer.ready()) throw new Error(NO_SIGNER);\n if (file.size > 64 * 1024 * 1024) throw new Error(\"At most 64 MB per import.\");\n const body = await file.text();\n const url = location.origin + \"/import?name=\" + encodeURIComponent(file.name);\n const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: \"\", tags: [[\"u\", url], [\"method\", \"PUT\"], [\"payload\", await sha256hex(body)]] });\n const resp = await fetch(url, { method: \"PUT\", headers: { authorization: \"Nostr \" + btoa(JSON.stringify(token)), \"content-type\": \"application/x-ndjson\" }, body });\n const r = await resp.json();\n if (!resp.ok) throw new Error(r.error || \"import failed\");\n ev.target.reset(); toast(\"Importing \" + fmtBytes(r.bytes)); await pollJobs();\n });\n $(\"#dumpnow\").onclick = guard(async () => { const d = await rpc(\"dumpnow\"); toast(\"Dumped \" + d.events.toLocaleString() + \" events\"); await loadStorage(); });\n $(\"#treeform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });\n toast(policy.memberInvites.depth ? \"Members may invite\" : \"Only you and moderators invite\");\n });\n $(\"#mintmine\").onclick = guard(async () => {\n const inv = await rpc(\"createinvite\", 259200, 1, \"\");\n await navigator.clipboard.writeText(location.origin + \"/invite/\" + inv.code).catch(() => {});\n toast(\"Invite created and copied\"); await loadMine();\n });\n $(\"#exportcfg\").onclick = guard(async () => {\n const cfg = await rpc(\"exportconfig\");\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: \"application/json\" }));\n a.download = host.split(\".\")[0] + \".bind.ws.config.json\";\n a.click(); URL.revokeObjectURL(a.href);\n });\n $(\"#importcfg\").onclick = () => $(\"#cfgfile\").click();\n $(\"#cfgfile\").onchange = guard(async (ev) => {\n const file = ev.target.files[0]; if (!file) return;\n const cfg = JSON.parse(await file.text());\n const check = await rpc(\"importconfig\", cfg, { dryRun: true });\n const lines = check.changes.summary.length ? check.changes.summary.join(\"\\n\") : \"Nothing would change.\";\n const dropped = check.warnings.length ? \"\\n\\nNot taken:\\n\" + check.warnings.join(\"\\n\") : \"\";\n if (!confirm(\"Apply \" + file.name + \" to this relay?\\n\\n\" + lines + dropped)) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // Succession: the status line under the heir form, from successionstatus.\n async function renderSuccession() {\n const el = $(\"#successionnote\"), f = $(\"#succession\");\n let st;\n try { st = await rpc(\"successionstatus\"); } catch { el.textContent = \"\"; return; }\n const sc = st.succession;\n $(\"#clearsuccession\").classList.toggle(\"hidden\", !sc);\n f.querySelector(\"button.btn:not(#clearsuccession)\").textContent = sc ? \"Change heir\" : \"Set heir\";\n if (!sc) { el.textContent = \"No heir named. Last signed in \" + fmtTime(st.ownerSeenAt) + \".\"; return; }\n if (f.heir.querySelector('[value=\"' + sc.heir + '\"]')) f.heir.value = sc.heir;\n f.afterDays.value = String(sc.afterDays);\n const who = (window.__members || []).find((m) => m.pubkey === sc.heir);\n const heir = who && who.name ? who.name + \"@\" + host : short(sc.heir);\n el.textContent = \"Heir: \" + heir + \". Last signed in \" + fmtTime(st.ownerSeenAt) + (st.silentDays ? \" (\" + st.silentDays + \" days ago)\" : \"\") + \". \" +\n (st.warning ? \"The warning month is running: the relay goes to \" + heir + \" on \" + fmtDay(st.handoverAt) + \" unless you sign in.\" : \"If you stay away, it goes to \" + heir + \" around \" + fmtDay(st.handoverAt) + \".\") +\n (st.log && st.log.length ? \" Handed over before: \" + st.log.map((l) => fmtDay(l.at) + \" to \" + short(l.to)).join(\", \") + \".\" : \"\");\n }\n $(\"#succession\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"setsuccession\", { heir: f.heir.value, afterDays: +f.afterDays.value });\n toast(\"Heir set\"); await loadAdmin();\n });\n $(\"#clearsuccession\").onclick = guard(async () => {\n await rpc(\"clearsuccession\");\n toast(\"Heir cleared\"); await loadAdmin();\n });\n $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n // ---- fork: a new name with this relay's events, claim reserved for a key ----\n $(\"#forkform\").scope.onchange = (ev) => $(\"#forkkinds\").classList.toggle(\"hidden\", ev.target.value !== \"kinds\");\n $(\"#joinform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Saved\"); await loadInfo(); await loadPeople();\n });\n $(\"#forkform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n const opts = { people: f.people.checked };\n if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();\n if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error(\"That is not a pubkey.\"); opts.holder = pk; }\n if (f.scope.value === \"mine\") opts.filter = { authors: [me] };\n if (f.scope.value === \"kinds\") { const kinds = f.kinds.value.split(/[\\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error(\"Give kinds as numbers.\"); opts.filter = { kinds }; }\n if (!confirm(\"Fork this relay into a new name\" + (opts.name ? \" (\" + opts.name + \")\" : \"\") + \"? It pulls \" + (f.scope.value === \"all\" ? \"everything\" : f.scope.value === \"mine\" ? \"your events\" : \"the chosen kinds\") + (opts.people ? \" and the people\" : \"\") + \", and only \" + (opts.holder ? \"that key\" : \"you\") + \" can claim it.\")) return;\n const r = await rpc(\"forkrelay\", opts);\n $(\"#forknote\").textContent = r.handover + \" Expires \" + fmtTime(r.expires_at) + \".\";\n $(\"#forkurl\").textContent = r.console;\n $(\"#forkresult\").classList.remove(\"hidden\");\n toast(\"Forked to \" + r.name);\n });\n $(\"#forkcopy\").onclick = async () => { await navigator.clipboard.writeText($(\"#forkurl\").textContent); toast(\"copied\"); };\n $(\"#deleterelay\").onclick = guard(async () => {\n const name = host.split(\".\")[0];\n const typed = prompt(\"This deletes everything on \" + host + \" and gives the name up. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing was deleted.\"); return; }\n await rpc(\"deleterelay\", name);\n localStorage.removeItem(\"me\");\n location.href = \"/\";\n });\n $(\"#topup\").onsubmit = guard((ev) => topUp(+ev.target.sats.value));\n $(\"#inv-copy\").onclick = async () => { await navigator.clipboard.writeText($(\"#inv-text\").value); toast(\"invoice copied\"); };\n $$(\"button[data-copy]\").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === \"ws\" ? wsURL : location.origin); toast(\"copied\"); }; });\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-act]\"); if (!b) return;\n const act = b.dataset.act, id = b.dataset.id;\n if (act === \"copy\") { await navigator.clipboard.writeText(id); toast(\"copied\"); return; }\n if ((act === \"banpubkey\" || act === \"banevent\" || act === \"resolve:ban\") && !confirm(act === \"banevent\" ? \"Delete this event and refuse it forever?\" : \"Ban this author and refuse everything they post?\")) return;\n const erase = (act === \"banpubkey\" || act === \"resolve:ban\") && confirm(\"Also erase everything they wrote and uploaded here?\");\n if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) return;\n if (act === \"deletedump\" && !confirm(\"Delete this dump?\")) return;\n if (act === \"downloaddump\") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }\n if ((act === \"removemember\" || act === \"banpubkey\") && (window.__members || []).some((m) => m.invited_by === id) && confirm(\"Also remove everyone this member invited, and everyone they invited in turn?\")) {\n b.disabled = true;\n try { const r = await rpc(\"removesubtree\", id); if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase); toast(\"Removed \" + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"removejob\" && !confirm(\"Remove this job?\")) return;\n if (act === \"purgekind\") {\n const kind = id === \"\" ? null : +id;\n const label = kind === null ? \"everything without its own rule\" : kindName(kind) + \" (kind \" + kind + \")\";\n const typed = prompt(\"Purge \" + label + \" older than how many days? 0 purges all of them. This cannot be undone.\", \"30\");\n if (typed === null) return;\n const days = Math.max(0, Math.floor(+typed || 0));\n b.disabled = true;\n try { const r = await rpc(\"purgekind\", kind, days); toast(\"Purged \" + r.deleted.toLocaleString() + \" events\"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n if (act === \"saveretention\") {\n const kind = id === \"\" ? null : +id;\n const days = Math.max(0, Math.floor(+b.closest(\"tr\").querySelector(\".days\").value || 0));\n b.disabled = true;\n try { await rpc(\"setretention\", kind, days); toast(days ? \"Kept for \" + days + \" days\" : \"Kept forever\"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }\n return;\n }\n b.disabled = true;\n try {\n if (act === \"savemember\") { const tr = b.closest(\"tr\"), role = tr.querySelector(\"select.role\"), keep = tr.querySelector(\".keep\"), cap = tr.querySelector(\".cap\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8), erase);\n else if (act === \"banpubkey\") await rpc(\"banpubkey\", id, \"\", erase);\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await (myRole ? refresh() : loadMine());\n if (act === \"pinevent\" || act === \"unpinevent\") await loadPins();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // The share block: the card picture, the group naddr and its QR.\n let card = null;\n async function loadCard() {\n try {\n card = await (await fetch(\"/card.json\", { cache: \"no-store\" })).json();\n $(\"#cardimg\").src = \"/card.svg?t=\" + Date.now();\n $(\"#naddr\").textContent = card.naddr || \"\";\n const q = $(\"#naddrqr\");\n if (card.naddr) { q.src = \"/qr.svg?text=\" + encodeURIComponent(card.naddr); q.classList.remove(\"hidden\"); } else q.classList.add(\"hidden\");\n } catch { /* the card is decoration */ }\n renderApps();\n }\n // The app rows on the Connect section: one per client people actually use,\n // with the link that lands on this relay in it. Relay apps take the relay\n // or the group address; feed apps take the owner's profile with this relay\n // as the hint, since they have no notion of opening a relay.\n function renderApps() {\n const el = $(\"#apps\"); if (!el) return;\n const enc = encodeURIComponent;\n const nprofile = card && card.nprofile ? card.nprofile : \"\";\n const naddr = card && card.naddr ? card.naddr : \"\";\n const whose = me && owner && me === owner ? \"your\" : \"the owner's\";\n const link = (label, href) => '<a class=\"btn\" href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\";\n const app = (label, uri) => '<a class=\"btn\" href=\"' + esc(uri) + '\">' + label + \"</a>\";\n const copy = (label, text) => '<button class=\"btn\" data-copytext=\"' + esc(text) + '\">' + label + \"</button>\";\n const row = (name, where, note, acts) => '<div class=\"app\"><div class=\"app-head\"><b>' + name + \"</b><small>\" + where + \"</small></div><p>\" + note + '</p><div class=\"app-acts\">' + acts.filter(Boolean).join(\"\") + \"</div></div>\";\n const profileNote = \"Opens \" + whose + \" profile with this relay attached.\";\n const groups = [\n [\"As a place\", \"These open the relay itself: its feed, its people, its group.\", [\n row(\"Jumble\", \"web\", \"A feed of everything on this relay.\", [link(\"Open\", \"https://jumble.social/?r=\" + enc(wsURL))]),\n row(\"Coracle\", \"web\", \"The relay's page: its feed and its people.\", [link(\"Open\", \"https://coracle.social/relays/\" + enc(host))]),\n row(\"Flotilla\", \"web, phone\", \"The relay as a space, with the group as a room.\", [link(\"Open\", \"https://app.flotilla.social/spaces/\" + enc(host)), naddr && app(\"Open group\", \"nostr:\" + naddr)]),\n row(\"0xchat\", \"phone\", \"The group, in a chat app.\", [naddr && app(\"Open group\", \"nostr:\" + naddr), naddr && copy(\"Copy naddr\", naddr)]),\n row(\"noStrudel\", \"web\", \"Relays, add this one, then open its page.\", [copy(\"Copy relay URL\", wsURL)]),\n ]],\n [\"Find me here\", \"Feed apps have no relay pages. They meet this relay through a profile link that names it, then keep it once it is in the relay settings.\", [\n row(\"Primal\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://primal.net/p/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"YakiHonne\", \"web, phone\", profileNote, [nprofile && link(\"Open\", \"https://yakihonne.com/profile/\" + nprofile), nprofile && app(\"Open in app\", \"nostr:\" + nprofile)]),\n row(\"Damus\", \"iPhone\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Amethyst\", \"Android\", profileNote + \" Then Relays in the drawer.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n row(\"Nostur\", \"iPhone, Mac\", profileNote + \" Then Settings, Relays.\", [nprofile && app(\"Open in app\", \"nostr:\" + nprofile), copy(\"Copy relay URL\", wsURL)]),\n ]],\n ];\n el.innerHTML = groups.map(([h, note, rows]) => '<div class=\"appgroup\"><h4>' + h + '</h4><p class=\"note\">' + note + '</p><div class=\"appgrid\">' + rows.join(\"\") + \"</div></div>\").join(\"\");\n $(\"#apps-ws\").textContent = wsURL;\n const tile = (label, text) => '<div class=\"door\"><small>' + label + '</small><img src=\"/qr.svg?text=' + enc(text) + '\" alt=\"QR code: ' + esc(label) + '\" width=\"150\" height=\"150\"></div>';\n $(\"#phones\").innerHTML = [nprofile && tile(\"Find me here, for a phone\", \"nostr:\" + nprofile), naddr && tile(\"The group, for a phone\", \"nostr:\" + naddr)].filter(Boolean).join(\"\");\n }\n // The folds are a group: opening one closes the rest. Browsers with the\n // details name attribute do this themselves; this covers the others.\n $$(\".folds details\").forEach((d) => d.addEventListener(\"toggle\", () => { if (d.open) $$(\".folds details\").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));\n document.addEventListener(\"click\", async (ev) => {\n const b = ev.target.closest(\"button[data-copytext]\"); if (!b) return;\n try { await navigator.clipboard.writeText(b.dataset.copytext); toast(\"copied\"); } catch { /* no clipboard */ }\n });\n $(\"#copynaddr\").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast(\"copied naddr\"); };\n $(\"#copyembed\").onclick = async () => { await navigator.clipboard.writeText('<a href=\"' + location.origin + '/\"><img src=\"' + location.origin + '/card.svg\" alt=\"' + host + '\" width=\"600\" height=\"315\"></a>'); toast(\"copied embed\"); };\n\n // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.\n document.addEventListener(\"dblclick\", (ev) => {\n const k = ev.target.closest(\".key\"); if (!k) return;\n const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(\".full\")); s.removeAllRanges(); s.addRange(r);\n });\n document.addEventListener(\"click\", async (ev) => {\n const k = ev.target.closest(\".key\"); if (!k || ev.detail > 1 || String(window.getSelection())) return;\n try { await navigator.clipboard.writeText(k.title); toast(\"copied \" + k.dataset.short); } catch { /* no clipboard */ }\n });\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n await loadCard();\n me = localStorage.getItem(\"me\");\n if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }\n else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n renderApps();\n await loadAdmin();\n await loadPeople();\n})();\n";
M src/gen/templates.ts +23 -0
A src/grasp-policy.ts +226 -0
@@ -0,0 +1,226 @@
1+// Pure GRASP-01 policy helpers. Runtime code supplies accepted events and2+// applies the returned decisions to the relay and Git object store.3+4+import { KIND_REPO, KIND_REPO_STATE } from "./kinds.ts";5+6+export interface GraspEvent {7+ id: string;8+ pubkey: string;9+ kind: number;10+ created_at: number;11+ tags: string[][];12+}13+14+export interface RepositoryAnnouncement {15+ id: string;16+ owner: string;17+ identifier: string;18+ clone: string[];19+ relays: string[];20+ maintainers: string[];21+ private: boolean;22+}23+24+export interface RepositoryState {25+ id: string;26+ owner: string;27+ created_at: number;28+ identifier: string;29+ refs: Record<string, string>;30+ head: string | null;31+}32+33+export interface ParseResult<T> {34+ value?: T;35+ error?: string;36+}37+38+const HEX64 = /^[0-9a-f]{64}$/;39+const SHA1 = /^[0-9a-f]{40}$/;40+const UTF8 = new TextEncoder();41+42+const identifierBytes = (identifier: string): Uint8Array | null => {43+ if (!identifier || identifier === "." || identifier === "..") return null;44+ for (let i = 0; i < identifier.length; i++) {45+ const code = identifier.charCodeAt(i);46+ if (code >= 0xd800 && code <= 0xdbff) {47+ if (i + 1 >= identifier.length || identifier.charCodeAt(i + 1) < 0xdc00 || identifier.charCodeAt(i + 1) > 0xdfff) return null;48+ i++;49+ } else if (code >= 0xdc00 && code <= 0xdfff) return null;50+ }51+ for (const character of identifier) {52+ const code = character.codePointAt(0)!;53+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return null;54+ }55+ const bytes = UTF8.encode(identifier);56+ return bytes.length <= 256 ? bytes : null;57+};58+59+const validIdentifier = (identifier: string): boolean => identifierBytes(identifier) !== null;60+61+const values = (event: GraspEvent, name: string): string[] => event.tags.filter((tag) => tag[0] === name).flatMap((tag) => tag.slice(1)).filter((value): value is string => typeof value === "string");62+63+const singleton = (event: GraspEvent, name: string): string | null => {64+ const tags = event.tags.filter((tag) => tag[0] === name);65+ if (tags.length !== 1 || tags[0].length !== 2) return null;66+ return tags[0][1] ?? null;67+};68+69+const validRef = (ref: string): boolean => {70+ if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) return false;71+ if (ref.length <= "refs/heads/".length || ref.includes("..") || ref.includes("@{") || ref === "@") return false;72+ if (/[\u0000-\u0020\u007f~^:?*[\\]/u.test(ref) || ref.endsWith("/") || ref.endsWith(".") || ref.includes("//")) return false;73+ return ref.split("/").every((part) => part !== "." && part !== ".." && !part.endsWith(".lock"));74+};75+76+// parseRepositoryAnnouncement extracts the NIP-34 repository record and77+// rejects malformed identifiers, URLs, keys, and duplicate singleton tags.78+export function parseRepositoryAnnouncement(event: GraspEvent): ParseResult<RepositoryAnnouncement> {79+ if (event.kind !== KIND_REPO) return { error: "invalid: expected kind 30617" };80+ if (!HEX64.test(event.id) || !HEX64.test(event.pubkey)) return { error: "invalid: bad event identity" };81+ const identifier = singleton(event, "d");82+ if (identifier === null || !validIdentifier(identifier)) return { error: "invalid: bad repository identifier" };83+ const clone = values(event, "clone");84+ const relays = values(event, "relays");85+ if (clone.some((url) => !isCanonicalCloneURL(url))) return { error: "invalid: bad clone URL" };86+ if (relays.some((url) => !isWebSocketURL(url))) return { error: "invalid: relays must be WebSocket URLs" };87+ const maintainers = [...new Set(values(event, "maintainers"))];88+ if (maintainers.some((key) => !HEX64.test(key))) return { error: "invalid: bad maintainer pubkey" };89+ return { value: { id: event.id, owner: event.pubkey, identifier, clone, relays, maintainers, private: values(event, "private").includes("true") } };90+}91+92+// isCanonicalCloneURL accepts standard NIP-34 Git clone URL schemes. The93+// service-specific HTTPS URL is checked separately by serviceListed.94+export function isCanonicalCloneURL(raw: string): boolean {95+ try {96+ const url = new URL(raw);97+ return ["https:", "http:", "ssh:", "git:", "nostr:"].includes(url.protocol) && !url.hash && !!url.hostname;98+ } catch {99+ return /^git@[^:]+:.+$/u.test(raw);100+ }101+}102+103+// isWebSocketURL validates a relay URL without allowing credentials or104+// fragments that would make service matching ambiguous.105+export function isWebSocketURL(raw: string): boolean {106+ try {107+ const url = new URL(raw);108+ return (url.protocol === "wss:" || url.protocol === "ws:") && !url.username && !url.password && !url.hash;109+ } catch {110+ return false;111+ }112+}113+114+// serviceListed checks the exact clone and relay URLs advertised by a115+// repository, after URL normalization that ignores a trailing slash only.116+export function serviceListed(announcement: RepositoryAnnouncement, cloneURL: string, relayURL: string): boolean {117+ return announcement.clone.some((url) => normalizeURL(url) === normalizeURL(cloneURL)) && announcement.relays.some((url) => normalizeURL(url) === normalizeURL(relayURL));118+}119+120+const normalizeURL = (raw: string): string => {121+ try {122+ const url = new URL(raw);123+ url.pathname = url.pathname.replace(/\/$/u, "");124+ return url.href;125+ } catch {126+ return raw;127+ }128+};129+130+// repositoryCoordinate returns the NIP-34 address used by a and q tags.131+export function repositoryCoordinate(announcement: Pick<RepositoryAnnouncement, "owner" | "identifier">): string {132+ return `30617:${announcement.owner}:${announcement.identifier}`;133+}134+135+// gitRepositoryPath constructs the GRASP HTTP path. The caller supplies the136+// npub because key encoding belongs at the transport boundary.137+export function gitRepositoryPath(npub: string, identifier: string): string {138+ const bytes = identifierBytes(identifier);139+ if (!npub.startsWith("npub1") || !bytes) throw new Error("invalid repository path");140+ let encoded = "";141+ for (const byte of bytes) {142+ const character = String.fromCharCode(byte);143+ if ((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39) || "-._~".includes(character)) encoded += character;144+ else encoded += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;145+ }146+ return `/${npub}/${encoded}.git`;147+}148+149+// parseRepositoryState parses the exact ref vocabulary defined by NIP-34.150+export function parseRepositoryState(event: GraspEvent): ParseResult<RepositoryState> {151+ if (event.kind !== KIND_REPO_STATE) return { error: "invalid: expected kind 30618" };152+ if (!HEX64.test(event.id) || !HEX64.test(event.pubkey)) return { error: "invalid: bad event identity" };153+ const identifier = singleton(event, "d");154+ if (identifier === null || !validIdentifier(identifier)) return { error: "invalid: bad repository identifier" };155+ const refs: Record<string, string> = {};156+ let head: string | null = null;157+ for (const tag of event.tags) {158+ if (tag[0] === "HEAD") {159+ if (head !== null || tag.length !== 2 || !tag[1]?.startsWith("ref: ") || !validRef(tag[1].slice(5))) return { error: "invalid: bad HEAD" };160+ head = tag[1].slice(5);161+ } else if (tag[0]?.startsWith("refs/")) {162+ if (tag[0].endsWith("^{}")) continue;163+ if (!validRef(tag[0]) || !tag[1] || !SHA1.test(tag[1]) || /^0+$/u.test(tag[1]) || refs[tag[0]]) return { error: "invalid: bad or duplicate ref" };164+ refs[tag[0]] = tag[1];165+ }166+ }167+ return { value: { id: event.id, owner: event.pubkey, created_at: event.created_at, identifier, refs, head } };168+}169+170+// recursiveMaintainers computes the bounded fixed point of maintainer171+// announcements. A maintainer can extend the set only through its own record.172+export function recursiveMaintainers(root: RepositoryAnnouncement, announcements: RepositoryAnnouncement[], maxDepth = 8, maxMaintainers = 64): Set<string> | null {173+ const result = new Set<string>([root.owner, ...root.maintainers]);174+ if (result.size > maxMaintainers) return null;175+ let frontier = new Set<string>([root.owner, ...root.maintainers]);176+ for (let depth = 0; depth < maxDepth && frontier.size > 0 && result.size < maxMaintainers; depth++) {177+ const next = new Set<string>();178+ for (const author of frontier) {179+ for (const announcement of announcements) {180+ if (announcement.owner !== author || announcement.identifier !== root.identifier) continue;181+ for (const maintainer of announcement.maintainers) {182+ if (!result.has(maintainer)) {183+ if (result.size >= maxMaintainers) return null;184+ result.add(maintainer); next.add(maintainer);185+ }186+ }187+ }188+ }189+ frontier = next;190+ }191+ return result;192+}193+194+// latestState applies NIP-34 addressable-event ordering: newest timestamp,195+// then lexicographically smallest event ID for an exact timestamp tie.196+export function latestState(events: RepositoryState[], owner: string, identifier: string, maintainers: Set<string>): RepositoryState | null {197+ return events.filter((state) => state.identifier === identifier && (state.owner === owner || maintainers.has(state.owner))).sort((a, b) => b.created_at - a.created_at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0] ?? null;198+}199+200+// stateAllowsRefs requires the post-push ref map and HEAD to equal the201+// accepted state event exactly.202+export function stateAllowsRefs(state: RepositoryState, refs: Record<string, string>, head: string | null): boolean {203+ const keys = Object.keys(refs);204+ const expected = Object.keys(state.refs);205+ return head === state.head && keys.length === expected.length && expected.every((key) => refs[key] === state.refs[key]);206+}207+208+// nostrRefEventID validates the GRASP PR ref namespace. Unknown event IDs are209+// intentionally syntactically valid; the runtime may hold them briefly.210+export function nostrRefEventID(ref: string): string | null {211+ const match = /^refs\/nostr\/([0-9a-f]{64})$/u.exec(ref);212+ return match?.[1] ?? null;213+}214+215+// relatedRepositoryCoordinates lists valid NIP-34 repository a-tags.216+export function relatedRepositoryCoordinates(event: GraspEvent): string[] {217+ return values(event, "a").filter((coordinate) => {218+ const parts = coordinate.split(":");219+ return parts.length >= 3 && parts[0] === "30617" && HEX64.test(parts[1]) && validIdentifier(parts.slice(2).join(":"));220+ });221+}222+223+// eventReferencesRepository checks whether an event is anchored to a repo.224+export function eventReferencesRepository(event: GraspEvent, coordinate: string): boolean {225+ return relatedRepositoryCoordinates(event).includes(coordinate);226+}
A src/grasp-state.ts +102 -0
@@ -0,0 +1,102 @@
1+// GRASP's accepted Nostr authority and purgatory. Git refs remain in ntig;2+// these tables record only visibility, cleanup deadlines and billed objects.3+import { now, tag, tagValues, type Event } from "./event.ts";4+import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "./kinds.ts";5+import { featureOn } from "./settings.ts";6+import { parseRepositoryAnnouncement, parseRepositoryState, recursiveMaintainers, latestState, serviceListed, gitRepositoryPath, relatedRepositoryCoordinates, repositoryCoordinate, type RepositoryAnnouncement } from "./grasp-policy.ts";7+import { npubEncode } from "nostr-tools/nip19";8+import type { Relay } from "./relay.ts";9+10+export const MAX_REPOSITORIES = 16;11+export const PURGATORY_SECONDS = 1800;12+export const PR_REF_SECONDS = 1200;13+export const GRASP_SCHEMA = `14+CREATE TABLE IF NOT EXISTS grasp_hosted (id TEXT PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE);15+CREATE TABLE IF NOT EXISTS grasp_pending (id TEXT PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE, until INTEGER NOT NULL);16+CREATE TRIGGER IF NOT EXISTS grasp_removed AFTER DELETE ON events BEGIN DELETE FROM grasp_pending WHERE id=old.id; DELETE FROM grasp_hosted WHERE id=old.id; END;17+CREATE TABLE IF NOT EXISTS grasp_tick (id INTEGER PRIMARY KEY CHECK(id=1), coordinate TEXT NOT NULL);18+CREATE TABLE IF NOT EXISTS grasp_pr_refs (repo TEXT NOT NULL, ref TEXT NOT NULL, until INTEGER NOT NULL, PRIMARY KEY(repo,ref));19+CREATE TABLE IF NOT EXISTS grasp_objects (key TEXT PRIMARY KEY, owner TEXT NOT NULL, size INTEGER NOT NULL);20+`;21+22+// graspEvents includes purgatory for write authority, but never expired or23+// moderated records. A pending signed state authorizes the data it awaits.24+export function graspEvents(relay: Relay, kind: number): Event[] {25+ return relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=? AND (expires=0 OR expires>?) AND NOT EXISTS (SELECT 1 FROM grasp_pending WHERE grasp_pending.id=events.id AND until<=?) ORDER BY created_at DESC,id ASC LIMIT 1025`, kind, now(), now()).toArray().map((r) => JSON.parse(r.raw) as Event).filter((e) => !relay.settings.isEventHidden(e.id) && !relay.settings.isEventBanned(e.id) && !relay.settings.isBanned(e.pubkey));26+}27+export const announcements = (relay: Relay): RepositoryAnnouncement[] => graspEvents(relay, KIND_REPO).flatMap((e) => { const p = parseRepositoryAnnouncement(e); return p.value ? [p.value] : []; });28+export const hostedAnnouncements = (relay: Relay): RepositoryAnnouncement[] => announcements(relay).filter((r) => relay.sql.exec(`SELECT 1 FROM grasp_hosted WHERE id=?`, r.id).toArray().length > 0);29+export const repository = (relay: Relay, owner: string, identifier: string) => hostedAnnouncements(relay).find((r) => r.owner === owner && r.identifier === identifier) ?? null;30+export function repositoryState(relay: Relay, repo: RepositoryAnnouncement) {31+ const states = graspEvents(relay, KIND_REPO_STATE).flatMap((e) => { const p = parseRepositoryState(e); return p.value ? [p.value] : []; });32+ return latestState(states, repo.owner, repo.identifier, recursiveMaintainers(repo, announcements(relay)) ?? new Set());33+}34+35+// eventRepository finds the repository named by a PR or by its parent PR.36+export function eventRepository(relay: Relay, e: Event): RepositoryAnnouncement | null {37+ const all = hostedAnnouncements(relay);38+ const coordinates = relatedRepositoryCoordinates(e);39+ for (const parent of [...tagValues(e, "e"), ...tagValues(e, "E")].slice(0, 16)) {40+ const row = relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE id=? AND kind=? AND (expires=0 OR expires>?)`, parent, KIND_GIT_PR, now()).toArray()[0];41+ if (row) {42+ const parent = JSON.parse(row.raw) as Event;43+ if (!relay.settings.isEventHidden(parent.id) && !relay.settings.isEventBanned(parent.id) && !relay.settings.isBanned(parent.pubkey)) coordinates.push(...relatedRepositoryCoordinates(parent));44+ }45+ }46+ return all.find((r) => coordinates.includes(repositoryCoordinate(r))) ?? null;47+}48+49+// graspGate validates authority before ordinary storage. Relay write policy,50+// bans, proof of work and member quotas continue to apply to these events.51+export function graspGate(relay: Relay, e: Event, host: string): string {52+ if (!featureOn(relay.settings.policy, "grasp")) return "";53+ if (e.kind === KIND_REPO) {54+ if (relay.settings.policy.reads !== "open") return "restricted: GRASP-01 hosting requires open reads";55+ const p = parseRepositoryAnnouncement(e);56+ if (!p.value) return p.error!;57+ const r = p.value;58+ const origin = relay.webURL(host || `${relay.slug}.${relay.domain}`);59+ const listed = serviceListed(r, origin + gitRepositoryPath(npubEncode(e.pubkey), r.identifier), origin.replace(/^http/, "ws"));60+ const all = announcements(relay);61+ const related = hostedAnnouncements(relay).some((root) => root.owner !== e.pubkey && root.identifier === r.identifier && recursiveMaintainers(root, all)?.has(e.pubkey));62+ if (!listed && !related) return "restricted: repository clone and relays tags must name this service";63+ if (listed && !repository(relay, r.owner, r.identifier) && hostedAnnouncements(relay).length >= MAX_REPOSITORIES) return `restricted: at most ${MAX_REPOSITORIES} repositories per relay`;64+ }65+ if (e.kind === KIND_REPO_STATE) {66+ const p = parseRepositoryState(e);67+ if (!p.value) return p.error!;68+ const all = announcements(relay);69+ if (!all.some((r) => r.identifier === p.value!.identifier && recursiveMaintainers(r, all)?.has(e.pubkey))) return "restricted: state author is not an accepted repository maintainer";70+ }71+ if (e.kind === KIND_GIT_PR || e.kind === KIND_GIT_PR_UPDATE) {72+ if (!eventRepository(relay, e)) return "restricted: pull request must name an accepted repository";73+ const tips = tagValues(e, "c");74+ if (tips.length !== 1 || !/^[0-9a-f]{40}$/.test(tips[0]) || /^0+$/.test(tips[0])) return "invalid: pull request needs one SHA-1 c tag";75+ }76+ return "";77+}78+79+// holdGrasp hides events whose Git objects have not been checked yet. The80+// durable row survives restarts and follows NIP-09/40 deletion via its FK.81+export function holdGrasp(relay: Relay, e: Event, host: string): boolean {82+ if (!featureOn(relay.settings.policy, "grasp") || ![KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE].includes(e.kind)) return false;83+ if (e.kind === KIND_REPO) {84+ const r = parseRepositoryAnnouncement(e).value!;85+ const origin = relay.webURL(host || `${relay.slug}.${relay.domain}`);86+ if (!serviceListed(r, origin + gitRepositoryPath(npubEncode(e.pubkey), r.identifier), origin.replace(/^http/, "ws"))) return false;87+ relay.sql.exec(`INSERT OR REPLACE INTO grasp_hosted(id) VALUES(?)`, e.id);88+ }89+ relay.sql.exec(`INSERT OR REPLACE INTO grasp_pending(id,until) VALUES(?,?)`, e.id, now() + PURGATORY_SECONDS);90+ return true;91+}92+export const graspVisible = (relay: Relay, id: string) => relay.sql.exec(`SELECT 1 FROM grasp_pending WHERE id=?`, id).toArray().length === 0;93+export const graspBytes = (relay: Relay) => relay.sql.exec<{ n: number }>(`SELECT coalesce(sum(size),0) AS n FROM grasp_objects`).one().n;94+95+// requiredObjects returns the tips an event needs before it leaves purgatory.96+export function requiredObjects(relay: Relay, e: Event): string[] | null {97+ if (e.kind === KIND_REPO_STATE) return Object.values(parseRepositoryState(e).value?.refs ?? {});98+ if (e.kind === KIND_GIT_PR || e.kind === KIND_GIT_PR_UPDATE) return [tag(e, "c")];99+ const r = parseRepositoryAnnouncement(e).value;100+ const state = r && repositoryState(relay, r);101+ return state ? Object.values(state.refs) : null;102+}
A src/grasp.ts +175 -0
@@ -0,0 +1,175 @@
1+// GRASP-01 Smart HTTP on a relay origin. Nostr events authorize changes;2+// ntig owns the conditional R2 root, pack verification and Git protocol.3+import { createGitHandler, createAcceptedStateRepository, WalRepository, NativeGitEngine, R2ObjectStore, parseRepositoryPath, repositoryAddress, repositoryStoragePrefix, readObjects, LimitError, type ObjectStore, type CommitRequest, type GitRepository } from "ntig";4+import { npubEncode, naddrEncode } from "nostr-tools/nip19";5+import { now, tag, type Event } from "./event.ts";6+import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "./kinds.ts";7+import { featureOn } from "./settings.ts";8+import { page, escapeHTML as esc } from "./ui.ts";9+import { repositoryCoordinate, type RepositoryAnnouncement } from "./grasp-policy.ts";10+import { hostedAnnouncements, repository, repositoryState, graspBytes, requiredObjects, eventRepository, PR_REF_SECONDS } from "./grasp-state.ts";11+import type { Relay } from "./relay.ts";12+13+const CORS = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST", "access-control-allow-headers": "Content-Type, Authorization, Git-Protocol, X-Git-Request-Id" };14+export function graspCORS(response: Response, preflight = false): Response {15+ const headers = new Headers(response.headers);16+ for (const [key, value] of Object.entries(CORS)) headers.set(key, value);17+ if (preflight) { void response.body?.cancel(); return new Response(null, { status: 204, headers }); }18+ return new Response(response.body, { status: response.status, headers });19+}20+const answer = (reason: string, status: number) => new Response(reason, { status, headers: { ...CORS, "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });21+// isGitPath reserves malformed Git paths too, so every failure has CORS.22+export const isGitPath = (url: URL) => /^\/(?:npub|prs\/)/.test(url.pathname) || /\.git(?:\/|$)/.test(url.pathname);23+24+// gitRepository records each R2 key's retained size, including orphan writes.25+// Reservations precede PUT: an ambiguous response never makes bytes free.26+export async function gitRepository(relay: Relay, repo: RepositoryAnnouncement): Promise<WalRepository> {27+ const prefix = `${relay.slug}/git/${await repositoryStoragePrefix(repositoryAddress(npubEncode(repo.owner), repo.identifier))}`;28+ const r2 = new R2ObjectStore(relay.media, { prefix, maxObjectBytes: 4 * 1024 * 1024 });29+ const store: ObjectStore = {30+ get: async (key) => r2.get(key),31+ put: async (key, bytes, expected) => {32+ const full = prefix + key;33+ const old = relay.sql.exec<{ size: number }>(`SELECT size FROM grasp_objects WHERE key=?`, full).toArray()[0]?.size ?? 0;34+ const increase = Math.max(0, bytes.length - old);35+ if (increase && relay.fuelStatus().outOfFuel) throw new LimitError("Relay storage or fuel limit reached");36+ const limit = relay.settings.limitsOf(repo.owner)?.cap ?? 0;37+ const owned = relay.sql.exec<{ n: number }>(`SELECT coalesce(sum(size),0) AS n FROM grasp_objects WHERE owner=?`, repo.owner).one().n;38+ if (limit && owned + relay.store.authorBytes(repo.owner) + increase > limit) throw new LimitError("Repository owner's storage cap reached");39+ if (graspBytes(relay) + increase > 320 * 1024 * 1024) throw new LimitError("Relay Git storage quota reached");40+ relay.sql.exec(`INSERT INTO grasp_objects(key,owner,size) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET size=max(size,excluded.size)`, full, repo.owner, bytes.length);41+ const stored = await r2.put(key, bytes, expected);42+ if (stored) relay.sql.exec(`UPDATE grasp_objects SET size=? WHERE key=?`, bytes.length, full);43+ else {44+ const actual = await relay.media.head(full);45+ if (actual) relay.sql.exec(`UPDATE grasp_objects SET size=? WHERE key=?`, actual.size, full);46+ else relay.sql.exec(`DELETE FROM grasp_objects WHERE key=?`, full);47+ }48+ return stored;49+ },50+ };51+ return new WalRepository(store, new NativeGitEngine(), { prefix: "data/" });52+}53+54+// prTip distinguishes unknown IDs from known events that cannot authorize55+// this repository. Purgatory PRs may authorize their own arriving objects.56+function prTip(relay: Relay, repo: RepositoryAnnouncement, id: string): string | null | false {57+ const row = relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE id=?`, id).toArray()[0];58+ if (!row) {59+ const timed = relay.sql.exec<{ until: number }>(`SELECT until FROM grasp_pr_refs WHERE repo=? AND ref=?`, repositoryCoordinate(repo), `refs/nostr/${id}`).toArray()[0];60+ return timed && timed.until <= now() ? false : null;61+ }62+ const e = JSON.parse(row.raw) as Event;63+ if (![KIND_GIT_PR, KIND_GIT_PR_UPDATE].includes(e.kind) || relay.settings.isEventHidden(id) || relay.settings.isEventBanned(id) || relay.settings.isBanned(e.pubkey)) return false;64+ const exp = Number(tag(e, "expiration"));65+ if (exp && exp <= now()) return false;66+ if (eventRepository(relay, e)?.id !== repo.id) return false;67+ return /^[0-9a-f]{40}$/.test(tag(e, "c")) ? tag(e, "c") : false;68+}69+70+// promote makes only verified, present Git tips visible to ordinary queries.71+async function promote(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepository) {72+ const snapshot = await wal.load();73+ if (!snapshot.sequence) return;74+ const objects = await readObjects(snapshot.packs);75+ const pending = relay.sql.exec<{ raw: string }>(`SELECT raw FROM events JOIN grasp_pending ON grasp_pending.id=events.id WHERE grasp_pending.until>? LIMIT 1024`, now()).toArray();76+ for (const { raw } of pending) {77+ const e = JSON.parse(raw) as Event;78+ const relevant = e.kind === KIND_REPO ? e.id === repo.id : e.kind === KIND_REPO_STATE ? tag(e, "d") === repo.identifier && repositoryState(relay, repo)?.id === e.id : eventRepository(relay, e)?.id === repo.id;79+ if (!relevant) continue;80+ const required = requiredObjects(relay, e);81+ if (required && required.every((oid) => objects.has(oid))) {82+ relay.sql.exec(`DELETE FROM grasp_pending WHERE id=?`, e.id);83+ relay.broadcast(e);84+ }85+ }86+}87+88+// authorizedRepository adds event authority to the WAL without duplicating refs.89+function authorizedRepository(relay: Relay, repo: RepositoryAnnouncement, wal: WalRepository): GitRepository {90+ const authorized = createAcceptedStateRepository(wal, {91+ lookupState: async () => { const state = repositoryState(relay, repo); return state ? { eventId: state.id, refs: state.refs, head: state.head } : null; },92+ lookupPrTip: async (id) => prTip(relay, repo, id),93+ allowUnknownPrRefs: true,94+ });95+ return {96+ load: () => authorized.load(),97+ commit: async (request: CommitRequest) => {98+ const receipt = await authorized.commit(request);99+ for (const update of request.updates) {100+ if (!/^refs\/nostr\/[0-9a-f]{64}$/.test(update.name)) continue;101+ if (update.new === null) relay.sql.exec(`DELETE FROM grasp_pr_refs WHERE repo=? AND ref=?`, repositoryCoordinate(repo), update.name);102+ else relay.sql.exec(`INSERT INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?) ON CONFLICT(repo,ref) DO NOTHING`, repositoryCoordinate(repo), update.name, now() + PR_REF_SECONDS);103+ }104+ await promote(relay, repo, wal);105+ return receipt;106+ },107+ };108+}109+110+// grasp serves Git only while open reads, admission and fuel permit hosting.111+// One in-flight operation bounds memory and fences all relay-side mutations.112+export async function grasp(relay: Relay, req: Request, url: URL): Promise<Response> {113+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });114+ if (!featureOn(relay.settings.policy, "grasp")) return answer("not found", 404);115+ if (relay.settings.policy.reads !== "open") return answer("restricted: public Git hosting requires open reads", 403);116+ if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return answer("restricted: relay is not active", 403);117+ const parsed = parseRepositoryPath(url.pathname);118+ if (!parsed || parsed.alternativePRs) return answer("not found", 404);119+ const repo = repository(relay, parsed.address.pubkey, parsed.address.identifier);120+ if (!repo) return answer("not found", 404);121+ const limited = relay.ipLimit(req.headers.get("x-relay-ip") || "unknown", req.method === "POST" ? "events" : "reqs");122+ if (limited) return answer(limited, 429);123+ if (relay.fuelStatus().outOfFuel) return answer("restricted: relay storage or fuel limit reached", 403);124+ if (relay.graspBusy || relay.graspControls) return answer("restricted: Git transaction in progress; retry", 429);125+ relay.graspBusy = true;126+ try {127+ if (parsed.endpoint === "root") {128+ if (req.method !== "GET" && req.method !== "HEAD") return answer("invalid: method not allowed", 405);129+ const coordinate = naddrEncode({ kind: KIND_REPO, pubkey: repo.owner, identifier: repo.identifier });130+ const html = page(repo.identifier, `<h1>${esc(repo.identifier)}</h1><p>Git storage follows signed Nostr repository state.</p><p><a href="nostr:${coordinate}">Open in a Nostr Git client</a></p><pre>git clone ${esc(relay.webURL(url.host) + parsed.prefix)}</pre>`);131+ return new Response(req.method === "HEAD" ? null : html, { headers: { ...CORS, "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });132+ }133+ const wal = await gitRepository(relay, repo);134+ const handler = createGitHandler(authorizedRepository(relay, repo, wal), { prefix: parsed.prefix, authorizePush: () => true, observe: (event) => relay.meterBytes(event.requestBytes, event.responseBytes) });135+ const response = await handler(req);136+ const headers = new Headers(response.headers);137+ for (const [key, value] of Object.entries(CORS)) headers.set(key, value);138+ return new Response(response.body, { status: response.status, headers });139+ } catch (error) {140+ return answer(error instanceof LimitError ? "restricted: Git repository limit reached" : "error: Git repository unavailable", error instanceof LimitError ? 413 : 503);141+ } finally {142+ relay.graspBusy = false;143+ await relay.ensureAlarm(now() + 60);144+ }145+}146+147+// graspTick expires purgatory and cleans one repository per alarm. Event148+// deletion removes visibility; retained R2 objects remain charged until teardown.149+export async function graspTick(relay: Relay): Promise<number> {150+ relay.sql.exec(`DELETE FROM events WHERE id IN (SELECT id FROM grasp_pending WHERE until<=?)`, now());151+ const all = hostedAnnouncements(relay);152+ const last = relay.sql.exec<{ coordinate: string }>(`SELECT coordinate FROM grasp_tick WHERE id=1`).toArray()[0]?.coordinate ?? "";153+ const ordered = all.sort((a, b) => repositoryCoordinate(a).localeCompare(repositoryCoordinate(b)));154+ const repo = ordered.find((r) => repositoryCoordinate(r) > last) ?? ordered[0];155+ if (repo) relay.sql.exec(`INSERT OR REPLACE INTO grasp_tick(id,coordinate) VALUES(1,?)`, repositoryCoordinate(repo));156+ for (const { repo: coordinate } of relay.sql.exec<{ repo: string }>(`SELECT DISTINCT repo FROM grasp_pr_refs`).toArray()) {157+ if (!all.some((r) => repositoryCoordinate(r) === coordinate)) relay.sql.exec(`DELETE FROM grasp_pr_refs WHERE repo=?`, coordinate);158+ }159+ if (repo && featureOn(relay.settings.policy, "grasp") && !relay.fuelStatus().outOfFuel) {160+ try {161+ const wal = await gitRepository(relay, repo);162+ const snapshot = await wal.load();163+ const refs = relay.sql.exec<{ ref: string }>(`SELECT ref FROM grasp_pr_refs WHERE repo=? AND until<=?`, repositoryCoordinate(repo), now()).toArray();164+ for (const { ref } of refs) {165+ const current = snapshot.refs[ref];166+ const tip = prTip(relay, repo, ref.slice("refs/nostr/".length));167+ if (current && tip !== current) await wal.commit({ id: `expire:${ref.slice(-64)}:${current}`, updates: [{ name: ref, old: current, new: null }] });168+ relay.sql.exec(`DELETE FROM grasp_pr_refs WHERE repo=? AND ref=?`, repositoryCoordinate(repo), ref);169+ }170+ await promote(relay, repo, wal);171+ } catch { /* The next bounded alarm retries; retained bytes stay metered. */ }172+ }173+ const earliest = relay.sql.exec<{ n: number | null }>(`SELECT min(at) AS n FROM (SELECT until AS at FROM grasp_pending UNION ALL SELECT until AS at FROM grasp_pr_refs)`).one().n;174+ return earliest === null ? 0 : now() + 60;175+}
M src/kinds.ts +7 -0
M src/landing.ts +14 -8
@@ -62,16 +62,16 @@ const MAP = `<svg viewBox="0 0 640 470" role="img" aria-label="how a request rea
6262 <text x="126" y="310">alarm sweep, retention, fuel</text>6363 <text x="126" y="338" class="k">hibernates when idle</text>6464 <path class="e" d="M270 356v26" marker-end="url(#ar)"/>65-<rect class="sh" x="224" y="390" width="100" height="34" rx="10"/><rect class="n" x="220" y="386" width="100" height="34" rx="10"/><text class="k" x="270.0" y="407.0" text-anchor="middle">r2</text><text class="d" x="330" y="407">blobs by sha256</text>65+<rect class="sh" x="224" y="390" width="100" height="34" rx="10"/><rect class="n" x="220" y="386" width="100" height="34" rx="10"/><text class="k" x="270.0" y="407.0" text-anchor="middle">r2</text><text class="d" x="330" y="407">files, dumps, Git objects</text>6666 <text class="k" x="600" y="266" text-anchor="end">zap</text><text class="d" x="600" y="250" text-anchor="end">kind 9735</text><path class="e" d="M556 262H432" marker-end="url(#ar)"/>6767 <text class="k" x="600" y="330" text-anchor="end">leave</text><text class="d" x="600" y="314" text-anchor="end">nip-77</text><path class="e" d="M432 326H556" marker-end="url(#ar)"/>6868 </svg>`;6969 7070 const NOTES = `<div class="notes">71-<h3 id="o">durable object</h3><p>Each name has one object. The object holds the SQLite database and the relay's open sockets. The <b>owner</b> is the pubkey that signed the first <b>claim</b>. After the claim, all management is done via NIP-86 calls signed by that key. The relay page is a client of this API. The object leaves memory between frames. An idle relay has no cost.</p>71+<h3 id="o">durable object</h3><p>Each name has one object. The object holds the SQLite database and the relay's open sockets. The <b>owner</b> is the pubkey that signed the first <b>claim</b>. After the claim, all management is done via NIP-86 calls signed by that key. The relay page is a client of this API. The object can hibernate between frames; retained storage and scheduled work still count.</p>7272 <h3 id="l">lease</h3><p>A lease is a relay at a name picked for you, open to everyone, for a fixed number of days. <b>POST /lease</b> returns one; no key needed. A claim before it expires keeps it, events and files included. After that, the object is wiped and the name is free again.</p>73-<h3 id="f">fuel</h3><p>The relay measures four values: events stored, files stored, hours awake, rows written. Each value is a line on the hosting bill. Traffic is free. Below the allowance, you owe nothing. Above the allowance, a zap to the relay adds balance. The zap receipt is the record.</p>74-<h3 id="x">leave</h3><p>NIP-77 sync copies all events to a different relay. Nothing on bind.ws is proprietary. You do not move anything else.</p>73+<h3 id="f">fuel</h3><p>Fuel prices four values: events stored, files stored, estimated hours awake and metered rows written. Mirrored site files and retained Git data share the file allowance. Traffic is visible but uncharged. Below the allowances, you owe nothing. Above them, a zap adds balance. The Health tab and public <b>/fuel</b> show usage and prices.</p>74+<h3 id="x">leave</h3><p>NIP-77 sync copies readable events to a different relay. Copy site files through Blossom and clone Git repositories separately; event sync does not copy their bytes.</p>7575 </div>`;7676 7777 const PROTOCOL: [string, string][] = [
@@ -83,7 +83,7 @@ const PROTOCOL: [string, string][] = [
8383 ["40", "Events with an expiration tag are removed at that time."],8484 ["42", "Clients prove their key. Needed for private kinds and members-only reads."],8585 ["45", "Count events without downloading them."],86- ["50", "Full-text search on notes, articles, profiles and comments."],86+ ["50", "Prose search by default; the owner can index all public content or switch search off."],8787 ["17, 59", "Private messages are sent only to the sender and the recipient."],8888 ["62", "Request to vanish: the relay deletes all your events and refuses older ones."],8989 ["70", "Protected events are accepted only from their author, over an authenticated socket."],
@@ -96,6 +96,9 @@ const PROTOCOL: [string, string][] = [
9696 ["66", "The relay signs a discovery record about itself, so directories find it without a probe."],9797 ["Blossom", "Upload and fetch files by hash on the same host. Check an upload before sending it, mirror a file from a URL, report one by hash."],9898 ["94, 96", "The same files through the NIP-96 door, with NIP-94 metadata in every answer."],99+ ["NIP-5A", "Root, named and snapshot websites from signed manifests, on isolated hostnames. Site hosting and mirroring are on by default; the relay's read rule governs access."],100+ ["Marmot", "Opt-in KeyPackages and opaque MLS group messages. Account admission and caps apply to ephemeral authors; clients handle encryption."],101+ ["GRASP-01", "Opt-in public Git Smart HTTP. Signed NIP-34 state authorizes refs, and publication waits for matching Git objects. Later GRASP specifications are not advertised."],99102 ];100103 101104 function table(heads: string[], rows: string[][], right: number[] = []): string {
@@ -124,6 +127,8 @@ export function landing(req: Request, env: Env): Response {
124127 ["GET /e/&lt;id&gt;, /a/&lt;d&gt;, /feed.xml", "pages, Atom", "none", "Notes and articles as pages with Open Graph tags, and a feed. Only when anyone may read."],125128 ["GET /card.json, /card.svg, /card.nostr", "card", "none", "A status card for links and profiles: members, rules, fuel, the group naddr as a QR. The .nostr form is signed by the relay key."],126129 ["GET /fuel, POST /fuel/invoice", "NIP-57", "none", "Gauges are public. The invoice comes from a signed zap request."],130+ [`https://&lt;site-label&gt;.${domain}/`, "NIP-5A", "relay read rule", "Root, named and snapshot sites, verified files and custom-domain targets. Restricted sites use browser sign-in or NIP-98."],131+ ["/&lt;npub&gt;/&lt;identifier&gt;.git", "GRASP-01", "signed NIP-34 state", "Public clone, fetch and state-authorized push while enabled with open reads. The identifier is percent-encoded. Bounded hosting; storage limits remain independent of fuel balance."],127132 ]);128133 const limits = table(["Parameter", "Default", "Range"], [129134 ["name", "3 to 32 of a-z 0-9 -", "some names are reserved"],
@@ -139,7 +144,7 @@ export function landing(req: Request, env: Env): Response {
139144 ["files stored", "R2", `${n(fuel.freeMediaMB)} MB`, `${n(fuel.satsPerGBMonthMedia)} sats/GB-mo`],140145 ["time awake", "DO duration", `${n(fuel.freeActiveHours)} h`, `${n(fuel.satsPerActiveHour)} sats/h`],141146 ["rows written", "SQLite writes", n(fuel.freeRowsWritten), `${n(fuel.satsPerMillionRows)} sats/M`],142- ["traffic, rows read", "no cost", "unlimited", "free"],147+ ["traffic, rows read", "not fuel-priced", "not billed", "free"],143148 ], [2, 3]);144149 const protocol = table(["NIP", "What it does here"], PROTOCOL.map(([a, b]) => [a, b.replace("name.bind.ws", `name.${domain}`)]));145150 const body = `<main>
@@ -150,7 +155,8 @@ export function landing(req: Request, env: Env): Response {
150155 <h2>Interfaces</h2>${interfaces}151156 <h2>Limits</h2>${limits}152157 <h2>Metering</h2>${metering}153- <p class="desc">Below the allowances, you owe nothing. Above an allowance with no balance, the relay is read-only. The relay does not delete data.</p>158+ <p class="desc">Below the allowances, you owe nothing. With no balance past an allowance, event writes and uploads stop; GRASP requests and remote site fetches also stop. Fuel exhaustion does not delete stored data.</p>159+ <p class="desc">The meters are relay totals, not a bill per feature. Git history remains in file storage after refs expire. Provider requests and object operations are host overhead, not separate tenant charges. <a href="${REPO}/blob/main/docs/02-understanding-fuel.md">Understanding fuel</a> explains the shared allowances and metering limits.</p>154160 <h2>Protocol</h2>${protocol}155161 </div>156162 <footer class="foot"><a href="${REPO}">-&gt; github.com/FelineStateMachine/bindws</a><span class="muted">v${VERSION}</span></footer>
@@ -158,7 +164,7 @@ export function landing(req: Request, env: Env): Response {
158164 <script>159165 document.getElementById("try").onclick = async (ev) => {160166 const b = ev.target, note = document.getElementById("trynote");161- b.disabled = true; note.textContent = "Finding a name…";167+ b.disabled = true; note.textContent = "Finding a name...";162168 try {163169 const r = await fetch("/lease", { method: "POST" });164170 const j = await r.json();
M src/nip11.ts +6 -1
@@ -15,7 +15,7 @@ import { nip11Views } from "./views.ts";
1515 // (publishDiscovery); 77 is negentropy sync (handleSync).1616 export const SUPPORTED_NIPS = [1, 5, 9, 11, 13, 17, 29, 40, 42, 45, 46, 50, 56, 59, 62, 66, 67, 70, 77, 86, 98];1717 // The numbers a switched-off feature takes with it (settings.ts, features).18-export const FEATURE_NIPS: Record<Feature, number[]> = { search: [50], sync: [77], count: [45], discovery: [66], names: [5], files: [], pages: [], signer: [46], sites: [], marmot: [] };18+export const FEATURE_NIPS: Record<Feature, number[]> = { search: [50], sync: [77], count: [45], discovery: [66], names: [5], files: [], pages: [], signer: [46], sites: [], marmot: [], grasp: [] };1919 2020 // supportedNips is the list as it stands with the features that are on.2121 export function supportedNips(relay: Relay): number[] {
@@ -66,6 +66,11 @@ export function nip11(relay: Relay, host: string) {
6666 if (p.owner) doc.pubkey = p.owner;6767 if (p.succession && relay.succession.warn) doc.succession_pending = new Date((relay.succession.warn.since + SUCCESSION_WARN_DAYS * 86400) * 1000).toISOString().slice(0, 10);6868 if (relay.settings.isLeased() && p.lease) doc.lease = { expires_at: p.lease.until, holder: p.lease.holder || undefined, claim_url: host ? "https://" + host + "/" : undefined };69+ if (featureOn(p, "grasp") && p.reads === "open") {70+ doc.supported_grasps = ["GRASP-01"];71+ doc.repo_acceptance_criteria = "Repository announcements name this service in clone and relays; the relay write rule, bans, proof of work and fuel apply. At most 16 repositories, 4 MiB per pack, 16 MiB packed history and 128 Git transactions per repository.";72+ if (p.writes !== "open" || p.blockedWords.length) doc.curation = "The relay's configured write rule and blocked topics apply to Nostr repository and collaboration events.";73+ }6974 if (featureOn(p, "sites")) doc.nsites = {7075 host: relay.domain,7176 kinds: SITE_KINDS,
M src/relay.ts +25 -4
@@ -1,6 +1,8 @@
11 // The relay: one Durable Object per name, holding its SQLite database and22 // its live websockets (hibernating while idle). Protocol handling mirrors33 // relay.go; policy is per relay and owner-managed (see manage.ts).4+import { graspTick, isGitPath, graspCORS } from "./grasp.ts";5+import { graspBytes, holdGrasp, graspVisible } from "./grasp-state.ts";46 import { queueMirrors } from "./site-mirror.ts";57 import { syncSiteIndex, forgetSites, serveSite } from "./sites.ts";68 import { DurableObject } from "cloudflare:workers";
@@ -125,6 +127,8 @@ export class Relay extends DurableObject<Env> {
125127 succession = new Succession(this);126128 // A job round in flight, so overlapping alarms do not run two.127129 private working = false;130+ graspBusy = false;131+ graspControls = 0;128132 // Presence (views.ts): who wrote lately, in memory; the sockets are asked129133 // directly. A broadcast is at most one per PRESENCE_THROTTLE_S.130134 presenceActive = new Map<string, number>();
@@ -739,7 +743,7 @@ export class Relay extends DurableObject<Env> {
739743 740744 // Files and dumps both live in R2 and both cost media storage.741745 mediaBytes(): number {742- return blobBytes(this.sql) + dumpBytes(this.sql) + importBytes(this.sql);746+ return blobBytes(this.sql) + dumpBytes(this.sql) + importBytes(this.sql) + graspBytes(this);743747 }744748 745749 fuelStatus() {
@@ -764,6 +768,7 @@ export class Relay extends DurableObject<Env> {
764768 await this.ctx.storage.put("slug", name);765769 }766770 const url = new URL(req.url);771+ const completed = (response: Response) => featureOn(this.settings.policy, "grasp") ? graspCORS(response, req.method === "OPTIONS") : response;767772 // Relays claimed before identities existed get theirs on first contact.768773 if (this.settings.policy.owner && !this.identity.pubkey) await this.publishMembership();769774 // The local custom-domain record wins over a cached edge mapping, so
@@ -774,11 +779,17 @@ export class Relay extends DurableObject<Env> {
774779 const upgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";775780 if ((site || upgrade || isGated(url, req)) && this.settings.isIPBlocked(clientIP(req))) {776781 const msg = "blocked: this address is blocked from this relay";777- return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "content-type": "application/json", "x-reason": msg, "access-control-allow-origin": "*" } });782+ return completed(new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "content-type": "application/json", "x-reason": msg, "access-control-allow-origin": "*" } }));778783 }779784 if (site) return serveSite(this, req, site);780785 if (upgrade) return this.acceptWebSocket(req);781- return route(this, req, url);786+ if (isGitPath(url)) return route(this, req, url);787+ if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS") {788+ if (this.graspBusy) return completed(new Response("restricted: Git transaction in progress; retry", { status: 429, headers: { "access-control-allow-origin": "*", "retry-after": "1" } }));789+ this.graspControls++;790+ try { return completed(await route(this, req, url)); } finally { this.graspControls--; }791+ }792+ return completed(await route(this, req, url));782793 }783794 784795 connections(): number {
@@ -960,6 +971,7 @@ export class Relay extends DurableObject<Env> {
960971 // acceptAny routes the special kinds (zap receipts, NIP-56 reports) and961972 // falls back to the ordinary write path. Callers broadcast when stored.962973 async acceptAny(e: Event, conn: ConnState): Promise<{ ok: boolean; msg: string; stored: boolean }> {974+ if (this.graspBusy) return { ok: false, msg: "restricted: Git transaction in progress; retry the event", stored: false };963975 if (e.kind === 9735) {964976 const r = await acceptReceipt(this, e, conn.host);965977 if (r) return r;
@@ -1052,6 +1064,8 @@ export class Relay extends DurableObject<Env> {
10521064 const err = this.store.save(e, t);10531065 if (err === ERR_DUPLICATE) return { ok: true, msg: err, stored: false };10541066 if (err) return no(err);1067+ const held = holdGrasp(this, e, conn?.host ?? "");1068+ if (held) void this.ensureAlarm(now() + 1);10551069 if (e.kind === 3) this.settings.noteContacts(e.pubkey);10561070 if (e.kind === KIND_MARMOT_GROUP && conn) {10571071 const principal = marmotPrincipal(this, e, conn);
@@ -1064,7 +1078,7 @@ export class Relay extends DurableObject<Env> {
10641078 if (conn) void this.succession.seen(e.pubkey);10651079 if (conn) notePresence(this, e.pubkey);10661080 if (e.kind === 30023) markView(this, "articles");1067- return { ok: true, msg: "", stored: true };1081+ return { ok: true, msg: held ? "purgatory: won't be served until git data arrives" : "", stored: !held };10681082 }10691083 10701084 private scheduleSweep(at: number) {
@@ -1074,6 +1088,9 @@ export class Relay extends DurableObject<Env> {
10741088 }10751089 10761090 async alarm() {1091+ if (this.graspBusy) { await this.ctx.storage.setAlarm(Date.now() + 1000); return; }1092+ this.graspControls++;1093+ try {10771094 this.touch();10781095 const t = now();10791096 // A lease that has run out is wiped whole: the name is free again.
@@ -1081,6 +1098,7 @@ export class Relay extends DurableObject<Env> {
10811098 await this.teardown();10821099 return;10831100 }1101+ const graspAt = await graspTick(this);10841102 await this.syncSites();10851103 await queueMirrors(this);10861104 if (await this.jobsTick()) {
@@ -1135,7 +1153,9 @@ export class Relay extends DurableObject<Env> {
11351153 if (this.settings.isLeased() && lease && lease.until < at) at = lease.until;11361154 const run = await this.nextJobRun();11371155 if (run > 0 && run < at) at = Math.max(run, t + 1);1156+ if (graspAt && graspAt < at) at = graspAt;11381157 await this.ctx.storage.setAlarm(at * 1000 + 500);1158+ } finally { this.graspControls--; }11391159 }11401160 11411161 // sweepRetention applies the owner's keep-for rules. Returns how many
@@ -1175,6 +1195,7 @@ export class Relay extends DurableObject<Env> {
11751195 }11761196 11771197 broadcast(e: Event) {1198+ if (!graspVisible(this, e.id)) return;11781199 const raw = canonical(e);11791200 for (const ws of this.ctx.getWebSockets()) {11801201 const s = this.state(ws);
M src/routes.ts +2 -0
M src/settings.ts +3 -2
@@ -88,7 +88,7 @@ export type ViewSetting = (typeof VIEW_SETTINGS)[number];
8888 // is not one: the console runs on it. What is off leaves supported_nips8989 // (nip11.ts), answers 404 at its door (routes.ts) and is refused at the9090 // socket (gates.ts, relay.ts).91-export const FEATURE_NAMES = ["search", "sync", "count", "discovery", "names", "files", "pages", "signer", "sites", "marmot"] as const;91+export const FEATURE_NAMES = ["search", "sync", "count", "discovery", "names", "files", "pages", "signer", "sites", "marmot", "grasp"] as const;9292 export type Feature = (typeof FEATURE_NAMES)[number];9393 export type SearchMode = "full" | "prose" | "off";9494 export interface Features {
@@ -100,10 +100,11 @@ export interface Features {
100100 files: boolean; // Blossom and NIP-96101101 pages: boolean; // notes and articles as pages, the feed102102 sites: { enabled: boolean; mirror: boolean }; // NIP-5A hosting and automatic blob copies103+ grasp: boolean; // GRASP Git repositories and accepted repository state103104 signer: boolean; // NIP-46 traffic carried for anyone104105 marmot: boolean; // Marmot's opaque MLS transport events105106 }106-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 };107+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 };107108 export const featureOn = (p: Policy, f: Feature): boolean => f === "sites" ? p.features.sites.enabled : p.features[f] !== false && p.features[f] !== "off";108109 109110 export function featureFields(patch: Record<string, unknown>, cur: Features): Partial<Policy> {
M src/store.ts +3 -0
A test/conformance/grasp.test.ts +110 -0
@@ -0,0 +1,110 @@
1+// GRASP-01 conformance uses the stock Git client against the public Smart HTTP door.2+import { execFile } from "node:child_process";3+import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";4+import { tmpdir } from "node:os";5+import { promisify } from "node:util";6+import { describe, expect, it } from "vitest";7+import { finalizeEvent, getPublicKey, generateSecretKey } from "nostr-tools/pure";8+import { KIND_REPO, KIND_REPO_STATE } from "../../src/kinds.ts";9+import { Client, HTTP_URL, RELAY_URL, sleep, now } from "./helpers.ts";10+import { npubEncode } from "nostr-tools/nip19";11+12+const exec = promisify(execFile);13+14+async function git(cwd: string, ...args: string[]): Promise<string> {15+ const result = await exec("git", args, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });16+ return result.stdout.trim();17+}18+19+function repositoryPath(npub: string, identifier: string): string {20+ return `/${npub}/${encodeURIComponent(identifier)}.git`;21+}22+23+async function waitForRefs(url: string): Promise<void> {24+ for (let attempt = 0; attempt < 60; attempt++) {25+ const response = await fetch(`${url}/info/refs?service=git-upload-pack`);26+ const text = await response.text();27+ if (response.status === 200 && text.includes("# service=git-upload-pack")) return;28+ await sleep(250);29+ }30+ throw new Error("GRASP repository did not become available");31+}32+33+async function publish(c: Client, sk: Uint8Array, kind: number, tags: string[][], createdAt: number): Promise<void> {34+ const event = finalizeEvent({ kind, content: "", tags, created_at: createdAt }, sk);35+ await c.publish(event);36+}37+38+describe("GRASP-01 stock Git", () => {39+ it("clones, pushes an accepted state, fetches incrementally, and supports partial clones", async ({ skip }) => {40+ const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } });41+ const info: any = await infoResponse.json();42+ if (!info.supported_grasps?.includes("GRASP-01")) skip();43+44+ const root = await mkdtemp(`${tmpdir()}/bindws-grasp-`);45+ const clone = await mkdtemp(`${tmpdir()}/bindws-grasp-clone-`);46+ const partial = await mkdtemp(`${tmpdir()}/bindws-grasp-partial-`);47+ const sk = generateSecretKey();48+ const npub = npubEncode(getPublicKey(sk));49+ const identifier = `stock-${Math.random().toString(36).slice(2, 10)}`;50+ const path = repositoryPath(npub, identifier);51+ const repoURL = `${HTTP_URL}${path}`;52+ const relayURL = RELAY_URL;53+ const createdAt = now();54+ const c = await Client.connect(RELAY_URL);55+ try {56+ await git(root, "init", "--initial-branch=main");57+ await git(root, "config", "user.email", "grasp-conformance@example.com");58+ await git(root, "config", "user.name", "GRASP conformance");59+ await writeFile(`${root}/README.md`, "initial GRASP repository\n");60+ await git(root, "add", "README.md");61+ await git(root, "commit", "-m", "initial");62+ const first = await git(root, "rev-parse", "HEAD");63+ await git(root, "tag", "-a", "v1", "-m", "first release");64+ const tag = await git(root, "rev-parse", "refs/tags/v1^{tag}");65+66+ await publish(c, sk, KIND_REPO, [67+ ["d", identifier],68+ ["clone", repoURL],69+ ["relays", relayURL],70+ ["maintainers", getPublicKey(sk)],71+ ], createdAt);72+ await publish(c, sk, KIND_REPO_STATE, [73+ ["d", identifier],74+ ["HEAD", "ref: refs/heads/main"],75+ ["refs/heads/main", first],76+ ["refs/tags/v1", tag],77+ ], createdAt + 1);78+79+ await waitForRefs(repoURL);80+ await git(root, "push", repoURL, "HEAD:refs/heads/main", "refs/tags/v1:refs/tags/v1");81+ await git(clone, "clone", repoURL, ".");82+ expect(await git(clone, "rev-parse", "HEAD")).toBe(first);83+ expect(await git(clone, "describe", "--tags")).toBe("v1");84+ expect((await git(clone, "fsck", "--full"))).not.toContain("dangling");85+86+ await writeFile(`${root}/README.md`, "incremental GRASP repository\n");87+ await git(root, "add", "README.md");88+ await git(root, "commit", "-m", "incremental");89+ const second = await git(root, "rev-parse", "HEAD");90+ await publish(c, sk, KIND_REPO_STATE, [91+ ["d", identifier],92+ ["HEAD", "ref: refs/heads/main"],93+ ["refs/heads/main", second],94+ ["refs/tags/v1", tag],95+ ], createdAt + 2);96+ await git(root, "push", repoURL, "HEAD:refs/heads/main");97+ await git(clone, "fetch", repoURL, "refs/heads/main:refs/remotes/origin/main");98+ expect(await git(clone, "rev-parse", "refs/remotes/origin/main")).toBe(second);99+100+ await git(partial, "clone", "--filter=blob:none", "--no-checkout", repoURL, ".");101+ await git(partial, "fsck", "--full");102+ await git(partial, "checkout", "main");103+ expect(await readFile(`${partial}/README.md`, "utf8")).toBe("incremental GRASP repository\n");104+ } finally {105+ await publish(c, sk, 5, [["a", `${KIND_REPO}:${getPublicKey(sk)}:${identifier}`], ["a", `${KIND_REPO_STATE}:${getPublicKey(sk)}:${identifier}`]], createdAt + 3).catch(() => {});106+ c.close();107+ await Promise.all([rm(root, { recursive: true, force: true }), rm(clone, { recursive: true, force: true }), rm(partial, { recursive: true, force: true })]);108+ }109+ }, 120_000);110+});
M test/object/exposure.test.ts +1 -0
M test/object/features.test.ts +15 -1
@@ -15,7 +15,7 @@ describe("features", () => {
1515 const owner = generateSecretKey();1616 await rpc(host, owner, "claim");1717 const p = (await rpc(host, owner, "getpolicy")).result;18- expect(p.features).toEqual({ search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false });18+ expect(p.features).toEqual({ search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false });1919 for (const n of [5, 45, 46, 50, 66, 77]) expect((await info(host)).supported_nips).toContain(n);2020 const r = await rpc(host, owner, "setpolicy", { features: { search: "full", sync: false, bogus: true, count: "no" } });2121 expect(r.result.features).toMatchObject({ search: "full", sync: false, count: true });
@@ -31,6 +31,20 @@ describe("features", () => {
3131 expect(doc.supported_nips).toContain(50);3232 });3333 34+ it("GRASP is off by default and survives policy export and import", async () => {35+ const host = "feat-grasp.bind.ws";36+ const owner = generateSecretKey();37+ await rpc(host, owner, "claim");38+ expect((await rpc(host, owner, "getpolicy")).result.features.grasp).toBe(false);39+ const policy = await rpc(host, owner, "setpolicy", { features: { grasp: true } });40+ expect(policy.result.features.grasp).toBe(true);41+ const config = (await rpc(host, owner, "exportconfig")).result;42+ const other = "feat-grasp-import.bind.ws";43+ await rpc(other, owner, "claim");44+ expect((await rpc(other, owner, "importconfig", config)).status).toBe(200);45+ expect((await rpc(other, owner, "getpolicy")).result.features.grasp).toBe(true);46+ });47+3448 it("search: prose indexes notes, full indexes reactions too, off refuses the filter", async () => {3549 const host = "feat-search.bind.ws";3650 const owner = generateSecretKey();
A test/object/grasp.test.ts +302 -0
@@ -0,0 +1,302 @@
1+// The GRASP door: repository events name the service before Git answers, and2+// pending authority stays out of ordinary relay reads until its data arrives.3+import { SELF, env, runInDurableObject } from "cloudflare:test";4+import { afterEach, describe, expect, it } from "vitest";5+import { generateSecretKey } from "nostr-tools/pure";6+import { npubEncode } from "nostr-tools/nip19";7+import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR, KIND_GIT_PR_UPDATE } from "../../src/kinds.ts";8+import { gitRepositoryPath } from "../../src/grasp-policy.ts";9+import { ev, info, pk, rpc, sleep } from "../helpers/relay.ts";10+import { WS } from "../helpers/ws.ts";11+import { decodePack, encodePack } from "ntig";12+13+const encoder = new TextEncoder();14+const packet = (value: string | Uint8Array) => {15+ const body = typeof value === "string" ? encoder.encode(value) : value;16+ const header = encoder.encode((body.length + 4).toString(16).padStart(4, "0"));17+ return new Uint8Array([...header, ...body]);18+};19+const flush = () => new Uint8Array([48, 48, 48, 48]);20+const concat = (...parts: Uint8Array[]) => {21+ const result = new Uint8Array(parts.reduce((length, part) => length + part.length, 0));22+ let offset = 0;23+ for (const part of parts) { result.set(part, offset); offset += part.length; }24+ return result;25+};26+const objectID = async (type: string, data: Uint8Array) => {27+ const header = encoder.encode(`${type} ${data.length}\0`);28+ const value = new Uint8Array(header.length + data.length);29+ value.set(header); value.set(data, header.length);30+ return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-1", value)), (byte) => byte.toString(16).padStart(2, "0")).join("");31+};32+const receivePack = (path: string, host: string, old: string | null, next: string | null, ref: string, pack?: Uint8Array) => SELF.fetch(`http://${host}${path}/git-receive-pack`, {33+ method: "POST",34+ headers: { "content-type": "application/x-git-receive-pack-request" },35+ body: concat(packet(`${old ?? "0".repeat(40)} ${next ?? "0".repeat(40)} ${ref}\0report-status\n`), flush(), ...(pack ? [pack] : [])),36+});37+const receiveSettled = async (path: string, host: string, old: string | null, next: string | null, ref: string, pack?: Uint8Array) => {38+ for (let attempt = 0; attempt < 20; attempt++) {39+ const response = await receivePack(path, host, old, next, ref, pack);40+ if (response.status !== 429) return response;41+ await new Promise((resolve) => setTimeout(resolve, 10));42+ }43+ throw new Error("Git transaction did not become available");44+};45+46+async function tinyRepository() {47+ const blob = encoder.encode("hello from grasp\n");48+ const blobID = await objectID("blob", blob);49+ const tree = new Uint8Array([...encoder.encode("100644 README\0"), ...Uint8Array.from(blobID.match(/../g)!, (part) => parseInt(part, 16))]);50+ const treeID = await objectID("tree", tree);51+ const commit = encoder.encode(`tree ${treeID}\nauthor GRASP <grasp@example.com> 1 +0000\ncommitter GRASP <grasp@example.com> 1 +0000\n\ninitial\n`);52+ const commitID = await objectID("commit", commit);53+ const pack = await encodePack([{ type: "blob", data: blob }, { type: "tree", data: tree }, { type: "commit", data: commit }]);54+ return { blobID, treeID, commitID, pack };55+}56+57+const repository = (sk: Uint8Array, host: string, identifier = "bindws") => {58+ const npub = npubEncode(pk(sk));59+ return ev(sk, KIND_REPO, "", [60+ ["d", identifier],61+ ["clone", `https://${host}${gitRepositoryPath(npub, identifier)}`],62+ ["relays", `wss://${host}`],63+ ["clone", `https://mirror.example/${identifier}.git`],64+ ["relays", "wss://relay.example"],65+ ["maintainers", pk(sk)],66+ ]);67+};68+69+describe("GRASP", () => {70+ afterEach(() => sleep(20));71+ it("is opt in and advertises the Git contract only while it is on", async () => {72+ const host = "grasp-feature.bind.ws";73+ const owner = generateSecretKey();74+ await rpc(host, owner, "claim");75+76+ expect((await info(host)).supported_grasps).toBeUndefined();77+ expect((await SELF.fetch(`http://${host}/npub1invalid/repo.git`)).status).toBe(404);78+79+ expect((await rpc(host, owner, "setpolicy", { features: { grasp: true } })).status).toBe(200);80+ const document = await info(host);81+ expect(document.supported_grasps).toEqual(["GRASP-01"]);82+ expect(document.repo_acceptance_criteria).toContain("clone and relays");83+ expect(document.nsites).toBeDefined();84+ expect((await rpc(host, owner, "setpolicy", { features: { grasp: false } })).status).toBe(200);85+ expect((await info(host)).supported_grasps).toBeUndefined();86+ });87+88+ it("accepts a service-listed announcement with every clone and relay value", async () => {89+ const host = "grasp-announcement.bind.ws";90+ const owner = generateSecretKey();91+ await rpc(host, owner, "claim");92+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });93+ const c = await WS.connect(host);94+ const event = repository(owner, host, "multi-source");95+ const accepted = await c.ok(event);96+ expect(accepted.ok, accepted.msg).toBe(true);97+98+ const found = await c.open("repo", { kinds: [KIND_REPO], authors: [pk(owner)] });99+ expect(found.events.map((e) => e.id)).not.toContain(event.id);100+ c.ws.close();101+ });102+103+ it("rejects an announcement that does not list this relay in both services", async () => {104+ const host = "grasp-rejection.bind.ws";105+ const owner = generateSecretKey();106+ await rpc(host, owner, "claim");107+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });108+ const c = await WS.connect(host);109+ const bad = ev(owner, KIND_REPO, "", [["d", "missing-service"], ["clone", "https://elsewhere.example/repo.git"], ["relays", "wss://elsewhere.example"]]);110+ const result = await c.ok(bad);111+ expect(result.ok).toBe(false);112+ expect(result.msg).toMatch(/^restricted:/);113+ c.ws.close();114+ });115+116+ it("keeps malformed Git paths CORS readable and answers preflight without opening a relay door", async () => {117+ const host = "grasp-paths.bind.ws";118+ const owner = generateSecretKey();119+ await rpc(host, owner, "claim");120+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });121+ const paths = ["/npub1invalid/repo.git", "/npub1invalid/repo.git/unknown", "/prs/anything.git"];122+ for (const path of paths) {123+ const response = await SELF.fetch(`http://${host}${path}`);124+ expect(response.status).toBe(404);125+ expect(response.headers.get("access-control-allow-origin")).toBe("*");126+ }127+ const options = await SELF.fetch(`http://${host}/npub1invalid/repo.git/info/refs`, { method: "OPTIONS" });128+ expect(options.status).toBe(204);129+ expect(options.headers.get("access-control-allow-methods")).toContain("GET");130+ });131+132+ it("keeps a repository path separate from a site hostname", async () => {133+ const host = "grasp-site-isolation.bind.ws";134+ const owner = generateSecretKey();135+ await rpc(host, owner, "claim");136+ const siteHost = `${npubEncode(pk(owner))}.bind.ws`;137+ const siteResponse = await SELF.fetch(`http://${siteHost}/`);138+ expect(siteResponse.status).toBe(404);139+ expect((await SELF.fetch(`http://${siteHost}/repo.git`)).status).toBe(404);140+ });141+142+ it("serves and updates a state-authorized repository through Smart HTTP", async () => {143+ const host = "grasp-git.bind.ws";144+ const owner = generateSecretKey();145+ const repo = repository(owner, host, "lifecycle");146+ const pack = await tinyRepository();147+ await rpc(host, owner, "claim");148+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });149+ const c = await WS.connect(host);150+ const repoResult = await c.ok(repo);151+ expect(repoResult.ok, repoResult.msg).toBe(true);152+153+154+ const state = ev(owner, KIND_REPO_STATE, "", [["d", "lifecycle"], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", pack.commitID]]);155+ expect((await c.ok(state)).ok).toBe(true);156+157+ c.ws.close();158+159+ const path = gitRepositoryPath(npubEncode(pk(owner)), "lifecycle");160+ const refs = await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`);161+ expect(refs.status).toBe(200);162+ expect(await refs.text()).toContain("symref=HEAD:refs/heads/main");163+164+ const receive = concat(packet(`${"0".repeat(40)} ${pack.commitID} refs/heads/main\0report-status\n`), flush(), pack.pack);165+ const pushed = await SELF.fetch(`http://${host}${path}/git-receive-pack`, { method: "POST", headers: { "content-type": "application/x-git-receive-pack-request" }, body: receive });166+ expect(pushed.status).toBe(200);167+ expect(await pushed.text()).toContain("ok refs/heads/main");168+ const visible = await WS.connect(host);169+ expect((await visible.req({ ids: [repo.id, state.id] })).map((e) => e.id).sort()).toEqual([repo.id, state.id].sort());170+ visible.ws.close();171+172+ const advertised = await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`);173+ expect(await advertised.text()).toContain(`${pack.commitID} refs/heads/main`);174+175+ const upload = concat(packet(`want ${pack.commitID}\n`), flush(), packet("done\n"));176+ const fetched = await SELF.fetch(`http://${host}${path}/git-upload-pack`, { method: "POST", headers: { "content-type": "application/x-git-upload-pack-request" }, body: upload });177+ expect(fetched.status).toBe(200);178+ const bytes = new Uint8Array(await fetched.arrayBuffer());179+ const objects = await decodePack(bytes.slice(8));180+ expect(objects.map((object) => object.oid).sort()).toEqual([pack.blobID, pack.treeID, pack.commitID].sort());181+ });182+183+ it("refuses a receive update whose resulting refs differ from accepted state", async () => {184+ const host = "grasp-state-gate.bind.ws";185+ const owner = generateSecretKey();186+ const repo = repository(owner, host, "state-gate");187+ const pack = await tinyRepository();188+ await rpc(host, owner, "claim");189+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });190+ const c = await WS.connect(host);191+ const repoResult = await c.ok(repo);192+ expect(repoResult.ok, repoResult.msg).toBe(true);193+ const state = ev(owner, KIND_REPO_STATE, "", [["d", "state-gate"], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", pack.commitID]]);194+ expect((await c.ok(state)).ok).toBe(true); c.ws.close();195+ const path = gitRepositoryPath(npubEncode(pk(owner)), "state-gate");196+ const wrong = concat(packet(`${"0".repeat(40)} ${"f".repeat(40)} refs/heads/other\0report-status\n`), flush(), pack.pack);197+ const response = await SELF.fetch(`http://${host}${path}/git-receive-pack`, { method: "POST", headers: { "content-type": "application/x-git-receive-pack-request" }, body: wrong });198+ expect(response.status).toBe(200);199+ expect(await response.text()).toMatch(/ng|unpack/);200+ const refs = await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`);201+ expect(await refs.text()).not.toContain("refs/heads/other");202+ });203+204+ it("holds unknown PR refs, checks published PR tips, and refuses anonymous deletion", async () => {205+ const host = "grasp-pr-refs.bind.ws";206+ const owner = generateSecretKey();207+ const repo = repository(owner, host, "pr-refs");208+ const pack = await tinyRepository();209+ await rpc(host, owner, "claim");210+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });211+ const c = await WS.connect(host);212+ expect((await c.ok(repo)).ok).toBe(true);213+ const state = ev(owner, KIND_REPO_STATE, "", [["d", "pr-refs"], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", pack.commitID]]);214+ expect((await c.ok(state)).ok).toBe(true); c.ws.close();215+ const path = gitRepositoryPath(npubEncode(pk(owner)), "pr-refs");216+217+ expect((await receivePack(path, host, null, pack.commitID, "refs/heads/main", pack.pack)).status).toBe(200);218+ const realPR = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:pr-refs`], ["c", pack.commitID]]);219+ const unknownRef = `refs/nostr/${realPR.id}`;220+ expect((await receivePack(path, host, null, pack.commitID, unknownRef, pack.pack)).status).toBe(200);221+ expect(await (await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`)).text()).toContain(`${pack.commitID} ${unknownRef}`);222+ const deletion = await receiveSettled(path, host, pack.commitID, null, unknownRef);223+ expect(deletion.status).toBe(200);224+ expect(await deletion.text()).toContain(`ng ${unknownRef}`);225+226+ const late = await WS.connect(host);227+ expect((await late.ok(realPR)).ok).toBe(true);228+ expect((await receivePack(path, host, pack.commitID, pack.commitID, "refs/heads/main")).status).toBe(200);229+ expect((await late.req({ ids: [realPR.id] })).map((e) => e.id)).toEqual([realPR.id]);230+ const listed = await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`);231+ expect(await listed.text()).toContain(`refs/nostr/${realPR.id}`);232+ const wrongPR = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:pr-refs`], ["c", "b".repeat(40)]]);233+ const wrongEvent = await WS.connect(host);234+ expect((await wrongEvent.ok(wrongPR)).ok).toBe(true);235+ const mismatch = await receivePack(path, host, null, pack.commitID, `refs/nostr/${wrongPR.id}`);236+ expect(await mismatch.text()).toContain(`ng refs/nostr/${wrongPR.id}`);237+ wrongEvent.ws.close();238+239+ const expiringID = "c".repeat(64);240+ const expiringRef = `refs/nostr/${expiringID}`;241+ expect(await (await receivePack(path, host, null, pack.commitID, expiringRef, pack.pack)).text()).toContain(`ok ${expiringRef}`);242+ await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), (relay) => {243+ relay.sql.exec("UPDATE grasp_pr_refs SET until=0 WHERE repo=? AND ref=?", `30617:${pk(owner)}:pr-refs`, expiringRef);244+ });245+ await runInDurableObject(env.RELAY.getByName(host.split(".")[0]), (relay) => relay.alarm());246+ const afterExpiry = await SELF.fetch(`http://${host}${path}/info/refs?service=git-upload-pack`);247+ expect(await afterExpiry.text()).not.toContain(expiringRef);248+ late.ws.close();249+ });250+251+ it("removes the public Git contract when reads become restricted", async () => {252+ const host = "grasp-restricted.bind.ws";253+ const owner = generateSecretKey();254+ await rpc(host, owner, "claim");255+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });256+ expect((await info(host)).supported_grasps).toEqual(["GRASP-01"]);257+ await rpc(host, owner, "setpolicy", { reads: "members" });258+ const document = await info(host);259+ expect(document.supported_grasps).toBeUndefined();260+ const response = await SELF.fetch(`http://${host}/npub1${"q".repeat(58)}/repo.git`);261+ expect(response.status).toBe(403);262+ expect(await response.text()).toMatch(/^restricted:/);263+ });264+265+ it("fences event and management mutations while Git owns the authority view", async () => {266+ const host = "grasp-fence.bind.ws";267+ const owner = generateSecretKey();268+ await rpc(host, owner, "claim");269+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });270+ const event = repository(owner, host, "fenced");271+ await runInDurableObject(env.RELAY.getByName("grasp-fence"), async (relay) => {272+ relay.graspBusy = true;273+ try {274+ const result = await relay.acceptAny(event, relay.virtualConn(host, pk(owner)));275+ expect(result.ok).toBe(false);276+ expect(result.msg).toContain("Git transaction in progress");277+ const response = await relay.fetch(new Request(`http://${host}/`, { method: "POST", body: "{}" }));278+ expect(response.status).toBe(429);279+ await response.text();280+ expect(relay.sql.exec("SELECT 1 FROM events WHERE id=?", event.id).toArray()).toEqual([]);281+ } finally { relay.graspBusy = false; }282+ });283+ });284+285+ it("does not derive a PR update's repository from a moderated parent", async () => {286+ const host = "grasp-parent.bind.ws";287+ const owner = generateSecretKey();288+ await rpc(host, owner, "claim");289+ await rpc(host, owner, "setpolicy", { features: { grasp: true } });290+ const c = await WS.connect(host);291+ expect((await c.ok(repository(owner, host, "parent"))).ok).toBe(true);292+ const parent = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:parent`], ["c", "a".repeat(40)]]);293+ expect((await c.ok(parent)).ok).toBe(true);294+ await runInDurableObject(env.RELAY.getByName("grasp-parent"), (relay) => relay.settings.setEvent(parent.id, "hide"));295+ const update = ev(owner, KIND_GIT_PR_UPDATE, "", [["E", parent.id], ["c", "b".repeat(40)]]);296+ const result = await c.ok(update);297+ expect(result.ok).toBe(false);298+ expect(result.msg).toContain("accepted repository");299+ c.ws.close();300+ });301+302+});
M test/object/presets.test.ts +1 -1
A test/unit/grasp-policy.test.ts +103 -0
@@ -0,0 +1,103 @@
1+import { describe, expect, it } from "vitest";2+import {3+ eventReferencesRepository,4+ gitRepositoryPath,5+ isCanonicalCloneURL,6+ isWebSocketURL,7+ latestState,8+ nostrRefEventID,9+ parseRepositoryAnnouncement,10+ parseRepositoryState,11+ recursiveMaintainers,12+ repositoryCoordinate,13+ serviceListed,14+ stateAllowsRefs,15+} from "../../src/grasp-policy.ts";16+17+const owner = "a".repeat(64);18+const maintainer = "b".repeat(64);19+const eventID = "c".repeat(64);20+const base = { id: eventID, pubkey: owner, kind: 30617, created_at: 10, tags: [] as string[][] };21+22+describe("GRASP-01 policy", () => {23+ it("parses and validates repository announcements", () => {24+ const parsed = parseRepositoryAnnouncement({ ...base, tags: [["d", "bindws"], ["clone", "https://git.example/bindws.git", "ssh://git.example/bindws"], ["relays", "wss://relay.example", "ws://localhost:8787"], ["maintainers", maintainer]] });25+ expect(parsed.error).toBeUndefined();26+ expect(parsed.value?.maintainers).toEqual([maintainer]);27+ expect(repositoryCoordinate(parsed.value!)).toBe(`30617:${owner}:bindws`);28+ expect(serviceListed(parsed.value!, "https://git.example/bindws.git", "wss://relay.example/")).toBe(true);29+ });30+31+ it("accepts third-party clone schemes and rejects malformed relay URLs", () => {32+ expect(isCanonicalCloneURL("http://git.example/x.git")).toBe(true);33+ expect(isCanonicalCloneURL("ssh://git.example/x")).toBe(true);34+ expect(isCanonicalCloneURL("git@git.example:x")).toBe(true);35+ expect(isCanonicalCloneURL("nostr://danconwaydev.com/relay.ngit.dev/ngit")).toBe(true);36+ expect(isWebSocketURL("https://relay.example")).toBe(false);37+ expect(parseRepositoryAnnouncement({ ...base, tags: [["d", "x"], ["clone", "https://git.example/x.git"], ["relays", "https://relay.example"]] }).error).toContain("relays");38+ });39+40+ it("preserves identifiers through the percent-encoded Git path", () => {41+ expect(gitRepositoryPath("npub1example", "my/repo repo")).toBe("/npub1example/my%2Frepo%20repo.git");42+ expect(gitRepositoryPath("npub1example", "a!b'c(d)*~")).toBe("/npub1example/a%21b%27c%28d%29%2A~.git");43+ expect(() => gitRepositoryPath("hex", "x")).toThrow();44+ });45+46+ it("uses the 256-byte identifier quota and rejects lone surrogates", () => {47+ expect(() => gitRepositoryPath("npub1example", "a".repeat(257))).toThrow();48+ expect(() => gitRepositoryPath("npub1example", "\ud800")).toThrow();49+ expect(parseRepositoryAnnouncement({ ...base, tags: [["d", "a".repeat(257)], ["clone", "https://git.example/x.git"], ["relays", "wss://relay.example"]] }).error).toContain("identifier");50+ });51+52+ it("rejects duplicate d tags but accepts multi-value tags", () => {53+ const duplicate = parseRepositoryAnnouncement({ ...base, tags: [["d", "one"], ["d", "two"], ["clone", "http://git.example/x.git"], ["relays", "ws://relay.example"]] });54+ expect(duplicate.error).toContain("identifier");55+ const multi = parseRepositoryAnnouncement({ ...base, tags: [["d", "my/repo"], ["clone", "http://git.example/x.git", "ssh://git.example/x"], ["relays", "ws://relay.example", "wss://relay.example"]] });56+ expect(multi.value?.clone).toHaveLength(2);57+ expect(multi.value?.relays).toHaveLength(2);58+ });59+60+ it("walks recursive maintainers with cycles", () => {61+ const root = { id: eventID, owner, identifier: "x", clone: [], relays: [], maintainers: [maintainer], private: false };62+ const second = { ...root, id: "d".repeat(64), owner: maintainer, maintainers: ["e".repeat(64)] };63+ const third = { ...root, id: "e".repeat(64), owner: "e".repeat(64), maintainers: [owner] };64+ expect(recursiveMaintainers(root, [second, third])).toEqual(new Set([owner, maintainer, "e".repeat(64)]));65+ });66+67+ it("parses state and compares refs exactly", () => {68+ const state = parseRepositoryState({ id: eventID, pubkey: owner, kind: 30618, created_at: 12, tags: [["d", "x"], ["refs/heads/main", "1".repeat(40)], ["HEAD", "ref: refs/heads/main"], ["refs/tags/v1^{}", "2".repeat(40)]] });69+ expect(state.error).toBeUndefined();70+ expect(state.value?.refs).toEqual({ "refs/heads/main": "1".repeat(40) });71+ expect(state.value && stateAllowsRefs(state.value, { "refs/heads/main": "1".repeat(40) }, "refs/heads/main")).toBe(true);72+ expect(state.value && stateAllowsRefs(state.value, {}, null)).toBe(false);73+ });74+75+ it("rejects unsafe refs and zero object IDs", () => {76+ for (const ref of ["refs/heads/a..b", "refs/heads/a@{b}", "refs/heads/a.lock", "refs/heads/a//b", "refs/heads/a~b"]) {77+ expect(parseRepositoryState({ id: eventID, pubkey: owner, kind: 30618, created_at: 12, tags: [["d", "x"], [ref, "1".repeat(40)]] }).error).toContain("ref");78+ }79+ expect(parseRepositoryState({ id: eventID, pubkey: owner, kind: 30618, created_at: 12, tags: [["d", "x"], ["refs/heads/main", "0".repeat(40)]] }).error).toContain("ref");80+ });81+82+ it("orders state ties by the lower event id and filters authors", () => {83+ const make = (id: string, created_at: number, author = owner) => ({ id, owner: author, created_at, identifier: "x", refs: {}, head: null });84+ const states = [make("f".repeat(64), 20), make("e".repeat(64), 20), make("0".repeat(64), 21, "z".repeat(64))];85+ expect(latestState(states, owner, "x", new Set([maintainer]))?.id).toBe("e".repeat(64));86+ });87+88+ it("recognizes unknown PR refs without requiring a stored event", () => {89+ expect(nostrRefEventID(`refs/nostr/${eventID}`)).toBe(eventID);90+ expect(nostrRefEventID("refs/heads/main")).toBeNull();91+ });92+93+ it("matches valid repository a-tags only", () => {94+ const coordinate = `30617:${owner}:x`;95+ expect(eventReferencesRepository({ ...base, kind: 1618, tags: [["a", coordinate]] }, coordinate)).toBe(true);96+ expect(eventReferencesRepository({ ...base, kind: 1618, tags: [["a", "30618:" + owner + ":x"]] }, coordinate)).toBe(false);97+ });98+99+ it("fails closed when recursive maintainers exceed the bound", () => {100+ const root = { id: eventID, owner, identifier: "x", clone: [], relays: [], maintainers: [maintainer], private: false };101+ expect(recursiveMaintainers(root, [], 8, 1)).toBeNull();102+ });103+});
A vendor/README.md +24 -0
A vendor/ntig-0.1.1.tgz +0 -0

Binary file.

A vendor/ntig-source.tar.gz +0 -0

Binary file.