clone

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

relay: temporary leases and pulling from another relay (#7)

FelineStateMachine committed 372d1ac
* relay: temporary leases at memorable names, claimed in place or wiped at expiry

A relay is now unclaimed, leased or claimed. POST /lease on the apex hands
out an adjective-animal name, open to everyone for LEASE_DAYS (14), with a
keep-for rule of the same length. A NIP-98 signature on the request reserves
the claim for that key. Claiming converts the relay in place; resetrules puts
the access rules, kind rules and retention back to the defaults. The alarm
tears down a lease past its expiry, which frees the name.

The endpoint is capped by two rate limiting bindings: per address and for
everyone together.

* relay: pull another relay's events and files into this one

pullfrom starts a job the alarm runs in rounds: one websocket per round,
NIP-77 reconciliation as the initiator with the existing negentropy code, a
bounded batch of the missing ids, signatures verified, bans and kind rules
applied, the write policy not. Relays on this host are dialled through their
object and their files copied in R2. A pull survives the object sleeping and
a second one fetches only what is new. pullstatus reports on it.

* console, landing: the lease banner, the pull form and a try-one button

A leased relay's page says when it expires and offers the claim; after a
conversion the console offers to reset the rules. The Storage tab gains a
pull form whose note follows the job. The front page gets a button that
leases a relay and opens its console, plus the endpoint in the datasheet.

* docs: leases, pulling and the lease settings
commit
372d1ac25a102f04dc3620bf0a2d0df4397c4c87
parent
1160010149b8400781da4b341d0c97600f70849b
author
FelineStateMachine <dami.etoile@gmail.com>
15 files changed, +832 -22
Mdocs/00-getting-started.md22++
Mdocs/01-relay-configuration.md1+
Mdocs/10-architecture.md6+-
Mdocs/11-hosting-bindws.md2+
Mdocs/12-develop-extend.md6+-
Msrc/dashboard.ts4+-
Msrc/index.ts53+++++-
Msrc/landing.ts23++-
Msrc/manage.ts33+++-
Asrc/names.ts48+++++
Asrc/pull.ts216+++++++++++++++++++++++
Msrc/relay.ts119++++++++++++-
Msrc/settings.ts33+++-
Atest/lease.test.ts277++++++++++++++++++++++++++++++
Mwrangler.jsonc11+-
M docs/00-getting-started.md +22 -0
@@ -17,6 +17,22 @@ You need a nostr key and a browser extension that signs with it, such as Alby, n
1717 1818 If the page shows someone else's name, the relay is taken. Pick another.1919 20+## Try one first21+22+Not ready to pick a name? Click **Try one now** on the front page, or ask for one from a script:23+24+```25+curl -X POST https://bind.ws/lease26+```27+28+You get a relay at a memorable name, such as `wss://brave-otter.bind.ws`. Anyone can read and write to it for 14 days. Then everything on it is deleted and the name is freed. Its page says when.29+30+To keep it, open its page and click **Claim** before it expires. The relay converts in place: the events and files stay, and you are the owner. The page then offers to switch to the default rules, since a temporary relay lets anyone write and keeps events for 14 days only.31+32+If you sign the request with your key (NIP-98), only that key can claim the relay. Agents and scripts with a key get a relay that nobody else can take from them.33+34+To keep the events but not the name, claim the name you want and pull the temporary relay into it. See [Bring events in](#bring-events-in).35+2036 ## Connect a client2137 2238 Add the relay URL to your client's relay list:
@@ -48,6 +64,12 @@ Your relay stores images and files too. Clients that support Blossom upload to `
4864 4965 If you write scripts, the relay has an HTTP door. Sign a request with your key (NIP-98) and post to `/events`, `/query` or `/count`. No websocket needed.5066 67+## Bring events in68+69+Any relay that lets you read can be copied into yours. On the Storage tab, under **Pull from a relay**, enter its URL. Your relay syncs with it (NIP-77) and fetches what it lacks, in the background. Run it again later and only new events come over. Files come along when the other relay is on bind.ws.70+71+Your bans and kind rules apply to what arrives. Your write rule does not: you asked for these events.72+5173 ## Leave5274 5375 Your data is yours. Any relay that supports sync (NIP-77) can pull everything from your relay:
M docs/01-relay-configuration.md +1 -0
M docs/10-architecture.md +5 -1
@@ -28,6 +28,8 @@ client ──wss/https──▶ worker ──host→object──▶ durable obje
2828 - **Fuel** (`fuel.ts`): usage meters, credits, prices.2929 - **Identity** (`identity.ts`): the relay's own keypair and the NIP-43 roster.3030 31+A relay is in one of three states. Unclaimed: no owner, no lease, writes refused. Leased: no owner yet, `policy.lease` holds an expiry and, optionally, the one pubkey that may claim; reads and writes are open, management is refused except `claim`. Claimed: an owner. A lease is handed out by `POST /lease` on the apex (`index.ts`), which tries memorable names from `names.ts` until one is unclaimed and calls the object's `lease` method over RPC. A claim on a leased relay converts it in place and clears the lease; the events, files and the 14-day retention rule stay until the owner resets the rules.32+3133 The constructor runs schema setup inside `blockConcurrencyWhile`, then handles fetches. Websockets use the hibernation API. Per-connection state (NIP-42 challenge, authenticated pubkeys, open subscriptions) is stored on the socket with `serializeAttachment`, so it survives hibernation. Negentropy sessions are in memory only and end with `closed:` when the object sleeps.3234 3335 ## Storage
@@ -43,7 +45,9 @@ Blobs live in R2 under `<name>/<sha256>`. Their descriptors live in the relay's
4345 4446 ## Alarm4547 46-One alarm per object, at most a day out, sooner if a NIP-40 expiry is due. It flushes usage counters, charges storage, applies retention rules and sweeps expired events.48+One alarm per object, at most a day out, sooner if a NIP-40 expiry is due or a lease expires. It flushes usage counters, charges storage, applies retention rules and sweeps expired events. A lease past its expiry is torn down whole, which returns the name to unclaimed.49+50+The alarm also drives pulls (`pull.ts`). `pullfrom` records a job and wakes the alarm; each round opens one websocket to the source, reconciles with NIP-77 as the initiator, fetches a bounded batch of the missing ids, verifies signatures, and stores them through the normal write path minus the client policy. Sources on this host are dialled through their object stub and their files copied in R2 by prefix. Rounds repeat until nothing is missing, so a pull survives the object sleeping, and a second pull only fetches the difference.4751 4852 ## Fuel4953
M docs/11-hosting-bindws.md +2 -0
M docs/12-develop-extend.md +4 -2
M src/dashboard.ts +2 -2
@@ -15,6 +15,6 @@ export function dashboard(): string {
1515 1616 const 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.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 { height: 18px; border: 2px solid var(--ink); border-radius: 999px; background: var(--paper); margin: .5rem 0 .35rem; overflow: hidden; padding: 2px; }\n.gauge .bar i { display: block; height: 100%; width: 0; border-radius: 999px; background: repeating-linear-gradient(-45deg, var(--forest) 0 8px, var(--forest-2) 8px 16px); transition: width .4s; }\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; }\ndetails.disclosure summary::before { content: \"+\"; font: 700 1.4rem var(--mono); width: 1.4rem; } details.disclosure[open] summary::before { content: \"\u2013\"; }\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: wrap; align-items: flex-end; margin: 0 0 0 1rem; }\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 { background: var(--paper); color: var(--ink); z-index: 3; padding-top: .7rem; }\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; }\n.actions { display: flex; justify-content: flex-end; margin-top: 1.1rem; } .actions.left { justify-content: flex-start; }\n.addrow { display: flex; gap: .5rem; margin-bottom: .9rem; } .addrow input { flex: 1; min-width: 0; } .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; }\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: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; } .mast { grid-template-columns: 1fr; } .mast .right { justify-items: start; text-align: left; } }\n@media (max-width: 40rem) { .events th:first-child, .events 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";1717 18-const BODY = "<main>\n <header class=\"mast\">\n <div class=\"wordmark\"><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=\"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><span class=\"note\" id=\"claimnote\"></span></div>\n </section>\n\n <section id=\"connectsec\">\n <h2>Connect</h2>\n <p class=\"lead hidden\" id=\"desc\"></p>\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 </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></div><span id=\"t-events\"></span></div>\n <div class=\"gauge\"><small>Files stored</small><div class=\"bar\"><i id=\"g-media\"></i></div><span id=\"t-media\"></span></div>\n <div class=\"gauge\"><small>Awake this month</small><div class=\"bar\"><i id=\"g-active\"></i></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></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=\"aboutsec\"><details class=\"disclosure\"><summary>About, for clients</summary><div id=\"about\" class=\"about\"></div></details></section>\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=\"#rules\" data-tab=\"rules\">Rules</a>\n <a href=\"#identity\" data-tab=\"identity\">Identity</a>\n <a href=\"#storage\" data-tab=\"storage\">Storage</a>\n <a href=\"#health\" data-tab=\"health\">Health</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>Joined</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block 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 </div>\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\"><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 <div class=\"block\">\n <h3>Reports <span class=\"count\" id=\"reports-count\"></span></h3>\n <p class=\"note\">Reports never show in the feed. Banning also deletes the reported event.</p>\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 </section>\n\n <section class=\"panel\" data-panel=\"rules\">\n <h2>Rules</h2>\n <p class=\"lead\">Bans apply regardless of these.</p>\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=\"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=\"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>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 </section>\n\n <section class=\"panel\" data-panel=\"identity\">\n <h2>Identity</h2>\n <form id=\"identity\">\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://\u2026/icon.png\" maxlength=\"2000\"></label>\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 identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans 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>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\n <section class=\"panel\" data-panel=\"storage\">\n <h2>Storage</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 <details class=\"disclosure block\"><summary>Browse recent events</summary>\n <p class=\"note\">Delete removes one thing. Ban also refuses it forever.</p>\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 </details>\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\">\u2013</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\">\u2013</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">\u2013</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 </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";18+const BODY = "<main>\n <header class=\"mast\">\n <div class=\"wordmark\"><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=\"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><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><span class=\"note\" id=\"leasenote\"></span></div>\n </section>\n\n <section id=\"connectsec\">\n <h2>Connect</h2>\n <p class=\"lead hidden\" id=\"desc\"></p>\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 </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></div><span id=\"t-events\"></span></div>\n <div class=\"gauge\"><small>Files stored</small><div class=\"bar\"><i id=\"g-media\"></i></div><span id=\"t-media\"></span></div>\n <div class=\"gauge\"><small>Awake this month</small><div class=\"bar\"><i id=\"g-active\"></i></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></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=\"aboutsec\"><details class=\"disclosure\"><summary>About, for clients</summary><div id=\"about\" class=\"about\"></div></details></section>\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=\"#rules\" data-tab=\"rules\">Rules</a>\n <a href=\"#identity\" data-tab=\"identity\">Identity</a>\n <a href=\"#storage\" data-tab=\"storage\">Storage</a>\n <a href=\"#health\" data-tab=\"health\">Health</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>Joined</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block 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 </div>\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\"><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 <div class=\"block\">\n <h3>Reports <span class=\"count\" id=\"reports-count\"></span></h3>\n <p class=\"note\">Reports never show in the feed. Banning also deletes the reported event.</p>\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 </section>\n\n <section class=\"panel\" data-panel=\"rules\">\n <h2>Rules</h2>\n <p class=\"lead\">Bans apply regardless of these.</p>\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=\"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=\"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>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 </section>\n\n <section class=\"panel\" data-panel=\"identity\">\n <h2>Identity</h2>\n <form id=\"identity\">\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://\u2026/icon.png\" maxlength=\"2000\"></label>\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 identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans 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>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\n <section class=\"panel\" data-panel=\"storage\">\n <h2>Storage</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>Pull from a relay</h3>\n <p class=\"note\">Copies what the other relay shows to anyone and this one lacks, by NIP-77 sync, so running it again only fetches what is new. Files come along from relays on this host. Bans and kind rules apply; the write rule does not.</p>\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><button class=\"btn\">Pull</button></form>\n <p class=\"note\" id=\"pullnote\"></p>\n </div>\n <details class=\"disclosure block\"><summary>Browse recent events</summary>\n <p class=\"note\">Delete removes one thing. Ban also refuses it forever.</p>\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 </details>\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\">\u2013</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\">\u2013</b><small>connected now</small><span class=\"sub\">websockets open</span></div>\n <div class=\"counter\"><b class=\"big\" id=\"h-fuel\">\u2013</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 </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";1919 20-const 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;\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 const short = (hex) => hex ? hex.slice(0, 8) + \"\u2026\" + hex.slice(-4) : \"\";\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\" }) : \"\u2013\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"\u2013\";\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 };\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 async function rpc(method, ...params) {\n if (!window.nostr) throw new Error(\"no NIP-07 extension\");\n const body = JSON.stringify({ method, params });\n const ev = await window.nostr.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 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 owner = (info && info.pubkey) || \"\";\n const isOwner = !!me && me === owner;\n $(\"#ownerline\").innerHTML = owner ? 'run by <b class=\"mono\" title=\"' + owner + '\">' + short(owner) + \"</b>\" : \"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);\n $(\"#signin\").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\", 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 [\"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\">\u2013</span>' : 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\") : 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 : short(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); g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", used > free); $(\"#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 $(\"#credits tbody\").innerHTML = fuel.credits.length ? fuel.credits.map((c) => '<tr><td class=\"dim\">' + esc(fmtTime(c.at)) + '</td><td class=\"mono\" title=\"' + esc(c.payer) + '\">' + av(c.payer) + short(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 (!window.nostr) throw new Error(\"A nostr extension is needed to sign the zap request.\");\n const msats = Math.round(sats * 1000);\n const zapRequest = await window.nostr.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\u2026\";\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\";\n const known = [\"people\", \"rules\", \"identity\", \"storage\", \"health\"];\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\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 $(\"#console\").classList.toggle(\"hidden\", !isOwner);\n if (!isOwner) { policy = null; renderCare(); return; }\n const [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.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon; fi.joinTerms.value = p.joinTerms; fi.directoryPublic.checked = !!p.directoryPublic;\n showTab(location.hash.slice(1));\n await Promise.all([loadLists(), loadEvents(true), loadStorage()]);\n }\n\n async function loadLists() {\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100)]);\n const members = mem.members;\n window.__members = members;\n $(\"#members tbody\").innerHTML = members.map((m) => '<tr data-pk=\"' + m.pubkey + '\"><td class=\"mono\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + short(m.pubkey) + (m.role === \"owner\" ? ' <span class=\"chip\">owner</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=\"dim\">' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '</td><td class=\"r\">' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (m.role === \"owner\" ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"</td></tr>\").join(\"\");\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 + '\">\u2026' + 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 $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '<li><i class=\"ev\">ev</i><span><b class=\"mono\" title=\"' + esc(r.id) + '\">' + short(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) + short(r.target_pubkey) + (r.target_event ? ' <span class=\"dim\">ev ' + short(r.target_event) + \"</span>\" : \"\") + '</td><td class=\"c\">' + esc(r.content) + '</td><td class=\"r\">' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", \"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 || \"\";\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\">' + short(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) + short(b.uploader) + '</td><td class=\"r\">' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no uploads</td></tr>';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n const tag = (k, cls) => '<span class=\"tag ' + cls + '\">' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"</span>\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : '<span class=\"tag plain\">all</span>';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : '<span class=\"tag plain\">none</span>';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 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]);\n let storage = null;\n\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\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 async function loadEvents(reset) {\n const list = 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) + short(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + 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\", list.length < 50);\n }\n\n // ---- actions ----\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 refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage()]);\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, \u2026) and reload.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signout\").onclick = async () => { me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople(); };\n $(\"#claim\").onclick = guard(async () => {\n if (!window.nostr) { $(\"#claimnote\").textContent = \"You need a nostr browser extension (Alby, nos2x, \u2026) to sign the claim.\"; return; }\n me = await window.nostr.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 });\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value, joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople();\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 $(\"#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.reset(); toast(\"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 $(\"#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 if (!confirm(\"Replace this relay's rules, identity, members, bans and kind rules with \" + file.name + \"?\")) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\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 if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) 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\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8));\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await refresh();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n me = localStorage.getItem(\"me\");\n if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n await loadAdmin();\n await loadPeople();\n})();\n";20+const 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;\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 const short = (hex) => hex ? hex.slice(0, 8) + \"\u2026\" + hex.slice(-4) : \"\";\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\" }) : \"\u2013\";\n const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" }) : \"\u2013\";\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 };\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 async function rpc(method, ...params) {\n if (!window.nostr) throw new Error(\"no NIP-07 extension\");\n const body = JSON.stringify({ method, params });\n const ev = await window.nostr.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 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 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 class=\"mono\" title=\"' + owner + '\">' + short(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 $(\"#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\", 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 [\"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\">\u2013</span>' : 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\") : 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 : short(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); g.style.width = pct(used, free) + \"%\"; g.classList.toggle(\"over\", used > free); $(\"#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 $(\"#credits tbody\").innerHTML = fuel.credits.length ? fuel.credits.map((c) => '<tr><td class=\"dim\">' + esc(fmtTime(c.at)) + '</td><td class=\"mono\" title=\"' + esc(c.payer) + '\">' + av(c.payer) + short(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 (!window.nostr) throw new Error(\"A nostr extension is needed to sign the zap request.\");\n const msats = Math.round(sats * 1000);\n const zapRequest = await window.nostr.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\u2026\";\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\";\n const known = [\"people\", \"rules\", \"identity\", \"storage\", \"health\"];\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\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 $(\"#console\").classList.toggle(\"hidden\", !isOwner);\n if (!isOwner) { policy = null; renderCare(); return; }\n const [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.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs;\n fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;\n fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon; fi.joinTerms.value = p.joinTerms; fi.directoryPublic.checked = !!p.directoryPublic;\n showTab(location.hash.slice(1));\n await Promise.all([loadLists(), loadEvents(true), loadStorage()]);\n }\n\n async function loadLists() {\n const [mem, bans, bannedEvents, allow, block, invites, reports, blobs] = await Promise.all([rpc(\"listmembers\"), rpc(\"listbannedpubkeys\"), rpc(\"listbannedevents\"), rpc(\"listallowedkinds\"), rpc(\"listblockedkinds\"), rpc(\"listinvites\"), rpc(\"listreports\"), rpc(\"listblobs\", 100)]);\n const members = mem.members;\n window.__members = members;\n $(\"#members tbody\").innerHTML = members.map((m) => '<tr data-pk=\"' + m.pubkey + '\"><td class=\"mono\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + short(m.pubkey) + (m.role === \"owner\" ? ' <span class=\"chip\">owner</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=\"dim\">' + esc(fmtDay(m.joined_at)) + \", \" + esc(m.via) + '</td><td class=\"r\">' + ib(\"check\", \"Save\", \"savemember\", m.pubkey) + (m.role === \"owner\" ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"</td></tr>\").join(\"\");\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 + '\">\u2026' + 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 $(\"#banned-events\").innerHTML = bannedEvents.map((r) => '<li><i class=\"ev\">ev</i><span><b class=\"mono\" title=\"' + esc(r.id) + '\">' + short(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) + short(r.target_pubkey) + (r.target_event ? ' <span class=\"dim\">ev ' + short(r.target_event) + \"</span>\" : \"\") + '</td><td class=\"c\">' + esc(r.content) + '</td><td class=\"r\">' + ib(\"check\", \"Dismiss\", \"resolve:dismiss\", r.id) + ib(\"trash\", \"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 || \"\";\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\">' + short(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) + short(b.uploader) + '</td><td class=\"r\">' + ib(\"trash\", \"Delete file\", \"deleteblob\", b.sha256, \"danger\") + \"</td></tr>\").join(\"\") : '<tr><td colspan=\"5\" class=\"muted\">no uploads</td></tr>';\n $(\"#blobs-count\").textContent = blobs.length || \"\";\n const tag = (k, cls) => '<span class=\"tag ' + cls + '\">' + k + ib(\"x\", \"Remove rule\", \"unrulekind\", String(k)) + \"</span>\";\n $(\"#kinds-allow\").innerHTML = allow.length ? allow.map((k) => tag(k, \"ok\")).join(\"\") : '<span class=\"tag plain\">all</span>';\n $(\"#kinds-block\").innerHTML = block.length ? block.map((k) => tag(k, \"blk\")).join(\"\") : '<span class=\"tag plain\">none</span>';\n await loadPeople();\n }\n\n const KIND_NAMES = { 0: \"profiles\", 1: \"notes\", 3: \"contacts\", 4: \"DMs\", 5: \"deletions\", 6: \"reposts\", 7: \"reactions\", 16: \"reposts\", 1059: \"gift wraps\", 1063: \"file headers\", 1111: \"comments\", 1984: \"reports\", 9734: \"zap requests\", 9735: \"zap receipts\", 9802: \"highlights\", 10002: \"relay lists\", 13534: \"roster\", 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]);\n let storage = null;\n\n async function loadStorage() {\n storage = await rpc(\"storagestats\");\n pollPull();\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 async function loadEvents(reset) {\n const list = 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) + short(e.pubkey) + '</td><td class=\"c\">' + esc(e.content) + '</td><td class=\"r\">' + 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\", list.length < 50);\n }\n\n // ---- actions ----\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 refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage()]);\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, \u2026) and reload.\");\n me = await window.nostr.getPublicKey();\n localStorage.setItem(\"me\", me);\n renderHeader(); await loadAdmin(); await loadPeople();\n });\n $(\"#signout\").onclick = async () => { me = null; localStorage.removeItem(\"me\"); renderHeader(); await loadAdmin(); await loadPeople(); };\n const claimNow = async (note) => {\n if (!window.nostr) { note.textContent = \"You need a nostr browser extension (Alby, nos2x, \u2026) to sign the claim.\"; return; }\n me = await window.nostr.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 // Pulls run in the background; the note follows them while the tab is open.\n let pullTimer = 0, pullSeen = 0;\n async function pollPull() {\n clearTimeout(pullTimer);\n let st;\n try { st = await rpc(\"pullstatus\"); } catch { return; }\n const el = $(\"#pullnote\");\n if (st.running) {\n const r = st.running;\n el.textContent = \"Pulling from \" + r.url + \": \" + r.stored.toLocaleString() + \" events so far\" + (r.blobs ? \", \" + r.blobs + \" files\" : \"\") + \"\u2026\";\n pullTimer = setTimeout(pollPull, 3000);\n return;\n }\n const l = st.last;\n el.textContent = !l ? \"\" : l.error ? \"Pull from \" + l.url + \" failed: \" + l.error : \"Pulled \" + l.stored.toLocaleString() + \" events\" + (l.blobs ? \" and \" + l.blobs + \" files\" : \"\") + \" from \" + l.url + (l.skipped ? \" (\" + l.skipped + \" skipped)\" : \"\") + \", \" + fmtTime(l.finishedAt) + \".\";\n if (l && l.finishedAt > pullSeen) { const first = pullSeen === 0; pullSeen = l.finishedAt; if (!first && (l.stored || l.blobs)) await refresh(); }\n }\n $(\"#pullform\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n await rpc(\"pullfrom\", f.url.value.trim());\n toast(\"Pull started\"); f.reset(); await pollPull();\n });\n $(\"#access\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { writes: f.writes.value, reads: f.reads.value, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });\n toast(\"Rules saved\"); await loadInfo();\n });\n $(\"#identity\").onsubmit = guard(async (ev) => {\n const f = ev.target;\n policy = await rpc(\"setpolicy\", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value, joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });\n toast(\"Identity saved\"); await loadInfo(); await loadPeople();\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 $(\"#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.reset(); toast(\"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 $(\"#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 if (!confirm(\"Replace this relay's rules, identity, members, bans and kind rules with \" + file.name + \"?\")) { ev.target.value = \"\"; return; }\n await rpc(\"importconfig\", cfg); ev.target.value = \"\"; toast(\"Configuration imported\"); await loadInfo(); await loadAdmin(); await loadPeople();\n });\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 if (act === \"deleteblob\" && !confirm(\"Delete this file for good?\")) 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\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value }); }\n else if (act.startsWith(\"resolve:\")) await rpc(\"resolvereport\", id, act.slice(8));\n else await rpc(act, act === \"unrulekind\" ? +id : id, \"\");\n toast(\"Done\"); await refresh();\n } catch (e) { toast(e.message); } finally { b.disabled = false; }\n });\n\n // ---- boot ----\n try { await loadInfo(); } catch { renderHeader(); }\n await loadFuel();\n me = localStorage.getItem(\"me\");\n if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }\n else if (me) me = null;\n renderHeader();\n await loadAdmin();\n await loadPeople();\n})();\n";
M src/index.ts +48 -5
@@ -4,14 +4,53 @@
44 import { Relay, type Env } from "./relay.ts";55 import { landing } from "./landing.ts";66 import { FAVICON_SVG } from "./ui.ts";7+import { verifyNIP98 } from "./manage.ts";8+import { leaseNames, leaseDays, validName } from "./names.ts";9+import { now } from "./event.ts";710 811 export { Relay };12+export { NAME_RE, RESERVED, validName } from "./names.ts";913 10-export const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/;11-export const RESERVED = new Set(["www", "api", "admin", "app", "mail", "relay", "relays", "gateway", "static", "cdn", "status", "docs", "help", "support", "abuse", "root", "ns1", "ns2", "smtp", "imap", "ftp"]);12-13-export function validName(name: string): boolean {14- return NAME_RE.test(name) && name.length >= 3 && !RESERVED.has(name);14+// lease hands out a temporary relay at a memorable name: open to everyone15+// for a while, wiped after unless claimed. Anyone may ask, no key needed;16+// a NIP-98 signature reserves the claim for that key. For scripts, agents17+// and the button on the front page alike.18+async function lease(req: Request, env: Env, apex: URL): Promise<Response> {19+ const cors = { "access-control-allow-origin": "*", "content-type": "application/json" };20+ const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: cors });21+ // Each lease is an object with storage, so the door is narrow: a few per22+ // address per minute, and a ceiling for everyone together.23+ const ip = req.headers.get("cf-connecting-ip") ?? "unknown";24+ const [perIP, all] = await Promise.all([env.LEASE_LIMIT_IP.limit({ key: ip }), env.LEASE_LIMIT_ALL.limit({ key: "all" })]);25+ if (!perIP.success || !all.success) return json({ error: "rate-limited: too many leases, try again in a minute" }, 429);26+ const body = await req.text();27+ let holder = "";28+ const authz = req.headers.get("authorization");29+ if (authz) {30+ const auth = verifyNIP98(authz, req.url, req.method, body);31+ if (typeof auth === "string") return json({ error: auth }, 401);32+ holder = auth.pubkey;33+ }34+ const days = leaseDays(env);35+ const until = now() + days * 86400;36+ // wrangler dev serves the apex at <domain>.localhost; relays are <name>.localhost.37+ const dev = apex.hostname !== env.DOMAIN.toLowerCase() && apex.hostname !== "www." + env.DOMAIN.toLowerCase();38+ const suffix = dev ? ".localhost" + (apex.port ? ":" + apex.port : "") : "." + env.DOMAIN;39+ for (const name of leaseNames()) {40+ const host = name + suffix;41+ const err = await env.RELAY.getByName(name).lease(name, host, until, holder);42+ if (err) continue;43+ return json({44+ name,45+ url: (dev ? "ws://" : "wss://") + host,46+ console: (dev ? "http://" : "https://") + host + "/",47+ expires_at: until,48+ days,49+ holder: holder || undefined,50+ claim: `Open the console or send a NIP-86 "claim" signed with NIP-98 before it expires. Once claimed it is yours for good, events and files included; the console then offers to reset the rules to the defaults.`,51+ }, 201);52+ }53+ return json({ error: "error: no free name found, try again" }, 503);1554 }1655 1756 export default {
@@ -27,6 +66,10 @@ export default {
2766 2867 if (name === null) {2968 if (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico") return new Response(FAVICON_SVG, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } });69+ if (url.pathname === "/lease" && req.method === "POST") return lease(req, env, url);70+ if (url.pathname === "/lease" && req.method === "OPTIONS") {71+ return new Response(null, { headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "POST, OPTIONS" } });72+ }3073 return landing(req, env);3174 }3275 if (!validName(name) || name.includes(".")) {
M src/landing.ts +21 -2
@@ -2,6 +2,7 @@
22 // datasheet under it. Every number comes from the same config the relay runs33 // on, so the page cannot drift from the service.44 import { MAX_MESSAGE, VERSION, type Env } from "./relay.ts";5+import { leaseDays } from "./names.ts";56 import { fuelConfig } from "./fuel.ts";67 import { DEFAULT_POLICY } from "./settings.ts";78 import { page, escapeHTML } from "./ui.ts";
@@ -13,6 +14,8 @@ main { max-width: 68rem; padding-top: 2rem; }
1314 .head { text-align: center; margin: 2rem 0 2.5rem; }1415 .head h1 { font-size: clamp(3rem, 8vw, 4.4rem); }1516 .head p { margin: 1rem auto 0; color: var(--ink-2); font-size: 1.1rem; }17+.head .try { display: flex; gap: .8rem; align-items: center; justify-content: center; flex-wrap: wrap; margin-top: 1.4rem; font-size: 15px; }18+.head .try .btn { box-shadow: 3px 3px 0 var(--ink); }1619 .map { display: grid; grid-template-columns: 1.25fr 1fr; gap: 2.5rem; align-items: start; }1720 .map > * { min-width: 0; }1821 .map .box { background: var(--paper); border: 2px solid var(--ink); border-radius: 12px; padding: 1rem; box-shadow: 4px 4px 0 var(--ink); }
@@ -65,6 +68,7 @@ const MAP = `<svg viewBox="0 0 640 470" role="img" aria-label="how a request rea
6568 6669 const NOTES = `<div class="notes">6770 <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="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>6872 <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>6973 <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>7074 </div>`;
@@ -102,10 +106,12 @@ export function landing(req: Request, env: Env): Response {
102106 const p = DEFAULT_POLICY;103107 const n = (x: number) => x.toLocaleString("en-US");104108 const bad = new URL(req.url).searchParams.get("bad");109+ const days = leaseDays(env);105110 const interfaces = table(["Endpoint", "Protocol", "Auth", "Notes"], [106111 [`wss://&lt;name&gt;.${domain}`, "NIP-01, 42, 45, 77", "optional NIP-42", "Sockets hibernate. Negentropy sessions end when the object hibernates."],107112 ["GET / (accept nostr+json)", "NIP-11", "none", "limitation, retention, payments_url, self"],108113 ["POST /", "NIP-86", "NIP-98, owner", "claim and the management methods"],114+ ["POST /lease", "lease", "optional NIP-98", `A temporary relay for ${leaseDays(env)} days. A signature reserves the claim for that key.`],109115 ["POST /events /query /count", "HTTP bridge", "NIP-98", "The signer has the same access as an authenticated socket."],110116 ["PUT /upload, GET /&lt;sha256&gt;", "Blossom", "kind 24242", "Stored in R2. Counted as files."],111117 ["GET /.well-known/nostr.json", "NIP-05", "none", "Members that have a name."],
@@ -129,7 +135,8 @@ export function landing(req: Request, env: Env): Response {
129135 ], [2, 3]);130136 const protocol = table(["NIP", "What it does here"], PROTOCOL.map(([a, b]) => [a, b.replace("name.bind.ws", `name.${domain}`)]));131137 const body = `<main>132- <div class="head"><h1>${domain.replace(".", "<em>.</em>")}</h1><p>Relay on demand. Sign once, and it's yours.</p>${bad ? `<p class="note">"${escapeHTML(bad)}" is not a valid name. Use 3 to 32 lowercase letters, digits or hyphens.</p>` : ""}</div>138+ <div class="head"><h1>${domain.replace(".", "<em>.</em>")}</h1><p>Relay on demand. Sign once, and it's yours.</p>${bad ? `<p class="note">"${escapeHTML(bad)}" is not a valid name. Use 3 to 32 lowercase letters, digits or hyphens.</p>` : ""}139+ <p class="try"><button id="try" class="btn pri">Try one now</button><span id="trynote">A relay at a name picked for you, open to anyone for ${days} days. Claim it to keep it.</span></p></div>133140 <div class="map"><div class="box">${MAP}</div>${NOTES}</div>134141 <div class="sheet">135142 <h2>Interfaces</h2>${interfaces}
@@ -139,6 +146,18 @@ export function landing(req: Request, env: Env): Response {
139146 <h2>Protocol</h2>${protocol}140147 </div>141148 <footer class="foot"><a href="${REPO}">-&gt; github.com/FelineStateMachine/bindws</a><span class="muted">v${VERSION}</span></footer>142-</main>`;149+</main>150+<script>151+document.getElementById("try").onclick = async (ev) => {152+ const b = ev.target, note = document.getElementById("trynote");153+ b.disabled = true; note.textContent = "Finding a name…";154+ try {155+ const r = await fetch("/lease", { method: "POST" });156+ const j = await r.json();157+ if (!r.ok) throw new Error(j.error || "no relay today");158+ location.href = j.console;159+ } catch (e) { note.textContent = e.message; b.disabled = false; }160+};161+</script>`;143162 return new Response(page(env.DOMAIN, body, CSS), { headers: { "content-type": "text/html; charset=utf-8" } });144163 }
M src/manage.ts +29 -4
@@ -8,6 +8,7 @@ import type { Relay } from "./relay.ts";
88 import { listInvites, mintInvite, revokeInvite } from "./invites.ts";99 import { descriptor, type Blob } from "./blossom.ts";1010 import { isReplaceable, isProtected } from "./settings.ts";11+import { checkPullURL } from "./pull.ts";1112 1213 // verifyNIP98 checks an "Authorization: Nostr <base64 event>" header against1314 // the request: kind 27235, fresh, u tag naming this URL, method tag, and a
@@ -51,7 +52,8 @@ const METHODS = [
5152 "listreports", "resolvereport",5253 "listblobs", "deleteblob",5354 "storagestats", "setretention", "listretention", "purgekind",54- "deleterelay", "exportconfig", "importconfig",55+ "deleterelay", "exportconfig", "importconfig", "resetrules",56+ "pullfrom", "pullstatus",5557 ];5658 5759 export async function manage(relay: Relay, req: Request): Promise<Response> {
@@ -77,15 +79,23 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
7779 7880 if (method === "claim") {7981 if (p.owner === "") {80- s.update({ owner: caller });82+ // A leased relay converts in place, keeping its events and files. A83+ // lease taken with a signature is reserved for that key.84+ const lease = p.lease;85+ if (lease && lease.holder && lease.holder !== caller) return reply({ error: "restricted: this temporary relay is reserved for another key" }, 403);86+ if (lease && lease.until <= t) return reply({ error: "restricted: this temporary relay has expired" }, 403);87+ s.update(lease ? { owner: caller, lease: null, name: "", description: "" } : { owner: caller });8188 s.upsertMember(caller, { via: "claimed" }, t);8289 await relay.publishRoster();83- return reply({ result: { owner: caller, claimed: true } });90+ return reply({ result: { owner: caller, claimed: true, ...(lease ? { converted: true } : {}) } });8491 }8592 return reply({ result: { owner: p.owner, claimed: s.isOwner(caller) } }, s.isOwner(caller) ? 200 : 403);8693 }8794 if (method === "supportedmethods") return reply({ result: METHODS });88- if (!s.isOwner(caller)) return reply({ error: p.owner === "" ? "restricted: this relay is unclaimed" : "restricted: not the relay owner" }, 403);95+ if (!s.isOwner(caller)) {96+ const why = p.owner !== "" ? "restricted: not the relay owner" : s.isLeased() ? "restricted: this is a temporary relay; claim it first" : "restricted: this relay is unclaimed";97+ return reply({ error: why }, 403);98+ }8999 90100 const str = (i: number) => (typeof params[i] === "string" ? (params[i] as string) : "");91101 const num = (i: number) => (Number.isInteger(params[i]) ? (params[i] as number) : parseInt(str(i), 10));
@@ -260,6 +270,21 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
260270 return reply({ result: s.listKinds("allow") });261271 case "listblockedkinds":262272 return reply({ result: s.listKinds("block") });273+ case "resetrules":274+ s.resetRules();275+ return reply({ result: s.policy });276+ case "pullfrom": {277+ // (url): copy what another relay has that this one lacks. Runs in278+ // the background; pullstatus reports on it.279+ const url = str(0).trim();280+ const bad = checkPullURL(url, relay.slug, relay.domain);281+ if (bad) return reply({ error: bad }, 400);282+ const err = await relay.pullStart(url);283+ if (err) return reply({ error: err }, 409);284+ return reply({ result: { started: true, url } });285+ }286+ case "pullstatus":287+ return reply({ result: await relay.pullStatus() });263288 case "changerelayname":264289 s.update({ name: str(0).slice(0, 200) });265290 return reply({ result: true });
A src/names.ts +48 -0
@@ -0,0 +1,48 @@
1+// Relay names: what a valid one looks like, which are kept back, and the2+// memorable ones handed out for temporary leases.3+4+export const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/;5+export const RESERVED = new Set(["www", "api", "admin", "app", "mail", "relay", "relays", "gateway", "static", "cdn", "status", "docs", "help", "support", "abuse", "root", "ns1", "ns2", "smtp", "imap", "ftp"]);6+7+export function validName(name: string): boolean {8+ return NAME_RE.test(name) && name.length >= 3 && !RESERVED.has(name);9+}10+11+// Two short lists, read over once for pairings that read badly. A lease name12+// is adjective-animal; after a few collisions it falls back to animal plus13+// two digits.14+export const ADJECTIVES = [15+ "amber", "ancient", "autumn", "bold", "brave", "bright", "brisk", "calm", "candid", "cheerful", "civil", "clear", "clever", "cool", "copper", "cosmic", "cozy", "crisp", "curious", "daring",16+ "dawn", "deep", "eager", "early", "earnest", "easy", "electric", "fair", "faithful", "fancy", "fine", "fleet", "fluent", "fond", "free", "fresh", "gentle", "gilded", "glad", "golden",17+ "good", "graceful", "grand", "green", "happy", "hardy", "hearty", "hidden", "honest", "humble", "icy", "indigo", "ivory", "jade", "jolly", "keen", "kind", "lively", "lucid", "lucky",18+ "lunar", "magic", "major", "marble", "mellow", "merry", "mighty", "misty", "modest", "nimble", "noble", "north", "olive", "opal", "patient", "pearl", "plain", "polar", "proud", "quick",19+ "quiet", "rapid", "ready", "rosy", "royal", "ruby", "rustic", "sage", "sandy", "scarlet", "serene", "sharp", "silent", "silver", "simple", "sleek", "smart", "snowy", "solar", "solid",20+ "sonic", "spry", "steady", "stellar", "still", "stormy", "sunny", "swift", "tidy", "tiny", "true", "velvet", "vivid", "warm", "wild", "windy", "wise", "witty", "young", "zesty",21+];22+export const ANIMALS = [23+ "alpaca", "badger", "beaver", "bison", "bobcat", "camel", "capybara", "cardinal", "caribou", "cheetah", "cobra", "condor", "coyote", "crane", "cricket", "deer", "dingo", "dolphin", "dove", "eagle",24+ "egret", "elk", "falcon", "ferret", "finch", "firefly", "fox", "gazelle", "gecko", "gibbon", "giraffe", "goose", "gopher", "heron", "hornet", "ibex", "ibis", "iguana", "impala", "jackal",25+ "jaguar", "kestrel", "koala", "lark", "lemur", "leopard", "lion", "lizard", "llama", "lynx", "macaw", "magpie", "manatee", "marlin", "marmot", "marten", "meerkat", "mole", "moose", "moth",26+ "narwhal", "newt", "ocelot", "octopus", "orca", "oriole", "osprey", "otter", "owl", "oyster", "panda", "panther", "parrot", "pelican", "penguin", "pike", "plover", "puffin", "puma", "quail",27+ "rabbit", "raven", "robin", "salmon", "seal", "shrike", "sparrow", "spider", "squid", "stork", "swan", "tapir", "tern", "tiger", "toucan", "trout", "turtle", "urchin", "viper",28+ "vole", "walrus", "wapiti", "weasel", "whale", "wombat", "wren", "yak", "zebra",29+];30+31+export const DEFAULT_LEASE_DAYS = 14;32+33+// leaseDays reads LEASE_DAYS, falling back to the default.34+export function leaseDays(env: { LEASE_DAYS?: string }): number {35+ const n = Number(env.LEASE_DAYS);36+ return Number.isInteger(n) && n > 0 && n <= 365 ? n : DEFAULT_LEASE_DAYS;37+}38+39+function rand(n: number): number {40+ return crypto.getRandomValues(new Uint32Array(1))[0] % n;41+}42+43+// leaseNames yields candidate names, memorable first, until one is free.44+export function* leaseNames(): Generator<string> {45+ const pick = (list: string[]) => list[rand(list.length)];46+ for (let i = 0; i < 8; i++) yield pick(ADJECTIVES) + "-" + pick(ANIMALS);47+ for (let i = 0; i < 8; i++) yield pick(ANIMALS) + String(10 + rand(90));48+}
A src/pull.ts +216 -0
@@ -0,0 +1,216 @@
1+// Pull: this relay fetches what another relay has and it lacks. One round2+// is one connection: reconcile with NIP-77, take a bounded batch of the3+// missing events, and, for a relay on this host, a batch of its files.4+// The alarm calls rounds until nothing is missing, so a pull survives the5+// object sleeping, and running it again later only fetches what is new.6+import { sha256 } from "@noble/hashes/sha2.js";7+import { validate, now, type Event } from "./event.ts";8+import { Negentropy, bytesToHex, hexToBytes } from "./negentropy.ts";9+import { ERR_DUPLICATE } from "./store.ts";10+import { validName } from "./names.ts";11+import type { Blob } from "./blossom.ts";12+import type { Relay } from "./relay.ts";13+14+export interface PullJob {15+ url: string;16+ startedAt: number;17+ rounds: number;18+ stored: number;19+ skipped: number;20+ blobs: number;21+ failures: number;22+}23+export interface PullResult extends PullJob {24+ finishedAt: number;25+ error: string;26+}27+28+const ROUND_EVENTS = 500;29+const ROUND_BLOBS = 20;30+const IDS_PER_REQ = 100;31+const MAX_ITEMS = 100_000;32+const MESSAGE_TIMEOUT_MS = 20_000;33+34+// localName is the relay name when the URL is one of ours, else "".35+export function localName(url: URL, domain: string): string {36+ const host = url.hostname.toLowerCase();37+ const d = domain.toLowerCase();38+ let name = "";39+ if (host.endsWith("." + d)) name = host.slice(0, -(d.length + 1));40+ else if (host.endsWith(".localhost")) name = host.slice(0, -".localhost".length);41+ return validName(name) && !name.includes(".") ? name : "";42+}43+44+// checkPullURL returns "" or a reason the URL cannot be pulled from.45+export function checkPullURL(raw: string, self: string, domain: string): string {46+ let u: URL;47+ try {48+ u = new URL(raw);49+ } catch {50+ return "invalid: not a URL";51+ }52+ if (u.protocol !== "wss:" && u.protocol !== "ws:") return "invalid: the URL must start with wss:// or ws://";53+ if (localName(u, domain) === self) return "invalid: a relay cannot pull from itself";54+ return "";55+}56+57+// dial opens a websocket to a relay. Our own relays are reached through58+// their object directly, which also works in wrangler dev and tests.59+async function dial(relay: Relay, raw: string): Promise<WebSocket> {60+ const u = new URL(raw);61+ const local = localName(u, relay.domain);62+ const headers: Record<string, string> = { upgrade: "websocket" };63+ let resp: Response;64+ if (local) resp = await relay.relays.getByName(local).fetch("https://" + u.host + "/", { headers: { ...headers, "x-relay-name": local } });65+ else resp = await fetch(u.href.replace(/^ws/, "http"), { headers });66+ const ws = resp.webSocket;67+ if (!ws) throw new Error(`relay did not accept a websocket (${resp.status})`);68+ ws.accept();69+ return ws;70+}71+72+// Socket turns a websocket into a queue of parsed messages.73+class Socket {74+ private queue: unknown[][] = [];75+ private waiters: { res: (m: unknown[]) => void; rej: (e: Error) => void }[] = [];76+ private closed: Error | null = null;77+78+ constructor(private ws: WebSocket) {79+ ws.addEventListener("message", (e) => {80+ let m: unknown;81+ try {82+ m = JSON.parse(typeof e.data === "string" ? e.data : "");83+ } catch {84+ return;85+ }86+ if (!Array.isArray(m)) return;87+ const w = this.waiters.shift();88+ if (w) w.res(m);89+ else this.queue.push(m);90+ });91+ const end = (why: string) => {92+ this.closed = new Error(why);93+ for (const w of this.waiters.splice(0)) w.rej(this.closed);94+ };95+ ws.addEventListener("close", (e) => end("connection closed" + (e.reason ? ": " + e.reason : "")));96+ ws.addEventListener("error", () => end("connection failed"));97+ }98+99+ send(...msg: unknown[]) {100+ this.ws.send(JSON.stringify(msg));101+ }102+103+ recv(): Promise<unknown[]> {104+ const m = this.queue.shift();105+ if (m) return Promise.resolve(m);106+ if (this.closed) return Promise.reject(this.closed);107+ return new Promise((res, rej) => {108+ const timer = setTimeout(() => {109+ this.waiters = this.waiters.filter((w) => w.res !== ok);110+ rej(new Error("the relay stopped answering"));111+ }, MESSAGE_TIMEOUT_MS);112+ const ok = (m: unknown[]) => {113+ clearTimeout(timer);114+ res(m);115+ };116+ this.waiters.push({ res: ok, rej: (e) => (clearTimeout(timer), rej(e)) });117+ });118+ }119+120+ close() {121+ try {122+ this.ws.close(1000, "done");123+ } catch {124+ /* already closed */125+ }126+ }127+}128+129+// runPullRound does one round of the job. more: call again. error: the130+// round failed and nothing more was learned.131+export async function runPullRound(relay: Relay, job: PullJob): Promise<{ more: boolean; error: string }> {132+ job.rounds++;133+ let sock: Socket | null = null;134+ try {135+ sock = new Socket(await dial(relay, job.url));136+ const need = await reconcile(relay, sock);137+ const want = need.slice(0, ROUND_EVENTS);138+ for (let i = 0; i < want.length; i += IDS_PER_REQ) await fetchEvents(relay, sock, want.slice(i, i + IDS_PER_REQ), job);139+ let more = need.length > want.length;140+ if (!more) {141+ const local = localName(new URL(job.url), relay.domain);142+ if (local) more = await copyBlobs(relay, local, job);143+ }144+ return { more, error: "" };145+ } catch (err) {146+ return { more: false, error: err instanceof Error ? err.message : String(err) };147+ } finally {148+ sock?.close();149+ }150+}151+152+// reconcile runs NIP-77 as the initiator over everything and returns the153+// ids the other side has that we do not.154+async function reconcile(relay: Relay, sock: Socket): Promise<string[]> {155+ const items = relay.store.syncItems({ tags: {} }, { pubkeys: [], all: true }, MAX_ITEMS, now());156+ if (items === "too big") throw new Error("this relay holds too many events to sync");157+ const neg = new Negentropy(items, sha256);158+ const id = "pull";159+ sock.send("NEG-OPEN", id, {}, bytesToHex(neg.initiate()));160+ const need: string[] = [];161+ for (;;) {162+ const m = await sock.recv();163+ if (m[0] === "NEG-ERR" && m[1] === id) throw new Error("sync refused: " + String(m[2]));164+ if (m[0] !== "NEG-MSG" || m[1] !== id) continue; // AUTH challenges, notices165+ const r = neg.reconcile(hexToBytes(String(m[2])));166+ need.push(...r.need);167+ if (r.reply === null) break;168+ sock.send("NEG-MSG", id, bytesToHex(r.reply));169+ }170+ sock.send("NEG-CLOSE", id);171+ return need;172+}173+174+// fetchEvents asks for a batch by id and stores what checks out. Signatures175+// are verified, bans and kind rules apply, the write policy does not: the176+// owner asked for these.177+async function fetchEvents(relay: Relay, sock: Socket, ids: string[], job: PullJob) {178+ const sub = "pull-" + job.stored + "-" + job.skipped;179+ sock.send("REQ", sub, { ids });180+ for (;;) {181+ const m = await sock.recv();182+ if (m[0] === "EOSE" && m[1] === sub) break;183+ if (m[0] === "CLOSED" && m[1] === sub) throw new Error("query refused: " + String(m[2]));184+ if (m[0] !== "EVENT" || m[1] !== sub) continue;185+ const e = m[2] as Event;186+ if (validate(e) || !ids.includes(e.id) || !relay.settings.kindAllowed(e.kind)) {187+ job.skipped++;188+ continue;189+ }190+ const r = relay.accept(e, null);191+ if (r.stored) {192+ job.stored++;193+ relay.broadcast(e);194+ } else if (r.msg !== ERR_DUPLICATE) job.skipped++;195+ }196+ sock.send("CLOSE", sub);197+}198+199+// copyBlobs brings over a batch of the other relay's files. Returns true200+// when more remain.201+async function copyBlobs(relay: Relay, srcName: string, job: PullJob): Promise<boolean> {202+ const rows: Blob[] = await relay.relays.getByName(srcName).listBlobs();203+ let n = 0;204+ for (const b of rows) {205+ if (relay.sql.exec(`SELECT 1 FROM blobs WHERE sha256=?`, b.sha256).toArray().length) continue;206+ if (n === ROUND_BLOBS) return true;207+ const obj = await relay.media.get(`${srcName}/${b.sha256}`);208+ if (!obj) continue;209+ await relay.media.put(`${relay.slug}/${b.sha256}`, obj.body, { httpMetadata: { contentType: b.type } });210+ relay.sql.exec(`INSERT OR IGNORE INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)`, b.sha256, b.size, b.type, b.uploader, b.uploaded);211+ relay.meterBytes(b.size, 0);212+ job.blobs++;213+ n++;214+ }215+ return false;216+}
M src/relay.ts +115 -4
@@ -20,12 +20,17 @@ import { claimFromProfile, nip05Document } from "./nip05.ts";
2020 import { checkInvite, claimInvite, invitePage } from "./invites.ts";2121 import { verifyNIP98 } from "./manage.ts";2222 import { FAVICON_SVG } from "./ui.ts";23+import { runPullRound, type PullJob, type PullResult } from "./pull.ts";24+import type { Blob } from "./blossom.ts";2325 2426 export interface Env {2527 RELAY: DurableObjectNamespace<Relay>;2628 MEDIA: R2Bucket;2729 DOMAIN: string;2830 DEV_RELAY: string;31+ LEASE_DAYS?: string; // how long a temporary relay lives; 14 by default32+ LEASE_LIMIT_IP: RateLimit; // leases per address per minute33+ LEASE_LIMIT_ALL: RateLimit; // leases per minute, everyone together2934 // Fuel (see fuel.ts); all optional.3035 LIGHTNING_ADDRESS?: string;3136 SERVICE_PUBKEY?: string;
@@ -46,6 +51,9 @@ export const VERSION = "0.1.0";
4651 export const MAX_MESSAGE = 128 * 1024;4752 const FAVICON = FAVICON_SVG;4853 const MAX_SYNC = 100_000;54+// How long a leased relay keeps everything, so a claim inherits a bounded55+// window until the owner resets the rules.56+const LEASE_RETENTION_DAYS = 14;4957 5058 // ConnState is everything about a websocket that must survive hibernation.5159 interface ConnState {
@@ -76,6 +84,8 @@ export class Relay extends DurableObject<Env> {
7684 private lnurl: LnurlParams | null = null;7785 // Outbound HTTP to the lightning provider; tests substitute a fake.7886 fetcher: Fetcher = (u, i) => fetch(u, i);87+ // A pull round in flight, so overlapping alarms do not run two.88+ private pulling = false;7989 8090 constructor(ctx: DurableObjectState, env: Env) {8191 super(ctx, env);
@@ -101,6 +111,15 @@ export class Relay extends DurableObject<Env> {
101111 return this.env.MEDIA;102112 }103113 114+ get domain(): string {115+ return this.env.DOMAIN;116+ }117+118+ // relays reaches the other objects on this host, for pulls between them.119+ get relays(): DurableObjectNamespace<Relay> {120+ return this.env.RELAY;121+ }122+104123 // meterBytes lets HTTP paths (bridge, media) add to the traffic counters.105124 meterBytes(bytesIn: number, bytesOut: number) {106125 this.meter.bytesIn += bytesIn;
@@ -188,7 +207,8 @@ export class Relay extends DurableObject<Env> {
188207 // mayUpload applies the write policy to Blossom uploads. "" allows.189208 mayUpload(pubkey: string, host: string): string {190209 const p = this.settings.policy;191- if (p.owner === "") return "restricted: this relay is unclaimed";210+ if (this.settings.isUnclaimed()) return "restricted: this relay is unclaimed";211+ if (this.settings.leaseExpired(now())) return "restricted: this temporary relay has expired";192212 if (this.settings.isBanned(pubkey)) return "blocked: this pubkey is banned from this relay";193213 if (p.writes === "owner" && !this.settings.isOwner(pubkey)) return "restricted: only the relay owner may upload here";194214 if (p.writes === "allowlist" && !this.settings.isAllowed(pubkey)) return "restricted: this relay only accepts uploads from its members";
@@ -234,6 +254,82 @@ export class Relay extends DurableObject<Env> {
234254 this.sql.exec(`DELETE FROM blobs WHERE sha256=?`, sha);235255 }236256 257+ // listBlobs is for another relay on this host pulling our files.258+ listBlobs(): Blob[] {259+ return this.sql.exec<Blob>(`SELECT * FROM blobs ORDER BY uploaded`).toArray();260+ }261+262+ // ---- temporary leases ----263+264+ // lease turns an unclaimed relay into a temporary one: open to everyone,265+ // everything kept for a bounded window, wiped at `until` unless claimed.266+ // holder "" lets anyone claim; a pubkey reserves the claim. Returns "" or267+ // a reason.268+ async lease(name: string, host: string, until: number, holder: string): Promise<string> {269+ if (!this.settings.isUnclaimed()) return "taken";270+ if (this.slug !== name) {271+ this.slug = name;272+ await this.ctx.storage.put("slug", name);273+ }274+ const day = new Date(until * 1000).toISOString().slice(0, 10);275+ this.settings.update({276+ lease: { until, holder },277+ writes: "open",278+ reads: "open",279+ name: "",280+ description: `Temporary relay. Anyone can read and write here until ${day}; then everything on it is deleted. To keep it, claim it at https://${host}/ (sign once). Or claim a new name and pull from this one.`,281+ });282+ this.settings.setRetention(null, LEASE_RETENTION_DAYS);283+ await this.ensureAlarm(until);284+ return "";285+ }286+287+ // ---- pulling from another relay ----288+289+ // pullStart records the job and wakes the alarm, which runs it in rounds.290+ async pullStart(url: string): Promise<string> {291+ if (await this.ctx.storage.get<PullJob>("pull")) return "restricted: a pull is already running";292+ if (this.fuelStatus().outOfFuel) return "restricted: this relay is out of fuel";293+ const job: PullJob = { url, startedAt: now(), rounds: 0, stored: 0, skipped: 0, blobs: 0, failures: 0 };294+ await this.ctx.storage.put("pull", job);295+ await this.ctx.storage.delete("lastPull");296+ await this.ctx.storage.setAlarm(Date.now() + 50);297+ return "";298+ }299+300+ async pullStatus(): Promise<{ running: PullJob | null; last: PullResult | null }> {301+ return { running: (await this.ctx.storage.get<PullJob>("pull")) ?? null, last: (await this.ctx.storage.get<PullResult>("lastPull")) ?? null };302+ }303+304+ // pullTick runs one round of the job. Returns whether another is due. A305+ // few failed rounds in a row end the job with the reason.306+ private async pullTick(): Promise<boolean> {307+ if (this.pulling) return true;308+ const job = await this.ctx.storage.get<PullJob>("pull");309+ if (!job) return false;310+ this.pulling = true;311+ let r: { more: boolean; error: string };312+ try {313+ r = await runPullRound(this, job);314+ } finally {315+ this.pulling = false;316+ }317+ const finish = async (error: string) => {318+ await this.ctx.storage.put("lastPull", { ...job, finishedAt: now(), error } satisfies PullResult);319+ await this.ctx.storage.delete("pull");320+ return false;321+ };322+ if (r.error) {323+ job.failures++;324+ if (job.failures >= 3) return finish(r.error);325+ } else {326+ job.failures = 0;327+ if (!r.more) return finish("");328+ }329+ await this.ctx.storage.put("pull", job);330+ return true;331+ }332+237333 // ---- fuel plumbing ----238334 239335 tally() {
@@ -268,6 +364,8 @@ export class Relay extends DurableObject<Env> {
268364 // ensureAlarm keeps a daily tick scheduled for storage charging and usage269365 // flushes, without pre-empting a sooner NIP-40 expiry.270366 private async ensureAlarm(latest = now() + 86400) {367+ const lease = this.settings.policy.lease;368+ if (this.settings.isLeased() && lease && lease.until < latest) latest = lease.until;271369 const cur = await this.ctx.storage.getAlarm();272370 if (cur === null || cur > latest * 1000) await this.ctx.storage.setAlarm(latest * 1000);273371 }
@@ -434,7 +532,7 @@ export class Relay extends DurableObject<Env> {
434532 max_subid_length: 64,435533 auth_required: p.reads === "auth",436534 payment_required: false,437- restricted_writes: p.writes !== "open" || p.owner === "",535+ restricted_writes: p.writes !== "open" || this.settings.isUnclaimed(),438536 created_at_upper_limit: p.maxFuture || undefined,439537 min_pow_difficulty: p.minPow || undefined,440538 },
@@ -449,6 +547,7 @@ export class Relay extends DurableObject<Env> {
449547 doc.payments_url = "https://" + host + "/";450548 }451549 if (p.owner) doc.pubkey = p.owner;550+ if (this.settings.isLeased() && p.lease) doc.lease = { expires_at: p.lease.until, holder: p.lease.holder || undefined, claim_url: host ? "https://" + host + "/" : undefined };452551 if (host) doc.self_url = this.relayURL(host);453552 if (this.identity.pubkey) {454553 doc.self = this.identity.pubkey;
@@ -620,7 +719,7 @@ export class Relay extends DurableObject<Env> {
620719 // acceptReport files a NIP-56 report in the moderation queue. It is never621720 // stored as an event or served: reports are for the owner, not the feed.622721 private acceptReport(e: Event): { ok: boolean; msg: string; stored: boolean } {623- if (this.settings.policy.owner === "") return { ok: false, msg: "restricted: this relay is unclaimed", stored: false };722+ if (this.settings.policy.owner === "") return { ok: false, msg: this.settings.isLeased() ? "restricted: this temporary relay has no owner to report to" : "restricted: this relay is unclaimed", stored: false };624723 if (this.settings.isBanned(e.pubkey)) return { ok: false, msg: "blocked: this pubkey is banned from this relay", stored: false };625724 const p = tagValues(e, "p")[0] ?? "";626725 const et = e.tags.find((t) => t[0] === "e");
@@ -647,7 +746,8 @@ export class Relay extends DurableObject<Env> {
647746 if (this.settings.isBanned(e.pubkey)) return no("blocked: this pubkey is banned from this relay");648747 if (this.settings.isEventBanned(e.id)) return no("blocked: this event is banned from this relay");649748 if (conn) {650- if (p.owner === "") return no("restricted: this relay is unclaimed; open https://" + conn.host + "/ to claim it");749+ if (this.settings.isUnclaimed()) return no("restricted: this relay is unclaimed; open https://" + conn.host + "/ to claim it");750+ if (this.settings.leaseExpired(t)) return no("restricted: this temporary relay has expired");651751 const f = this.fuelStatus();652752 if (f.outOfFuel) {653753 return no(f.enabled ? "restricted: this relay is out of fuel; zap it at https://" + conn.host + "/ to top up" : "restricted: this relay has reached its storage or traffic limit");
@@ -689,6 +789,15 @@ export class Relay extends DurableObject<Env> {
689789 async alarm() {690790 this.touch();691791 const t = now();792+ // A lease that has run out is wiped whole: the name is free again.793+ if (this.settings.leaseExpired(t)) {794+ await this.teardown();795+ return;796+ }797+ if (await this.pullTick()) {798+ await this.ctx.storage.setAlarm(Date.now() + 250);799+ return;800+ }692801 this.flushUsage();693802 this.fuel.chargeStorage(t, this.eventBytes(), this.mediaBytes());694803 this.store.drain();
@@ -696,6 +805,8 @@ export class Relay extends DurableObject<Env> {
696805 const next = this.store.sweepExpired(t);697806 let at = t + 86400;698807 if (next > 0 && next < at) at = next;808+ const lease = this.settings.policy.lease;809+ if (this.settings.isLeased() && lease && lease.until < at) at = lease.until;699810 await this.ctx.storage.setAlarm(at * 1000 + 500);700811 }701812
M src/settings.ts +32 -1
@@ -1,7 +1,16 @@
11 // Per-relay settings and policy, kept in the same SQLite database.22 3+// A lease makes an unclaimed relay usable for a while: open to everyone,4+// then wiped at `until` unless somebody claims it first. holder "" means5+// anyone may claim; a pubkey means only that key.6+export interface Lease {7+ until: number;8+ holder: string;9+}10+311 export interface Policy {412 owner: string; // pubkey; "" while unclaimed13+ lease: Lease | null; // set while the relay is a temporary lease514 name: string;615 description: string;716 icon: string;
@@ -21,6 +30,7 @@ export interface Policy {
2130 2231 export const DEFAULT_POLICY: Policy = {2332 owner: "",33+ lease: null,2434 name: "",2535 description: "",2636 icon: "",
@@ -120,6 +130,27 @@ export class Settings {
120130 return this.policy.owner !== "" && this.policy.owner === pubkey;121131 }122132 133+ // Three states: unclaimed (nobody, nothing), leased (nobody yet, open134+ // until a date), claimed (an owner). Only the first refuses writes.135+ isUnclaimed() {136+ return this.policy.owner === "" && this.policy.lease === null;137+ }138+ isLeased() {139+ return this.policy.owner === "" && this.policy.lease !== null;140+ }141+ leaseExpired(now: number) {142+ return this.isLeased() && (this.policy.lease as Lease).until <= now;143+ }144+145+ // resetRules puts the access rules, limits, kind rules and retention back146+ // to the defaults. Identity, people and bans stay.147+ resetRules() {148+ const d = DEFAULT_POLICY;149+ this.update({ writes: d.writes, reads: d.reads, directoryPublic: d.directoryPublic, maxBlobMB: d.maxBlobMB, eventsPerMinute: d.eventsPerMinute, reqsPerMinute: d.reqsPerMinute, minPow: d.minPow, maxFuture: d.maxFuture, maxLimit: d.maxLimit, maxSubs: d.maxSubs });150+ for (const k of [...this.listKinds("allow"), ...this.listKinds("block")]) this.setKind(k, null);151+ for (const r of this.listRetention()) this.setRetention(r.kind, 0);152+ }153+123154 // ---- people ----124155 125156 members(): Member[] {
@@ -178,7 +209,7 @@ export class Settings {
178209 format: "bind.ws/relay-config/1",179210 exported_at: Math.floor(Date.now() / 1000),180211 name,181- policy: { ...this.policy, owner: undefined },212+ policy: { ...this.policy, owner: undefined, lease: undefined },182213 members: this.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, name: m.name, note: m.note })),183214 bans: this.listBans(),184215 banned_events: this.listEvents("ban"),
A test/lease.test.ts +277 -0
@@ -0,0 +1,277 @@
1+// Temporary leases: a memorable name anyone can use for a while, a claim2+// that converts it in place, an expiry that wipes it, and a pull that3+// copies one relay into another.4+import { SELF, env, runInDurableObject } from "cloudflare:test";5+import { describe, it, expect } from "vitest";6+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";7+import { getToken } from "nostr-tools/nip98";8+import { sha256 } from "@noble/hashes/sha2.js";9+import type { Relay } from "../src/relay.ts";10+import { bytesToHex } from "../src/negentropy.ts";11+import { ADJECTIVES, ANIMALS } from "../src/names.ts";12+13+const now = () => Math.floor(Date.now() / 1000);14+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);15+const APEX = "http://bind.ws";16+17+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {18+ const url = `http://${host}/`;19+ const payload = { method, params };20+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);21+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });22+ return { status: resp.status, ...(await resp.json<any>()) };23+}24+25+// Each caller gets its own address so the per-address limit is tested on its own.26+let callers = 0;27+async function lease(sk: Uint8Array | null = null, ip = "10.0.0." + ++callers) {28+ const headers: Record<string, string> = { "cf-connecting-ip": ip };29+ if (sk) headers.authorization = await getToken(APEX + "/lease", "POST", (e) => finalizeEvent(e, sk), true);30+ const resp = await SELF.fetch(APEX + "/lease", { method: "POST", headers });31+ return { status: resp.status, ...(await resp.json<any>()) };32+}33+34+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();35+36+class WS {37+ private queue: any[][] = [];38+ private waiters: ((m: any[]) => void)[] = [];39+ constructor(public ws: WebSocket) {40+ ws.accept();41+ ws.addEventListener("message", (e) => {42+ const m = JSON.parse(e.data as string);43+ const w = this.waiters.shift();44+ if (w) w(m);45+ else this.queue.push(m);46+ });47+ }48+ static async connect(host: string) {49+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });50+ const c = new WS(resp.webSocket!);51+ await c.expect("AUTH");52+ return c;53+ }54+ send(...m: unknown[]) {55+ this.ws.send(JSON.stringify(m));56+ }57+ recv(): Promise<any[]> {58+ const m = this.queue.shift();59+ if (m) return Promise.resolve(m);60+ return new Promise((res) => this.waiters.push(res));61+ }62+ async expect(type: string) {63+ const m = await this.recv();64+ expect(m[0], JSON.stringify(m)).toBe(type);65+ return m;66+ }67+ async ok(e: Event) {68+ this.send("EVENT", e);69+ const m = await this.expect("OK");70+ return { ok: m[2] as boolean, msg: m[3] as string };71+ }72+ private n = 0;73+ async req(filter: unknown) {74+ const id = "q" + ++this.n;75+ this.send("REQ", id, filter);76+ const events: Event[] = [];77+ for (;;) {78+ const m = await this.recv();79+ if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);80+ else if (m[0] === "EOSE" && m[1] === id) {81+ this.send("CLOSE", id);82+ return events;83+ } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);84+ }85+ }86+}87+88+async function upload(host: string, sk: Uint8Array, text: string) {89+ const body = new TextEncoder().encode(text);90+ const sha = bytesToHex(sha256(body));91+ const token = "Nostr " + btoa(JSON.stringify(ev(sk, 24242, "upload", [["t", "upload"], ["x", sha], ["expiration", String(now() + 300)]])));92+ const resp = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: token, "content-type": "text/plain" }, body });93+ return { status: resp.status, sha };94+}95+96+// pull drives the alarm until the job is done; the runtime may fire it too.97+async function pull(host: string, owner: Uint8Array, from: string) {98+ const started = await rpc(host, owner, "pullfrom", from);99+ expect(started.status, JSON.stringify(started)).toBe(200);100+ const stub = env.RELAY.getByName(host.split(".")[0]);101+ for (let i = 0; i < 40; i++) {102+ await runInDurableObject(stub, async (r: Relay) => r.alarm());103+ const st = (await rpc(host, owner, "pullstatus")).result;104+ if (!st.running) return st.last;105+ await new Promise((r) => setTimeout(r, 25));106+ }107+ throw new Error("pull did not finish");108+}109+110+describe("temporary leases", () => {111+ it("hands out a memorable name that anyone can write to, and a claim converts it in place", async () => {112+ const l = await lease();113+ expect(l.status).toBe(201);114+ const [adj, animal] = l.name.split("-");115+ if (animal) expect(ADJECTIVES.includes(adj) && ANIMALS.includes(animal)).toBe(true);116+ else expect(/^[a-z]+\d\d$/.test(l.name) && ANIMALS.includes(l.name.slice(0, -2))).toBe(true);117+ expect(l.url).toBe(`wss://${l.name}.bind.ws`);118+ expect(l.holder).toBeUndefined();119+ expect(l.expires_at).toBeGreaterThan(now() + 13 * 86400);120+ const host = `${l.name}.bind.ws`;121+122+ let doc = await info(host);123+ expect(doc.lease.expires_at).toBe(l.expires_at);124+ expect(doc.lease.claim_url).toBe(`https://${host}/`);125+ expect(doc.limitation.restricted_writes).toBe(false);126+ expect(doc.pubkey).toBeUndefined();127+ expect(doc.description).toMatch(/Temporary relay/);128+ expect(doc.retention).toEqual([{ time: 14 * 86400 }]);129+130+ const stranger = generateSecretKey();131+ const c = await WS.connect(host);132+ expect((await c.ok(ev(stranger, 1, "hello from a lease"))).ok).toBe(true);133+ const denied = await rpc(host, stranger, "stats");134+ expect(denied.status).toBe(403);135+ expect(denied.error).toMatch(/temporary relay/);136+137+ const owner = generateSecretKey();138+ const claimed = await rpc(host, owner, "claim");139+ expect(claimed.result).toEqual({ owner: getPublicKey(owner), claimed: true, converted: true });140+ doc = await info(host);141+ expect(doc.lease).toBeUndefined();142+ expect(doc.pubkey).toBe(getPublicKey(owner));143+ expect(doc.description).toBe("");144+ expect(doc.self).toBeDefined();145+ // The events stay; the lease's keep-for rule stays until the owner resets.146+ expect((await c.req({ kinds: [1] })).length).toBe(1);147+ expect((await rpc(host, owner, "listretention")).result).toEqual([{ kind: null, days: 14 }]);148+ const reset = await rpc(host, owner, "resetrules");149+ expect(reset.result.writes).toBe("open");150+ expect((await rpc(host, owner, "listretention")).result).toEqual([]);151+ expect((await rpc(host, owner, "claim")).result.converted).toBeUndefined();152+ });153+154+ it("a signed lease is reserved for its key", async () => {155+ const holder = generateSecretKey();156+ const l = await lease(holder);157+ expect(l.status).toBe(201);158+ expect(l.holder).toBe(getPublicKey(holder));159+ const host = `${l.name}.bind.ws`;160+ expect((await info(host)).lease.holder).toBe(getPublicKey(holder));161+ const other = generateSecretKey();162+ const denied = await rpc(host, other, "claim");163+ expect(denied.status).toBe(403);164+ expect(denied.error).toMatch(/reserved/);165+ expect((await rpc(host, holder, "claim")).result.claimed).toBe(true);166+ });167+168+ it("caps leases per address", async () => {169+ const ip = "203.0.113.7";170+ for (let i = 0; i < 5; i++) expect((await lease(null, ip)).status).toBe(201);171+ const sixth = await lease(null, ip);172+ expect(sixth.status).toBe(429);173+ expect(sixth.error).toMatch(/^rate-limited/);174+ expect((await lease(null, "203.0.113.8")).status).toBe(201);175+ });176+177+ it("refuses a bad signature on the lease request", async () => {178+ const sk = generateSecretKey();179+ const token = await getToken("http://elsewhere.bind.ws/lease", "POST", (e) => finalizeEvent(e, sk), true);180+ const resp = await SELF.fetch(APEX + "/lease", { method: "POST", headers: { authorization: token, "cf-connecting-ip": "10.1.1.1" } });181+ expect(resp.status).toBe(401);182+ });183+184+ it("expires: the alarm wipes everything and frees the name", async () => {185+ const l = await lease();186+ const host = `${l.name}.bind.ws`;187+ const sk = generateSecretKey();188+ const c = await WS.connect(host);189+ expect((await c.ok(ev(sk, 1, "soon gone"))).ok).toBe(true);190+ expect((await upload(host, sk, "a file on a lease")).status).toBe(200);191+ const stub = env.RELAY.getByName(l.name);192+ await runInDurableObject(stub, async (r: Relay, state) => {193+ expect(await state.storage.getAlarm()).toBeLessThanOrEqual(l.expires_at * 1000);194+ r.settings.update({ lease: { until: now() - 1, holder: "" } });195+ await r.alarm();196+ });197+ const doc = await info(host);198+ expect(doc.lease).toBeUndefined();199+ expect(doc.pubkey).toBeUndefined();200+ expect(doc.limitation.restricted_writes).toBe(true);201+ expect((await env.MEDIA.list({ prefix: `${l.name}/` })).objects).toEqual([]);202+ const fresh = await WS.connect(host);203+ expect(await fresh.req({ kinds: [1] })).toEqual([]);204+ expect((await fresh.ok(ev(sk, 1, "again"))).msg).toMatch(/unclaimed/);205+ // The freed name can be leased again.206+ await runInDurableObject(stub, async (r: Relay) => expect(await r.lease(l.name, host, now() + 60, "")).toBe(""));207+ expect((await info(host)).lease).toBeDefined();208+ });209+210+ it("an expired lease refuses writes even before the alarm runs", async () => {211+ const l = await lease();212+ const host = `${l.name}.bind.ws`;213+ await runInDurableObject(env.RELAY.getByName(l.name), async (r: Relay) => r.settings.update({ lease: { until: now() - 1, holder: "" } }));214+ const c = await WS.connect(host);215+ expect((await c.ok(ev(generateSecretKey(), 1, "too late"))).msg).toMatch(/expired/);216+ expect((await rpc(host, generateSecretKey(), "claim")).status).toBe(403);217+ });218+});219+220+describe("pull from another relay", () => {221+ it("copies events and files from a lease into a claimed relay, and again only what is new", async () => {222+ const l = await lease();223+ const src = `${l.name}.bind.ws`;224+ const a = generateSecretKey();225+ const b = generateSecretKey();226+ const c = await WS.connect(src);227+ const e1 = ev(a, 1, "one", [], now() - 30);228+ const e2 = ev(b, 1, "two", [], now() - 20);229+ const e3 = ev(a, 7, "+", [["e", e1.id]], now() - 10);230+ for (const e of [e1, e2, e3]) expect((await c.ok(e)).ok).toBe(true);231+ const { sha } = await upload(src, a, "a picture, allegedly");232+233+ const owner = generateSecretKey();234+ const host = "fresh.bind.ws";235+ await rpc(host, owner, "claim");236+ await rpc(host, owner, "setpolicy", { writes: "owner" });237+ expect((await rpc(host, owner, "pullfrom", "https://" + src)).status).toBe(400);238+ expect((await rpc(host, owner, "pullfrom", "wss://" + host)).error).toMatch(/itself/);239+ expect((await rpc(host, generateSecretKey(), "pullfrom", "wss://" + src)).status).toBe(403);240+241+ let last = await pull(host, owner, "wss://" + src);242+ expect(last.error).toBe("");243+ expect(last.stored).toBe(3);244+ expect(last.blobs).toBe(1);245+ const mine = await WS.connect(host);246+ expect((await mine.req({ kinds: [1, 7] })).map((e) => e.id).sort()).toEqual([e1.id, e2.id, e3.id].sort());247+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(200);248+ expect((await rpc(host, owner, "listblobs")).result.map((x: any) => x.sha256)).toEqual([sha]);249+250+ // A second pull after one more event on the source fetches only that one.251+ const e4 = ev(b, 1, "four");252+ expect((await c.ok(e4)).ok).toBe(true);253+ last = await pull(host, owner, "wss://" + src);254+ expect(last.stored).toBe(1);255+ expect(last.blobs).toBe(0);256+ expect((await mine.req({ ids: [e4.id] })).length).toBe(1);257+ // Kind rules on the puller apply; the write policy does not.258+ await rpc(host, owner, "disallowkind", 7);259+ const e5 = ev(a, 7, "-", [["e", e2.id]]);260+ expect((await c.ok(e5)).ok).toBe(true);261+ last = await pull(host, owner, "wss://" + src);262+ expect(last.stored).toBe(0);263+ expect(last.skipped).toBe(1);264+ });265+266+ it("gives up on a source that refuses to sync, and says why", async () => {267+ const owner = generateSecretKey();268+ const closedHost = "closed.bind.ws";269+ await rpc(closedHost, owner, "claim");270+ await rpc(closedHost, owner, "setpolicy", { reads: "members" });271+ const host = "puller.bind.ws";272+ await rpc(host, owner, "claim");273+ const last = await pull(host, owner, "wss://" + closedHost);274+ expect(last.error).toMatch(/^sync refused: auth-required/);275+ expect(last.rounds).toBe(3);276+ });277+});
M wrangler.jsonc +10 -1