clone

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

relay: NIP-29 groups, moderators and ownership transfer (#9)

FelineStateMachine committed 388111f
* relay: roles, ownership transfer and relay-signed group records

One permission table (roles.ts) says what an owner and a moderator may do.
Members carry a role; the owner can hand the relay to a member and stays on
as a moderator. The identity signs NIP-29 put-user and remove-user records
and the group's metadata, admins, members and roles, on the same strictly
increasing clock as the roster. Invite codes may be chosen by the caller.

* relay: NIP-29, one group per relay, moderated by role

The relay's name is the group id; events tagged for any other group are
refused. Join and leave requests, put-user, remove-user, edit-metadata,
delete-event and create-invite are applied through the permission table
instead of the write policy, then stored and broadcast. Every membership or
rule change republishes the relay-signed group state. The NIP-86 methods use
the same table: a moderator gets people, bans, deletions, invites and
reports, and transferowner hands the relay over.

* console: roles, ownership transfer and a moderator's view

The owner sets a member's role from the People tab and hands the relay over
from the Identity tab with a typed confirmation. A signed-in moderator gets
the People tab and the recent-events browser, nothing else.

* docs: groups, moderators and ownership transfer
commit
388111fc1df3a70244cb629c29a109b6af4fbf75
parent
b4c4b036b3c0f660013aa075de6e17b8f8c7fb83
author
FelineStateMachine <dami.etoile@gmail.com>
16 files changed, +794 -42
Mdocs/00-getting-started.md12+
Mdocs/01-relay-configuration.md5+
Mdocs/10-architecture.md17++-
Mdocs/12-develop-extend.md4+-
Msrc/dashboard.ts6+-
Asrc/groups.ts158+++++++++++++++
Msrc/identity.ts58+++++-
Msrc/invites.ts10+-
Msrc/landing.ts1+
Msrc/manage.ts49++++-
Msrc/relay.ts95++++++++-
Asrc/roles.ts54+++++
Msrc/settings.ts37+++-
Mtest/adopt.test.ts10+-
Atest/groups.test.ts318++++++++++++++++++++++++++++++
Mtest/relay.test.ts2+-
M docs/00-getting-started.md +12 -0
M docs/01-relay-configuration.md +5 -0
@@ -19,9 +19,12 @@ Three things read that table: the write rule, the signed roster your relay publi
1919 - **Invite** with a link. Choose how long the link lives and how many people can use it.2020 - **Ban** a pubkey. Their events are refused and their open connections are closed.2121 - **Reports** filed against people or events arrive here. Resolve each one by dismissing it, deleting the event or banning the author.22+- **Role** is *member* or *moderator*, set from the selector next to the pubkey. A moderator can add and remove members, ban, delete events, mint invites and resolve reports, here or from a group-aware client. They cannot touch the owner, other moderators, rules, identity, storage or fuel.2223 2324 Removing a member ends their live subscriptions when reads are members-only.2425 26+A moderator who signs in on the relay's page sees the People tab and the recent-events browser, nothing else.27+2528 ## Rules2629 2730 Who can do what, and how much.
@@ -47,6 +50,8 @@ Join terms are shown to people who open an invite link. The public directory swi
4750 4851 **Export configuration** downloads rules, identity, members, bans, kind rules and retention as one file. **Import** replaces those lists on any relay you own. Events, files and the owner are never touched.4952 53+**Transfer ownership** hands the relay to a member you pick. You stay on as a moderator. The relay's key, events, files and fuel do not change. You type the relay's name to confirm.54+5055 **Delete relay** removes everything and returns the name to unclaimed. You type the relay's name to confirm.5156 5257 ## Storage
M docs/10-architecture.md +16 -1
@@ -59,10 +59,25 @@ Credits come from NIP-57 receipts. A receipt is validated (provider signature, s
5959 6060 On claim, the object generates a keypair, advertised as `self` in NIP-11. It signs the member roster (kind 13534) and its deltas (8000, 8001), all NIP-70 protected, with strictly increasing timestamps.6161 62+## Groups and roles63+64+Every relay is one NIP-29 group (`src/groups.ts`). The group id is the relay's name. An event with an `h` tag for any other id is refused; events without one are ordinary relay events. The group's flags are derived from the rules: `private` is reads set to members, `restricted` and `closed` are writes not open. The identity signs the group's state as addressable events, kind 39000 metadata, 39001 admins, 39002 members (only while the directory is public), 39003 roles, on the same strictly increasing clock as the roster, and republishes them on every membership, role or rule change. Membership changes also produce relay-signed 9000 put-user and 9001 remove-user records next to the NIP-43 deltas. All of these are protected kinds.65+66+Joins (9021, with an optional invite `code`), leaves (9022) and the moderation kinds 9000, 9001, 9002, 9005 and 9009 are applied before the write policy and gated by role instead. Unsupported moderation kinds are refused with `unsupported:`. Timeline `previous` references, pins, subgroups and livekit are not implemented.67+68+`src/roles.ts` is the one permission table, used by the NIP-86 methods and the NIP-29 events alike:69+70+| Role | May |71+|---|---|72+| owner | everything, including rules, identity, storage, config, pull, transfer, delete |73+| moderator | read stats and lists, add and edit plain members, ban, delete events, invites, reports |74+75+Moderators cannot act on the owner or on each other; only the owner appoints or removes moderators. `transferowner` hands the relay to a member and leaves the old owner as a moderator; the identity key and fuel do not change.76+6277 ## Management6378 6479 `src/manage.ts` implements NIP-86 over NIP-98. The console is a client of it. `verifyNIP98` is a local implementation: kind 27235, 60-second window, `u` matches host and path, method matches, payload hash matches.6580 6681 ## Protocol surface6782 68-NIP-01, 09, 11, 13, 17/59 private kinds, 40, 42, 45 with HLL, 50, 56, 62, 67, 70, 77, 86, 98, plus NIP-05, NIP-43, NIP-57 and Blossom BUD-01/02/04/06. `test/conformance` is a black-box suite that runs against any relay URL.83+NIP-01, 09, 11, 13, 17/59 private kinds, 29, 40, 42, 45 with HLL, 50, 56, 62, 67, 70, 77, 86, 98, plus NIP-05, NIP-43, NIP-57 and Blossom BUD-01/02/04/06. `test/conformance` is a black-box suite that runs against any relay URL.
M docs/12-develop-extend.md +3 -1
M src/dashboard.ts +3 -3
@@ -13,8 +13,8 @@ export function dashboard(): string {
1313 return page("relay", BODY + "<script>" + JS + "</script>", CSS);1414 }1515 16-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";16+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\n#console.mod .tabs a:not([data-tab=people]):not([data-tab=storage]) { display: none; }\n#console.mod [data-panel=storage] > :not(h2):not(details) { display: none; }\n#members select.role { width: auto; padding: .1rem .3rem; font-size: 12px; margin-left: .3rem; }";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=\"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";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>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>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 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";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, myRole = \"\";\n\n const toast = (msg) => { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"show\"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove(\"show\"), 2600); };\n 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 = myRole === \"moderator\" ? [\"people\", \"storage\"] : [\"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 myRole = isOwner ? \"owner\" : \"\";\n let stats = null, p = null;\n // A signed-in member may be a moderator; the relay says by answering stats.\n if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]); myRole = \"moderator\"; } catch { myRole = \"\"; } }\n $(\"#console\").classList.toggle(\"hidden\", !myRole);\n $(\"#console\").classList.toggle(\"mod\", myRole === \"moderator\");\n if (!myRole) { policy = null; renderCare(); return; }\n if (isOwner) [stats, p] = await Promise.all([rpc(\"stats\"), rpc(\"getpolicy\")]);\n policy = p;\n renderHealth(stats);\n renderCare();\n const fa = $(\"#access\"), fi = $(\"#identity\");\n fa.writes.value = p.writes; fa.reads.value = p.reads; fa.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), ...(isOwner ? [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 const roleCell = (m) => m.role === \"owner\" ? ' <span class=\"chip\">owner</span>' : myRole === \"owner\" ? ' <select class=\"txt role\" title=\"Role\"><option value=\"member\"' + (m.role === \"member\" ? \" selected\" : \"\") + '>member</option><option value=\"moderator\"' + (m.role === \"moderator\" ? \" selected\" : \"\") + '>moderator</option></select>' : m.role === \"moderator\" ? ' <span class=\"chip\">moderator</span>' : \"\";\n const untouchable = (m) => m.role === \"owner\" || (myRole !== \"owner\" && m.role === \"moderator\");\n $(\"#members tbody\").innerHTML = members.map((m) => '<tr data-pk=\"' + m.pubkey + '\"><td class=\"mono\" title=\"' + esc(m.pubkey) + '\">' + av(m.pubkey) + short(m.pubkey) + roleCell(m) + '</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) + (untouchable(m) ? \"\" : ib(\"x\", \"Remove\", \"removemember\", m.pubkey) + ib(\"banuser\", \"Ban\", \"banpubkey\", m.pubkey, \"danger\")) + \"</td></tr>\").join(\"\");\n $(\"#transfer [name=pubkey]\").innerHTML = members.filter((m) => m.role !== \"owner\").map((m) => '<option value=\"' + m.pubkey + '\">' + esc(m.name ? m.name + \"@\" + host : short(m.pubkey)) + \"</option>\").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\", 9000: \"group adds\", 9001: \"group removals\", 9021: \"join requests\", 9022: \"leave requests\", 39000: \"group info\", 39001: \"group admins\", 39002: \"group members\", 39003: \"group roles\", 30023: \"articles\", 30024: \"drafts\", 30078: \"app data\", 30311: \"live events\", 30818: \"wiki\" };\n const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? \"ephemeral\" : k >= 30000 && k < 40000 ? \"addressable\" : k >= 10000 && k < 20000 ? \"replaceable\" : \"kind \" + k);\n const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);\n let storage = null;\n\n 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 $(\"#transfer\").onsubmit = guard(async (ev) => {\n const pk = ev.target.pubkey.value; if (!pk) return;\n const name = host.split(\".\")[0];\n const typed = prompt(\"This hands \" + host + \" to \" + short(pk) + \" for good. You stay on as a moderator. Type the relay name (\" + name + \") to confirm.\");\n if (typed === null) return;\n if (typed.trim() !== name) { toast(\"That didn't match; nothing changed.\"); return; }\n await rpc(\"transferowner\", pk);\n toast(\"Transferred\");\n await loadInfo(); await loadAdmin(); await loadPeople();\n });\n $(\"#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\"), role = tr.querySelector(\"select.role\"); await rpc(\"setmember\", id, { name: tr.querySelector(\".name\").value.trim() || null, note: tr.querySelector(\".note\").value, ...(role ? { role: role.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";
A src/groups.ts +158 -0
@@ -0,0 +1,158 @@
1+// NIP-29, one group per relay. The group id is the relay's name; events that2+// carry an h tag for any other id are out of context and refused. The group's3+// rules are the relay's rules: private is reads set to members, restricted4+// and closed are writes not open. Membership and roles are the member list;5+// the relay signs the group's metadata, admins, members and roles itself.6+//7+// Joins, leaves and moderation events are gated by role (roles.ts), not by8+// the write policy: a join request comes from a stranger by definition, and9+// a moderator moderates whatever the write rule says.10+import { now, tagValues, type Event } from "./event.ts";11+import { claimInvite, mintInvite } from "./invites.ts";12+import { KIND_PUT_USER, KIND_REMOVE_USER, type GroupFacts } from "./identity.ts";13+import { can, ROLES, type Action } from "./roles.ts";14+import type { Relay } from "./relay.ts";15+16+export const KIND_EDIT_METADATA = 9002;17+export const KIND_DELETE_EVENT = 9005;18+export const KIND_CREATE_GROUP = 9007;19+export const KIND_DELETE_GROUP = 9008;20+export const KIND_CREATE_INVITE = 9009;21+export const KIND_PINS = 9010;22+export const KIND_JOIN = 9021;23+export const KIND_LEAVE = 9022;24+25+export const isModeration = (kind: number) => kind >= 9000 && kind <= 9020;26+export const isGroupManagement = (kind: number) => isModeration(kind) || kind === KIND_JOIN || kind === KIND_LEAVE;27+// The addressable state kinds only the relay writes.28+export const isGroupState = (kind: number) => kind >= 39000 && kind <= 39003;29+30+const HEX64 = /^[0-9a-f]{64}$/;31+32+// groupFacts derives the group's NIP-29 state from the relay's settings.33+export function groupFacts(relay: Relay): GroupFacts {34+ const p = relay.settings.policy;35+ const members = relay.settings.members();36+ return {37+ id: relay.slug,38+ name: p.name || relay.slug,39+ about: p.description,40+ picture: p.icon,41+ private: p.reads === "members",42+ restricted: p.writes !== "open",43+ closed: p.writes !== "open",44+ admins: members.filter((m) => m.role !== "member").map((m) => ({ pubkey: m.pubkey, role: m.role })),45+ members: p.directoryPublic ? members.map((m) => m.pubkey) : null,46+ roles: ROLES,47+ };48+}49+50+type Result = { ok: boolean; msg: string; stored: boolean };51+const OK: Result = { ok: true, msg: "", stored: true };52+const no = (msg: string): Result => ({ ok: false, msg, stored: false });53+54+// handleGroupEvent applies a join, leave or moderation event that has passed55+// the common gate. It does not store the event; stored: true asks the caller56+// to keep and broadcast it.57+export async function handleGroupEvent(relay: Relay, e: Event): Promise<Result> {58+ const s = relay.settings;59+ const t = now();60+ const role = s.roleOf(e.pubkey);61+ const need = (action: Action) => (can(role, action) ? "" : role === "moderator" ? "restricted: moderators cannot do that" : "restricted: not a group admin");62+ const pTag = e.tags.find((x) => x[0] === "p" && HEX64.test(x[1] ?? ""));63+ const target = pTag?.[1] ?? "";64+65+ switch (e.kind) {66+ case KIND_JOIN: {67+ if (s.isAllowed(e.pubkey)) return no("duplicate: already a member");68+ const code = tagValues(e, "code")[0] ?? "";69+ let via = "";70+ if (code) {71+ const r = claimInvite(relay.sql, code, t);72+ if (r === "ok") via = "invite " + code.slice(0, 8);73+ else if (s.policy.writes !== "open") {74+ return no("restricted: " + { invite_invalid: "that invite code is not valid", invite_expired: "that invite has expired", invite_exhausted: "that invite has been used up" }[r]);75+ }76+ }77+ if (!via) {78+ if (s.policy.writes !== "open") return no("restricted: this group is closed; join with an invite");79+ via = "join";80+ }81+ const err = await relay.setMember(e.pubkey, { via });82+ return err ? no(err) : OK;83+ }84+ case KIND_LEAVE: {85+ if (s.isOwner(e.pubkey)) return no("restricted: the owner cannot leave; transfer ownership first");86+ if (!(await relay.removeMember(e.pubkey))) return no("invalid: not a member");87+ return OK;88+ }89+ case KIND_PUT_USER: {90+ const gate = need("members");91+ if (gate) return no(gate);92+ if (!target) return no("invalid: put-user needs a p tag");93+ if (s.isOwner(target)) return no("invalid: the owner's role changes by transfer");94+ const wantsModerator = (pTag as string[]).slice(2).includes("moderator");95+ const cur = s.roleOf(target);96+ if (cur === "moderator" && role !== "owner") return no("restricted: only the owner changes moderators");97+ if (wantsModerator && role !== "owner") return no("restricted: only the owner appoints moderators");98+ if (s.isBanned(target)) s.setBan(target, false);99+ const err = await relay.setMember(target, { via: "put-user" });100+ if (err) return no(err);101+ const next = wantsModerator ? "moderator" : "member";102+ if ((cur ?? "member") !== next) {103+ s.setRole(target, next);104+ await relay.publishGroup(target);105+ }106+ return OK;107+ }108+ case KIND_REMOVE_USER: {109+ const gate = need("members");110+ if (gate) return no(gate);111+ if (!target) return no("invalid: remove-user needs a p tag");112+ if (s.isOwner(target)) return no("invalid: the owner cannot be removed");113+ if (s.roleOf(target) === "moderator" && role !== "owner") return no("restricted: only the owner removes moderators");114+ if (!(await relay.removeMember(target))) return no("invalid: not a member");115+ return OK;116+ }117+ case KIND_EDIT_METADATA: {118+ const gate = need("identity");119+ if (gate) return no(gate);120+ const patch: { name?: string; description?: string; icon?: string } = {};121+ const name = tagValues(e, "name")[0];122+ const about = tagValues(e, "about")[0];123+ const picture = tagValues(e, "picture")[0];124+ if (name !== undefined) patch.name = name.slice(0, 200);125+ if (about !== undefined) patch.description = about.slice(0, 2000);126+ if (picture !== undefined) patch.icon = picture.slice(0, 2000);127+ s.update(patch);128+ await relay.publishGroup();129+ return OK;130+ }131+ case KIND_DELETE_EVENT: {132+ const gate = need("deleteEvent");133+ if (gate) return no(gate);134+ const ids = tagValues(e, "e").filter((id) => HEX64.test(id));135+ if (!ids.length) return no("invalid: delete-event needs an e tag");136+ for (const id of ids) {137+ const row = relay.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM events WHERE id=?`, id).toArray()[0];138+ if (row && row.pubkey === relay.identity.pubkey) return no("blocked: the relay's own records cannot be deleted");139+ }140+ for (const id of ids) relay.store.deleteEvent(id);141+ return OK;142+ }143+ case KIND_CREATE_INVITE: {144+ const gate = need("invites");145+ if (gate) return no(gate);146+ const r = mintInvite(relay.sql, e.pubkey, 0, 0, e.content, t, tagValues(e, "code")[0] ?? "");147+ return typeof r === "string" ? no(r) : OK;148+ }149+ case KIND_CREATE_GROUP:150+ return no("unsupported: this relay is one group; there is nothing to create");151+ case KIND_DELETE_GROUP:152+ return no("unsupported: delete the relay from its page");153+ case KIND_PINS:154+ return no("unsupported: pins are not supported");155+ default:156+ return no("unsupported: unknown moderation kind " + e.kind);157+ }158+}
M src/identity.ts +56 -2
@@ -1,17 +1,39 @@
11 // A relay has its own nostr identity: a keypair kept in the object's storage,22 // advertised as NIP-11 "self", used to sign facts only the relay can vouch3-// for. Today that is the NIP-43 membership roster (kind 13534) and its4-// added/removed deltas (kinds 8000/8001), all NIP-70 protected.3+// for: the NIP-43 membership roster (kind 13534) and its added/removed4+// deltas (kinds 8000/8001), the NIP-29 put-user and remove-user records5+// (9000/9001) and the group's metadata, admins, members and roles6+// (39000-39003). All NIP-70 protected.57 import { finalizeEvent, getPublicKey, generateSecretKey, type Event } from "nostr-tools/pure";68 import { bytesToHex, hexToBytes } from "./negentropy.ts";79 810 export const KIND_MEMBER_ADDED = 8000;911 export const KIND_MEMBER_REMOVED = 8001;1012 export const KIND_ROSTER = 13534;13+export const KIND_PUT_USER = 9000;14+export const KIND_REMOVE_USER = 9001;15+export const KIND_GROUP_METADATA = 39000;16+export const KIND_GROUP_ADMINS = 39001;17+export const KIND_GROUP_MEMBERS = 39002;18+export const KIND_GROUP_ROLES = 39003;19+20+export interface GroupFacts {21+ id: string;22+ name: string;23+ about: string;24+ picture: string;25+ private: boolean; // members-only reads26+ restricted: boolean; // members-only writes27+ closed: boolean; // join requests need an invite28+ admins: { pubkey: string; role: string }[];29+ members: string[] | null; // null: not published30+ roles: { role: string; about: string }[];31+}1132 1233 export class Identity {1334 private sk: Uint8Array | null = null;1435 private lastRoster = 0;36+ private lastGroup = 0;1537 1638 constructor(private storage: DurableObjectStorage) {}1739
@@ -19,6 +41,7 @@ export class Identity {
1941 const hex = await this.storage.get<string>("relay-key");2042 if (hex) this.sk = hexToBytes(hex);2143 this.lastRoster = (await this.storage.get<number>("relay-roster-at")) ?? 0;44+ this.lastGroup = (await this.storage.get<number>("relay-group-at")) ?? 0;2245 }2346 2447 // ensure creates the key on first use, so unclaimed relays have none.
@@ -52,4 +75,35 @@ export class Identity {
5275 delta(added: boolean, pubkey: string): Event {5376 return this.sign(added ? KIND_MEMBER_ADDED : KIND_MEMBER_REMOVED, [["-"], ["p", pubkey]]);5477 }78+79+ // NIP-29 records of a membership change, signed by the relay as the80+ // group's authority. roles are the ones the person holds now.81+ putUser(group: string, pubkey: string, roles: string[]): Event {82+ return this.sign(KIND_PUT_USER, [["-"], ["h", group], ["p", pubkey, ...roles]]);83+ }84+ removeUser(group: string, pubkey: string): Event {85+ return this.sign(KIND_REMOVE_USER, [["-"], ["h", group], ["p", pubkey]]);86+ }87+88+ // group signs the addressable NIP-29 state: metadata, admins, members89+ // (when published) and roles. Same strictly increasing clock as the roster,90+ // so a newer set always replaces an older one.91+ group(f: GroupFacts, now = Math.floor(Date.now() / 1000)): Event[] {92+ const at = Math.max(now, this.lastGroup + 1);93+ this.lastGroup = at;94+ this.storage.put("relay-group-at", at);95+ const d = ["d", f.id];96+ const meta: string[][] = [["-"], d, ["name", f.name], ["about", f.about]];97+ if (f.picture) meta.push(["picture", f.picture]);98+ if (f.private) meta.push(["private"]);99+ if (f.restricted) meta.push(["restricted"]);100+ if (f.closed) meta.push(["closed"]);101+ const out = [102+ this.sign(KIND_GROUP_METADATA, meta, "", at),103+ this.sign(KIND_GROUP_ADMINS, [["-"], d, ...f.admins.map((a) => ["p", a.pubkey, a.role])], "", at),104+ this.sign(KIND_GROUP_ROLES, [["-"], d, ...f.roles.map((r) => ["role", r.role, r.about])], "", at),105+ ];106+ if (f.members) out.push(this.sign(KIND_GROUP_MEMBERS, [["-"], d, ...f.members.map((p) => ["p", p])], "", at));107+ return out;108+ }55109 }
M src/invites.ts +8 -2
M src/landing.ts +1 -0
M src/manage.ts +42 -7
@@ -9,6 +9,7 @@ import { listInvites, mintInvite, revokeInvite } from "./invites.ts";
99 import { descriptor, type Blob } from "./blossom.ts";1010 import { isReplaceable, isProtected } from "./settings.ts";1111 import { checkPullURL } from "./pull.ts";12+import { can, METHOD_ACTIONS } from "./roles.ts";1213 1314 // verifyNIP98 checks an "Authorization: Nostr <base64 event>" header against1415 // the request: kind 27235, fresh, u tag naming this URL, method tag, and a
@@ -53,7 +54,7 @@ const METHODS = [
5354 "listblobs", "deleteblob",5455 "storagestats", "setretention", "listretention", "purgekind",5556 "deleterelay", "exportconfig", "importconfig", "resetrules",56- "pullfrom", "pullstatus",57+ "pullfrom", "pullstatus", "transferowner",5758 ];5859 5960 export async function manage(relay: Relay, req: Request): Promise<Response> {
@@ -92,10 +93,15 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
9293 return reply({ result: { owner: p.owner, claimed: s.isOwner(caller) } }, s.isOwner(caller) ? 200 : 403);9394 }9495 if (method === "supportedmethods") return reply({ result: METHODS });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";96+ // One permission table for the console and for NIP-29 (roles.ts).97+ const role = s.roleOf(caller);98+ const action = METHOD_ACTIONS[method];99+ if (!action || !can(role, action)) {100+ const why = role === "moderator" ? "restricted: moderators cannot do that" : p.owner !== "" ? "restricted: not the relay owner" : s.isLeased() ? "restricted: this is a temporary relay; claim it first" : "restricted: this relay is unclaimed";97101 return reply({ error: why }, 403);98102 }103+ // Moderators keep their hands off the owner and each other.104+ const outranks = (pk: string) => role !== "owner" && (s.isOwner(pk) || s.roleOf(pk) === "moderator");99105 100106 const str = (i: number) => (typeof params[i] === "string" ? (params[i] as string) : "");101107 const num = (i: number) => (Number.isInteger(params[i]) ? (params[i] as number) : parseInt(str(i), 10));
@@ -124,12 +130,14 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
124130 if (typeof clean.maxLimit === "number") clean.maxLimit = Math.min(Math.max(clean.maxLimit as number, 1), 5000);125131 if (typeof clean.maxSubs === "number") clean.maxSubs = Math.min(Math.max(clean.maxSubs as number, 1), 200);126132 s.update(clean);133+ await relay.publishGroup();127134 return reply({ result: s.policy });128135 }129136 case "banpubkey": {130137 const pk = str(0);131138 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);132139 if (s.isOwner(pk)) return reply({ error: "invalid: cannot ban the owner" }, 400);140+ if (outranks(pk)) return reply({ error: "restricted: moderators cannot ban other moderators" }, 403);133141 await relay.ban(pk, str(1));134142 return reply({ result: true });135143 }
@@ -138,16 +146,26 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
138146 // NIP-86 allowpubkey(pubkey, reason) and the richer setmember(pubkey, {name, note}).139147 const pk = str(0);140148 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);141- const patch = method === "allowpubkey" ? { note: str(1) } : (params[1] && typeof params[1] === "object" ? (params[1] as { name?: string | null; note?: string }) : {});149+ const patch = method === "allowpubkey" ? { note: str(1) } : (params[1] && typeof params[1] === "object" ? (params[1] as { name?: string | null; note?: string; role?: string }) : {});150+ if (outranks(pk)) return reply({ error: "restricted: moderators cannot edit the owner or other moderators" }, 403);151+ const wantsRole = patch.role === "moderator" || patch.role === "member" ? patch.role : undefined;152+ if (wantsRole && role !== "owner") return reply({ error: "restricted: only the owner sets roles" }, 403);153+ if (wantsRole && s.isOwner(pk)) return reply({ error: "invalid: the owner's role changes by transferowner" }, 400);142154 if (s.isBanned(pk)) s.setBan(pk, false);143155 const err = await relay.setMember(pk, { name: patch.name, note: typeof patch.note === "string" ? patch.note : undefined }, true);144- return err ? reply({ error: err }, 400) : reply({ result: s.member(pk) });156+ if (err) return reply({ error: err }, 400);157+ if (wantsRole && s.roleOf(pk) !== wantsRole) {158+ s.setRole(pk, wantsRole);159+ await relay.publishGroup(pk);160+ }161+ return reply({ result: s.member(pk) });145162 }146163 case "unrulepubkey":147164 case "removemember": {148165 // Removes a ban or a membership, whichever the pubkey has; never the owner.149166 const pk = str(0);150167 if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);168+ if (outranks(pk)) return reply({ error: "restricted: moderators cannot remove the owner or other moderators" }, 403);151169 if (s.isBanned(pk)) s.setBan(pk, false);152170 await relay.removeMember(pk);153171 return reply({ result: true });
@@ -159,8 +177,10 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
159177 case "listmembers":160178 case "listpeople":161179 return reply({ result: { self: relay.identity.pubkey, members: s.members() } });162- case "createinvite":163- return reply({ result: mintInvite(relay.sql, caller, num(0) || 0, num(1) || 0, str(2), t) });180+ case "createinvite": {181+ const inv = mintInvite(relay.sql, caller, num(0) || 0, num(1) || 0, str(2), t);182+ return typeof inv === "string" ? reply({ error: inv }, 400) : reply({ result: inv });183+ }164184 case "listinvites":165185 return reply({ result: listInvites(relay.sql, t) });166186 case "revokeinvite":
@@ -187,6 +207,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
187207 if (!["ban", "delete", "dismiss"].includes(action)) return reply({ error: "invalid: action must be ban, delete or dismiss" }, 400);188208 if (action === "ban") {189209 if (s.isOwner(row.target_pubkey)) return reply({ error: "invalid: cannot ban the owner" }, 400);210+ if (outranks(row.target_pubkey)) return reply({ error: "restricted: moderators cannot ban other moderators" }, 403);190211 await relay.ban(row.target_pubkey, "report " + id.slice(0, 8));191212 if (row.target_event) {192213 s.setEvent(row.target_event, "ban", "report " + id.slice(0, 8), t);
@@ -272,6 +293,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
272293 return reply({ result: s.listKinds("block") });273294 case "resetrules":274295 s.resetRules();296+ await relay.publishGroup();275297 return reply({ result: s.policy });276298 case "pullfrom": {277299 // (url): copy what another relay has that this one lacks. Runs in
@@ -285,14 +307,27 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
285307 }286308 case "pullstatus":287309 return reply({ result: await relay.pullStatus() });310+ case "transferowner": {311+ // (pubkey): hands the relay to a member; the old owner stays as a moderator.312+ const pk = str(0);313+ if (!hex64(pk)) return reply({ error: "invalid: pubkey must be 64 hex chars" }, 400);314+ const err = s.transferOwner(pk);315+ if (err) return reply({ error: err }, 400);316+ await relay.publishRoster();317+ await relay.publishGroup(caller);318+ return reply({ result: { owner: pk, previous: caller } });319+ }288320 case "changerelayname":289321 s.update({ name: str(0).slice(0, 200) });322+ await relay.publishGroup();290323 return reply({ result: true });291324 case "changerelaydescription":292325 s.update({ description: str(0).slice(0, 2000) });326+ await relay.publishGroup();293327 return reply({ result: true });294328 case "changerelayicon":295329 s.update({ icon: str(0).slice(0, 2000) });330+ await relay.publishGroup();296331 return reply({ result: true });297332 }298333 return reply({ error: "unsupported: " + method }, 400);
M src/relay.ts +81 -14
@@ -21,6 +21,8 @@ import { checkInvite, claimInvite, invitePage } from "./invites.ts";
2121 import { verifyNIP98 } from "./manage.ts";2222 import { FAVICON_SVG } from "./ui.ts";2323 import { runPullRound, type PullJob, type PullResult } from "./pull.ts";24+import { groupFacts, handleGroupEvent, isGroupManagement, isGroupState } from "./groups.ts";25+import { KIND_GROUP_MEMBERS } from "./identity.ts";2426 import type { Blob } from "./blossom.ts";2527 2628 export interface Env {
@@ -44,7 +46,7 @@ export interface Env {
4446 SATS_PER_MILLION_ROWS?: string;4547 }4648 47-export const SUPPORTED_NIPS = [1, 5, 9, 11, 13, 17, 40, 42, 45, 50, 56, 59, 62, 67, 70, 77, 86, 98];49+export const SUPPORTED_NIPS = [1, 5, 9, 11, 13, 17, 29, 40, 42, 45, 50, 56, 59, 62, 67, 70, 77, 86, 98];4850 export const KIND_REPORT = 1984;4951 export const SOFTWARE = "https://bind.ws";5052 export const VERSION = "0.1.0";
@@ -145,8 +147,43 @@ export class Relay extends DurableObject<Env> {
145147 await this.identity.ensure();146148 const t = now();147149 const events: Event[] = [];148- if (change) events.push(this.identity.delta(change.added, change.pubkey));150+ if (change) {151+ events.push(this.identity.delta(change.added, change.pubkey));152+ events.push(change.added ? this.identity.putUser(this.slug, change.pubkey, this.rolesOf(change.pubkey)) : this.identity.removeUser(this.slug, change.pubkey));153+ }149154 events.push(this.identity.roster(this.settings.members(), t));155+ events.push(...this.groupState(t));156+ this.emit(events, t);157+ }158+159+ // publishGroup refreshes the NIP-29 state after something other than a160+ // membership change: a rule, the name, or, with a pubkey, that person's role.161+ async publishGroup(roleChanged?: string) {162+ if (this.settings.policy.owner === "") return;163+ await this.identity.ensure();164+ const t = now();165+ const events: Event[] = roleChanged ? [this.identity.putUser(this.slug, roleChanged, this.rolesOf(roleChanged))] : [];166+ events.push(...this.groupState(t));167+ this.emit(events, t);168+ }169+170+ private rolesOf(pubkey: string): string[] {171+ const r = this.settings.roleOf(pubkey);172+ return r && r !== "member" ? [r] : [];173+ }174+175+ // groupState signs the group's metadata, admins, roles and, when the176+ // directory is public, members. A members list that is no longer public is177+ // taken down.178+ private groupState(t: number): Event[] {179+ const f = groupFacts(this);180+ if (f.members === null) {181+ for (const r of this.sql.exec<{ id: string }>(`SELECT id FROM events WHERE kind=? AND pubkey=?`, KIND_GROUP_MEMBERS, this.identity.pubkey).toArray()) this.store.deleteEvent(r.id);182+ }183+ return this.identity.group(f, t);184+ }185+186+ private emit(events: Event[], t: number) {150187 for (const e of events) {151188 const err = this.store.save(e, t);152189 if (!err) this.broadcast(e);
@@ -713,9 +750,49 @@ export class Relay extends DurableObject<Env> {
713750 if (r) return r;714751 }715752 if (e.kind === KIND_REPORT) return this.acceptReport(e);753+ if (isGroupManagement(e.kind) && hasTag(e, "h")) return this.acceptGroup(e, conn);716754 return this.accept(e, conn);717755 }718756 757+ // acceptGroup handles NIP-29 joins, leaves and moderation: the common gate,758+ // then role checks instead of the write policy, then stored like any event.759+ private async acceptGroup(e: Event, conn: ConnState): Promise<{ ok: boolean; msg: string; stored: boolean }> {760+ const t = now();761+ const reason = this.gate(e, conn, t);762+ if (reason) return { ok: false, msg: reason, stored: false };763+ if (this.sql.exec(`SELECT 1 FROM events WHERE id=?`, e.id).toArray().length) return { ok: true, msg: ERR_DUPLICATE, stored: false };764+ const r = await handleGroupEvent(this, e);765+ if (!r.ok) return r;766+ const err = this.store.save(e, t);767+ if (err) return { ok: false, msg: err, stored: false };768+ return { ok: true, msg: "", stored: true };769+ }770+771+ // gate is what every write must pass, whoever sends it: shape, bans, the772+ // relay's state, fuel, and the one-group rule. "" lets it through.773+ private gate(e: Event, conn: ConnState | null, t: number): string {774+ const p = this.settings.policy;775+ if (e.kind === KIND_AUTH) return "blocked: kind 22242 is only accepted inside an AUTH message";776+ if (p.maxFuture > 0 && e.created_at > t + p.maxFuture) return "invalid: event creation date is too far off from the current time";777+ const exp = expiration(e);778+ if (exp > 0 && exp <= t) return "invalid: event has already expired";779+ if (this.settings.isBanned(e.pubkey)) return "blocked: this pubkey is banned from this relay";780+ if (this.settings.isEventBanned(e.id)) return "blocked: this event is banned from this relay";781+ const h = tagValues(e, "h")[0];782+ if (h !== undefined && h !== this.slug) return "blocked: this relay hosts one group: " + this.slug;783+ if (conn) {784+ if (isGroupState(e.kind)) return "blocked: group metadata is written by the relay";785+ if (this.settings.isUnclaimed()) return "restricted: this relay is unclaimed; open https://" + conn.host + "/ to claim it";786+ if (this.settings.leaseExpired(t)) return "restricted: this temporary relay has expired";787+ const f = this.fuelStatus();788+ if (f.outOfFuel) {789+ return 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";790+ }791+ if (hasTag(e, "-") && !conn.authed.includes(e.pubkey)) return "auth-required: this event may only be published by its author";792+ }793+ return "";794+ }795+719796 // acceptReport files a NIP-56 report in the moderation queue. It is never720797 // stored as an event or served: reports are for the owner, not the feed.721798 private acceptReport(e: Event): { ok: boolean; msg: string; stored: boolean } {
@@ -739,19 +816,10 @@ export class Relay extends DurableObject<Env> {
739816 const p = this.settings.policy;740817 const t = now();741818 const no = (msg: string) => ({ ok: false, msg, stored: false });742- if (e.kind === KIND_AUTH) return no("blocked: kind 22242 is only accepted inside an AUTH message");743- if (p.maxFuture > 0 && e.created_at > t + p.maxFuture) return no("invalid: event creation date is too far off from the current time");819+ const gate = this.gate(e, conn, t);820+ if (gate) return no(gate);744821 const exp = expiration(e);745- if (exp > 0 && exp <= t) return no("invalid: event has already expired");746- if (this.settings.isBanned(e.pubkey)) return no("blocked: this pubkey is banned from this relay");747- if (this.settings.isEventBanned(e.id)) return no("blocked: this event is banned from this relay");748822 if (conn) {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");751- const f = this.fuelStatus();752- if (f.outOfFuel) {753- 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");754- }755823 if (!this.settings.kindAllowed(e.kind)) return no("blocked: this relay does not accept kind " + e.kind);756824 const keep = this.settings.retentionDays(e.kind);757825 if (keep > 0 && e.created_at < t - keep * 86400) return no(`blocked: this relay keeps kind ${e.kind} for ${keep} days and this event is older`);
@@ -762,7 +830,6 @@ export class Relay extends DurableObject<Env> {
762830 if (d.difficulty < p.minPow) return no(`pow: difficulty ${d.difficulty} is less than ${p.minPow}`);763831 if (d.target > 0 && d.target < p.minPow) return no(`pow: committed target ${d.target} is less than ${p.minPow}`);764832 }765- if (hasTag(e, "-") && !conn.authed.includes(e.pubkey)) return no("auth-required: this event may only be published by its author");766833 }767834 if (e.kind === KIND_VANISH) {768835 const host = conn?.host ?? "";
A src/roles.ts +54 -0
@@ -0,0 +1,54 @@
1+// Who may do what. One table for both doors: the NIP-86 methods the console2+// calls and the NIP-29 moderation events clients send. The owner may do3+// everything; a moderator keeps the peace and nothing more.4+5+export type Role = "owner" | "moderator" | "member";6+7+export type Action =8+ | "read" // stats, policy and the lists the console renders9+ | "members" // add, edit and remove plain members10+ | "ban"11+ | "deleteEvent"12+ | "invites"13+ | "reports"14+ | "rules" // access rules, kinds, retention15+ | "identity" // name, description, icon16+ | "storage"17+ | "config" // export and import18+ | "pull"19+ | "transfer"20+ | "deleteRelay";21+22+const MODERATOR = new Set<Action>(["read", "members", "ban", "deleteEvent", "invites", "reports"]);23+24+export function can(role: Role | null | undefined, action: Action): boolean {25+ if (role === "owner") return true;26+ if (role === "moderator") return MODERATOR.has(action);27+ return false;28+}29+30+// The roles a relay advertises in its NIP-29 kind 39003.31+export const ROLES: { role: Role; about: string }[] = [32+ { role: "owner", about: "everything: rules, identity, people, storage, fuel" },33+ { role: "moderator", about: "people, bans, deletions, invites, reports" },34+];35+36+// Every NIP-86 method and the action it needs. claim and supportedmethods37+// are open and not listed.38+export const METHOD_ACTIONS: Record<string, Action> = {39+ stats: "read", getpolicy: "read",40+ listbannedpubkeys: "read", listallowedpubkeys: "read", listmembers: "read", listpeople: "read", listinvites: "read",41+ listbannedevents: "read", listrecentevents: "read", listallowedkinds: "read", listblockedkinds: "read", listblobs: "read", listretention: "read", listreports: "read",42+ setmember: "members", allowpubkey: "members", removemember: "members", unrulepubkey: "members",43+ banpubkey: "ban", banevent: "ban", allowevent: "ban",44+ deleteevent: "deleteEvent",45+ createinvite: "invites", revokeinvite: "invites",46+ resolvereport: "reports",47+ setpolicy: "rules", allowkind: "rules", disallowkind: "rules", unrulekind: "rules", setretention: "rules", purgekind: "rules", resetrules: "rules",48+ changerelayname: "identity", changerelaydescription: "identity", changerelayicon: "identity",49+ storagestats: "storage", deleteblob: "storage",50+ exportconfig: "config", importconfig: "config",51+ pullfrom: "pull", pullstatus: "pull",52+ transferowner: "transfer",53+ deleterelay: "deleteRelay",54+};
M src/settings.ts +31 -6
@@ -1,4 +1,5 @@
11 // Per-relay settings and policy, kept in the same SQLite database.2+import type { Role } from "./roles.ts";23 34 // A lease makes an unclaimed relay usable for a while: open to everyone,45 // then wiped at `until` unless somebody claims it first. holder "" means
@@ -67,7 +68,7 @@ CREATE INDEX IF NOT EXISTS blobs_uploader ON blobs(uploader, uploaded DESC);
6768 // optional name is their NIP-05 handle at the relay's host.6869 export type Member = {6970 pubkey: string;70- role: "owner" | "member";71+ role: Role;7172 name: string | null;7273 note: string;7374 joined_at: number;
@@ -154,7 +155,7 @@ export class Settings {
154155 // ---- people ----155156 156157 members(): Member[] {157- return this.sql.exec<Member>(`SELECT * FROM members ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, joined_at, pubkey`).toArray();158+ return this.sql.exec<Member>(`SELECT * FROM members ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'moderator' THEN 1 ELSE 2 END, joined_at, pubkey`).toArray();158159 }159160 member(pubkey: string): Member | null {160161 return this.sql.exec<Member>(`SELECT * FROM members WHERE pubkey=?`, pubkey).toArray()[0] ?? null;
@@ -165,6 +166,26 @@ export class Settings {
165166 isAllowed(pubkey: string) {166167 return this.memberSet.has(pubkey) || this.isOwner(pubkey);167168 }169+ // roleOf is the pubkey's role, or null for a stranger.170+ roleOf(pubkey: string): Role | null {171+ if (this.isOwner(pubkey)) return "owner";172+ return this.member(pubkey)?.role ?? null;173+ }174+ // setRole promotes or demotes a member; the owner's role only changes by transfer.175+ setRole(pubkey: string, role: "moderator" | "member"): boolean {176+ return this.sql.exec(`UPDATE members SET role=? WHERE pubkey=? AND role!='owner'`, role, pubkey).rowsWritten > 0;177+ }178+ // transferOwner hands the relay to a member. The old owner stays on as a179+ // moderator so nobody is locked out. Returns "" or a reason.180+ transferOwner(pubkey: string): string {181+ const old = this.policy.owner;182+ if (old === "" || pubkey === old) return "invalid: that is already the owner";183+ if (!this.member(pubkey)) return "invalid: the new owner must be a member first";184+ this.update({ owner: pubkey });185+ this.sql.exec(`UPDATE members SET role='moderator' WHERE pubkey=?`, old);186+ this.sql.exec(`UPDATE members SET role='owner' WHERE pubkey=?`, pubkey);187+ return "";188+ }168189 169190 // upsertMember adds or edits a member. name "" clears the name; a name held170191 // by someone else is refused unless force (owner action). Returns "" or a reason.
@@ -210,7 +231,7 @@ export class Settings {
210231 exported_at: Math.floor(Date.now() / 1000),211232 name,212233 policy: { ...this.policy, owner: undefined, lease: undefined },213- members: this.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, name: m.name, note: m.note })),234+ members: this.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, name: m.name, note: m.note, ...(m.role === "moderator" ? { role: "moderator" } : {}) })),214235 bans: this.listBans(),215236 banned_events: this.listEvents("ban"),216237 kinds: { allow: this.listKinds("allow"), block: this.listKinds("block") },
@@ -235,7 +256,9 @@ export class Settings {
235256 for (const m of this.members()) if (m.role !== "owner") this.removeMember(m.pubkey);236257 for (const m of Array.isArray(c.members) ? c.members : []) {237258 const r = m as Record<string, unknown>;238- if (hex64(r.pubkey)) this.upsertMember(r.pubkey, { name: typeof r.name === "string" ? r.name : null, note: typeof r.note === "string" ? r.note : "", via: "import" }, now, true);259+ if (!hex64(r.pubkey)) continue;260+ this.upsertMember(r.pubkey, { name: typeof r.name === "string" ? r.name : null, note: typeof r.note === "string" ? r.note : "", via: "import" }, now, true);261+ if (r.role === "moderator") this.setRole(r.pubkey, "moderator");239262 }240263 for (const b of this.listBans()) this.setBan(b.pubkey, false);241264 for (const b of Array.isArray(c.bans) ? c.bans : []) {
@@ -347,8 +370,10 @@ const RETENTION_ANY = -1;
347370 348371 // Kinds the relay itself depends on: who people are (profiles, contact and349372 // relay lists), what was paid (zap receipts), and who belongs (the roster and350-// its deltas). Never expired, never purged, only deleted one at a time.351-export const PROTECTED_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001]);373+// its deltas, the NIP-29 put-user and remove-user records, and the group's374+// metadata, admins, members and roles). Never expired, never purged, only375+// deleted one at a time.376+export const PROTECTED_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);352377 export function isProtected(kind: number): boolean {353378 return PROTECTED_KINDS.has(kind);354379 }
M test/adopt.test.ts +5 -5
@@ -518,8 +518,8 @@ describe("storage: retention and purge", () => {
518518 await runInDurableObject(stub, async (r: Relay) => expect(r.sweepRetention(now())).toBe(1));519519 let st = (await rpc(host, owner, "storagestats")).result;520520 const byKind = Object.fromEntries(st.kinds.map((k: any) => [k.kind, k.n]));521- expect(byKind).toEqual({ 1: 2, 7: 1, 0: 1, 13534: 1 }); // the roster is the relay's own522- expect(st.events).toBe(5);521+ expect(byKind).toEqual({ 1: 2, 7: 1, 0: 1, 13534: 1, 39000: 1, 39001: 1, 39002: 1, 39003: 1 }); // the roster and group state are the relay's own522+ expect(st.events).toBe(9);523523 expect(st.eventBytes).toBeGreaterThan(0);524524 expect(st.retention).toEqual([{ kind: 7, days: 30 }, { kind: null, days: 100 }]);525525
@@ -527,7 +527,7 @@ describe("storage: retention and purge", () => {
527527 expect((await rpc(host, owner, "purgekind", 1, 10)).result).toEqual({ deleted: 1 });528528 expect((await rpc(host, owner, "purgekind", 7, 0)).result).toEqual({ deleted: 1 });529529 st = (await rpc(host, owner, "storagestats")).result;530- expect(st.events).toBe(3);530+ expect(st.events).toBe(7);531531 532532 // Rules travel with the configuration; removing one is days 0.533533 const cfg = (await rpc(host, owner, "exportconfig")).result;
@@ -539,7 +539,7 @@ describe("storage: retention and purge", () => {
539539 expect((await rpc(host, owner, "setretention", 0, 5)).status).toBe(400);540540 expect((await rpc(host, owner, "purgekind", 13534, 0)).status).toBe(400);541541 await rpc(host, owner, "setretention", null, 1);542- expect((await rpc(host, owner, "purgekind", null, 0)).result.deleted).toBe(1); // the fresh note; profile and roster stay543- expect((await rpc(host, owner, "storagestats")).result.kinds.map((k: any) => k.kind).sort()).toEqual([0, 13534]);542+ expect((await rpc(host, owner, "purgekind", null, 0)).result.deleted).toBe(1); // the fresh note; profile, roster and group state stay543+ expect((await rpc(host, owner, "storagestats")).result.kinds.map((k: any) => k.kind).sort((a: number, b: number) => a - b)).toEqual([0, 13534, 39000, 39001, 39002, 39003]);544544 });545545 });
A test/groups.test.ts +318 -0
@@ -0,0 +1,318 @@
1+// NIP-29: one group per relay. Joins and leaves, moderation by owner and2+// moderator with the permission boundaries, the relay-signed group state,3+// and ownership transfer.4+import { SELF } 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+9+const now = () => Math.floor(Date.now() / 1000);10+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = [], created_at = now()) => finalizeEvent({ kind, content, tags, created_at }, sk);11+12+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {13+ const url = `http://${host}/`;14+ const payload = { method, params };15+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);16+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });17+ return { status: resp.status, ...(await resp.json<any>()) };18+}19+20+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();21+22+class WS {23+ private queue: any[][] = [];24+ private waiters: ((m: any[]) => void)[] = [];25+ constructor(public ws: WebSocket) {26+ ws.accept();27+ ws.addEventListener("message", (e) => {28+ const m = JSON.parse(e.data as string);29+ const w = this.waiters.shift();30+ if (w) w(m);31+ else this.queue.push(m);32+ });33+ }34+ static async connect(host: string) {35+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });36+ const c = new WS(resp.webSocket!);37+ await c.expect("AUTH");38+ return c;39+ }40+ send(...m: unknown[]) {41+ this.ws.send(JSON.stringify(m));42+ }43+ recv(): Promise<any[]> {44+ const m = this.queue.shift();45+ if (m) return Promise.resolve(m);46+ return new Promise((res) => this.waiters.push(res));47+ }48+ async expect(type: string) {49+ const m = await this.recv();50+ expect(m[0], JSON.stringify(m)).toBe(type);51+ return m;52+ }53+ // ok publishes and waits for the OK. Live pushes the relay fans out while54+ // handling the event (its own put-user, say) arrive first; they are kept55+ // in order for a later expect.56+ async ok(e: Event) {57+ this.send("EVENT", e);58+ const held: any[][] = [];59+ for (;;) {60+ const m = await this.recv();61+ if (m[0] === "OK" && m[1] === e.id) {62+ this.queue.unshift(...held);63+ return { ok: m[2] as boolean, msg: m[3] as string };64+ }65+ held.push(m);66+ }67+ }68+ private n = 0;69+ async req(filter: unknown) {70+ const id = "q" + ++this.n;71+ this.send("REQ", id, filter);72+ const events: Event[] = [];73+ for (;;) {74+ const m = await this.recv();75+ if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);76+ else if (m[0] === "EOSE" && m[1] === id) {77+ this.send("CLOSE", id);78+ return events;79+ } else if (m[0] === "CLOSED" && m[1] === id) throw new Error(m[2]);80+ }81+ }82+}83+84+const tagsOf = (e: Event, name: string) => e.tags.filter((t) => t[0] === name);85+const has = (e: Event, name: string) => e.tags.some((t) => t[0] === name);86+87+// state fetches the four relay-signed group events, keyed by kind.88+async function state(c: WS, self: string) {89+ const list = await c.req({ kinds: [39000, 39001, 39002, 39003], authors: [self] });90+ return Object.fromEntries(list.map((e) => [e.kind, e])) as Record<number, Event | undefined>;91+}92+93+describe("NIP-29 group state", () => {94+ it("is signed by the relay after the claim and follows the rules", async () => {95+ const host = "grp.bind.ws";96+ const name = "grp";97+ const owner = generateSecretKey();98+ await rpc(host, owner, "claim");99+ const doc = await info(host);100+ expect(doc.supported_nips).toContain(29);101+ const c = await WS.connect(host);102+ let st = await state(c, doc.self);103+ expect(st[39000]!.tags).toEqual(expect.arrayContaining([["d", name], ["name", name], ["about", ""]]));104+ expect(has(st[39000]!, "private") || has(st[39000]!, "restricted") || has(st[39000]!, "closed")).toBe(false);105+ expect(tagsOf(st[39001]!, "p")).toEqual([["p", getPublicKey(owner), "owner"]]);106+ expect(tagsOf(st[39002]!, "p")).toEqual([["p", getPublicKey(owner)]]);107+ expect(tagsOf(st[39003]!, "role").map((t) => t[1])).toEqual(["owner", "moderator"]);108+109+ await rpc(host, owner, "setpolicy", { reads: "members", writes: "allowlist", name: "Pizza", icon: "https://x/p.png" });110+ // Members-only reads now: the owner authenticates to keep reading.111+ const m = await WS.connect(host);112+ // (the AUTH challenge was consumed by connect; fetch it again through a fresh socket's state)113+ const chal = await (async () => {114+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });115+ const w = new WS(resp.webSocket!);116+ const a = await w.expect("AUTH");117+ return { w, challenge: a[1] as string };118+ })();119+ chal.w.send("AUTH", ev(owner, 22242, "", [["relay", "ws://" + host], ["challenge", chal.challenge]]));120+ await chal.w.expect("OK");121+ st = await state(chal.w, doc.self);122+ expect(has(st[39000]!, "private") && has(st[39000]!, "restricted") && has(st[39000]!, "closed")).toBe(true);123+ expect(st[39000]!.tags).toEqual(expect.arrayContaining([["name", "Pizza"], ["picture", "https://x/p.png"]]));124+ void m;125+126+ await rpc(host, owner, "setpolicy", { reads: "open", directoryPublic: false });127+ st = await state(c, doc.self);128+ expect(st[39002]).toBeUndefined();129+ expect(st[39001]).toBeDefined();130+ });131+});132+133+describe("NIP-29 joins and leaves", () => {134+ it("lets strangers join an open group, refuses duplicates and foreign groups, and records leaves", async () => {135+ const host = "open.bind.ws";136+ const owner = generateSecretKey();137+ await rpc(host, owner, "claim");138+ const doc = await info(host);139+ const alice = generateSecretKey();140+ const c = await WS.connect(host);141+ c.send("REQ", "live", { kinds: [9000, 9001], authors: [doc.self] });142+ await c.expect("EOSE");143+144+ expect((await c.ok(ev(alice, 9021, "hi", [["h", "other"]]))).msg).toMatch(/^blocked: this relay hosts one group: open/);145+ expect((await c.ok(ev(alice, 9021, "hi", [["h", "open"]]))).ok).toBe(true);146+ const put = await c.expect("EVENT");147+ expect(put[2].kind).toBe(9000);148+ expect(put[2].tags).toEqual(expect.arrayContaining([["h", "open"], ["p", getPublicKey(alice)]]));149+ expect((await rpc(host, owner, "listmembers")).result.members.find((m: any) => m.pubkey === getPublicKey(alice)).via).toBe("join");150+ expect((await c.ok(ev(alice, 9021, "again", [["h", "open"]]))).msg).toMatch(/^duplicate: already a member/);151+ // The join request itself is kept.152+ expect((await c.req({ kinds: [9021], authors: [getPublicKey(alice)] })).length).toBe(1);153+154+ expect((await c.ok(ev(owner, 9022, "", [["h", "open"]]))).msg).toMatch(/transfer ownership first/);155+ expect((await c.ok(ev(alice, 9022, "bye", [["h", "open"]]))).ok).toBe(true);156+ const rem = await c.expect("EVENT");157+ expect(rem[2].kind).toBe(9001);158+ expect(tagsOf(rem[2], "p")[0][1]).toBe(getPublicKey(alice));159+ expect((await c.ok(ev(alice, 9022, "bye again", [["h", "open"]]))).msg).toMatch(/^invalid: not a member/);160+ });161+162+ it("needs an invite code in a closed group", async () => {163+ const host = "closed.bind.ws";164+ const owner = generateSecretKey();165+ await rpc(host, owner, "claim");166+ await rpc(host, owner, "setpolicy", { writes: "allowlist" });167+ const bob = generateSecretKey();168+ const c = await WS.connect(host);169+ expect((await c.ok(ev(bob, 9021, "", [["h", "closed"]]))).msg).toMatch(/^restricted: this group is closed/);170+ expect((await c.ok(ev(bob, 9021, "", [["h", "closed"], ["code", "nope"]]))).msg).toMatch(/not valid/);171+ const inv = (await rpc(host, owner, "createinvite", 3600, 1, "friends")).result;172+ expect((await c.ok(ev(bob, 9021, "", [["h", "closed"], ["code", inv.code]]))).ok).toBe(true);173+ expect((await rpc(host, owner, "listmembers")).result.members.find((m: any) => m.pubkey === getPublicKey(bob)).via).toMatch(/^invite /);174+ const carol = generateSecretKey();175+ expect((await c.ok(ev(carol, 9021, "", [["h", "closed"], ["code", inv.code]]))).msg).toMatch(/used up/);176+ });177+});178+179+describe("NIP-29 moderation", () => {180+ it("applies put-user, remove-user, edit-metadata, delete-event and create-invite by role", async () => {181+ const host = "mod.bind.ws";182+ const h = ["h", "mod"];183+ const owner = generateSecretKey();184+ const mod = generateSecretKey();185+ const mod2 = generateSecretKey();186+ const alice = generateSecretKey();187+ const stranger = generateSecretKey();188+ await rpc(host, owner, "claim");189+ const doc = await info(host);190+ const c = await WS.connect(host);191+ const pk = getPublicKey;192+193+ expect((await c.ok(ev(stranger, 9000, "", [h, ["p", pk(alice)]]))).msg).toMatch(/^restricted: not a group admin/);194+ expect((await c.ok(ev(owner, 9000, "", [h, ["p", pk(mod), "moderator"]]))).ok).toBe(true);195+ expect((await c.ok(ev(owner, 9000, "", [h, ["p", pk(mod2), "moderator", "gardener"]]))).ok).toBe(true);196+ let members = (await rpc(host, owner, "listmembers")).result.members;197+ expect(members.find((m: any) => m.pubkey === pk(mod)).role).toBe("moderator");198+ let st = await state(c, doc.self);199+ expect(tagsOf(st[39001]!, "p")).toEqual(expect.arrayContaining([["p", pk(mod), "moderator"], ["p", pk(mod2), "moderator"]]));200+ // The relay's own put-user carries the roles held now.201+ const records = await c.req({ kinds: [9000], authors: [doc.self], "#p": [pk(mod)] });202+ expect(records.some((e) => tagsOf(e, "p")[0].includes("moderator"))).toBe(true);203+204+ expect((await c.ok(ev(mod, 9000, "", [h, ["p", pk(alice)]]))).ok).toBe(true);205+ expect((await c.ok(ev(mod, 9000, "", [h, ["p", pk(stranger), "moderator"]]))).msg).toMatch(/only the owner appoints/);206+ expect((await c.ok(ev(mod, 9000, "", [h, ["p", pk(mod2)]]))).msg).toMatch(/only the owner changes moderators/);207+ expect((await c.ok(ev(mod, 9000, "", [h, ["p", pk(owner)]]))).msg).toMatch(/changes by transfer/);208+ expect((await c.ok(ev(mod, 9001, "", [h, ["p", pk(owner)]]))).msg).toMatch(/owner cannot be removed/);209+ expect((await c.ok(ev(mod, 9001, "", [h, ["p", pk(mod2)]]))).msg).toMatch(/only the owner removes moderators/);210+ expect((await c.ok(ev(mod, 9001, "", [h, ["p", pk(alice)]]))).ok).toBe(true);211+ expect((await rpc(host, owner, "listmembers")).result.members.some((m: any) => m.pubkey === pk(alice))).toBe(false);212+ // Demotion by the owner.213+ expect((await c.ok(ev(owner, 9000, "", [h, ["p", pk(mod2)]]))).ok).toBe(true);214+ members = (await rpc(host, owner, "listmembers")).result.members;215+ expect(members.find((m: any) => m.pubkey === pk(mod2)).role).toBe("member");216+217+ expect((await c.ok(ev(mod, 9002, "", [h, ["name", "Nope"]]))).msg).toMatch(/moderators cannot do that/);218+ expect((await c.ok(ev(owner, 9002, "", [h, ["name", "Mods"], ["about", "a tidy place"], ["picture", "https://x/i.png"]]))).ok).toBe(true);219+ const d2 = await info(host);220+ expect([d2.name, d2.description, d2.icon]).toEqual(["Mods", "a tidy place", "https://x/i.png"]);221+ st = await state(c, doc.self);222+ expect(st[39000]!.tags).toEqual(expect.arrayContaining([["name", "Mods"], ["about", "a tidy place"]]));223+224+ // Alice was removed; re-add so she can post, then a moderator deletes her note.225+ expect((await c.ok(ev(owner, 9000, "", [h, ["p", pk(alice)]]))).ok).toBe(true);226+ const note = ev(alice, 1, "rude", [h]);227+ expect((await c.ok(note)).ok).toBe(true);228+ expect((await c.ok(ev(stranger, 9005, "", [h, ["e", note.id]]))).ok).toBe(false);229+ expect((await c.ok(ev(mod, 9005, "", [h, ["e", note.id]]))).ok).toBe(true);230+ expect((await c.req({ ids: [note.id] })).length).toBe(0);231+ const roster = (await c.req({ kinds: [13534], authors: [doc.self] }))[0];232+ expect((await c.ok(ev(mod, 9005, "", [h, ["e", roster.id]]))).msg).toMatch(/relay's own records/);233+234+ expect((await c.ok(ev(mod, 9009, "come along", [h, ["code", "party-2026"]]))).ok).toBe(true);235+ expect((await rpc(host, owner, "listinvites")).result.some((i: any) => i.code === "party-2026" && i.note === "come along")).toBe(true);236+ expect((await c.ok(ev(mod, 9009, "", [h, ["code", "party-2026"]]))).msg).toMatch(/^duplicate:/);237+ expect((await c.ok(ev(mod, 9009, "", [h, ["code", "x"]]))).msg).toMatch(/^invalid:/);238+ await rpc(host, owner, "setpolicy", { writes: "allowlist" });239+ expect((await c.ok(ev(stranger, 9021, "", [h, ["code", "party-2026"]]))).ok).toBe(true);240+241+ expect((await c.ok(ev(owner, 9007, "", [h]))).msg).toMatch(/^unsupported:/);242+ expect((await c.ok(ev(owner, 9008, "", [h]))).msg).toMatch(/^unsupported:/);243+ expect((await c.ok(ev(owner, 9010, "", [h]))).msg).toMatch(/^unsupported:/);244+ expect((await c.ok(ev(owner, 39000, "", [["d", "mod"], ["name", "forged"]]))).msg).toMatch(/written by the relay/);245+ });246+247+ it("gives a moderator the NIP-86 methods for keeping the peace and nothing else", async () => {248+ const host = "team.bind.ws";249+ const owner = generateSecretKey();250+ const mod = generateSecretKey();251+ const mod2 = generateSecretKey();252+ const alice = generateSecretKey();253+ const pk = getPublicKey;254+ await rpc(host, owner, "claim");255+ await rpc(host, owner, "setmember", pk(alice), { name: "alice" });256+ expect((await rpc(host, mod, "stats")).status).toBe(403);257+ await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });258+ await rpc(host, owner, "setmember", pk(mod2), { role: "moderator" });259+ expect((await rpc(host, owner, "listmembers")).result.members.map((m: any) => m.role)).toEqual(["owner", "moderator", "moderator", "member"]);260+261+ expect((await rpc(host, mod, "stats")).status).toBe(200);262+ expect((await rpc(host, mod, "getpolicy")).status).toBe(200);263+ expect((await rpc(host, mod, "listreports")).status).toBe(200);264+ expect((await rpc(host, mod, "setmember", pk(alice), { note: "ok" })).status).toBe(200);265+ expect((await rpc(host, mod, "setmember", pk(alice), { role: "moderator" })).error).toMatch(/only the owner sets roles/);266+ expect((await rpc(host, mod, "setmember", pk(mod2), { note: "x" })).status).toBe(403);267+ expect((await rpc(host, mod, "removemember", pk(mod2))).status).toBe(403);268+ expect((await rpc(host, mod, "banpubkey", pk(mod2), "grr")).status).toBe(403);269+ expect((await rpc(host, mod, "banpubkey", pk(owner), "grr")).status).toBe(400);270+ for (const m of ["setpolicy", "storagestats", "exportconfig", "pullstatus", "deleterelay", "changerelayname", "resetrules"]) {271+ const r = await rpc(host, mod, m, "x");272+ expect(r.status, m).toBe(403);273+ expect(r.error, m).toMatch(/moderators cannot do that/);274+ }275+ expect((await rpc(host, mod, "transferowner", pk(mod))).status).toBe(403);276+ expect((await rpc(host, mod, "banpubkey", pk(alice), "rude")).status).toBe(200);277+ expect((await rpc(host, mod, "listbannedpubkeys")).result.map((b: any) => b.pubkey)).toEqual([pk(alice)]);278+ const inv = await rpc(host, mod, "createinvite", 3600, 0, "");279+ expect(inv.status).toBe(200);280+ expect((await rpc(host, mod, "revokeinvite", inv.result.code)).result).toBe(true);281+ // Demoted, the door closes again.282+ await rpc(host, owner, "setmember", pk(mod), { role: "member" });283+ expect((await rpc(host, mod, "stats")).status).toBe(403);284+ });285+});286+287+describe("ownership transfer", () => {288+ it("hands the relay to a member and keeps the old owner on as a moderator", async () => {289+ const host = "handover.bind.ws";290+ const owner = generateSecretKey();291+ const bob = generateSecretKey();292+ const pk = getPublicKey;293+ await rpc(host, owner, "claim");294+ const before = await info(host);295+ expect((await rpc(host, owner, "transferowner", pk(bob))).error).toMatch(/must be a member first/);296+ expect((await rpc(host, owner, "transferowner", pk(owner))).status).toBe(400);297+ await rpc(host, owner, "setmember", pk(bob), { name: "bob" });298+ const r = await rpc(host, owner, "transferowner", pk(bob));299+ expect(r.result).toEqual({ owner: pk(bob), previous: pk(owner) });300+301+ const after = await info(host);302+ expect(after.pubkey).toBe(pk(bob));303+ expect(after.self).toBe(before.self);304+ const members = (await rpc(host, bob, "listmembers")).result.members;305+ expect(members.map((m: any) => [m.pubkey, m.role])).toEqual([[pk(bob), "owner"], [pk(owner), "moderator"]]);306+ const c = await WS.connect(host);307+ const st = await state(c, after.self);308+ expect(tagsOf(st[39001]!, "p")).toEqual([["p", pk(bob), "owner"], ["p", pk(owner), "moderator"]]);309+ const roster = (await c.req({ kinds: [13534], authors: [after.self] }))[0];310+ expect(tagsOf(roster, "member")).toEqual(expect.arrayContaining([["member", pk(bob), "owner"], ["member", pk(owner), "moderator"]]));311+312+ expect((await rpc(host, owner, "setpolicy", { writes: "owner" })).status).toBe(403);313+ expect((await rpc(host, bob, "setpolicy", { writes: "owner" })).status).toBe(200);314+ expect((await rpc(host, owner, "banpubkey", pk(bob), "")).status).toBe(400);315+ expect((await rpc(host, bob, "banpubkey", pk(owner), "")).status).toBe(200);316+ expect((await rpc(host, owner, "stats")).status).toBe(403);317+ });318+});
M test/relay.test.ts +1 -1