The member table's role selector shows its whole word and the name and note inputs have fixed widths so the actions stay inside the card. scripts/stage.mjs sets up a local relay for pictures.
- commit
- 57888af38258148d1ccbe4dae365004cae7838cd
- parent
- fd0845a273e6a4397a050a221af56c0a6e9543cb
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 43 | +++++++++++++++++++++++++++--- |
| A | docs/ | bin | |
| M | docs/ | bin | |
| A | scripts/ | 36 | +++++++++++++++++++++++++ |
| M | src/ | 2 | +- |
@@ -1,25 +1,54 @@
11 <p align="center"><img src="docs/img/wordmark.svg" alt="bind.ws" width="320"></p>22 <p align="center">Relay on demand. Sign once, and it's yours.</p>33 4-Every name under bind.ws is a nostr relay. Claim one with your nostr key, point any client at `wss://<name>.bind.ws` and it's yours: members, invites, names, media, search, sync and an HTTP door for scripts, all from the relay's own page. An idle relay costs nothing. A busy one pays its way with zaps.4+Every name under bind.ws is a nostr relay. Claim one with your nostr key, point any client at `wss://<name>.bind.ws` and it's yours. An idle relay costs nothing. A busy one pays its way with zaps.55 6-It runs on Cloudflare Workers with one Durable Object per name, SQLite inside it and R2 beside it.6+Not ready to pick a name? Ask for one:7+8+```9+curl -X POST https://bind.ws/lease10+```11+12+That answers with a relay at a memorable name, open to anyone for 14 days. Claim it before then and it stays, events and all.713 814 <p align="center"><img src="docs/img/relay.png" alt="A relay's page: connect, people, fuel" width="720"></p>915 16+## What a name gives you17+18+- **A relay.** The standard protocol plus search, counts, sync, expiry, deletion, proof of work and an HTTP door for scripts. Works with every client.19+- **A community.** Members, invite links, an invite tree, moderators, a signed roster and one NIP-29 group per relay, so group-aware clients show it as a place.20+- **A site.** Notes and articles as pages with link previews, an Atom feed, and a card with a QR of the group address.21+- **A file host.** Blossom and NIP-96 on the same bucket, with mirroring, pre-flight checks and file reports.22+- **A console.** Seven tabs on the relay's own page, signed by a browser extension or a remote signer app on a phone. Presets set up an outbox, an inbox, a private relay, a chat, a search replica or a media host in one click.23+- **Jobs.** Pull another relay in, keep a standing mirror, fetch your own history from your relay list, rebroadcast to other relays, and dump everything to a file on a schedule.24+- **Your own domain.** `wss://relay.example.com` onto your relay with one CNAME.25+- **Succession.** Name an heir. If your key goes silent, the relay warns you for a month and then hands itself over.26+27+<p align="center"><img src="docs/img/console.png" alt="The console's People tab" width="720"></p>28+29+It runs on Cloudflare Workers with one Durable Object per name, SQLite inside it and R2 beside it.30+1031 ## Docs1132 1233 **For users**1334 14-- [Getting started](docs/00-getting-started.md): claim a name, connect a client, invite people, leave.35+- [Getting started](docs/00-getting-started.md): try one, claim a name, connect, invite.1536 - [Relay configuration](docs/01-relay-configuration.md): the console, tab by tab.1637 - [Understanding fuel](docs/02-understanding-fuel.md): what is measured, what is free, what a zap buys.38+- [People and groups](docs/03-people-and-groups.md): members, invites, moderators, groups, handing over.39+- [Data and names](docs/04-data-and-names.md): jobs, dumps, presets, forks, leaving.40+- [Your relay on the web](docs/05-your-relay-on-the-web.md): pages, feed, card, custom domain, media.41+42+**For scripts and agents**43+44+- [Scripts and agents](docs/13-scripts-and-agents.md): a relay end to end with a key and curl, and every management method.45+- [HTTP reference](docs/14-http-reference.md): every path, method, auth and answer.1746 1847 **For developers**1948 20-- [Architecture](docs/10-architecture.md): worker, object, storage, alarm, fuel, identity.49+- [Architecture](docs/10-architecture.md): routing, the object, storage, alarm, jobs, identity, groups.2150 - [Hosting bind.ws](docs/11-hosting-bindws.md): run your own on your own domain.22-- [Develop and extend](docs/12-develop-extend.md): layout, tests, adding methods and NIPs.51+- [Develop and extend](docs/12-develop-extend.md): layout, tests, adding methods and NIPs, the console.2352 2453 ## Quick start2554 @@ -30,6 +59,10 @@ npm test
3059 npm run deploy3160 ```3261 62+## Protocol surface63+64+NIP-01, 05, 09, 11, 13, 17, 29, 40, 42, 43, 45, 46 transport, 50, 56, 57, 59, 62, 67, 70, 77, 86, 94, 96, 98, and Blossom BUD-01, 02, 04, 06, 08, 09.65+3366 ## License3467 3568 MITBinary file.
Binary file.
@@ -0,0 +1,36 @@
1+// Stages a local relay for screenshots: claims it with the dev signer's key,2+// gives it a name and a description, and adds a few members. Run the dev3+// server first. Usage: node scripts/stage.mjs <name>4+import { finalizeEvent, getPublicKey } from "nostr-tools/pure";5+import { getToken } from "nostr-tools/nip98";6+7+const name = process.argv[2] || "kitchen";8+const url = `http://${name}.localhost:8787/`;9+const sk = new Uint8Array(32).fill(7); // the dev signer's key; never real10+const ws = (n) => `ws://${n}.localhost:8787`;11+12+async function rpc(method, ...params) {13+ const payload = { method, params };14+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);15+ const r = await fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });16+ const j = await r.json();17+ if (j.error) throw new Error(method + ": " + j.error);18+ return j.result;19+}20+21+await rpc("claim");22+await rpc("setpolicy", { name: "Kitchen", description: "Where the cooks talk. Members post, anyone reads.", writes: "allowlist" });23+const people = [24+ ["alice", "brings the bread"],25+ ["bo", "runs the pass"],26+ ["cy", "sunday roasts"],27+];28+for (const [n, note] of people) {29+ const key = new Uint8Array(32).fill(n.charCodeAt(0));30+ await rpc("setmember", getPublicKey(key), { name: n, note });31+}32+const e = finalizeEvent({ kind: 1, content: "Soup's on. First batch of the season.", tags: [], created_at: Math.floor(Date.now() / 1000) - 600 }, sk);33+const evUrl = url + "events";34+const t = await getToken(evUrl, "POST", (x) => finalizeEvent(x, sk), true, e);35+await fetch(evUrl, { method: "POST", headers: { "content-type": "application/json", authorization: t }, body: JSON.stringify(e) });36+console.log(`staged ${ws(name)} owner ${getPublicKey(sk).slice(0, 8)}`);@@ -16,7 +16,7 @@ export function dashboard(): string {
1616 return page("relay", BODY + '<script>window.SIGNER_URL = "/signer.js?v=' + SIGNER_HASH + '";</script><script>' + JS + "</script>", CSS);1717 }1818 19-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: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 1rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { 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\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }";19+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: nowrap; align-items: flex-end; margin: 0 0 -2px 1rem; padding: 2px 1rem 2px 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: 0 0 auto; white-space: nowrap; }\n.tabs a { font: 600 14px var(--sans); padding: .55rem 1rem; border: 2px solid var(--ink); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--head); color: var(--ink-2); text-decoration: none; margin-bottom: -2px; position: relative; z-index: 1; }\n.tabs a.on { 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; } td select.role { width: auto; min-width: 7rem; padding: .25rem .4rem; font-size: 13px; } .people-table input.name { width: 7rem; } .people-table input.note { width: 9.5rem; }\n.kind { font-family: var(--mono); font-size: 12px; background: var(--sun); border: 1.5px solid var(--ink); padding: 0 .5rem; border-radius: 999px; font-weight: 500; white-space: nowrap; }\n.events td.c { max-width: 0; width: 100%; overflow-x: auto; white-space: nowrap; scrollbar-width: thin; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 2rem), transparent); }\n.people-table td.name { width: 12rem; }\n.ev { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 4px; vertical-align: -5px; margin-right: .45rem; font: 500 9px var(--mono); background: var(--line); color: var(--ink-2); font-style: normal; }\n.plain { list-style: none; margin: 0; padding: 0; } .plain li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 2px dotted var(--line-2); } .plain li: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\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=moderation]) { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }\n.wire-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1.2rem; padding: .8rem 0; border-bottom: 1px solid var(--line); align-items: start; } .wire-row:first-child { border-top: 2px solid var(--ink); }\n.wire-main { min-width: 0; } .wire-side { display: grid; justify-items: end; gap: .45rem; max-width: 26rem; }\n.wire-acts { display: flex; gap: .4rem; white-space: nowrap; } .wire-acts .btn { padding: .35rem .75rem; font-size: 13px; }\n.wire-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem .5rem; align-items: center; font-size: 13px; color: var(--ink-2); text-align: right; } .wire-meta:empty { display: none; }\n.pill { display: inline-block; padding: .1rem .6rem; border: 1.5px solid var(--ink); border-radius: 999px; font: 600 12px var(--sans); color: var(--ink); background: var(--paper); } .pill.on { background: var(--mint); } .pill.off { color: var(--ink-3); } .pill.bad { color: var(--red); }\n.chip { display: inline-block; padding: .05rem .45rem; border: 1px solid var(--forest); border-radius: 6px; font: 12px var(--mono); color: var(--forest); } .chip.bad { border-color: var(--red); color: var(--red); }\n.share { display: grid; grid-template-columns: minmax(0, 3fr) minmax(14rem, 2fr); gap: 1.4rem; align-items: start; } .share #cardimg { width: 100%; height: auto; display: block; }\n.share-side { display: grid; gap: .8rem; justify-items: start; } .naddr { display: block; font-size: 12px; line-height: 1.5; word-break: break-all; color: var(--ink-2); }\n@media (max-width: 52rem) { .share { grid-template-columns: 1fr; } }\n#presets .btn { margin: 0 .4rem .4rem 0; }\n.panel form h3.gap { margin-top: 1.4rem; }";2020 2121 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=\"signin46\" class=\"btn hidden\">Remote signer</button>\n <button id=\"signout\" class=\"ib hidden\" title=\"Sign out\" aria-label=\"Sign out\"><svg viewBox=\"0 0 24 24\"><path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4M15 8l5 4-5 4M20 12H9\"/></svg></button>\n </span>\n </div>\n </header>\n\n <section id=\"unclaimed\" class=\"hidden\">\n <h2>Nobody owns this relay yet.</h2>\n <p class=\"lead\">Claim it and it's yours: you decide who can post, who can read, and what stays. One signature with a nostr browser extension; no account, no email, no card.</p>\n <div class=\"row\"><button id=\"claim\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"claimnote\">Use a remote signer</button><span class=\"note\" id=\"claimnote\"></span></div>\n </section>\n\n <section id=\"leased\" class=\"hidden\">\n <h2>A temporary relay, for now.</h2>\n <p class=\"lead\">Anyone can read and write here until <b id=\"lease-until\"></b>. Then everything on it is deleted and the name is freed. Claim it and it stays, events and files included: one signature with a nostr browser extension. Or claim a new name and pull this one into it from its Storage tab.</p>\n <div class=\"row\"><button id=\"claimlease\" class=\"btn pri\">Claim this relay</button><button class=\"btn remote\" data-note=\"leasenote\">Use a remote signer</button><span class=\"note\" id=\"leasenote\"></span></div>\n </section>\n\n <section id=\"remotesec\" class=\"hidden\">\n <h2>Connect a remote signer.</h2>\n <p class=\"lead\">Your key stays in a signer app such as Amber or nsec.app; this page asks it to sign. On a phone, open the link and approve there. On a computer, paste the <span class=\"mono\">bunker://</span> URL the app gives you.</p>\n <div class=\"row\"><a id=\"nclink\" class=\"btn pri\" href=\"#\">Open in your signer app</a><button id=\"nccopy\" class=\"btn\">Copy link</button><span class=\"note\" id=\"ncnote\"></span></div>\n <form id=\"bunkerform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"bunker://\u2026\" required><button class=\"btn\">Connect</button></form>\n <img id=\"ncqr\" class=\"hidden\" alt=\"QR code of the signer link\" width=\"180\" height=\"180\">\n <p class=\"note\" id=\"remotenote\"></p>\n </section>\n\n <section id=\"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\">⚡ Zap to top up</button><span class=\"note\" id=\"topup-note\"></span></form>\n <div id=\"invoice\" class=\"invoice hidden\">\n <p>Pay from any lightning wallet. The receipt lands on this relay and credits it automatically.</p>\n <div class=\"row\"><a id=\"inv-link\" class=\"btn\" href=\"#\">Open in wallet</a><button id=\"inv-copy\" class=\"ib\" title=\"Copy invoice\" aria-label=\"Copy invoice\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button><span class=\"state\" id=\"inv-state\"></span></div>\n <textarea id=\"inv-text\" readonly></textarea>\n </div>\n </section>\n\n <section id=\"myinvites\" class=\"hidden\">\n <h2>Your invites</h2>\n <p class=\"lead\">The owner lets members bring people in. Each link admits one person and lasts three days.</p>\n <div class=\"row\"><button id=\"mintmine\" class=\"btn pri\">New invite link</button><span class=\"note\" id=\"minenote\"></span></div>\n <ul id=\"mine\" class=\"plain\"></ul>\n </section>\n\n <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=\"#moderation\" data-tab=\"moderation\">Moderation<span class=\"count\" id=\"tc-reports\"></span></a>\n <a href=\"#rules\" data-tab=\"rules\">Rules</a>\n <a href=\"#identity\" data-tab=\"identity\">Identity</a>\n <a href=\"#data\" data-tab=\"data\">Data</a>\n <a href=\"#health\" data-tab=\"health\">Health</a>\n <a href=\"#owner\" data-tab=\"owner\">Owner</a>\n </nav>\n\n <section class=\"panel\" data-panel=\"people\">\n <h2>People</h2>\n <p class=\"lead\">The member list is published as a signed roster; a name makes someone <span class=\"mono\" id=\"nip05-example\"></span>.</p>\n <div class=\"block\">\n <form id=\"addmember\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"name\" placeholder=\"name (optional)\" pattern=\"[a-z0-9._-]{1,64}\" style=\"max-width:9rem\"><input class=\"txt\" name=\"note\" placeholder=\"note\" style=\"max-width:9rem\"><button class=\"ib pri\" title=\"Add member\" aria-label=\"Add member\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <div class=\"scroll\"><table id=\"members\" class=\"people-table\"><thead><tr><th>Who</th><th>Name</th><th>Note</th><th>Limits</th><th>Joined</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <div class=\"two\">\n <div>\n <h3>Invites</h3>\n <form id=\"mintinvite\" class=\"addrow\"><select class=\"txt\" name=\"ttl\" style=\"max-width:8rem\"><option value=\"3600\">1 hour</option><option value=\"86400\">1 day</option><option value=\"259200\" selected>3 days</option><option value=\"2592000\">30 days</option></select><input class=\"txt num\" type=\"number\" name=\"max\" min=\"0\" value=\"0\" title=\"uses, 0 = unlimited\" style=\"width:5rem;flex:0 0 auto\"><input class=\"txt\" name=\"note\" placeholder=\"note\"><button class=\"ib pri\" title=\"New invite\" aria-label=\"New invite\"><svg viewBox=\"0 0 24 24\"><path d=\"M12 5v14M5 12h14\"/></svg></button></form>\n <ul id=\"invites\" class=\"plain\"></ul>\n <form id=\"treeform\" class=\"addrow hidden\" title=\"Members invite members: how many hops from you the tree reaches, and how many live invites each member may hold\"><span class=\"dim\">members invite</span><input class=\"txt num\" type=\"number\" name=\"depth\" min=\"0\" max=\"10\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">hops deep,</span><input class=\"txt num\" type=\"number\" name=\"quota\" min=\"0\" max=\"100\" value=\"0\" style=\"width:4rem;flex:0 0 auto\"><span class=\"dim\">each</span><button class=\"btn\">Save</button></form>\n </div>\n <div>\n <h3>Joining</h3>\n <form id=\"joinform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Shown before someone accepts an invite</span><textarea name=\"joinTerms\" placeholder=\"House rules, expectations, anything a newcomer should agree to.\" maxlength=\"20000\"></textarea></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"directoryPublic\"> Show the people directory to visitors</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save</button></div>\n </form>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"moderation\">\n <h2>Moderation</h2>\n <p class=\"lead\">Reports never show in the feed. Banning also deletes the reported thing.</p>\n <div class=\"block\">\n <h3>Reports <span class=\"count\" id=\"reports-count\"></span></h3>\n <div class=\"scroll\"><table id=\"reports\" class=\"events\"><thead><tr><th>Time</th><th>Type</th><th>About</th><th>Reason</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block two\">\n <div>\n <h3>Bans</h3>\n <form id=\"addban\" class=\"addrow\"><input class=\"txt mono\" name=\"pubkey\" placeholder=\"hex pubkey or npub\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><button class=\"ib danger\" title=\"Ban\" aria-label=\"Ban\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"bans\" class=\"plain\"></ul>\n <ul id=\"banned-events\" class=\"plain\"></ul>\n </div>\n <div>\n <h3>Blocked addresses</h3>\n <form id=\"addblock\" class=\"addrow\"><input class=\"txt mono\" name=\"ip\" placeholder=\"IP address\" required><input class=\"txt\" name=\"reason\" placeholder=\"reason\" style=\"max-width:8rem\"><button class=\"ib danger\" title=\"Block\" aria-label=\"Block\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M6 6l12 12\"/></svg></button></form>\n <ul id=\"blocks\" class=\"plain\"></ul>\n </div>\n </div>\n <div class=\"block\">\n <h3>Recent events</h3>\n <p class=\"note\">Delete removes one thing. Ban also refuses it forever.</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 </div>\n </section>\n\n <section class=\"panel\" data-panel=\"rules\">\n <h2>Rules</h2>\n <p class=\"lead\">Bans apply regardless of these.</p>\n <div class=\"block\">\n <h3>Presets</h3>\n <p class=\"note\">One click sets writes, reads, kinds and keep-for together. Limits, identity and people stay.</p>\n <div id=\"presets\"></div>\n <div class=\"row hidden\" id=\"presetsourcerow\"><input class=\"txt mono\" id=\"presetsource\" placeholder=\"wss://source.relay to mirror\" style=\"max-width:24rem\"><span class=\"note\">Replica presets keep a standing pull of their kinds from this relay.</span></div>\n <p class=\"note\" id=\"presetnote\"></p>\n </div>\n <form id=\"access\">\n <div class=\"two\">\n <div><h3>Writes</h3><div class=\"choices\">\n <label><input type=\"radio\" name=\"writes\" value=\"open\"><b>Anyone</b><small>Every valid event is accepted, unless banned.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"allowlist\"><b>Members</b><small>Only people on the member list, and you.</small></label>\n <label><input type=\"radio\" name=\"writes\" value=\"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 <h3>Profile</h3>\n <div class=\"form\">\n <label><span>Name</span><input class=\"txt\" name=\"name\" maxlength=\"200\"></label>\n <label><span>Contact</span><input class=\"txt\" name=\"contact\" placeholder=\"mailto:you@example.com\" maxlength=\"500\"></label>\n <label class=\"wide\"><span>Description</span><textarea name=\"description\" maxlength=\"2000\"></textarea></label>\n <label class=\"wide\"><span>Icon URL</span><input class=\"txt\" name=\"icon\" placeholder=\"https://\u2026/icon.png\" maxlength=\"2000\"></label>\n <label class=\"wide\"><span>Banner URL</span><input class=\"txt\" name=\"banner\" placeholder=\"https://\u2026/banner.png\" maxlength=\"2000\"></label>\n </div>\n <h3 class=\"gap\">For directories</h3>\n <div class=\"form\">\n <label><span>Posting policy URL</span><input class=\"txt\" name=\"postingPolicy\" placeholder=\"https://\u2026/posting\" maxlength=\"2000\"></label>\n <label><span>Privacy policy URL</span><input class=\"txt\" name=\"privacyPolicy\" placeholder=\"https://\u2026/privacy\" maxlength=\"2000\"></label>\n <label><span>Tags</span><input class=\"txt\" name=\"tags\" placeholder=\"bitcoin, cats\"></label>\n <label><span>Languages</span><input class=\"txt\" name=\"languageTags\" placeholder=\"en, pt-BR\"></label>\n <label><span>Countries</span><input class=\"txt\" name=\"relayCountries\" placeholder=\"US, DE\"></label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save identity</button></div>\n </form>\n <div class=\"block\">\n <h3>Your own domain</h3>\n <p class=\"note\">Point a hostname you control at this relay, such as <span class=\"mono\">relay.example.com</span>. Add it here, then create the record shown at your DNS. The certificate is issued on its own once the record resolves.</p>\n <form id=\"adddomain\" class=\"addrow\"><input class=\"txt mono\" name=\"host\" placeholder=\"relay.example.com\" required><button class=\"btn\">Add</button></form>\n <div id=\"domains\"></div>\n <p class=\"note\" id=\"domainnote\"></p>\n </div>\n <div class=\"block\">\n <h3>Tell your clients</h3>\n <p class=\"note\">Fetches your current list from here and a few indexers, puts this relay first, asks for one signature, and publishes the merged list back. Nothing you had listed is lost.</p>\n <div id=\"wire\"></div>\n </div>\n <div class=\"block\">\n <h3>Share</h3>\n <p class=\"note\">A card for links and profiles, and the group address that group-aware clients open. Both are public and refresh every five minutes.</p>\n <div class=\"share\">\n <a href=\"/card.svg\" target=\"_blank\" rel=\"noopener\"><img id=\"cardimg\" src=\"/card.svg\" alt=\"relay card\" width=\"600\" height=\"315\"></a>\n <div class=\"share-side\">\n <img id=\"naddrqr\" class=\"hidden\" alt=\"QR code of the group address\" width=\"150\" height=\"150\">\n <code class=\"mono naddr\" id=\"naddr\"></code>\n <div class=\"row\"><button class=\"btn\" id=\"copynaddr\">Copy naddr</button><button class=\"btn\" id=\"copyembed\">Copy embed</button></div>\n </div>\n </div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"data\">\n <h2>Data</h2>\n <p class=\"lead\">Keep-for rules run once a day. Purges happen now and cannot be undone.</p>\n <div class=\"kbar\" id=\"s-bar\"></div><span class=\"legend slegend\" id=\"s-legend\"></span>\n <div class=\"usage\" id=\"s-totals\"></div>\n <div class=\"block\">\n <h3>By kind</h3>\n <div class=\"scroll\"><table id=\"kinds\" class=\"events\"><thead><tr><th>Kind</th><th class=\"r\">Count</th><th class=\"r\">Size</th><th>Oldest</th><th>Keep for</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Files <span class=\"count\" id=\"blobs-count\"></span></h3>\n <div class=\"scroll\"><table id=\"blobs\" class=\"events\"><thead><tr><th>Time</th><th>File</th><th>Size</th><th>Uploader</th><th></th></tr></thead><tbody></tbody></table></div>\n </div>\n <div class=\"block\">\n <h3>Dumps <span class=\"count\" id=\"dumps-count\"></span></h3>\n <p class=\"note\">Every event as one JSONL file on a schedule, kept for a few runs and counted as files. Downloads need your signature.</p>\n <form id=\"dumpform\" class=\"addrow\"><select class=\"txt\" name=\"dumps\" style=\"max-width:8rem\"><option value=\"off\">off</option><option value=\"daily\">daily</option><option value=\"weekly\">weekly</option></select><input class=\"txt num\" type=\"number\" name=\"keep\" min=\"1\" max=\"60\" value=\"7\" title=\"how many to keep\" style=\"width:5rem;flex:0 0 auto\"><button class=\"btn\">Save</button><button type=\"button\" id=\"dumpnow\" class=\"btn\">Dump now</button></form>\n <ul id=\"dumps\" class=\"plain\"></ul>\n </div>\n <div class=\"block\">\n <h3>Jobs</h3>\n <p class=\"note\">Work the relay does on its own, one round at a time. A pull copies what another relay has and this one lacks. Fetch my history pulls your own events from the relays in your relay list. Rebroadcast sends what is here to other relays. Bans and kind rules apply to what arrives.</p>\n <div class=\"scroll\"><table id=\"jobs\" class=\"events\"><thead><tr><th>Job</th><th>Relays</th><th>Filter</th><th>Schedule</th><th>Result</th><th></th></tr></thead><tbody></tbody></table></div>\n <form id=\"pullform\" class=\"addrow\"><input class=\"txt mono\" name=\"url\" placeholder=\"wss://other.relay\" required><select class=\"txt\" name=\"every\" style=\"max-width:9rem\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select><button class=\"btn\">Pull</button></form>\n <form id=\"backfillform\" class=\"addrow\"><input class=\"txt mono\" name=\"relays\" placeholder=\"relays, comma separated; blank uses your relay list\"><button class=\"btn\">Fetch my history</button></form>\n <form id=\"pushform\">\n <div class=\"form\">\n <label class=\"wide\"><span>Rebroadcast to</span><input class=\"txt mono\" name=\"targets\" placeholder=\"wss://a.relay, wss://b.relay\" required></label>\n <label><span>Kinds <small>blank for all</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label><span>Newer than <small>days, blank for all</small></span><input class=\"txt num\" type=\"number\" name=\"days\" min=\"0\"></label>\n <label><span>Schedule</span><select class=\"txt\" name=\"every\"><option value=\"0\">once</option><option value=\"1\">every hour</option><option value=\"6\">every 6 hours</option><option value=\"24\">every day</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Rebroadcast</button></div>\n </form>\n </div>\n <div class=\"block\">\n <h3>Fork this relay</h3>\n <p class=\"note\">A new name, temporary until claimed, filled from this relay. Yours to split a name by job, or somebody else's to hand them a community with its history. One fork an hour.</p>\n <form id=\"forkform\">\n <div class=\"form\">\n <label><span>Name <small>optional</small></span><input class=\"txt mono\" name=\"name\" placeholder=\"a memorable one if blank\" pattern=\"[a-z0-9-]{3,32}\"></label>\n <label><span>Who claims it <small>npub or hex, you if blank</small></span><input class=\"txt mono\" name=\"holder\"></label>\n <label><span>What to copy</span><select class=\"txt\" name=\"scope\"><option value=\"all\">everything</option><option value=\"mine\">only my events</option><option value=\"kinds\">these kinds</option></select></label>\n <label id=\"forkkinds\" class=\"hidden\"><span>Kinds <small>comma separated</small></span><input class=\"txt mono\" name=\"kinds\" placeholder=\"1, 30023\"></label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"people\" checked> Copy the people</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Fork</button></div>\n </form>\n <p class=\"note\" id=\"forknote\"></p>\n <div class=\"row hidden\" id=\"forkresult\"><span class=\"mono\" id=\"forkurl\"></span><button class=\"ib\" id=\"forkcopy\" title=\"Copy\" aria-label=\"Copy\"><svg viewBox=\"0 0 24 24\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"/><path d=\"M5 15V6a2 2 0 0 1 2-2h9\"/></svg></button></div>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"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 <div class=\"block\">\n <h3>Notifications</h3>\n <p class=\"note\">The relay writes you a private message (NIP-17) with its own key. It lands here, in your inbox on your own relay, and on your DM relays when this relay holds your kind 10050.</p>\n <form id=\"notify\">\n <div class=\"form\">\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"reports\"> A report arrives</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"fuel\"> Fuel runs low</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"jobs\"> A pull finishes</label>\n <label class=\"wide switch\"><input type=\"checkbox\" name=\"succession\"> The handover clock is running</label>\n </div>\n <div class=\"actions\"><button class=\"btn pri\">Save notifications</button><button type=\"button\" id=\"notifytest\" class=\"btn\">Send a test message</button></div>\n </form>\n </div>\n </section>\n\n <section class=\"panel\" data-panel=\"owner\">\n <h2>Owner</h2>\n <p class=\"lead\">The relay's key, events, files and fuel stay put through everything here except delete.</p>\n <div class=\"block\">\n <h3>Configuration</h3>\n <p class=\"note\">Rules, identity, members, bans and kind rules as a file. Importing replaces those lists; it never touches events, files, or the owner.</p>\n <div class=\"row\"><button id=\"exportcfg\" class=\"btn\">Export configuration</button><button id=\"importcfg\" class=\"btn\">Import configuration</button><input type=\"file\" id=\"cfgfile\" accept=\"application/json,.json\" class=\"hidden\"></div>\n </div>\n <div class=\"block danger-zone\">\n <h3>Transfer ownership</h3>\n <p class=\"note\">Hands the relay to a member. You stay on as a moderator. The relay's key, events, files and fuel do not change. There is no undo.</p>\n <form id=\"transfer\" class=\"addrow\"><select class=\"txt mono\" name=\"pubkey\" required></select><button class=\"btn danger\">Transfer</button></form>\n </div>\n <div class=\"block danger-zone\">\n <h3>If I lose my key</h3>\n <p class=\"note\">Name a member as your heir. If you do not sign in here for the time you pick, the relay writes to you once a week for a month, then hands itself to the heir and keeps you on as a moderator. Any signed action on the relay resets the clock.</p>\n <form id=\"succession\">\n <div class=\"form\">\n <label><span>Heir <small>a member</small></span><select class=\"txt mono\" name=\"heir\" required></select></label>\n <label><span>Hand over</span><select class=\"txt\" name=\"afterDays\"><option value=\"90\">after 3 months away</option><option value=\"180\" selected>after 6 months away</option><option value=\"365\">after a year away</option></select></label>\n </div>\n <div class=\"actions\"><button class=\"btn\">Set heir</button><button type=\"button\" id=\"clearsuccession\" class=\"btn hidden\">Clear</button></div>\n </form>\n <p class=\"note\" id=\"successionnote\"></p>\n </div>\n <div class=\"block danger-zone\">\n <h3>Delete this relay</h3>\n <p class=\"note\">Deletes every event, file, member, invite and setting, closes every connection, and returns the name to unclaimed for anyone to take. There is no undo.</p>\n <button id=\"deleterelay\" class=\"btn danger\">Delete this relay</button>\n </div>\n </section>\n </div>\n\n <footer class=\"pg\"><p>a bind.ws relay</p><a href=\"https://github.com/FelineStateMachine/bindws\">source</a></footer>\n</main>\n";2222